はじめまして。株式会社カミナシでアプリケーションエンジニアをやってる keinuma です。
カミナシではAPIの開発にGo言語を使用しています。自分はGraphQLが好きなのですがこれまでGoのライブラリを利用してGraphQLランタイムを実装したことがありませんでした。なのでGoのライブラリの一つであるgqlgenを利用してサンプルアプリケーションを実装してみました。
今回は勉強会で発表した内容を編集して書いていきます。
※ただし書き
カミナシのプロダクトではGraphQLを使っていません。
サービスサイトではGatsbyを使っていてこちらについてのまとめは後日公開予定です。
GraphQLの実装手段
BaaS
GraphQLはクライアントの柔軟性が高い分、ランタイムが複雑になりがちです。 そのため実装するときはGraphQLランタイムをBackend as a Service(BaaS)に任せるか自らライブラリを利用して実装するか選択します。
現在主要なBaaSは以下になります。
- AWS AppSync
- AWSが提供しているGraphQLのマネージドサービス
- バックエンドにDynamoDB, Lambda, HTTPなど複数のサービスを連携できる
- Hasura
- Herokuを利用したPostgresQLによるGraphQLマネージドサービス
- データベースのテーブル定義に応じてGraphQL APIを生成できる
BaaSを利用する場合はGraphQLランタイムの実装コストを抑えられるためスキーマ定義がシンプルな場合は有効な手段です。 ただBaaSによってはFragmentなどの機能に対応していないためGraphQLの機能を十分に活用したいときは自前でランタイムを実装します。
ライブラリ
Goにおける主要なGraphQLライブラリはGraphQL公式ドキュメントにまとめられています。 ライブラリはGoコードにスキーマ情報を記述するコードファーストのライブラリとスキーマからコードを出力するスキーマファーストのライブラリに別れます。
コードファースト
- graphql-go/graphql
- GraphQL Founderが提供している graphql-jsのGoバージョン
- スター数が多く、扱われている記事も多いため詰まったときに解決しやすい
- samsarahq/thunder
- Goのstructをもとにスキーマ定義するライブラリ
スキーマファースト
- graph-gophers/graphql-go
- スキーマとコードの整合性を実行時に行う
- 99designs/gqlgen/
今回はスキーマファーストのgqlgenを採用しました。
gqlgenの使い方
gqlgenはスキーマを定義してCLIを実行する流れで開発します。
CLIは設定ファイルをもとにコードを出力します。
schema: - ./*.graphql # GraphQL Schemaファイルを指定 resolver: layout: follow-schema dir: graphql package: graphql filename_template: "{name}.resolvers.go" exec: filename: graphql/generated/generated.go package: generated models: User: model: github.com/keinuma/hogehoge/model.User
CLIを実行すると3種類のファイルが生成されます。
model.go
: スキーマに定義されているデータファイルresolver.go
: リクエストを受け取りレスポンスを整形するファイルの出力設定generated.go
: GraphQLオブジェクトからResolverへの変換を実装(編集しない)
modelは設定ファイルのUserのようにマッピング先を記述すると既存のGoファイルとの整合性チェックだけ行われます。
resolver.go
は関数の定義だけ実装され中身はpanic
が入っているので開発者が編集する必要があります。
gqlgenの採用理由
今回は2つの観点でgqlgenを採用しました。
- GraphQLのサポート範囲が広い
- 型安全
それぞれ紹介していきます。
GraphQLのサポート範囲が広い
gqlgenのドキュメントにてGoのGraphQLライブラリにおけるサポート一覧が紹介されています。
他のライブラリに比べてEnumやInputなどコード生成のサポート範囲が広く、昨年公開されたApollo Federationもサポートされています。 GraphQL自体新しい機能が追加されていくので、今後もgqlgenのサポート範囲が拡大していくことを期待できます。
型安全
CLI実行時にスキーマ情報からGoコードとマッピングします。そのため型が不明確な interface{}
が発生しません。
さらにスキーマとコードに差分が発生する場合は、差分を埋めるためのコードが生成されます。
例えばスキーマとGoが以下のように定義されているとします。
# GraphQL type Todo { id: ID! text: String! done: Boolean! }
// Go type Todo struct { id: string text: string }
GraphQLにはTODOの done
プロパティが追加されていますが、Goのstructには含まれていません。
この場合CLIを実行すると以下のようなコードが出力されます。
func (t *TodoResolver) Done(ctx context.Context, *model.Todo) (bool, error) { // 編集する panic("panic") }
structの記述漏れの場合はstructを編集してCLIを再実行することで上の出力はなくなり、structに done
が含まれない場合は既存のプロパティをもとに出力された上のResolverに done
を定義できます。
スキーマとGo間の整合性を担保できることでIDEのサポートも効くようになり開発しやすくなります。
gqlgenの実装例
今回実装したコードは以下のリポジトリにまとめています。
コンセプト
開発者のマッチングアプリを想定して開発しています。 Schema定義を一部抜粋します。
type Story { id: Int! title: String! user: User! } type Match { id: Int! story: Story! date: Time! attendees: [User!]! } type User { id: Int! uid: String! name: String! description: String } type Query { getStories: [Story!]! getMatches: [Match!]! } type Mutation { createStory(input: NewStory!): Story! createUser(input: NewUser!): User! createMatch(input: NewMatch!): Match! }
ユーザーが Story
というイベントを作成し気になったユーザーが参加することで Matching
オブジェクトが生成される構造です。
データベースにMySQL + GORM、WebフレームワークにEchoを使いました。
設計思想
設計のベースにドメイン3層のアーキテクチャを採用しています。 リクエストからレスポンスまでの流れを図に記します。
ドメインの3層に加えてリクエストを受け取る controller
, レスポンスのデータ整形を行う presenter
, データベースの実行を担う infra
を追加しました。
ドメインロジックは domain/model
と domain/service
に寄せて、データ取得部分のインターフェースを domain/repository
に定義して infra
に実体を記述するようにしています。
gqlgenとの結合
gqlgenが生成するファイルを先ほどのアーキテクチャに以下のように組み込みました。
GraphQLのResolver部分を controller
に、Typeを domain/model
, Input Typeを presenter/input
に出力しています。
Story
作成のResolverは以下のように記述しました。
func (r *mutationResolver) CreateStory(ctx context.Context, input request.NewStory) (*model.Story, error) { storyPresenter := presenter.NewStory(*service.NewStory(gateway.NewStory(ctx, r.DB))) story, err := storyPresenter.CreateStory(input) if err != nil { return nil, err } return story, nil }
GraphQLのInput Typeを受け取り、各層を初期化・実行してレスポンスを返しています。
domain
は以下のようになります。
# model type Stories []*Story type Story struct { ID int `json:"id"` Title string `json:"title"` User *User `json:"user"` }
# service import ( "github.com/keinuma/tech-story/domain/model" "github.com/keinuma/tech-story/domain/repository" ) type StoryService interface { GetStories(limit, offset int) (*model.Stories, error) CreateStory(story model.Story) (*model.Story, error) } type Story struct { storyRepository repository.StoryRepository } func NewStory(storyRepository repository.StoryRepository) *Story { return &Story{ storyRepository: storyRepository, } } func (s *Story) CreateStory(input model.Story) (*model.Story, error) { story, err := s.storyRepository.CreateStory(input) if err != nil { return nil, err } return story, err }
package repository import "github.com/keinuma/tech-story/domain/model" type StoryRepository interface { GetStories(limit, offset int) (*model.Stories, error) CreateStory(story model.Story) (*model.Story, error) }
domain/model
にてGraphQLのデータ型とマッピングさせつつ domain/service
, domain/repository
に外部処理を実装しています。
最後にdomain/repository
の詳細を実装している infra
は以下のようになります。
package gateway import ( "github.com/keinuma/tech-story/domain/model" "github.com/keinuma/tech-story/infra/database/dao" ) type Story struct { ctx context.Context tx *gorm.DB } func NewStory(ctx context.Context, tx *gorm.DB) *Story { return &Story{ ctx: ctx, tx: tx, } } func (s *Story) CreateStory(story model.Story) (*model.Story, error) { var daoStory dao.Story daoStory = daoStory.ToDAO(story) if err := s.tx.Create(&daoStory).Error; err != nil { return nil, errors.New("[gateway.CreateStory] failed create story") } entityStory, err := daoStory.ToEntity() if err != nil { return nil, err } return entityStory, nil }
GraphQL Resolverから infra
を利用することで依存を逆転させてます。
まとめ
gqlgenを使ったGraphQLランタイムの実装を紹介しました。 今回はgqlgenの生成物を設計に組み込むことで責務を分離してGraphQLを実装しています。 今後はResolverの実装が肥大化しやすいので、Resolverを分割しつつスケールしやすい設計を考えていきたいです。