2020年6月23日火曜日

フロントエンドに型の秩序を与えるGraphQLとTypeScript(コードファーストにGraphQLの型ファイルとTypeScriptの型定義ファイルを生成できる Nexus を採用) GraphQL and TypeScript that give type order to the front end (Nex is used to generate GraphQL type files and TypeScript type definition files in code first)

https://www.wantedly.com/companies/wantedly/post_articles/183567?auto_login_flag=true#_=_
シェアしました。

フロントエンドに型の秩序を与える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.では一緒に働く仲間を募集しています







 
31 いいね!






 
31 いいね!

GraphQL and TypeScript that give type order to the front end (Nex is used to generate GraphQL: GraphQL and TypeScript that gives type order to the front end (Nex is used to generate GraphQL) type files and TypeScript type definition files in code first)

https://www.wantedly.com/companies/wantedly/post_articles/183567?auto_login_flag=true#_=_
Share.

GraphQL and TypeScript to give type order to the front end

Hello. It is Hara ( chloe463 ) who is a Frontend Engineer in the Product Squad of Wantedly Visit .
Product Squad mainly develops and renews the Web version of Wantedly Visit.
In this article, I will introduce the development of the GraphQL server that was introduced in the development of the recruitment creation screen that was just released the other day.

TL;DR

  • Introduced GraphQL server to Wantedly Visit
  • We used Nexus,  which allows code-first schema definition for GraphQL server development  .
  • Generating client files and type definition files from API definitions (swagger and protobuf) ensures the type safety from backend to frontend.

Background of GraphQL server introduction as BFF

The Wantedly Visit service itself is a monolithic Rails application.
Despite regular debt repayments, long-running applications are enormous and have technical strata that reduce productivity.
Therefore, the Wantedly Visit development team is currently promoting microservices, and from the beginning of this year we have been promoting the renewal of the recruitment creation screen, which is one of the core functions of Wantedly Visit.
On the new recruitment creation screen, not only the appearance but also the technology used has been revamped. In particular...
  • The front end is SPA (New!) using React.
  • GraphQL server as BFF (New!)
  • API server related to recruitment creation (New!)
  • Wantedly Visit API Server
It became a structure.
This time, we introduced BFF (Backend For Frontend) using GraphQL as a new approach.
As a background to the introduction, for the backend API, which will continue to increase as microservices continue to be made, requests are made with a uniform interface without handling which service is requested on the frontend side. Because I wanted it. The overall picture is as shown in the figure below.

Schema-first development

First of all, as a premise, new front end development in Wantedly is done using TypeScript.
The SPA created this time also implements the React application with TypeScript. GraphQL server was developed using TypeScript and Apollo server.
A major feature of GraphQL is that you can define types for data related to API request and response . In this development, we first started with the definition of Query/Mutation and the type definition of each Field, then developed the mock response, and finally implemented this Resolver. By preparing a mock response, you can proceed with front-end development without waiting for the API implementation behind the GraphQL server.
Also, in this development,  we adopted Nexus, which can generate GraphQL type files and TypeScript type definition files in code first  With Nexus, you can generate GraphQL schema definitions and TypeScript type definition files from code. Below is an excerpt from the actual code.
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;   // 注目ポイント②
  },
});
The above GraphQL schema definition is generated from the above code.
type Project {
  category: String!
  format: String!
  id: Int!
  state: String!
  summaryTags: [ProjectSummaryTag!]
  title: String
}

type Query {
  project(id: Int!): Project!
}
The typedef file of TypeScript corresponding to the happy thing is also generated. Below is an excerpt of what was generated. )
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!
    }
  }
}
Note that  there is no NexusGenRootTypes["Project"]["summaryTags"] in the TypeScript type files generated by Nexus . (Point of attention ①)
This is because   "summaryTags" describes individual resolve definitions in the type definition of Project . The project resolver is expected to return a GraphQL type world  Project, a TypeScript world  NexusGenRootTypes["Project"] .
If the  NexusGenRootTypes["Project"]  has "summaryTags" defined, this resolver type error will occur. (I want you to return summaryTags, but if it is not in the return value, it will be an error because it does not match.)
When returning data with a certain query, the main data is obtained from service A, but certain fields are service I think there is a use case where I want to get from B. The good part of Nexus is that it produces good types that take that into account.
As mentioned above, using GraphQL, it is possible to define types for all fields of objects related to request/response, but that is a benefit that the client who actually makes the API call receives, and the type check does not work during development. .. Even if you do your best to define the schema file, it is possible that you will accidentally write code that will return different types of values ​​during development. However, if you use Nexus, a schema file and a type definition file of TS will be generated, and the type check for all fields will work during development, so you can prevent bugs from entering due to different types.
Using a text editor that is strong against TS such as VSCode will perform this type check in real time, which is very helpful.
Regarding the schema-first development, it is closely related to " Protocol Buffers / Protocol Buffers for Web API #builderscon " that we announced at the Builderscon held the other day , which gives order to the Web API . I think that you can deepen your understanding if you also see the slides of.

Maintaining cross-service types

With Nexus, we were able to ensure type safety within our GraphQL server. To further strengthen this, API client file generation and TS type definition generation are performed from the back-end API definition file. As mentioned earlier, there are two API servers that will be the GraphQL backend introduced this time.
  • Wantedly visit main service API server (Restful API)
  • API server related to recruitment creation (protobuf over HTTP)
And each of these has a swagger file that describes the API definition and a proto file.
Using these,  client class and type definition file are generated from swagger file through openapi-generator and client class and type definition file from proto file through 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
As a result, when mapping the response from the backend as a GraphQL response, you can now perform type inconsistency checks and field excess/shortage checks.
The type safety from the backend to the GraphQL server can be secured, but of course this type definition is the information that we want to share at the front as well. This is achieved by using the function of Apollo. You can download the schema file from the GraphQL server from the front end by making a request of type Introspection to the GraphQL server.
$ apollo client:download-schema --endpoint ${GRAPHQL_ENDPOINT}
In addition, Apollo can be used to generate a TS type definition file from this schema file and the Query/Mutation written in the source code.
$ apollo client:codegen --localSchemaFile=/path/to/schema_file --target=typescript --includes='./src/**/*.{ts,tsx,graphql}' --watch
By  adding the watch option, the type definition will be updated immediately when the query in the source code is rewritten. When developing locally, you can develop it without being aware of the type file generation by always executing this. It is a very good development experience.
By doing so, we were able to share the API type definition on the backend API server-GraphQL server-frontend service.
Having the type improves the development experience by getting support from the IDE, and I think that checking the null values ​​of each value becomes strict and leading to the prevention of bugs.

Front side implementation

Front side uses React. The newly created recruitment creation screen uses hooks entirely. That's because React 16.8.0 was released with react-hooks just when we started development.
We also use hooks to call GraphQL query/mutation. (Use react-apollo-hooks. Currently planning to move to @apollo/react-hooks) As for
state management, we also use state management using apollo-link instead of Redux.
When calling Query, we have defined a fragment in each component, aggregate it as a query in the parent component,  and use useQuery to obtain the data.
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,
    }
}
The type passed to generics of useQuery is the type generated from query/fragment etc. in the code.
Since apollo generates a type from the schema definition and query, each Component can define that type as props and safely receive it while retaining the type information from its parent.
I'll post another blog for more info on the frontend implementation and hooks...!

Summary

We introduced GraphQL server in order to promote the microservices of Wantedly Visit, and adopted nexus for its development that allows code-first development. In addition, code generation from swagger and protobuf improves type safety. Currently, other features are being renewed, but I feel that the speed of development has also increased by being able to generate code and type files in the same way.
Developing with GraphQL is difficult because there are still trial and error, but that is also a fun part. We would like to share the knowledge of GraphQL development, so if you are interested, let's talk about it at in-house study sessions!
Wantedly, Inc. is looking for colleagues to work with







  
31 Like!






  
31 Like!

0 コメント:

コメントを投稿