23
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

GraphQLAdvent Calendar 2018

Day 17

GAE GoとDatastoreでGraphQL APIを開発する

Posted at

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ではプラットフォームのことはあまり意識することなく普通にサーバーを実装すればよいです。
にもかかわらず、必要な時、必要な分だけ高速に起動するので最高です。

主なライブラリ

使用した主なライブラリは以下のとおりです。

Go言語用のGraphQLライブラリには大きく2つのアプローチがあるようです。

  • コードからスキーマを生成する(リフレクション)
  • スキーマからコードを生成する

両方試してみたいですが、今回試したライブラリは前者です。
ただし、実際試してみて、後者のアプローチのほうが良さそうな印象を持ちました。Goではコードジェネレーションが主流な印象がありますし、どうしても空インターフェースや型アサーションに依存する箇所が多くなりがちだからです。
今度は、後者のアプローチ(99designs/gqlgenvektah/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. スキーマを定義する
  2. リゾルバーを実装する
  3. エンドポイント(サーバー)を実装する

それでは、順番に見ていきましょう。

1. スキーマを定義する

スキーマはGoの構造体で表現します。
SchemaConfigQueryMutationを定義していきます。
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を使うと簡単にgraphiqlplaygroundの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通信していて、シーケンシャルに動作していることが分かりました。

slow-trace.png

これは、他の記事でも言及されているN+1問題に関係があります。

N+1問題

GraphQLがリゾルバーを個別にかつ再帰的に実行していくため発生する問題です。
要するに複数のN個のデータをまとめてINSERRTすればいいのに、1個ずつN回のINSERTが実行されるといったことが容易に発生します。

処理時間も長くなってしまいますし、何度も通信してしまうのは避けたいところです。
GraphQLでは遅延評価することでこの問題を解決するようです。

遅延評価

N回繰り返しているSQLを1つにまとめる必要があります。
リゾルバーですぐに評価せず、一旦ためて後でまとめて評価することでN+1問題を解決します。

FacebookがJavaScript向けのライブラリを公開していますが、
今回はGo実装であるgraph-gophers/dataloaderを使用します。
このライブラリによってDatastoreでGetPutではなくGetMultiPutMultiを使って一括処理ができるようになります。

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が複数同時に呼び出されています。
処理時間はだいたい半分になりました。
複雑なリクエストであれば効果はかなり大きいはずです。
fast-trace.png

やりのこしたこと

Datastoreを使ってRelay-Style Cursor Paginationを実装してみたかったのですが時間切れです。

まとめ

GAEとDatastoreを使ってGraphQL APIを実装してみました。
Datastoreは、並行処理も気軽にできるしRDBよりも相性良いのではと感じました。
Stackdriver Traceを使えばパフォーマンスチューニングも容易にできますし、非常に開発しやすいです。

GraphQLやApp Engineに興味のある方は是非遊んでみてください ❗

誤りやアドバイス等ありましたらコメントお願いしますm(_ _)m

23
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?