【LT参加レポート】GoによるGraphQL実装

はじめまして。株式会社カミナシでアプリケーションエンジニアをやってる keinuma です。

カミナシではAPIの開発にGo言語を使用しています。自分はGraphQLが好きなのですがこれまでGoのライブラリを利用してGraphQLランタイムを実装したことがありませんでした。なのでGoのライブラリの一つであるgqlgenを利用してサンプルアプリケーションを実装してみました。

今回は勉強会で発表した内容を編集して書いていきます。

speakerdeck.com

※ただし書き
カミナシのプロダクトでは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コードにスキーマ情報を記述するコードファーストのライブラリとスキーマからコードを出力するスキーマファーストのライブラリに別れます。

コードファースト

スキーマファースト

今回はスキーマファーストの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ライブラリにおけるサポート一覧が紹介されています。

gqlgen.com

他のライブラリに比べて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の実装例

今回実装したコードは以下のリポジトリにまとめています。

github.com

コンセプト

開発者のマッチングアプリを想定して開発しています。 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層のアーキテクチャを採用しています。 リクエストからレスポンスまでの流れを図に記します。

f:id:kaminashi-developer:20201013224246p:plain

ドメインの3層に加えてリクエストを受け取る controller, レスポンスのデータ整形を行う presenter, データベースの実行を担う infra を追加しました。 ドメインロジックは domain/modeldomain/service に寄せて、データ取得部分のインターフェースを domain/repositoryに定義して infra に実体を記述するようにしています。

gqlgenとの結合

gqlgenが生成するファイルを先ほどのアーキテクチャに以下のように組み込みました。

f:id:kaminashi-developer:20201013224218p:plain

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を分割しつつスケールしやすい設計を考えていきたいです。