2021年1月8日金曜日

Go(言語)におけるGraphQLライブラリ大横断

https://tech.hicustomer.jp/posts/go-graphql/
シェアしました。

author icon
Posted on 
tech

この記事は GraphQL Advent Calendar 2018 の22日目です。

どうも、HiCustomerのエンジニアの@m4s4k3xです。 GraphQLが広く使われるようになって久しいですが、弊社でも、以下の理由などからGraphQLを導入することにしました。

  • BFFを立てるほどのエンジニアリソースを割かなくてよい
  • Union TypesやEnumと言ったスキーマの型表現が可能である
  • エコシステムが豊富
  • 大量のエンドポイントを増やさなくても良い
  • 一度のリクエストでフロントエンドで必要なリソースが取得可能

Goにおいての採用事例もちらほら見かけるようになりました。GoにおけるサーバーサイドにおけるGraphQLライブラリはメジャーなものが5つほどあります。各々のライブラリの横断的に紹介した後、弊社での選定理由を紹介します。

GoにてGraphQLの導入を検討している方への助けとなれば幸いです。

Goにおける主要なGraphQLライブラリ

graphql.orgの紹介によると、下記が主要なライブラリのようです。

詳細な実装方法などは、各々のドキュメントやExampleに譲るとして、俯瞰したときにどのような分類に分けられるか?、あるいは特徴が見られるか紹介します。

(便宜上、ライブラリ名の重複があるため、上述の名称で話を進行します)

1.  リフレクションベース

コンパイル時点では厳密な型付けをせずに、実行時にリフレクションベースで処理する設計のライブラリです。そのため、interface{} が大量に出現します。

1.1.graphql-go/graphql

記事執筆時点において、スター数が最も多く、ドキュメントが充実しており、日本語情報が多いライブラリです。そのため、情報量の多さからハマった場合に解決が容易です。

ライブラリの設計として、GoのコードとしてSchemaとResolverなどを定義する設計となっています。そのため、Schema定義として独立しておらず、バックエンドと密結合になり、フロントエンドとのスキーマ共有がGraphQLの形式では難しいです(以下に例を引用します)。

// define custom GraphQL ObjectType `todoType` for our Golang struct `Todo`
// Note that
// - the fields in our todoType maps with the json tags for the fields in our struct
// - the field type matches the field type in our struct
var todoType = graphql.NewObject(graphql.ObjectConfig{
	Name: "Todo",
	Fields: graphql.Fields{
		"id": &graphql.Field{
			Type: graphql.String,
		},
		"text": &graphql.Field{
			Type: graphql.String,
		},
		"done": &graphql.Field{
			Type: graphql.Boolean,
		},
	},
})

引用元 https://github.com/graphql-go/graphql/blob/master/examples/todo/main.go#L43-L56

Resolverの記述も下記のようになり、GoのStructベースのコードです。

var rootQuery = graphql.NewObject(graphql.ObjectConfig{
	Name: "RootQuery",
	Fields: graphql.Fields{

		/*
		   curl -g 'http://localhost:8080/graphql?query={todo(id:"b"){id,text,done}}'
		*/
		"todo": &graphql.Field{
			Type:        todoType,
			Description: "Get single todo",
			Args: graphql.FieldConfigArgument{
				"id": &graphql.ArgumentConfig{
					Type: graphql.String,
				},
			},
			Resolve: func(params graphql.ResolveParams) (interface{}, error) {

				idQuery, isOK := params.Args["id"].(string)
				if isOK {
					// Search for el with id
					for _, todo := range TodoList {
						if todo.ID == idQuery {
							return todo, nil
						}
					}
				}

				return Todo{}, nil
			},
		},
...

引用元 https://github.com/graphql-go/graphql/blob/master/examples/todo/main.go#L135-L189

1.2.graphql-relay-go

名前の通り、GraphQLのRelay対応を謳ったライブラリです。 Schema定義はGoのStrutベースで行い、その後地道にQuery, Mutaionを実装していく設計になっています。

欠点として、どうしてもinterface{}多くなってしまい、且つボイラープレートが多くなってしまう点です。 また、2017/12/8以降更新メンテンスがされていない模様です(記事執筆時点)

下記のチュートリアルが一連の流れを掴みやすいです。

Learn Golang + GraphQL + Relay #2
https://wehavefaces.net/learn-golang-graphql-relay-2-a56cbcc3e341

Starwars example
https://github.com/graphql-go/relay/tree/master/examples/starwars

下記Schema定義の例です。

type Ship struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

type Faction struct {
	ID    string   `json:"id"`
	Name  string   `json:"name"`
	Ships []string `json:"ships"`
}

引用元 https://github.com/graphql-go/relay/blob/master/examples/starwars/data.go

以下、Mutaionの例です。

/**
	 * This will return a GraphQLField for our ship
	 * mutation.
	 *
	 * It creates these two types implicitly:
	 *   input IntroduceShipInput {
	 *     clientMutationID: string!
	 *     shipName: string!
	 *     factionId: ID!
	 *   }
	 *
	 *   input IntroduceShipPayload {
	 *     clientMutationID: string!
	 *     ship: Ship
	 *     faction: Faction
	 *   }
	 */
	shipMutation := relay.MutationWithClientMutationID(relay.MutationConfig{
		Name: "IntroduceShip",
		InputFields: graphql.InputObjectConfigFieldMap{
			"shipName": &graphql.InputObjectFieldConfig{
				Type: graphql.NewNonNull(graphql.String),
			},
			"factionId": &graphql.InputObjectFieldConfig{
				Type: graphql.NewNonNull(graphql.ID),
			},
		},
		OutputFields: graphql.Fields{
			"ship": &graphql.Field{
				Type: shipType,
				Resolve: func(p graphql.ResolveParams) (interface{}, error) {
					if payload, ok := p.Source.(map[string]interface{}); ok {
						return GetShip(payload["shipId"].(string)), nil
					}
					return nil, nil
				},
			},
			"faction": &graphql.Field{
				Type: factionType,
				Resolve: func(p graphql.ResolveParams) (interface{}, error) {
					if payload, ok := p.Source.(map[string]interface{}); ok {
						return GetFaction(payload["factionId"].(string)), nil
					}
					return nil, nil
				},
			},
		},
		MutateAndGetPayload: func(inputMap map[string]interface{}, info graphql.ResolveInfo, ctx context.Context) (map[string]interface{}, error) {
			// `inputMap` is a map with keys/fields as specified in `InputFields`
			// Note, that these fields were specified as non-nullables, so we can assume that it exists.
			shipName := inputMap["shipName"].(string)
			factionId := inputMap["factionId"].(string)

			// This mutation involves us creating (introducing) a new ship
			newShip := CreateShip(shipName, factionId)
			// return payload
			return map[string]interface{}{
				"shipId":    newShip.ID,
				"factionId": factionId,
			}, nil
		},
	})

引用元 https://github.com/graphql-go/relay/blob/master/examples/starwars/schema.go#L270-L314

2. スキーマドリブン設計

続いて、スキーマを最初に定義し、それを元にコードを生成、あるいは実装するような設計のものです。

2.1. GQLGen

Schema定義に沿った、ボイラープレートを自動生成し、エンジニアがResolverのメインのロジックの実装に注力できるライブラリです。また、デフォルトで GraphiQL が使用可能なコードも生成してくれるため、ブラウザですばやく実行することができます。 Getting Started.comを見ると大まかな流れが把握できます。

Getting Started
https://gqlgen.com/getting-started/

また、既存コードとのヒモ付も可能で、既存システムへのGraphQL導入もスムーズです。 https://gqlgen.com/config/

懸念点としては、自動生成されたボイラープレートが生成されるため、エッジケースなどでデバッグするときに大変かもしれません。

詳細は、GQLgen自身のREADMEにある他ライブラリとの比較表が大変わかりやすいです。

https://github.com/99designs/gqlgen

TODOのExample

https://github.com/99designs/gqlgen/tree/master/example/todo

一例として、自動生成されたResolverのコードを掲載します。panic("not implemented")の箇所を実装していく流れです。

package graph

import
        "context"

        "github.com/hicustomer/hicustomer-lambda/src/mvp/variable_settings/request_handler"
)

type Resolver struct{}

func (r *Resolver) Mutation() MutationResolver {
        return &mutationResolver{r}
}
func (r *Resolver) Query() QueryResolver {
        return &queryResolver{r}
}

type mutationResolver struct{ *Resolver }

func (r *mutationResolver) CreateCustomer(ctx context.Context, input NewCustomer) (Customer, error) {
        panic("not implemented")
}
func (r *mutationResolver) CreateVariable(ctx context.Context, input request_handler.VariableInput) (string, error) {
        panic("not implemented")
}

type queryResolver struct{ *Resolver }

func (r *queryResolver) Customers(ctx context.Context, input *CustomerDetailInput) ([]*Customer, error) {
        panic("not implemented")
}
func (r *queryResolver) Customer(ctx context.Context, input *CustomerDetailInput) (*Customer, error) {
        panic("not implemented")
}
func (r *queryResolver) Variables(ctx context.Context, input request_handler.VariableListInput) (request_handler.VariableListOutput, error) {
        panic("not implemented")
}

2.2. graph-gophers/graphql-go

こちらもSchema定義を元に開発すすめるのは変わらないのですが、GQLGenと違い、ランタイム時にスキーマとResolverとの整合性との比較を行います。そのため、IDEの補完やコンパイル時にエラーの発見などはできません。

小規模なAPIであれば、こちらのほうが向いているかもしれません。

ただし、ドキュメントがないようなので、コードを読んで実装していく形になるのが難点かもしれません。 (といっても、有志によるExampleがREADMEにいくつか書いてあります)

スキーマ定義例

type human struct {
	ID        graphql.ID
	Name      string
	Friends   []graphql.ID
	AppearsIn []string
	Height    float64
	Mass      int
	Starships []graphql.ID
}

引用元 https://github.com/graph-gophers/graphql-go/blob/master/example/starwars/starwars.go#L146-L154

Resolver例

func (r *Resolver) Hero(args struct{ Episode string }) *characterResolver {
	if args.Episode == "EMPIRE" {
		return &characterResolver{&humanResolver{humanData["1000"]}}
	}
	return &characterResolver{&droidResolver{droidData["2001"]}}
}

引用元 https://github.com/graph-gophers/graphql-go/blob/master/example/starwars/starwars.go#L289-L294

2.3. samsarahq/thunder

上2つとは設計思想が違い、GoのStructをベースに実装を進める設計となっています。

ただし、実際のスキーマ定義とのズレやフロントエンドとのスキーマ共有が直接できないなどのデメリットが考えられます。

Resolver例

func (s *Server) registerMutation(schema *schemabuilder.Schema) {
	object := schema.Mutation()

	object.FieldFunc("echo", func(ctx context.Context, args struct{ Text string }) (string, error) {
		return args.Text, nil
	})

	object.FieldFunc("echoEnum", func(ctx context.Context, args struct {
		EnumField RoleType
	}) (RoleType, error) {
		return args.EnumField, nil
	})
}

引用元 https://github.com/samsarahq/thunder/blob/master/example/minimal/main.go#L70-L82

 結局どのライブラリを採用すべき?

つらつらとGoの主要の5大ライブラリを書きました。弊社では、下記の理由によりGQLGenを採用しました。

  • Type Safe(せっかくGoを使っているの)
  • IDEの補完が聞きやすい
  • 開発が活発であり、Open Tracingやdataloaderなどの対応に積極的
  • スキーマドリブンであり、自動生成に注力している

なぜスキーマドリブンが重要であるかを、アーキテクチャの変化と開発体験の変化を交えて説明します。

アーキテクチャの変化と必要とされるライブラリの変化

モノリシックなアーキテクチャが主流でしたが、現在はマイクロサービスなアーキテクチャが浸透してきました。ApiGatewayを挟んで、バックエンドでは複数のサービスが連携している構成なども見かけるようになりました。弊社でも 以下のようなサーバレスアーキテクチャを採用しております。

( プロダクトとアーキテクチャの詳しい説明はこちらの記事にて)

変化に伴い発生する課題

アーキテクチャの変化に伴い、様々な課題が顕著になってきました。

  • バックエンドとフロントエンド感のコミュニケーションコストの増加
  • APIの定義とスキーマのデグレによるバグの発生(リクエストのパラメータの齟齬によるエラーなど)
  • Schema定義を複数のフロントエンド、ないしはバックエンドで使用するために、サービスに依存せずに独立したSchema定義が必要
  • Schema定義からすばやくドキュメントが生成可能

そこで、先程紹介したスキーマドリブンの考え方を導入して、下記のようなレイヤー構造にして、課題の解決を図ります

導入前

バックエンド <- フロントエンド
(<- 依存の向き先)

導入後

バックエンド-> API定義 <- フロントエンド
(<- 依存の向き先)

(所謂、 抽象的に見れば、中間層、あるいはDDDにおける腐敗防止層的な役割を果たすレイヤですね)

開発体験(DX)の向上

スキーマドリブンにすることで下記のような開発フローです。

  1. スキーマを定義
  2. コードジェネレート
  3. 自動生成できないメインロジックの実装

課題の解決が図られ、以下のようなDX向上が見込めます。

  • ボイラーな処理を自動で行ってくれるため、ドメイン知識の実装などに注力できる
  • CIなどでSchemaと実装の乖離の発見によるバグの減少
  • Schema定義が独立しているので、フロントエンドとバックエンドが独立して開発が進められる
  • ドキュメントの生成が容易

最後に

 おおまかに主要ライブリの紹介をしました。今後は、Schemaを中心としたSchema駆動な開発が広まっていくと予想しています。実際に、gRRPC-webではTypescriptの生成機能が実装されていたり、 フロントエンド側でもコード生成のアプローチは多く採用されており、apollographql/apollo-toolingdotansimha/graphql-code-generatorなどがあります。

また、今回は考慮しませんでしたが、GraphQLを採用するときの課題として:

  • dataloaderによる n+1 の解消
  • Apollo Tracing, Open Tracingやjaegerなどの分散トレーシング

などがあります。また、AppSync や Prisma などの所謂 Baas も採用する手段もあります。

Goでは型パラメータを用いるなど、抽象的なコードが書けないため、GQLGenのようにコードジェネレートの文化が盛んに思われます。例えば、goaでは、DSLによってAPIの仕様を定義し、それをボイラーコードを生成して、必要な箇所だけをエンジニアが実装するアプローチを採用しています。

map[string]interface{}と戦いたくないのであれば、GQLGenおすすめです。

機会があれば上記の事柄も記事にできればと思います。

広告

HiCustomer ではエンジニアを募集しています。この記事を読んで少しでも興味をお持ちいただけた方、ぜひオフィスに遊びにきてください。弊社のVP of Engineeringの@hizeny まで、もしくは Wantedly 経由でご連絡をお待ちしています!

参考

GraphQL Concepts Visualized(図解されていて大変わかりやすい)

https://blog.apollographql.com/the-concepts-of-graphql-bc68bd819be3

Tutorial: Designing a GraphQL API(ベストプラクティスとして参考になる)

https://github.com/Shopify/graphql-design-tutorial/blob/master/TUTORIAL.md

Choosing a GraphQL server library in Go(シンプルに比較が書いてある)

https://medium.com/open-graphql/choosing-a-graphql-server-library-in-go-8836f893881b

GraphQL の情報を雑にまとめる(フロントエンド周りが参考になる)

https://katashin.info/2018/08/26/236

author icon
エンジニア

技術に関わる奴らは大体友達。特に、ドメイン知識を綺麗にコードにすることに幸せを感じる。直近の趣味は散歩して東京の街並みに思いをふけること。

0 コメント:

コメントを投稿