一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

Go + TypeScriptによるGraphQLスキーマ駆動開発

こんにちは。宿泊事業本部の宇都宮です。この記事では、GraphQLをベースに、GoとTypeScriptでスキーマを共有しながら開発を進める方法について紹介します。

この記事は 一休.com Advent Calendar 2019 の16日目の記事です。

f:id:ryo-utsunomiya:20191216113246p:plain

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 であると思われます。

GoのGraphQLライブラリの機能比較: https://gqlgen.com/feature-comparison/

このgqlgenにしても、 未実装機能がいくつもあります 。たとえば、Fragmentsはサポートされていません。

このように、ライブラリの選定に際しては、「自分たちがGraphQLによって実現したいことは何か」をまず考えた上で、その用途に合ったライブラリを選定する必要があります。

GraphQLのサーバサイド実装が最も活発なのはNode.jsのため、GraphQLを最大限に活用した開発をしたい場合は、サーバサイドにはNode.jsを選ぶのが最も無難だと思います。Apolloをはじめとして、様々なライブラリが開発されています。

一方、Node.js以外でサーバを書く場合でも、GraphQLをRESTのお手軽な代替として使いたい向きもあるでしょう。そのような場合は、ライブラリがGraphQLの仕様をどの程度実装しているか確認したほうがよいです。

コードファースト vs スキーマファースト

GraphQLのライブラリ選定において、頭を悩ませるポイントになるのが「コードファースト」と「スキーマファースト」です。

この記事によると、Node.jsのGraphQLツールでは、スキーマ定義方法に3つの世代があります。

  1. 第一世代コードファースト
  2. SDLによるスキーマファースト
  3. TypeGraphQL, GraphQL Nexus等の第二世代コードファースト

第二世代コードファーストは、第一世代コードファーストの問題点であるコードの煩雑さを解消しつつ、スキーマファーストの問題点も回避する実装です。Goのエコシステムでは、第二世代コードファーストにあたるようなライブラリは出てきていないため、第一世代コードファースト相当かスキーマファーストかの二択になります。

コードファーストでは、初めにサーバサイドの言語でスキーマの定義とスキーマ解決方法(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は、gqlgenでは以下のように書けます。

func (r *queryResolver) Hello(ctx context.Context) (string, error) {
    return "world", nil
}

このように、スキーマ定義をサーバサイド言語で書くかSQLで書くかがコードファーストとスキーマファーストとの違いです。この2つのいずれのアプローチを選ぶかで、GraphQLのツールチェインとの連携の容易さが変わってきます。

Webフロントエンドのツールチェインと簡単に連携できるのは、スキーマファーストの方です。コードファーストの場合は、スキーマを何らかの形で書き出して、フロントエンドのツールでも活用できるようにする工夫が必要でしょう。

また、Goの言語特性を考えても、リフレクションや interface{} を活用するコードファーストより、コンパイル時に型を決めてしまうスキーマファースト + go generateの方が向いていると思います。

Goによるサーバ実装

一休.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に肉付けをするだけで、GraphQLサーバが実装できます。

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サーバの監視はどうするか)。

本記事で紹介した知見にアップデートがあれば、その都度ブログで記事にしていきたいと思います。

参考文献