Google Cloud Platformのマネージドサービスを使ってGraphQL APIを開発してみました。GraphQLについては初心者でしたので、実装しながらGraphQLについて学んだことを記録します。
利用した技術
- 
App Engine SE Go 1.11
Go 1.11が11月にβリリース
2nd genと呼ばれる次世代ランタイム - 
Cloud Datastore
スケーラビリティの高いNoSQLデータベース - 
Stackdriver
Stackdriver Trace for Goを使いたかったため使用 
個人で使う分には無料でいけます。動作も速い!!
GAEの2nd genではプラットフォームのことはあまり意識することなく普通にサーバーを実装すればよいです。
にもかかわらず、必要な時、必要な分だけ高速に起動するので最高です。
主なライブラリ
使用した主なライブラリは以下のとおりです。
- 
googleapis/google-cloud-go
GCPが提供するSDK - 
graphql-go/graphql
Golang向けのGraphQLライブラリ - 
graph-gophers/dataloader
リゾルバーを遅延評価してバッチ処理するためのライブラリ(後述) 
Go言語用のGraphQLライブラリには大きく2つのアプローチがあるようです。
- コードからスキーマを生成する(リフレクション)
 - スキーマからコードを生成する
 
両方試してみたいですが、今回試したライブラリは前者です。
ただし、実際試してみて、後者のアプローチのほうが良さそうな印象を持ちました。Goではコードジェネレーションが主流な印象がありますし、どうしても空インターフェースや型アサーションに依存する箇所が多くなりがちだからです。
今度は、後者のアプローチ(99designs/gqlgenとvektah/dataloadenの組み合わせ)も試してみたいと思います。
なお、今回の記事では、GraphQLを理解することとGAEで動かすことを主眼に置いているので、このあたりについては深く言及しません。
つくってみたもの
コードはGitHubで公開しています。
ブログ投稿APIを題材にしました。題材に特に大きな意味はありません。
Datastoreでは構造体の定義がそのままスキーマになります。
Datastoreには外部キーといった概念はないので、Blog構造体にUser構造体のIDフィールドと同じ値であるUserIDフィールドを持たせることで関連を表現しています。
ユーザーが複数のブログを持つという単純な1対多の関係です。
type User struct {
	ID    string `json:"id" datastore:"-"`
	Name  string `json:"name"`
	EMail string `json:"email"`
}
type Blog struct {
	ID        string    `json:"id" datastore:"-"`
	UserID    string    `json:"userId"`
	CreatedAt time.Time `json:"createdAt"`
	Title     string    `json:"title"`
	Content   string    `json:"content"`
}
type BlogList struct {
	Nodes      []Blog `json:"nodes"`
	TotalCount int    `json:"totalCount"`
}
開発の流れ
ざっくり以下の手順です。
- スキーマを定義する
 - リゾルバーを実装する
 - エンドポイント(サーバー)を実装する
 
それでは、順番に見ていきましょう。
1. スキーマを定義する
スキーマはGoの構造体で表現します。
SchemaConfigにQueryやMutationを定義していきます。
https://github.com/monmaru/gae-graphql/blob/master/application/gql/scheme.go#L9-L13
func NewSchema(ur repository.UserRepository, br repository.BlogRepository) (graphql.Schema, error) {
	resolver := newResolver(ur, br)
	return graphql.NewSchema(graphql.SchemaConfig{
		Query:    newQuery(resolver),
		Mutation: newMutation(resolver),
	})
}
Mutationの例です。やることはQueryの場合でも同じです。
必要なFieldを定義していきます。
今回は、ユーザーを作成するcreateUserとブログを投稿するcreateBlogです。
TypeにはAPIで扱うデータを定義し、Argsにはリクエストから受け取るパラメータを定義します。
Resolveには、Fieldを解決するために実行する関数を渡します。(後述)
https://github.com/monmaru/gae-graphql/blob/master/application/gql/mutation.go#L6-L29
func newMutation(r resolver) *graphql.Object {
	return graphql.NewObject(graphql.ObjectConfig{
		Name: "Mutation",
		Fields: graphql.Fields{
			"createUser": &graphql.Field{
				Type:        newCreateUserInputType(),
				Description: "Add a user",
				Args: graphql.FieldConfigArgument{
					"name":  &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
					"email": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
				},
				Resolve: r.createUsersBatch,
			},
			"createBlog": &graphql.Field{
				Type:        newCreateBlogInputType(),
				Description: "Add a blog",
				Args: graphql.FieldConfigArgument{
					"userId":  &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
					"title":   &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
					"content": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
				},
				Resolve: r.createBlogsBatch,
			},
		},
	})
}
2. リゾルバーを実装する
単純な例で説明します。
リゾルバーは普通に実装すればよくてgraphql.ResolveParamsからcontextや必要なパラメータを取り出してDatastoreにぶん投げるだけです。
RDBで言うところのINSERT文を実行しています。
このあたりで空インターフェースや型アサーションに頼るところが少々辛いところです。
https://github.com/monmaru/gae-graphql/blob/master/application/gql/resolver.go#L93-L103
func (r *graphQLResolver) createUser(params graphql.ResolveParams) (interface{}, error) {
	ctx := params.Context
	defer log.Duration(ctx, time.Now(), "[graphQLResolver.createUser]")
	name, _ := params.Args["name"].(string)
	email, _ := params.Args["email"].(string)
	user := &model.User{
		Name:  name,
		EMail: email,
	}
	return r.ur.Create(ctx, user)
}
3. エンドポイント(サーバー)を実装する
エンドポイントは一つ定義すればよいです。
リクエストボディを全部文字列として読み込んで、Schemaと一緒にgraphql.Doに渡します。
最後に、受け取った結果をJSONで返します。
以上!!
http.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) {
    body, err := ioutil.ReadAll(r.Body)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
    result := graphql.Do(graphql.Params{
        Schema:        schema,
        RequestString: string(body),
    })
    if len(result.Errors) > 0 {
        fmt.Printf("wrong result, unexpected errors: %v", result.Errors)
    }
    json.NewEncoder(w).Encode(result)
})
また、graphql-go/handlerを使うと簡単にgraphiqlやplaygroundのWebページを簡単につくれます。
https://github.com/monmaru/gae-graphql/blob/master/interfaces/handler/graphiql.go
動きを追ってみよう
2人のユーザー情報とユーザーが投稿した記事を取得してみます。
{
  u1: user(id: "5635703144710144") {
    name
    posts(limit: 3) {
      nodes {
        title
        createdAt
      }
    }
  }
  u2: user(id: "5153049148391424") {
    name
    posts(limit: 3) {
      nodes {
        title
      }
    }
  }
}
無事動きました 🎉🎉
{
  "data": {
    "u1": {
      "name": "Jiro",
      "posts": {
        "nodes": [
          {
            "createdAt": "2018-12-16T06:19:15.683822Z",
            "title": "pogeff"
          },
          {
            "createdAt": "2018-12-16T06:19:15.68376Z",
            "title": "mogefff"
          },
          {
            "createdAt": "2018-12-16T06:19:15.683629Z",
            "title": "hogeff"
          }
        ]
      }
    },
    "u2": {
      "name": "Taro",
      "posts": {
        "nodes": []
      }
    }
  }
}
さて、内部的にはどのように処理しているのでしょうか 🤔
Stackdriver Traceを使って見てみます。
Stackdriver Trace
2nd genだと自動で収集してくれないのでちょっと実装が必要です。
まずExporterを登録します。
// Stackdriver Trace
exporter, err := stackdriver.NewExporter(stackdriver.Options{
    ProjectID: projID,
})
if err != nil {
    log.Fatal(err)
}
trace.RegisterExporter(exporter)
Propagationを設定してサーバーを起動します。
server := &http.Server{
    Addr: fmt.Sprintf(":%s", port),
    Handler: &ochttp.Handler{
        Handler:     router,
        Propagation: &propagation.HTTPFormat{},
    },
}
こんな感じのコードを追加してデプロイすると、どのように動いているか分かります。
4回ほどDatastoreとRPC通信していて、シーケンシャルに動作していることが分かりました。
これは、他の記事でも言及されているN+1問題に関係があります。
N+1問題
GraphQLがリゾルバーを個別にかつ再帰的に実行していくため発生する問題です。
要するに複数のN個のデータをまとめてINSERRTすればいいのに、1個ずつN回のINSERTが実行されるといったことが容易に発生します。
処理時間も長くなってしまいますし、何度も通信してしまうのは避けたいところです。
GraphQLでは遅延評価することでこの問題を解決するようです。
遅延評価
N回繰り返しているSQLを1つにまとめる必要があります。
リゾルバーですぐに評価せず、一旦ためて後でまとめて評価することでN+1問題を解決します。
FacebookがJavaScript向けのライブラリを公開していますが、
今回はGo実装であるgraph-gophers/dataloaderを使用します。
このライブラリによってDatastoreでGetやPutではなくGetMultiやPutMultiを使って一括処理ができるようになります。
dataloaderを使ってみる
dataloaderを使うには以下の実装が必要です。
- バッチ処理用の関数を定義
 - リゾルバーを実装
 - 
dataloader.Loaderを生成 
バッチ処理用の関数を定義
dataloader.Keysからパラメータのリストを受け取り、まとめてDatastoreに渡します。
処理結果もリストとして[]*dataloader.Resultにつめて返します。
https://github.com/monmaru/gae-graphql/blob/master/application/gql/batch_functions.go#L23-L51
return func(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {
	defer log.Duration(ctx, time.Now(), "[GetUsersBatchFunc]")
	var strIDs []string
	for _, key := range keys {
		strID, ok := key.Raw().(string)
		if !ok {
			return handleError(ctx, errors.New("Invalid key value"))
		}
		strIDs = append(strIDs, strID)
	}
	users, err := ur.GetMulti(ctx, strIDs)
	if err != nil {
		log.Errorf(ctx, err.Error())
		return handleError(ctx, err)
	}
	var results []*dataloader.Result
	for _, user := range users {
		result := dataloader.Result{
			Data:  user,
			Error: nil,
		}
		results = append(results, &result)
	}
	log.Infof(ctx, "[GetUsersBatchFunc] batch size: %d", len(results))
	return results
}
リゾルバーを実装する
リゾルバーは以下のように実装します。
contextからLoaderを取り出します。
受け取ったパラメータをKeyにつめてLoaderに渡します。
この時点では、Datastoreとの通信は発生せずサンクとしてためられていきます。先に説明したバッチ処理用の関数でまとめて処理されます。
func (r *graphQLResolver) getUsersBatch(params graphql.ResolveParams) (interface{}, error) {
	ctx := params.Context
	defer log.Duration(ctx, time.Now(), "[graphQLResolver.getUsersBatch]")
	strID, ok := params.Args["id"].(string)
	if !ok {
		return nil, errors.New("invalid id")
	}
	key := newGetUserKey(strID)
	v := ctx.Value(GetUsersKey)
	loader, ok := v.(*dataloader.Loader)
	if !ok {
		return nil, errors.New("loader is empty")
	}
	thunk := loader.Load(ctx, key)
	return func() (interface{}, error) {
		return thunk()
	}, nil
}
dataloader.Loaderを生成
いろいろと順番前後していますがdataloader.Loaderに最初に説明したバッチ処理関数をわたして生成します。
Loaderにはキャッシュ機能があるので、HTTPリクエストに対して一つのLoaderが生成されるようにしています。
そのため、contextに対してLoaderをセットしています。
私はミドルウェアを実装して対応しました。
func (i *contextInjector) Inject(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := i.setupContext(r)
		defer log.Duration(ctx, time.Now(), fmt.Sprintf("[%s]", r.URL.Path))
		r = r.WithContext(ctx)
		next.ServeHTTP(w, r)
	})
}
func (i *contextInjector) setupContext(r *http.Request) context.Context {
	ctx := r.Context()
	ctx = context.WithValue(
		ctx,
		gql.GetUsersKey,
		dataloader.NewBatchedLoader(gql.GetUsersBatchFunc(i.ur)))
// 省略!!
}
並行処理でがんばる
DatastoreをGoで使う場合、クエリでOR検索できなかったりするので、遅延評価でまとめて処理しても意味がないことがあります。
そういったところではヘルパー関数を作り、goroutineで並行に処理することでパフォーマンスを向上させました。
Datastoreに対して並行にリクエストするのは定石です。
func concurrentResolve(fn graphql.FieldResolveFn) graphql.FieldResolveFn {
	return func(params graphql.ResolveParams) (interface{}, error) {
		type result struct {
			data interface{}
			err  error
		}
		ch := make(chan *result, 1)
		go func() {
			defer close(ch)
			data, err := fn(params)
			ch <- &result{data: data, err: err}
		}()
		return func() (interface{}, error) {
			r := <-ch
			return r.data, r.err
		}, nil
	}
}
もう一度Traceを見てみる
単純な例なので劇的な変化はありませんが、GetMultiによってまとめて処理されていることが分かります。
また、Query.GetAllが複数同時に呼び出されています。
処理時間はだいたい半分になりました。
複雑なリクエストであれば効果はかなり大きいはずです。

やりのこしたこと
Datastoreを使ってRelay-Style Cursor Paginationを実装してみたかったのですが時間切れです。
まとめ
GAEとDatastoreを使ってGraphQL APIを実装してみました。
Datastoreは、並行処理も気軽にできるしRDBよりも相性良いのではと感じました。
Stackdriver Traceを使えばパフォーマンスチューニングも容易にできますし、非常に開発しやすいです。
GraphQLやApp Engineに興味のある方は是非遊んでみてください ❗
誤りやアドバイス等ありましたらコメントお願いしますm(_ _)m