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