Diary of a Perpetual Student

Perpetual Student: A person who remains at university far beyond the normal period

安価に手間なく readonly な GraphQL API を作る

私は個人開発で、読み込みのみが求められる(ユーザーの行動起因でのデータ更新が起こらない) GraphQL API を作っています。情報サイトや個人ブログのバックエンドとしての利用が挙げられます。

今回は趣味の個人開発向けにインフラ費用を抑え、そして手間なく GraphQL API を構成するための流れをご紹介します。

構成のポイント

安価に手間なく readonly な GraphQL API を作るためのポイントは以下の3点です:

Go 言語の ORM である ent の GraphQL インテグレーション機能を用いる

ent という Go 言語の ORM があります。ent には GraphQL インテグレーションという機能があり、これを使うと、ent 上のスキーマ定義を用いて簡単に RDB をデータストアとした GraphQL API サーバを作れます。GraphQL のスキーマを手作りする必要もないし、リゾルバ―を実装するために必要なメソッドもコード生成されます。

entgo.io

実は、この話は過去にもブログに書いたのですが、今回は具体的なコードや手順を紹介できればと思います。

DB には SQLite を用いて、db ファイルをランタイムに同梱する

今回は前述の通り、ユーザーの行動起因でのデータ更新が起こらない API を想定しています。API サーバとは別にデータストアの RDB を用意しなければなりません。

SQLite を用いることで、 API サーバの裏に RDB サーバを用意する必要がなくなり、インフラの構成がシンプルになります。

インフラには Amazon API Gateway (HTTP API) と AWS Lambda を用いる

作った API サーバを公開・運用するためのインフラには、 Amazon API Gateway と AWS Lambda を選択します。

Amazon API Gateway の HTTP API は最初の12か月に無料枠があるほか、無料枠がなくても1.29USD/100万リクエストと非常に安価です*1。REST API のほうが自由度は高いですが料金は高くなります。今回の要件では HTTP API で十分です。

Lambda は月あたり100万リクエスト分の永年無料枠があるほか、アーキテクチャに arm64 を選択したりメモリサイズを適切に設定したりすることで料金を抑えることができます。コールドスタートに掛かる時間が気になりますが、実用に足るパフォーマンスが出せるのでしょうか。記事の最後にリクエストに掛かる時間の計測結果を掲載しておくので、お楽しみに。

サンプルコード

サンプルコードを以下のリポジトリに掲載しています。

github.com

サンプルコードでは、楽曲(Song)とアーティスト(Artist)の情報を提供する GraphQL API を構成しています。

例えば、以下のようなクエリを投げると、

{
  songs(first: 3) {
    edges {
      node {
        title
        releasedYear
        artist {
          name
        }
      }
    }
    pageInfo {
      startCursor
      endCursor
    }
    totalCount
  }
}

このようなレスポンスが帰ってきます*2

{
  "data": {
    "songs": {
      "edges": [
        {
          "node": {
            "title": "北ウイング",
            "releasedYear": 1984,
            "artist": {
              "name": "中森明菜"
            }
          }
        },
        {
          "node": {
            "title": "悲しみがとまらない",
            "releasedYear": 1983,
            "artist": {
              "name": "杏里"
            }
          }
        },
        {
          "node": {
            "title": "シンデレラ・ハネムーン",
            "releasedYear": 1978,
            "artist": {
              "name": "岩崎宏美"
            }
          }
        }
      ],
      "pageInfo": {
        "startCursor": "gaFp0wAAAAAAAAAB",
        "endCursor": "gaFp0wAAAAAAAAAD"
      },
      "totalCount": 19
    }
  }
}

サンプルコードの解説

手間なく、と書いたものの、すべてをこの記事で解説するのは難しいです。サンプルコードの commit を丁寧に分けておいたので、適宜差分を確認して読み進めていくと良いでしょう。ent や gqlgen のドキュメントも合わせて参照してください。

Step 1: ent 上でスキーマを定義する

まずは ent でスキーマを定義するためのファイルを作りましょう。

go run -mod=mod entgo.io/ent/cmd/ent new Song

のように go run コマンドを実行すると、ent のスキーマ定義ファイルが生成されます。(commit

できたファイルに具体的なフィールド名などを書き込んでいきます。(commit

ent のスキーマ定義のやり方については他の記事をあたってほしいのですが、GraphQL インテグレーションを使う際のポイントとして、Annotation を付与する必要があります。

func (Artist) Annotations() []schema.Annotation {
    return []schema.Annotation{
        entgql.QueryField(),
        entgql.RelayConnection(),
    }
}

entgql.QueryField() は、検索条件などが指定できる GraphQL スキーマを生成するために必要です。entgql.RelayConnection() は必須ではないですが、Relay Cursor Connections と呼ばれるページングに対応した GraphQL スキーマを生成するために指定しています。

Step 2: ent の Client と GraphQL の schema やサーバの雛形をコード生成する

ent のスキーマ定義から、ent の Client と GraphQL スキーマを生成します。また、生成された GraphQL スキーマ を gqlgen に渡すことで GraphQL リゾルバ―を生成します。

go generate コマンドでこれらのコード生成を行うための準備をします。

go run -mod=mod entgo.io/ent/cmd/ent したときに ent/ ディレクトリに generate.go が作成されているのですが、こちらは消してしまいます。ent の GraphQL インテグレーションを行うために設定を指定する必要があるのと、gqlgen によるコード生成が ent に依存しているのでコード生成の順番を制御したいからです。

代わりに、ent/entc.go ファイルを作り、ent によるコード生成の設定と、実際にコード生成を行うためのコードを書いていきます。また、ent が書き出した GraphQL スキーマからさらにコード生成するための gqlgen の設定ファイルも用意します。

コード生成の準備を行った commit はこちらです。

ここまでできたら go generate ./... でコード生成していきましょう。途中で失敗した場合は go mod tidy を実行してから再度 go generate ./... すると成功するはずです。(commit

Step 3: ent Client を用いて、SQLite の db ファイルを生成するプログラムを作る

ent の ORM Client がコード生成できたので、DB スキーマやマスタデータを SQLite の db ファイルに書き出すプログラムを作っていきます。

準備として、ent で SQLite を利用する際に cgo に依存しないようにしていきましょう。(commit)詳しくは以下の記事を読んでください。

zenn.dev

準備ができたら、ent Client を用いて DB スキーマを適用し初期データを挿入するプログラムを書きます(commit)。

//go:embed "csv/artists.csv"
var artistsBytes []byte

type Artist struct {
    ID   int    `csv:"id"`
    Name string `csv:"name"`
}

func initArtists(ctx context.Context, tx *ent.Tx) error {
    artists := []*Artist{}
    if err := csvutil.Unmarshal(artistsBytes, &artists); err != nil {
        return err
    }

    builders := make([](*ent.ArtistCreate), 0, len(artists))
    for _, a := range artists {
        builder := tx.Artist.Create().
            SetID(a.ID).
            SetName(a.Name)
        builders = append(builders, builder)
    }
    return tx.Artist.CreateBulk(builders...).Exec(ctx)
}

初期データ挿入では、csv ファイルを構造体のスライスに変換し、ent の CreateBulk メソッドを使うことで bulk insert を実現しています。CSV ファイルの読み込みには Go の標準パッケージである embed を利用しています。プログラムに付随させたいファイルを同じバイナリにまとめて同梱できるのが便利です。

ここまでできたら、 go run cmd/gen-db-file を実行することで、スキーマと初期データが反映された SQLite のファイルが書き出されます。

Step 4: GraphQL Resolver を ent Client で実装する

次は GraphQL の Resolver を実装していきます。Step 2 でのコード生成で Resolver はできたのですが、実装が空っぽになっています。

func (r *queryResolver) Node(ctx context.Context, id int) (ent.Noder, error) {
    panic(fmt.Errorf("not implemented"))
}

func (r *queryResolver) Nodes(ctx context.Context, ids []int) ([]ent.Noder, error) {
    panic(fmt.Errorf("not implemented"))
}

func (r *queryResolver) Artists(ctx context.Context, after *entgql.Cursor[int], first *int, before *entgql.Cursor[int], last *int, where *ent.ArtistWhereInput) (*ent.ArtistConnection, error) {
    panic(fmt.Errorf("not implemented"))
}

func (r *queryResolver) Songs(ctx context.Context, after *entgql.Cursor[int], first *int, before *entgql.Cursor[int], last *int, where *ent.SongWhereInput) (*ent.SongConnection, error) {
    panic(fmt.Errorf("not implemented"))
}

この panic 部分を消して、ent Client を用いた実装で埋めていきます。(commit

難しいところとして、 node クエリを実装するために、グローバルID から型を特定する仕組みを用意しなければなりません。今回のデータは、1-1000 までを楽曲 ID、1001- をアーティスト ID としているので、以下のような関数を作ります。

func getTypeFromID(id int) string {
    if id > 1000 {
        return artist.Table
    } else {
        return song.Table
    }
}

ent.WithFixedNodeType() と上記関数を用いることで、resolver がグローバル ID から対応するエンティティを取得して node として返せるようになります。

func (r *queryResolver) Node(ctx context.Context, id int) (ent.Noder, error) {
    return r.Client.Noder(ctx, id, ent.WithFixedNodeType(getTypeFromID(id)))
}

2024-01-09追記: ent.WithFixedNodeType()を用いるよりent.WithNodeType()を使う方が良さそうです。こちらは引数が関数になっていて、範囲外の場合などにエラーを返すことができます。

func getTypeFromID(ctx context.Context, id int) (string, error) {
    switch {
        case id > 0 && id <= 1000:
            return song.Table, nil
        case id > 1000 && id <= 2000:
            return artist.Table, nil
        default:
            return "", fmt.Errorf("undefined")
    }
}

func (r *queryResolver) Node(ctx context.Context, id int) (ent.Noder, error) {
    return r.Client.Noder(ctx, id, ent.WithNodeType(getTypeFromID))
}

Step 5: ローカルで動くサーバを作る

プログラムの大半がこれまでに完成しました。あとは HTTP サーバを作って GraphQL ハンドラを呼べるようにするだけです。

cmd/server/main.go を実装していきます(commit)。

go run cmd/server でサーバが立ち上がるので、http://localhost:8080 にアクセスしましょう。PlayGround が表示されるはずです。

Step 6: Lambda で動くサーバを作る

最後に、このサーバを Lambda + API Gateway で動くようにしていきましょう。

まずは、Go の様々な HTTP サーバを Lambda のハンドラーに変換するライブラリ awslabs/aws-lambda-go-api-proxy を用いて Lambda 用のサーバを実装します(commit)。基本的には http.ListenAndServe() する代わりに lambda.Start(httpadapter.NewV2(http.DefaultServeMux).ProxyWithContext) するだけです。

Makefile も整備して、Lambda 向けのバイナリと db ファイルを同梱した zip ファイルを作れるようにしておきます。make server-lambda.zip を実行すると、Lambda 向けの zip ファイルが生成されます。

API Gateway や Lambda、Lambda を実行するための IAM ロールなどを作る terraform のコードを用意しておきました(commit)。AWS_PROFILE を設定した上で、(cd terraform; terraform init; terraform apply) すると、AWS 上でリソースが生成され、用意した zip ファイルを用いて deploy されます。

AWS のコンソールから API Gateway の endpoint URL を確認し、アクセスをして PlayGround が表示されたら無事完成です!クエリも叩いてみて動くかどうかを確認してみましょう。

パフォーマンス

今回のサンプルとは別のものになりますが、同じ構成で提供している GraphQL API のレスポンスに掛かる時間をお見せします。サンプルよりも多いレコード(数千レコード)が永続化されているものです。

計測のために、Mackerel の外形監視を設定してみました。リクエストボディやヘッダを以下のように設定することで、外形監視で GraphQL のクエリを実際に投げることができます。

外形監視ルールをサービスに紐付けることで、レスポンスタイムをメトリックにして投稿することができます。以下にレスポンスタイムのグラフを掲載します。

ログを見るとコールドスタートが発生しているようですが、グラフに現れている通り、50 ms ほどでレスポンスを返せているようです。十分なパフォーマンスだと言えるでしょう。

まとめ

以上のようにして、readonly な GraphQL API を安価に手間なく構築することができました。パフォーマンスに関しても満足いく水準で得られています。

今回採用したアーキテクチャやライブラリをみなさんもぜひ使ってみてください。

*1:AWSの無料枠・課金体系に関する情報は執筆当時のものであり、今後変更される可能性があります。

*2:サンプルコードのマスタデータには、id:arthur-1 が某日にカラオケで歌った曲を用いています。