こんにちは。宿泊事業本部の宇都宮です。この記事では、GraphQLをベースに、GoとTypeScriptでスキーマを共有しながら開発を進める方法について紹介します。
この記事は 一休.com Advent Calendar 2019 の16日目の記事です。
GraphQLとは
GraphQLは、Facebookによって開発された、Web APIのための クエリ言語 です。その特徴もSQLに似ていて、データの取得や更新を宣言的な記述によって行うことが出来ます。
仕様は公開されており、リファレンス実装として graphql-js がありますが、それ以外にも様々な言語でGraphQLサーバを実装できます。
GraphQLでは以下のようなフォーマットで問い合わせ(query)を行います。
{ accommodation(accommodationID: "00001290") { accommodationID name } }
結果は以下のようなJSONになります。
{ "data": { "accommodation": { "accommodationID": "00001290", "name": "ザ・リッツ・カールトン東京" } }
ここで、ある施設の近隣施設(neighborhoods)を取得したい、となった場合、クエリを以下のように書き換えます。
{ accommodation(accommodationID: "00001290") { accommodationID name neighborhoods { accommodationID name } } }
レスポンスは以下のように変わります。
{ "data": { "accommodation": { "accommodationID": "00001290", "name": "ザ・リッツ・カールトン東京", "neighborhoods": [ { "accommodationID": "00002708", "name": "三井ガーデンホテル六本木プレミア" }, { "accommodationID": "00000662", "name": "グランド ハイアット 東京" } ] } } }
このように、取得したいデータの形を宣言すると、その通りに返してくれる、というのがGraphQLの特徴です。ポイントは、取得したいデータの形を決める主導権は、クライアントにある、というところ。RESTでは、各APIがリソースを表すため、複数のリソースを取得して、その取得結果を合成したい、といった場合に不便なことがありますが、GraphQLではそういった問題点が解消されています。
ライブラリの選定
GraphQLを使い始める上で最初に考慮すべきことは、「GraphQLの機能をどの程度使うか」という点です。というのも、GraphQLサーバの実装は様々にありますが、GraphQLの仕様を完全に実装しているとは限らないからです。
GoでGraphQLサーバを書くためのライブラリはさまざまにありますが、その中で最もカバー率の高いのは gqlgen であると思われます。
https://gqlgen.com/feature-comparison/
このgqlgenにしても、 未実装機能がいくつもあります 。たとえば、Fragmentsはサポートされていません。
このように、ライブラリの選定に際しては、「自分たちがGraphQLによって実現したいことは何か」をまず考えた上で、その用途に合ったライブラリを選定する必要があります。
GraphQLのサーバサイド実装が最も活発なのはNode.jsのため、GraphQLを最大限に活用した開発をしたい場合は、サーバサイドにはNode.jsを選ぶのが最も無難だと思います。Apolloをはじめとして、様々なライブラリが開発されています。
一方、Node.js以外でサーバを書く場合でも、GraphQLをRESTのお手軽な代替として使いたい向きもあるでしょう。そのような場合は、ライブラリがGraphQLの仕様をどの程度実装しているか確認したほうがよいです。
コードファースト vs スキーマファースト
GraphQLのライブラリ選定において、頭を悩ませるポイントになるのが「コードファースト」と「スキーマファースト」です。
私見ですが、GraphQLのスキーマ定義方法には3つの世代があります。
- 第一世代コードファースト
- SDLによるスキーマファースト
- GraphQL Nexus, TypeGraphQL等の第二世代コードファースト
Node.jsであれば第二世代コードファーストを採用するのもありですが、Goでは第一世代コードファーストか、SDLによるスキーマファースト の2択になります。そこで、本節では第一世代コードファーストとSDLによるコードファーストを紹介します。
コードファーストでは、初めにサーバサイドの言語でスキーマの定義とスキーマ解決方法(resolver)の実装を同時に行います。以下は graphql-go/graphqlを使った例です。
fields := graphql.Fields{ "hello": &graphql.Field{ Type: graphql.String, Resolve: func(p graphql.ResolveParams) (interface{}, error) { return "world", nil }, }, } rootQuery := graphql.ObjectConfig{Name: "RootQuery", Fields: fields}
一方、スキーマファーストは、GraphQLのスキーマ定義言語(SDL)によって先にAPIの形を決め、その後スキーマを満たすようにコードを実装していくアプローチです。↑と同等のスキーマは以下のようになります。
schema { query: Query } type Query { hello: String! }
これを満たすresolverは以下のように書けます。
func (r *queryResolver) Hello(ctx context.Context) (string, error) { return "world", nil }
このように、スキーマ定義をサーバサイド言語で書くかSQLで書くかがコードファーストとスキーマファーストとの違いです。この2つのいずれのアプローチを選ぶかで、GraphQLのツールチェインとの連携の容易さが変わってきます。
Webフロントエンドのツールチェインと簡単に連携できるのは、スキーマファーストの方です。コードファーストの場合は、スキーマを何らかの形で書き出して、フロントエンドのツールでも活用できるようにする工夫が必要でしょう。
また、Goの言語特性を考えても、リフレクションや interface{} を活用するコードファーストより、コンパイル時に型を決めてしまうスキーマファーストアプローチの方が向いていると思います。
gqlgen
一休.comでは現在、gqlgenというスキーマファーストのGraphQLサーバライブラリを使用して開発を進めています。
gqlgenの採用事例は国内でもチラホラ見かけますが、たとえば技術書典のサイトなどはgqlgenを採用しているようです。
gqlgenでは、まずGraphQLのスキーマ言語でスキーマを定義します。
schema { query: Query } type Query { accommodation(accommodationID: String!): Accommodation } type Accommodation { accommodationID: String! name: String! }
次に、 go generate ./...
で、スキーマからコードを自動生成します。
スキーマを元にGoのインタフェースを定義(generated.go):
... type ExecutableSchema interface { Schema() *ast.Schema Complexity(typeName, fieldName string, childComplexity int, args map[string]interface{}) (int, bool) Query(ctx context.Context, op *ast.OperationDefinition) *Response Mutation(ctx context.Context, op *ast.OperationDefinition) *Response Subscription(ctx context.Context, op *ast.OperationDefinition) func() *Response } ...
レスポンスに使用する型の定義(models_gen.go):
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT. package graphql type Accommodation struct { AccommodationID string `json:"accommodationID"` Name string `json:"name"` }
クエリのを解決するResolverのひな形:
package graphql //go:generate go run github.com/99designs/gqlgen type Resolver struct {} func NewResolver() *Resolver { return &Resolver{} } func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } type queryResolver struct{ *Resolver } func (r *queryResolver) Accommodation(ctx context.Context, accommodationID string) (*Accommodation, error) { return nil, nil }
あとは、resolverに肉付けをするだけ。
func (r *queryResolver) Accommodation(ctx context.Context, accommodationID string) (*Accommodation, error) { x, err := r.accommodationsRepository.Find(ctx, r.dao.Read(), accommodationID) if err != nil { return nil, err } return &Accommodation{ AccommodationID: x.AccommodationID.String(), Name: x.Name, }, nil }
このとき、GraphQLはあくまでPresentationレイヤーである、という点を意識し、resolverはドメインオブジェクトをレスポンスにマッピングする程度の仕事しかしないようにしておくのが重要だと思っています。
TypeScriptによるクライアント実装
最後に、TypeScriptによるクライアント実装を行います。ここは実際の開発では、サーバサイドの実装と平行することが多いでしょう。
GraphQLはクエリ文字列の入力を受け取り、JSONを返すので、fetchやXHRを使用した実装も可能です。しかし、GraphQLの特徴である型定義を最大限に活かすにはクライアント側にも型がほしいところ。
本節では、graphql-code-generatorを使用して、GraphQLクライアントライブラリ graphql-requestを使ったAPIクライアントを生成します。
まず、依存ライブラリを一式入れます。
yarn add graphql-request yarn add -D graphql @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-graphql-request @graphql-codegen/typescript-operations
次に、graphql-code-generatorで使う設定ファイル(codegen.yml)を用意します。
overwrite: true schema: "./api/graphql/schema.graphql" documents: - "./api/graphql/queries/*.graphql" generates: web/src/generated/graphql.ts: plugins: - "typescript" - "typescript-operations" - "typescript-graphql-request"
ここでは、スキーマの定義ファイルを /api/graphql/schema.graphql に置き、スキーマに対する操作を記述したドキュメントファイルを /api/graphql/queries/*.graphql に置いています。
ドキュメントは以下のように、実際のアプリケーションで使う操作を定義します。
query accommodation($id: String!) { accommodation(accommodationID: $id) { accommodationID name } }
この状態で yarn run graphql-codegen --config codegen.yml
を実行すると、APIクライアントが自動生成されます。
... export function getSdk(client: GraphQLClient) { return { accommodation(variables: AccommodationQueryVariables): Promise<AccommodationQuery> { return client.request<AccommodationQuery>(print(AccommodationDocument), variables); } }; }
あとはAPIクライアントを使うだけ。クエリの引数の型が間違っていたりするとコンパイルエラーになりますし、取得したレスポンスにも型がついています。
import {GraphQLClient} from "graphql-request"; import {getSdk} from "./generated/graphql"; async function main() { const client = new GraphQLClient('http://localhost:8080/graphql') const sdk = getSdk(client) const {accommodation} = await sdk.accommodation({ id: "00001290", }) console.log(accommodation.accommodationID) console.log(accommodation.name) } main()
おわりに
GraphQLを使うことで、GoとTypeScriptでスキーマを共有しながら開発を行う方法を紹介しました。これらの技術は、一休.comでもこれから本番投入、というフェーズなので、まだまだ実運用を考える上では考慮すべきポイントが残っています(たとえば、GraphQLサーバの監視はどうするか)。
本記事で紹介した知見にアップデートがあれば、その都度ブログで記事にしていきたいと思います。
参考文献
GraphQL Code-First and SDL-First, the Current Landscape in Mid-2019