春の入門祭りの7日目です。
はじめに
※このエントリーはGoでGraphQLサーバアプリ開発の入門記事です。技術要素にGo, gqlgen, Docker, PosgreSQLなどが登場します。
TIG DXユニット 1の真野です。技術ブログ運営もしています。
フューチャーではOpenAPI関連の過去記事からお察しもできるように、REST-likeなWeb APIを実装することが多いです。しかし日本製HeadlessCMSのmicroCMSを触ってみたの記事で紹介されたように、HeadlessCMS界隈を初めGraphQLのAPIを提供するサービスが増えている体感もあり、GraphQLを春の入門祭りのテーマにしました。
学習する上でドキュメントを読み込むだけでは忘れがちです。手を動かしながらタイトルにあるように鉄道データ検索APIをGraphQLで実装していきましょう。実装の前に結果のみを知りたい人は クエリ:「渋谷駅」を検索するにお進みください。スキーマは#GraphQLスキーマ設計にあります。
GraphQLとは
GraphQLはWeb API用のクエリ言語です。誤解を恐れずに書けばWeb APIにおけるSQLのような存在です。GraphQL(つまりクエリ)と呼ぶとSelectしかない印象を持ってしまいそうですが、Mutationと呼ばれるスキーマを定義すればInsert/Update/Delteが相当の処理を実行できます。今回はMutationは省略してQueryのみを実装していきます。
GraphQLはRDBのDDL(Data Definition Language)のようなスキーマも持て、.graphqls
の拡張子で管理します。このGraphQLスキーマを元に、クライアントやサーバサイドのテンプレートコードを自動生成できます。サーバサイド実装者側の立場に立てば、この宣言したGraphQLに沿ったリクエストを受付、レスポンスを応答する必要があります。
GraphQLスキーマ定義
スキーマ定義の文法は以下のような形式で、項目ごとに型定義を行えます。 !
の意味は、Not Null条件です。
type Query { |
このスキーマに対して、以下のクエリを実行すると…
{ |
以下の形式のレスポンスが取得されます。
{ |
この例だと宣言している項目が少ないので恩恵が少なそうですが、属性が増えたりネストした属性(Lukeの友人関係もスキーマ定義するなど)をすると、名前だけ取得したい場合と、友人関係も取得したいときで、クエリによって呼び分けられるので便利です(実装次第ですが、おそらくサーバサイドの負荷も下げることができます)。
文法詳細は公式のlearnページをさらっと目を通すとオススメです。公式がスターウオーズのデータで説明しているため、この界隈に入門するときはスター・ウォーズシリーズの視聴をしておくと有利な気がします。他にもポケモン初代のデータや実装が公開されているため、トレーナの皆様におかれましてはこちらの方が良いかもです。ポケモン、非常に良いと思ったのですがポケモン名や技名が、英語限定なのでそこがネックかも知れません。
スターウォーズスキーマお試し環境の構築手順
スターウォーズで良ければサンプル実装で試すのが早いです。Goの開発環境が入っていれば、以下の手順でクイックにサーバを起動できます。
> mkdir $GOPATH/src/github.com/graphql-go |
GraphQLクライアントですが、ログ出力している通り、curl
で試しても良いですが、GraphiQLというElectron製のtypoしているような名前のツールが便利です。
GraphiQLを起動後に、Endpiontには先ほどログ出力されていた、http://localhost:8080/graphql
と GET
に書き換えると実行可能です。てっきりGraphQLは POST
で /graphql
で固定的に提供されるものだと思っていましたが、実装元の提供方法次第で自由に変えられることがわかります。
サンプルのクエリは、ここのテストケースを参考に試してみてください。
GoでのGraphQL実装方法
さて、ここからは実際に手を動かしてみるパートです。コードは全てこちらのリポジトリにコミットしていますので、適時参照ください。
GoでのGraphQLサーバの実装に用いるライブラリは、メジャーなのが2種類ありそれぞれ特色が大きく違います。
- graphql-go/graphql
- コードでGraphQLスキーマを表現するタイプ
- 99designs/gqlgen
- GraphQLのスキーマを元にコードを自動生成するタイプ
どちらを選ぶかはチームの戦略次第だと思いますが、今回はスキーマ駆動で開発を進められる gqlgen を採用します。
実装するもの
スターウォーズ・ポケモン(英語)、どちらも不朽の名作ですが、今回は趣向を変えます。昨今の情勢下でフューチャーはリモートワーク推進のためでしたが、それまで非常にお世話になった鉄道🚃の駅情報を検索するAPIをGraphQLサーバで実装していきます。データは駅データ.jpを使わせていただきました。
今回利用する駅データ
駅データ.jpさんからCSVファイルをダウンロードします。データ取得にはユーザ登録(無料)が必要です。有料データには路線カラー情報や新幹線駅データなどさらに有益な情報が含まれているそうなので、必要に応じて切り替えてください。今回は無料版で行います。スキーマ情報は仕様書のページに記載されています。控えめに言って神サイトです。
PostgreSQLにデータを登録する
GraphQLサーバから直接CSVファイルを読み取っても良いですが、結合が面倒なのでPostgreSQLに登録します。以下のDockerfile, docker-compose.ymlを作成します。
FROM postgres:10.7 |
version: "3.5" |
記事ではstationのCreate文とcopy句を記載します。
create table station |
copy company(company_cd,rr_cd,company_name,company_name_k,company_name_h,company_name_r,company_url,company_type,e_status,e_sort) |
また、先ほどダウンロードしたCSVファイルと、それぞれに相当するDDL文を作成して、以下のフォルダに格納します。こんな感じで作成してコンテナを起動させます。PostgreSQLのコンテナは/docker-entrypoint-initdb.d/
配下にSQLを配備すると、起動時にファイル名の順番で実行してくれて便利です。
.gqlgen-ekiapp |
# 初回だけ |
上手く起動できれば、psqlツールを用いると結果が取得できるようになっています。psqlを使わずともお好きなSQLクライアントツールで確認してみてください。先ほどのDockerfileだと postgres/postgresでログインできます。
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'select * from station limit 10'; |
データ登録はこれでおしまいです。
GraphQLスキーマ設計
ここからGraphQLのスキーマを検討します。GraphQLスキーマ設計のコツは、上図の事業マスタ(Company)、路線マスタ(Line)、駅マスタ(Station)、接続駅マスタ(Join)といったRDBのスキーマ構造に縛られないことです。設計上の重要なインプットではありますが、いったんRDBは忘れWeb APIとしてどのような構造が使いやすいかを考えて設計します。
あるべき構造を考え、さらに仕様書ページを参考に、型・必須有無を設定します。元データにはもっと多くの属性が存在しますが、今回は簡略化のためかなり削っています。必要に応じて追加してください。
type Station { |
今回はStationという型のみを定義します。beforeStation/afterStationは同じ路線(JR山手線とかJR東海道線とか)の前後の駅を指し、transferStationは乗り換え駅を指します。今回グラフぽいところは、Stationの中に、beforeStation/afterStation/transferSationという別のStationのフィールドを持っていることです。
type Query { |
データ型が宣言できれば、Queryというエンドポイントとなるルートの関数を宣言します。stationByName
とstationByCD
の2つです。駅名で検索できるか、駅CDで検索できるかの2種類です。[Station]
で配列であることを示します。駅名だと”横浜”で検索すると、JR根岸線
やJR横須賀線
など11路線がヒットしますので複数受け取れるようにしないとならないです。stationCD
は「路線×駅」を示すIDで、JR根岸線の横浜駅といったかたちで駅データ.jpさんに登録されていて、ユニークな値ですので単一のStationを返すようにします。
type Query { |
これらを1つにした.graphqlsファイルを作成し、gqlgenでサーバコードのテンプレートを作成します。
GraphQLスキーマからGoコードを生成
gqlgenのGetting Startedを参考にしてgqlgenをインストールします。Goのインストールは必須です。私は1.14を使っています。
$ go version |
今回作成するサンプルを仮にgqlgen-ekiapp
と名付けます。
mkdir gqlgen-ekiapp |
以下のような構成になります。
. |
このルートディレクトリでgqlgen init
(2回目以降はgqlgen generate
)コマンドでテンプレートコードを生成します。
gqlgen init |
そうすると、以下のような構成になります。
. |
最初はresolver.goを実装していきます。
SQLクエリからGoのStructを作成(シンプルな実装)
resolver.goの実装に入る前に前処理を行います。今回はデータストアがPostgreSQLに存在し、すでにテーブルが実装済みでデータも登録されている状態ですこれを上手く利用したいと思います。
DBスキーマからGoのStructを作成するツールはsqlboilerなどたくさんの選択肢がありますが、今回はxo/xoを利用します。xo/xoではSQLからStructを生成できて便利です。
インプットとして利用するSQLは、PostgreSQLからJoinを駆使して、同一路線の前後の駅や、乗り換え情報を一度に取得しています。そのため少しばかり重厚です。
# xoのインストール |
正常に実行できると、models配下にクエリ結果を格納するStructと実行用の関数が生成されます。
. |
Resolverの実装(初回)
resolver.go
を実装していきます。ResolverにgetStation関数を追加して実装していきます。この実装は後で書き直すので流すくらいでOKです。少し長いです。
resolver.goの実装
func (r *Resolver) getStationByCD(ctx context.Context, stationCd *int) (*model.StationConn, error) {
stations, err := models.StationConnsByStationCD(db, *stationCd)
if err != nil {
return nil, err
}
if len(stations) == 0 {
return nil, errors.New("not found")
}
first := stations[0]
var beforeStation *model.Station
if first.BeforeStationName != "" {
beforeStation = &model.Station{
LineName: &first.LineName,
StationCd: first.BeforeStationCd,
StationName: first.BeforeStationName,
Address: nil,
}
}
var afterStation *model.Station
if first.AfterStationName != "" {
afterStation = &model.Station{
LineName: &first.LineName,
StationCd: first.AfterStationCd,
StationName: first.AfterStationName,
Address: nil,
}
}
transfers := make([]*model.Station, 0, len(stations))
for _, v := range stations {
if v.TransferStationName == "" {
continue
}
transfers = append(transfers, &model.Station{
LineName: &v.TransferLineName,
StationCd: v.TransferStationCd,
StationName: v.TransferStationName,
Address: nil,
})
}
return &model.StationConn{
Station: &model.Station{
LineName: &first.LineName,
StationCd: first.StationCd,
StationName: first.StationName,
Address: &first.Address,
},
TransferStation: transfers,
BeforeStation: beforeStation,
AfterStation: afterStation,
}, nil
}
xoで生成された関数を呼び出すだけです。ただし、呼び出し後はGraphQLのスキーマに沿った応答を返すためStruct詰め替えなGlueコードが多いです。
これを、schema.resolvers.go
から呼び出してあげます。
func (r *queryResolver) StationByCd(ctx context.Context, stationCd *int) (*model.Station, error) { |
queryResolverから先ほどの関数を呼び出せることについてちょっと謎感がありますね。これはqueryResolver
は先ほど実装した Resolver
を埋め込んでいるため、Resolverに実装した関数をそのまま呼べるというテクニックがgqlgenで行われています。簡単にインジェクションできて面白いですね。
type queryResolver struct{ *Resolver } |
記事中では省略していますが、この手順と同様の流れで getStationByName
も実装しています。
実装したデータのお試し
先ほどのサーバを起動します。
$go run server.go |
ブラウザでlocalhost:8080にアクセスするとGraphQLコンソールが開けるのでそれでお試しします。JR東海道本線の横浜駅を示す station_cdの1130105
で検索してみます。
上手く動きました🎉
今の実装の課題
先ほどのGraphQLサーバは表面上は上手く動きました。一方で今の実装では以下の課題があります。
- 1つのSQLで全てのデータ(前後の隣駅や乗換駅)を取得しているため、クエリにそれらのフィールド無い場合もDBに負荷をかけてしまう(また巨大なSQLになりがちで性能劣化の懸念がある)
- 乗換駅の隣駅の隣駅といった、ネストしたクエリを実行できない
これを解決する1つとして、SQLの結合が必要になるフィールドには GraphQL resolverを分離し別に用意する 方法があります。gqlgen側にクエリの実行順序やレスポンス整形を委ねるということです。これを行うと各resolverの実装をシンプルに保ちつつ、複雑なクエリに対応できます。
Resolverを別に用意する
gqlgen init
で作成された、gqlgenの設定ファイルを以下のように変更します。
models: |
GraphQLスキーマで定義したStation
のフィールドである、beforeStation
, afterStation
, transferStation
にたいして、 resolver: true
を設定することで、このフィールドを取得する際にはそれぞれ個別のresolverを用いるようにgqlgenに指定します。
変更を保存したら、既存のresolver.go
とschema.resolvers.go
はバックアップを取って削除しておきましょう。続いてgql generateで再生成します。
gqlgen generate |
そうすると、schema.resolvers.go
で実装すべき関数が増えます。引数は *model.StationでここからstationCD
が取得できるので、これをキーに前後の隣駅と、乗り換え駅を取得していきます。
|
実装ですが、今まで通りResolver.go側に実体の実装を行います。SQLの結合部分がなくなったので、どのSQLもかなりシンプルになります。本文では駅CD検索と乗り換え駅検索の2つのResolverの実装を載せて、残りは省略しています。
まずはxoでSQLからStructと検索用の関数を生成します。
# 駅CD検索 |
Resolverを分離した分、SQLが少しシンプルになりました。
// 駅CD検索部分 |
Resolver側の実装は、xoで生成された検索用の関数を呼び出して、Structの詰め替え作業をしているだけです。これを schema.resolvers.go
で生成されたテンプレート関数から呼び出してあげます。
こういったResolver関数だけ用意しておけば、GraphQLのクエリでそのフィールドが指定されているときだけSQLが実行されるようになります。残りのResolverの実装が終わったらgo run server.go
でサーバを起動させ、localhost:8080のコンソールで確認します。
クエリ:「渋谷駅」を検索する
今回開発したGraphQLサーバに対してクエリを実行して動作確認しましょう。
まずはstationByName
で大崎駅で検索します。
query osaki{ |
すると、大崎駅を利用する各路線とその駅CD(stationCD)が取得できます。
{ |
クエリ:「大崎駅」の隣の駅を調べる
先ほど取得した1130201
のstationCDを元に、stationByCDを指定して隣駅を調べます。隣駅は、beforeStation
、afterStation
で調べられます。
query nextStation { |
すると、大崎の隣駅は五反田
と品川
であることがわかります。
{ |
クエリ:五反田の乗り換え駅を調べる
transferStation
を追加することで、乗換駅を取得できます。beforeStationにbeforeStation
を追加することで、五反田駅の乗換駅を取得します。
query stationByCD { |
そうすると、五反田駅の乗換駅が、東急池上線と都営浅草線があることがわかります。
{ |
フラグメントを活用する
どの要素も、同じような属性を持っているので冗長な気がしますね。GraphQL クエリの「フラグメント」を使って共通化できます。stationF
というフラグメントに、3つのフィールドを集約しました。利用する側は ...stationF
という形で呼び出します。
fragment stationF on Station { |
結果はフラグメントを利用する前と同様です。
{ |
フラグメントを活用すると、クエリ本体の密度が高まって良いですね(SQLにもこういう文法が欲しい..)
見知ったデータで試すと意図したクエリになっているかすぐ分かるのでGraphQLの学習には向いているとおもいます。
次の拡張にむけて
現状の実装ではいくつか課題が出ることが分かっています。ざっと問題になりやすいものだけ上げておきます
- N+1クエリ
- beforeStation/afterStation/transferStationなどResolverを分割したのは良いですが、gqlgen側によって1回ずつ実行されます。クエリによってはとても大きな負荷になりえます。対応策としては
DataLoader
と呼ばれるバッチ処理に対応したライブラリでサーバサイドを実装すると良いでしょう。
- beforeStation/afterStation/transferStationなどResolverを分割したのは良いですが、gqlgen側によって1回ずつ実行されます。クエリによってはとても大きな負荷になりえます。対応策としては
- それでも負荷が大きいクエリ対策
- サーバに負荷を書けるようなネストが深いクエリを実行されると、1のバッチ処理対応をしても負荷が高くなります。また、今回は駅名重複や乗り換え駅でしか複数件数が取得できず、かつ件数が限られるため良いですが、データによっては最大件数の制約をかけたほうが良いでしょう。クエリの複雑とその制約に関しては Limiting Query Complexity で指定ができるようです
- 認証認可
- GraphQLの仕様とは外れますが、実アプリだと認証認可が必要になってくると思います。認証についてはRecipes/Autehtificationで触れられています。認可もこのページの
directives
という仕組みで対応できるか検討します
- GraphQLの仕様とは外れますが、実アプリだと認証認可が必要になってくると思います。認証についてはRecipes/Autehtificationで触れられています。認可もこのページの
まとめ
GraphQL クエリを学ぶ場合、スターウォーズや英語版ポケモンに馴染みがあればそれらを用いる良いでしょう。もし駅や路線の方が理解しやすいのであれば、今回実装したサーバを用いると便利だと思います。少しでも皆さまのGraphQLライフの参考になればと思います。
GraphQLサーバを実装について。実装前はイマイチどこまでがフレームワークが担って、どこから個別実装なのかよく理解できていませんでしたが、実際に手を動かすことによって、その区別が非常にクリアになりました。仕組みが分かるとこのクエリは負荷が高そうだということもすぐ分かるようになると思います。今回は鉄道路線のデータを利用しましたが、他にも公開されているデータを用いたサンプルアプリ実装が増える流れになると良いですね。
- 1.Technology Innovation Groupの略で、フューチャーの中でも特にIT技術に特化した部隊です。その中でもDXチームは特にデジタルトランスフォーメーションに関わる仕事を推進していくチームです。 ↩