This page is intended for users in Hong Kong(Chinese (Traditional)). Go to the page for users in United States.

フロントエンドに型の秩序を与えるGraphQLとTypeScript

こんにちは。Wantedly Visit の Product Squad で Frontend Engineer をしている原 (chloe463)です。
Product Squad では主に Wantedly Visit の Web 版の新規機能開発やリニューアルを行っています。
本記事では、つい先日リリースされた募集作成画面の開発で導入したGraphQLサーバーの開発について紹介します。

TL;DR

  • Wantedly Visit に GraphQLサーバーを導入した
  • GraphQL サーバーの開発にはコードファーストにスキーマ定義ができる Nexus を採用した
  • API 定義(swaggerやprotobuf)からクライアントファイルと型定義ファイルを生成することでバックエンド〜フロントエンドまでの型の安全性を確保している

BFFとしてのGraphQLサーバー導入の背景

Wantedly Visit のサービス自体はモノリシックなRailsアプリケーションです。
定期的に負債返済をしているとはいえ、長期間稼働しているアプリケーションは巨大で、技術的な地層もあり生産性を下げる原因となっています。
そこで Wantedly Visit の開発チームでは現在マイクロサービス化を進めており、今年のはじめからは Wantedly Visit の最もコアな機能のひとつである募集作成画面のリニューアルを進めてきました。
新しい募集作成画面では見た目はもちろん、使用している技術も刷新しました。 具体的には...

  • フロントエンドは React を使った SPA (New!)
  • BFF として GraphQL サーバー (New!)
  • 募集作成関連の API サーバー (New!)
  • Wantedly Visit の API サーバー

という構成になりました。
今回新たな取り組みとして、GraphQLを用いたBFF (Backend For Frontend)を導入しました。
導入した背景としては、今後もマイクロサービス化を進めていくと増えていくバックエンドのAPIに対して、フロントエンド側でどのサービスにリクエストするかをハンドリングせずに画一的なインターフェイスでリクエストしたかったためです。全体像としては次の図のような状態になっています。

スキーマファースト開発

まず、前提として最近のWantedlyでの新規のフロントエンド開発はTypeScriptを使って行っています。
今回作成したSPAもTypeScriptでReactアプリケーションを実装しています。GraphQLサーバーはTypeScriptとApollo serverを使って開発しました。

GraphQLの大きな特徴としてAPIのリクエスト・レスポンスに関わるデータについて型の定義ができるという点があります。今回の開発ではまず必要となるQuery/Mutationの定義と各Fieldの型定義から始め、次にモックレスポンスの開発、最後に各Resolverの本実装という手順で進めていきました。モックレスポンスを用意しておくことで、GraphQLサーバーより後ろのAPIの実装を待つことなくフロントエンドの開発を進めることができます。
また、今回の開発ではコードファーストにGraphQLの型ファイルとTypeScriptの型定義ファイルを生成できる Nexus を採用しました。Nexus を使うとコードから GraphQL のスキーマ定義と、TypeScript の型定義ファイルを生成することができます。実際のコードから抜粋したものが下記になります。

import { objectType, queryField, intArg } from "nexus"

export const Project = objectType({
name: "Project",
definition(t) {
t.int("id");
t.string("category");
t.string("format");
t.string("state");
t.string("title", { nullable: true });
t.list.field("summaryTags", {
type: ProjectSummaryTag, // この型は別途定義必要
nullable: true,
async resolve(root, _args, { api: { visitApi } }, info) {
const req = visitApi.requestCreator.apiV2ProjectsIdSummaryTagsGet(root.id.toString(), { info });
const res = await visitApi.fetchWithGql(req, { info });
return res.data;
},
});
},
});

export const project = queryField("project", {
type: "Project",
nullable: false,
args: {
id: intArg({ nullable: false }),
},
authorize: authorizeResolver("user"),
async resolve(_root, { id }, { api: { visitApi } }, info) {
const request = visitApi.requestCreator.apiV2ProjectsIdGet(id.toString(), {
query: {
fields: ["wanted_tags.name", "raw_looking_for"],
include: ["wanted_tags"],
},
});
const res = await visitApi.fetchWithGql(request, { info, exclude: /summary_tags/ });
return res.data; // 注目ポイント②
},
});

上記のコードから下記のGraphQLスキーマ定義が生成されます。

type Project {
category: String!
format: String!
id: Int!
state: String!
summaryTags: [ProjectSummaryTag!]
title: String
}

type Query {
project(id: Int!): Project!
}

さらに嬉しいことに対応したTypeScriptの型定義ファイルも生成されます。下記は生成されたものの抜粋です。)

export interface NexusGenRootTypes {
Project: { // root type
category: string; // String!
format: string; // String!
id: number; // Int!
state: string; // String!
// 注目ポイント①
title?: string | null; // String
}
}

export interface NexusGenFieldTypes {
Project: { // field return type
category: string; // String!
format: string; // String!
id: number; // Int!
state: string; // String!
summaryTags: NexusGenRootTypes['ProjectSummaryTag'][] | null; // [ProjectSummaryTag!]
title: string | null; // String
}
}

export interface NexusGenArgTypes {
Query: {
project: { // args
id: number; // Int!
}
}
}

ここで注目なのが、Nexusにより生成されたTypeScriptの型ファイル中で、NexusGenRootTypes["Project"]["summaryTags"] がないことです。 (注目ポイント①)
これは Project の type 定義のところで、"summaryTags" は個別の resolve 定義を記述しているためです。project の resolver は GraphQL の型の世界の Project、つまり TypeScript の世界の NexusGenRootTypes["Project"]を返却するように期待されます。
もし NexusGenRootTypes["Project"] に "summaryTags" が定義されているとこの resolver の型エラーが起こってしまいます。(summaryTagsを返してほしいのに、return する値の中になかったら不一致でエラーになってしまう。)
ある Query でデータを返す際、メインのデータはサービスAから取得するが、特定のフィールドはサービスBから取得したい、というユースケースがあると思います。そのあたりのことが考慮された良い型が生成されるのが Nexus の賢いところです。

前述の通り、GraphQL を使うとリクエスト・レスポンスに関わるオブジェクト全てのフィールドに型が定義できるというものがありますが、それは実際にAPIコールをするクライアントが受けられる恩恵で、開発時には型チェックは動きません。頑張ってスキーマファイルを定義していても、開発時に誤って型の違う値を返すようなコードを書いてしまうということはありえます。しかし Nexus を使えばスキーマファイル・TSの型定義ファイルが生成され、すべてのフィールドに対しての型チェックが開発時に働くため、型が違うことによるバグの混入を未然に防ぐことができます。
VSCode など TS に強いテキストエディタを使うとこの型チェックをリアルタイムに行ってくれるので非常に助かります。

また、スキーマファーストな開発については先日開催された Builderscon で弊社の南が発表した「Web API に秩序を与える Protocol Buffers / Protocol Buffers for Web API #builderscon」と密接に関係している内容ですので、そちらのスライドも合わせて見てもらえるとより理解が深まるかと思います。


サービスをまたいだ型の保持

Nexus により、GraphQLサーバー内での型の安全性は確保できました。これをさらに強化するために、バックエンドのAPIの定義ファイルからAPIのクライアントファイル生成と、TSの型定義生成を実施しています。前述したとおり、今回導入したGraphQLのバックエンドとなるAPIサーバーは2つあります。

  • Wantedly visit のメインサービスのAPIサーバー (Restful API)
  • 募集作成に関わるAPIサーバー (protobuf over HTTP)

そしてこれらはそれぞれAPIの定義を記述した swagger ファイルと、proto ファイルを持っています。
これらを利用して swagger ファイルからは openapi-generator を通してクライアントクラスと型定義ファイルを、proto ファイルからは grpc tool を通してクライアントクラスと型定義ファイルを生成しています。

# Swagger ファイルからのクライアントクラスと型定義ファイルの生成
yarn openapi-generator generate \
-t /path/to/template \
-l typescript-axios \
-c /path/to/config
-i /path/to/swagger_file
-o /path/to/output

# proto ファイルからのクライアントクラスと型定義ファイルの生成
yarn grpc_tools_node_protoc \
--plugin="protoc-gen-ts=node_modules/.bin/protoc-gen-ts" \
--js_out=import_style=commonjs,binary:/path/to/js-output \
--ts_out=/path/to/ts-output \
/path/to/proto-file

これによりバックエンドからのレスポンスをGraphQLのレスポンスとしてマッピングする際に、型の不整合チェックやフィールドの過不足チェックなどができるようになりました。

バックエンドからGraphQLサーバーまでの型の安全性が担保することができるようになりましたが、もちろんこの型定義はフロントでも共有したい情報です。こちらは Apollo の機能を使って実現しています。フロントエンドから、GraphQLサーバーに対して Introspection というタイプのリクエストを投げることにより、GraphQLサーバーからスキーマファイルをダウンロードすることができます。

$ apollo client:download-schema --endpoint ${GRAPHQL_ENDPOINT}

さらに Apollo を使って、このスキーマファイルとソースコード内に書かれた Query/Mutation から TS の型定義ファイルを生成を行うことができます。

$ apollo client:codegen --localSchemaFile=/path/to/schema_file --target=typescript --includes='./src/**/*.{ts,tsx,graphql}' --watch

watch オプションをつけることで、ソースコード内のQueryを書き換えると即座に型定義がupdateされます。ローカルでの開発時はこれを常時実行させておくことで型ファイル生成を意識することなく開発することができます。開発体験としては非常に良いです。

ここまで実行したことで、バックエンドのAPIサーバー - GraphQLサーバー - フロントエンドまでのサービス上でAPIの型定義を共有することができました。
型があることで、IDEのサポートを受けられることで開発体験も良くなりましたし、各値のnullチェックなども厳密になりバグの混入を未然に防ぐことにつながっていると思います。

Front側の実装

Front側はReactを採用しています。今回新たに開発した募集作成画面では全面的に hooks を採用しています。ちょうど開発を始めた頃に react-hooks が入った React 16.8.0 がリリースされたためです。
GraphQL の query/mutation の呼び出しにも hooks を使っています。 (react-apollo-hooks を使用。現在 @apollo/react-hooks への移行を計画中)
状態管理に関しても Redux を使わず、apollo-link を使った状態管理を採用しています。
Query の呼び出しには、各 component で fragment を定義し、親 component で query として集約し、useQuery を使ってデータ取得するという設計をしています。

const projectFragment = gql`
fragment ProjectFragment on Project {
state
...ProjectStepFragment
}
${projectStepFragment}
`;

const projectEditPageQuery = gql`
query ProjectEditPageQuery($id: Int!) {
projectToEdit(id: $id) {
state
...ProjectFragment
}
}
${projectFragment}
`;

const ContainerComponent = () => {
const { data, loading, refetch } = useQuery<ProjectEditPageQuery, ProjectEditPageQueryVariables>(
projectEditPageQuery,
{
variables: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: projectId!,
},
skip: !projectId,
}
}

useQuery の generics に渡している型はコード中の query/fragment などから生成された型です。
apollo によってスキーマ定義とqueryから型が生成されるため、各Componentではその型をpropsとして定義し、親から型情報を保持したまま安全に受け取ることができます。
フロントエンドの詳しい実装や hooks に関しての知見はまた別のblogが投稿されると思います…!

まとめ

Wantedly Visit のマイクロサービス化を促進するために GraphQL サーバーを導入し、その開発にはコードファーストで開発ができる nexus を採用しました。さらに、swagger や protobuf からコード生成することで型の安全性を高めています。現在他の機能のリニューアルも進めていますが、同じようにコード生成や型ファイル生成ができることで開発スピードも上がっているように感じています。
GraphQL を使った開発ではまだまだ試行錯誤することもあり大変ですが、それが楽しい部分でもあります。GraphQL 開発の知見を共有できればと思いますので、興味のある方是非社内の勉強会などでお話しましょう!

Wantedly, Inc.的招募
29 Likes
29 Likes

本週排名

展示其他排名