フューチャー技術ブログ

作って学ぶGraphQL。gqlgenを用いて鉄道データ検索API開発入門

春の入門祭りの7日目です。

はじめに

※このエントリーはGoでGraphQLサーバアプリ開発の入門記事です。技術要素にGo, gqlgen, Docker, PosgreSQLなどが登場します。

TIG DXユニット 1の真野です。技術ブログ運営もしています。

フューチャーではOpenAPI関連の過去記事からお察しもできるように、REST-likeなWebAPIを実装することが多いです。しかし日本製HeadlessCMSのmicroCMSを触ってみたの記事で紹介されたように、HeadlessCMS界隈を初めGraphQLのAPIを提供するサービスが増えている体感もあり、GraphQLを春の入門祭りのテーマにしました。

学習する上でドキュメントを読み込むだけでは忘れがちです。手を動かしながらタイトルにあるように鉄道データ検索APIをGraphQLで実装していきましょう。実装の前に結果のみを知りたい人は クエリ:「渋谷駅」を検索するにお進みください。スキーマは#GraphQLスキーマ設計にあります。

GraphQLとは

GraphQLはWebAPI用のクエリ言語です。誤解を恐れずに書けばWebAPIにおけるSQLのような存在です。GraphQL(つまりクエリ)と呼ぶとSelectしかない印象を持ってしまいそうですが、Mutationと呼ばれるスキーマを定義すればInsert/Update/Delteが相当の処理を実行できます。今回はMutationは省略してQueryのみを実装していきます。

GraphQLはRDBのDDL(Data Definition Language)のようなスキーマも持て、.graphqlsの拡張子で管理します。このGraphQLスキーマを元に、クライアントやサーバサイドのテンプレートコードを自動生成できます。サーバサイド実装者側の立場に立てば、この宣言したGraphQLに沿ったリクエストを受付、レスポンスを応答する必要があります。

GraphQLスキーマ定義

スキーマ定義の文法は以下のような形式で、項目ごとに型定義を行えます。 ! の意味は、Not Null条件です。

GraphQLのスキーマ例
type Query {
me: User
}

type User {
id: ID!
name: String
}

このスキーマに対して、以下のクエリを実行すると…

クエリ実行例
{
me {
name
}
}

以下の形式のレスポンスが取得されます。

{
"me": {
"name": "Luke Skywalker"
}
}

この例だと宣言している項目が少ないので恩恵が少なそうですが、属性が増えたりネストした属性(Lukeの友人関係もスキーマ定義するなど)をすると、名前だけ取得したい場合と、友人関係も取得したいときで、クエリによって呼び分けられるので便利です(実装次第ですが、おそらくサーバサイドの負荷も下げることができます)。

文法詳細は公式のlearnページをさらっと目を通すとオススメです。公式がスターウオーズのデータで説明しているため、この界隈に入門するときはスター・ウォーズシリーズの視聴をしておくと有利な気がします。他にもポケモン初代のデータや実装が公開されているため、トレーナの皆様におかれましてはこちらの方が良いかもです。ポケモン、非常に良いと思ったのですがポケモン名や技名が、英語限定なのでそこがネックかも知れません。

スターウォーズスキーマお試し環境の構築手順

スターウォーズで良ければサンプル実装で試すのが早いです。Goの開発環境が入っていれば、以下の手順でクイックにサーバを起動できます。

> mkdir $GOPATH/src/github.com/graphql-go
> cd $GOPATH/src/github.com/graphql-go
> git clone https://github.com/graphql-go/graphql.git
> cd graphql-go/examples
> go run main.go
Now server is running on port 8080
Test with Get : curl -g 'http://localhost:8080/graphql?query={hero{name}}'

GraphQLクライアントですが、ログ出力している通り、curl で試しても良いですが、GraphiQLというElectron製のtypoしているような名前のツールが便利です。

GraphiQLを起動後に、Endpiontには先ほどログ出力されていた、http://localhost:8080/graphqlGETに書き換えると実行可能です。てっきりGraphQLは POST/graphql で固定的に提供されるものだと思っていましたが、実装元の提供方法次第で自由に変えられることがわかります。

サンプルのクエリは、ここのテストケースを参考に試してみてください。

GoでのGraphQL実装方法

さて、ここからは実際に手を動かしてみるパートです。コードは全てこちらのリポジトリにコミットしていますので、適時参照ください。

GoでのGraphQLサーバの実装に用いるライブラリは、メジャーなのが2種類ありそれぞれ特色が大きく違います。

  1. graphql-go/graphql
    • コードでGraphQLスキーマを表現するタイプ
  2. 99designs/gqlgen
    • GraphQLのスキーマを元にコードを自動生成するタイプ

どちらを選ぶかはチームの戦略次第だと思いますが、今回はスキーマ駆動で開発を進められる gqlgen を採用します。

実装するもの

スターウォーズ・ポケモン(英語)、どちらも不朽の名作ですが、今回は趣向を変えます。昨今の情勢下でフューチャーはリモートワーク推進のためでしたが、それまで非常にお世話になった鉄道🚃の駅情報を検索するAPIをGraphQLサーバで実装していきます。データは駅データ.jpを使わせていただきました。

今回利用する駅データ

駅データ.jpさんからCSVファイルをダウンロードします。データ取得にはユーザ登録(無料)が必要です。有料データには路線カラー情報や新幹線駅データなどさらに有益な情報が含まれているそうなので、必要に応じて切り替えてください。今回は無料版で行います。スキーマ情報は仕様書のページに記載されています。控えめに言って神サイトです。

PostgreSQLにデータを登録する

GraphQLサーバから直接CSVファイルを読み取っても良いですが、結合が面倒なのでPostgreSQLに登録します。以下のDockerfile, docker-compose.ymlを作成します。

駅データ格納PosgreSQL
FROM postgres:10.7
WORKDIR ./
COPY init /docker-entrypoint-initdb.d/
docker-compose.yml
version: "3.5"
services:
postgresql:
build: ./
container_name: postgre-eki
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_INITDB_ARGS=--encoding=UTF-8
ports:
- "5432:5432"
user: root
volumes:
- pg-data-eki:/var/lib/pgdata
volumes:
pg-data-eki:
driver: local

記事ではstationのCreate文とcopy句を記載します。

1_create.sql(company,line,joinは省略)
create table station
(
station_cd integer not null,
station_g_cd integer not null,
station_name varchar not null,
station_name_k varchar,
station_name_r varchar,
line_cd integer,
pref_cd integer,
post varchar,
address varchar,
lon float,
lat float,
open_ymd varchar ,
close_ymd varchar,
e_status integer,
e_sort integer,
PRIMARY KEY (station_cd)
);
comment on table station is 'station20200316free.csv';
2_copy.sql(company,line,joinは省略)
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)
from '/docker-entrypoint-initdb.d/company20200309.csv' with csv header;

また、先ほどダウンロードしたCSVファイルと、それぞれに相当するDDL文を作成して、以下のフォルダに格納します。こんな感じで作成してコンテナを起動させます。PostgreSQLのコンテナは/docker-entrypoint-initdb.d/配下にSQLを配備すると、起動時にファイル名の順番で実行してくれて便利です。

.gqlgen-ekiapp
├── Dockerfile
├── docker-compose.yml
└── init
├── 1_create.sql # DDL
├── 2_copy.sql # Copy句
├── company20200309.csv # DownloadしたCSV
├── join20200306.csv # DownloadしたCSV
├── line20200306free.csv # DownloadしたCSV
└── station20200316free.csv # DownloadしたCSV
# 初回だけ
docker volume create pg-data-eki

# 起動(コンテナイメージ作成処理でinitフォルダ以下が/docker-entrypoint-initdb.d/にコピーされ、createとcopyが実行される)
docker-compose up --build

# 停止
#docker-compose down

上手く起動できれば、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は忘れWebAPIとしてどういう構造が使いやすいかを考えて設計します。

あるべき構造を考え、さらに仕様書ページを参考に、型・必須有無を設定します。元データにはもっと多くの属性が存在しますが、今回は簡略化のためかなり削っています。必要に応じて追加してください。

type Station {
stationCD: Int!
lineName: String
stationName: String!
address: String
beforeStation: Station
afterStation: Station
transferStation: [Station]
}

今回はStationという型のみを定義します。beforeStation/afterStationは同じ路線(JR山手線とかJR東海道線とか)の前後の駅を指し、transferStationは乗り換え駅を指します。今回グラフぽいところは、Stationの中に、beforeStation/afterStation/transferSationという別のStationのフィールドを持っていることです。

type Query {
stationByCD(stationCD: Int): Station!
stationByName(stationName: String): [Station]
}

データ型が宣言できれば、Queryというエンドポイントとなるルートの関数を宣言します。stationByNamestationByCDの2つです。駅名で検索できるか、駅CDで検索できるかの2種類です。[Station]で配列であることを示します。駅名だと”横浜”で検索すると、JR根岸線JR横須賀線など11路線がヒットしますので複数受け取れるようにしないとならないです。stationCDは「路線×駅」を示すIDで、JR根岸線の横浜駅といったかたちで駅データ.jpさんに登録されていて、ユニークな値ですので単一のStationを返すようにします。

type Query {
stationByName(stationName: String): [Station]
stationByCD(stationCD: Int): Station!
}

これらを1つにした.graphqlsファイルを作成し、gqlgenでサーバコードのテンプレートを作成します。

GraphQLスキーマからGoコードを生成

gqlgenのGetting Startedを参考にしてgqlgenをインストールします。Goのインストールは必須です。私は1.14を使っています。

$ go version
go version go1.14.2 linux/amd64

# gqlgenのインストール
$ go get -u github.com/99designs/gqlgen

今回作成するサンプルを仮にgqlgen-ekiappと名付けます。

プロジェクトの作成
$ mkdir gqlgen-ekiapp
$ cd gqlgen-ekiapp
$ go mod init

以下のような構成になります。

プロジェクトディレクトリ構成(コード生成前)
.
├── go.mod
└── graph
└── schema.graphqls # GraphQLスキーマ

このルートディレクトリでgqlgen init(2回目以降はgqlgen generate)コマンドでテンプレートコードを生成します。

テンプレートコードの生成
$ gqlgen init
$ go mod tidy

そうすると、以下のような構成になります。

.
├── go.mod
├── go.sum
├── gqlgen.yml # gqlgenコマンドのコンフィグファイル(初回のみ作成)
├── graph
│   ├── generated
│   │   └── generated.go # 自動生成されたクエリのパース処理などの部分
│   ├── model
│   │   └── models_gen.go # 自動生成されたmodel
│   ├── resolver.go # Resolverコードの実体を実装する部分(☆初回のみ作成)
│   ├── schema.graphqls # GraphQLスキーマ
│   └── schema.resolvers.go # 各Resolverのエンドポイント(☆初回のみ作成)
├── server.go # mainパッケージ
|
└── postgresql
   ├── ...
...

最初はresolver.goを実装していきます。

SQLクエリからGoのStructを作成(シンプルな実装)

resolver.goの実装に入る前に前処理を行います。今回はデータストアがPostgreSQLに存在し、すでにテーブルが実装済みでデータも登録されている状態ですこれを上手く利用したいと思います。

DBスキーマからGoのStructを作成するツールはsqlboilerなどたくさんの選択肢がありますが、今回はxo/xoを利用します。xo/xoではSQLからStructを生成できて便利です。

インプットとして利用するSQLは、PostgreSQLからJoinを駆使して、同一路線の前後の駅や、乗り換え情報を一度に取得しています。そのため少しばかり重厚です。

# xoのインストール
$ go get -u github.com/xo/xo

# 出力先フォルダの作成
$ mkdir -p models

# Structの生成
$ xo pgsql://postgres:postgres@localhost/postgres?sslmode=disable -N -M -B -T StationConn -o models/ << ENDSQL
select li.line_name,
li.line_name_h,
li.line_cd,
st.station_cd,
st.station_g_cd,
st.address,
st.station_name,
COALESCE(s2l.line_name, '') as before_line_name,
COALESCE(st2.station_cd, 0) as before_station_cd,
COALESCE(st2.station_name, '') as before_station_name,
COALESCE(st2.address, '') as before_address,
COALESCE(s3l.line_name, '') as after_line_name,
COALESCE(st3.station_cd, 0) as after_station_cd,
COALESCE(st3.station_name, '') as after_station_name,
COALESCE(st2.address, '') as after_address,
COALESCE(gli.line_name, '') as transfer_line_name,
COALESCE(gs.station_cd, 0) as transfer_station_cd,
COALESCE(gs.station_name, '') as transfer_station_name,
COALESCE(gs.address, '') as transfer_address
from station st
inner join line li on st.line_cd = li.line_cd
left outer join station_join sjb on st.line_cd = sjb.line_cd and st.station_cd = sjb.station_cd2
left outer join station_join sja on st.line_cd = sja.line_cd and st.station_cd = sja.station_cd1
left outer join station st2 on sjb.line_cd = st2.line_cd and sjb.station_cd1 = st2.station_cd
left outer join line s2l on st2.line_cd = s2l.line_cd
left outer join station st3 on sja.line_cd = st3.line_cd and sja.station_cd2 = st3.station_cd
left outer join line s3l on st3.line_cd = s3l.line_cd
left outer join station gs on st.station_g_cd = gs.station_g_cd and st.station_cd <> gs.station_cd
left outer join line gli on gs.line_cd = gli.line_cd
where st.station_cd = %%stationCD int%%
and st.e_status = 0
order by st.e_sort
ENDSQL

正常に実行できると、models配下にクエリ結果を格納するStructと実行用の関数が生成されます。

.
├── README.md
├── go.mod
├── go.sum
├── models # 自動生成対象のフォルダ
│   ├── stationconn.xo.go # 自動生成コード
│   └── xo_db.xo.go # 自動生成コード
|
└── postgresql
   ├── ...
...

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 から呼び出してあげます。

schema.resolvers.go
func (r *queryResolver) StationByCd(ctx context.Context, stationCd *int) (*model.Station, error) {
return r.getStationByCD(ctx, stationCd)
}

queryResolverから先ほどの関数を呼び出せることについてちょっと謎感がありますね。これはqueryResolverは先ほど実装した Resolver を埋め込んでいるため、Resolverに実装した関数をそのまま呼べるというテクニックがgqlgenで行われています。簡単にインジェクションできて面白いですね。

type queryResolver struct{ *Resolver }

記事中では省略していますが、この手順と同様の流れで getStationByName も実装しています。

実装したデータのお試し

先ほどのサーバを起動します。

$go run server.go
2020/06/09 09:00:11 connect to http://localhost:8080/ for GraphQL playground

ブラウザでlocalhost:8080にアクセスするとGraphQLコンソールが開けるのでそれでお試しします。JR東海道本線の横浜駅を示す station_cdの1130105で検索してみます。

上手く動きました🎉

今の実装の課題

先ほどのGraphQLサーバは表面上は上手く動きました。一方で今の実装では以下の課題があります。

  1. 1つのSQLで全てのデータ(前後の隣駅や乗換駅)を取得しているため、クエリにそれらのフィールド無い場合もDBに負荷をかけてしまう。(また巨大なSQLになりがちで性能劣化の懸念がある)
  2. 乗換駅の隣駅の隣駅といった、ネストしたクエリを実行できない

これを解決する1つとして、SQLの結合が必要になるフィールドには GraphQL resolverを分離し別に用意する 方法があります。gqlgen側にクエリの実行順序やレスポンス整形を委ねるということです。これを行うと各resolverの実装をシンプルに保ちつつ、複雑なクエリに対応できます。

Resolverを別に用意する

gqlgen initで作成された、gqlgenの設定ファイルを以下のように変更します。

models:
# 中略
Station: # GraphQLスキーマのStation型
fields:
beforeStation: # フィールド名
resolver: true
afterStation: # フィールド名
resolver: true
transferStation: # フィールド名
resolver: true

GraphQLスキーマで定義したStationのフィールドである、beforeStation, afterStation, transferStationにたいして、 resolver: true を設定することで、このフィールドを取得する際にはそれぞれ個別のresolverを用いるようにgqlgenに指定します。

変更を保存したら、既存のresolver.goschema.resolvers.goはバックアップを取って削除しておきましょう。続いてgql generateで再生成します。

gqlgen generate

そうすると、schema.resolvers.goで実装すべき関数が増えます。引数は *model.StationでここからstationCD が取得できるので、これをキーに前後の隣駅と、乗り換え駅を取得していきます。

schema.resolvers.goで増えた関数

func (r *stationResolver) BeforeStation(ctx context.Context, obj *model.Station) (*model.Station, error) {
// TODO 実装
}

func (r *stationResolver) AfterStation(ctx context.Context, obj *model.Station) (*model.Station, error) {
// TODO 実装
}

func (r *stationResolver) TransferStation(ctx context.Context, obj *model.Station) ([]*model.Station, error) {
// TODO 実装
}

実装ですが、今まで通りResolver.go側に実体の実装を行います。SQLの結合部分がなくなったので、どのSQLもかなりシンプルになります。本文では駅CD検索と乗り換え駅検索の2つのResolverの実装を載せて、残りは省略しています。

まずはxoでSQLからStructと検索用の関数を生成します。

# 駅CD検索
xo pgsql://postgres:postgres@localhost/postgres?sslmode=disable -N -M -B -T StationByCD -o models/ << ENDSQL
select l.line_cd, l.line_name, s.station_cd, station_g_cd, s.station_name, s.address
from station s
inner join line l on s.line_cd = l.line_cd
where s.station_cd = %%stationCD int%%
and s.e_status = 0
ENDSQL

# 乗り換え検索
# 乗換駅検索
xo pgsql://postgres:postgres@localhost/postgres?sslmode=disable -N -M -B -T Transfer -o models/ << ENDSQL
select s.station_cd,
ls.line_cd,
ls.line_name,
s.station_name,
s.station_g_cd,
s.address,
COALESCE(lt.line_cd, 0) as transfer_line_cd,
COALESCE(lt.line_name, '') as transfer_line_name,
COALESCE(t.station_cd, 0) as transfer_station_cd,
COALESCE(t.station_name, '') as transfer_station_name,
COALESCE(t.address, '') as transfer_address
from station s
left outer join station t on s.station_g_cd = t.station_g_cd and s.station_cd <> t.station_cd
left outer join line ls on s.line_cd = ls.line_cd
left outer join line lt on t.line_cd = lt.line_cd
where s.station_cd = %%stationCD int%%
ENDSQL

Resolverを分離した分、SQLが少しシンプルになりました。

Reolverの実装部分(駅CD検索と乗り換え駅取得の2つだけ抜粋)
// 駅CD検索部分
func (r *Resolver) getStationByCD(ctx context.Context, stationCd *int) (*model.Station, error) {
stations, err := models.StationByCDsByStationCD(db, *stationCd)
if err != nil {
return nil, err
}
if len(stations) == 0 {
return nil, errors.New("not found")
}
first := stations[0]

return &model.Station{
StationCd: first.StationCd,
StationName: first.StationName,
LineName: &first.LineName,
Address: &first.Address,
}, nil
}

// 乗り換え駅取得部分
func (r *Resolver) transferStation(ctx context.Context, obj *model.Station) ([]*model.Station, error) {
stationCd := obj.StationCd

records, err := models.TransfersByStationCD(db, stationCd)
if err != nil {
return nil, err
}

resp := make([]*model.Station, 0, len(records))
for _, v := range records {
if v.TransferStationName == "" {
continue
}
resp = append(resp, &model.Station{
StationCd: v.TransferStationCd,
StationName: v.TransferStationName,
LineName: &v.TransferLineName,
Address: &v.TransferAddress,
})
}

return resp, nil
}

Resolver側の実装は、xoで生成された検索用の関数を呼び出して、Structの詰め替え作業をしているだけです。これを schema.resolvers.go で生成されたテンプレート関数から呼び出してあげます。

こういったResolver関数だけ用意しておけば、GraphQLのクエリでそのフィールドが指定されているときだけSQLが実行されるようになります。残りのResolverの実装が終わったらgo run server.go でサーバを起動させ、localhost:8080のコンソールで確認します。

クエリ:「渋谷駅」を検索する

今回開発したGraphQLサーバに対してクエリを実行して動作確認しましょう。

まずはstationByNameで大崎駅で検索します。

query osaki{
stationByName(stationName: "大崎") {
lineName
stationCD
stationName
}
}

すると、大崎駅を利用する各路線とその駅CD(stationCD)が取得できます。

{
"data": {
"stationByName": [
{
"lineName": "JR山手線",
"stationCD": 1130201,
"stationName": "大崎"
},
{
"lineName": "JR埼京線",
"stationCD": 1132101,
"stationName": "大崎"
},
{
"lineName": "JR湘南新宿ライン",
"stationCD": 1133307,
"stationName": "大崎"
},
{
"lineName": "りんかい線",
"stationCD": 9933708,
"stationName": "大崎"
}
]
}
}

クエリ:「大崎駅」の隣の駅を調べる

先ほど取得した1130201のstationCDを元に、stationByCDを指定して隣駅を調べます。隣駅は、beforeStationafterStationで調べられます。

query nextStation {
stationByCD(stationCD: 1130201) {
lineName
stationCD
stationName
beforeStation {
lineName
stationCD
stationName
}
afterStation {
lineName
stationCD
stationName
}
}
}

すると、大崎の隣駅は五反田品川であることがわかります。

{
"data": {
"stationByCD": {
"lineName": "JR山手線",
"stationCD": 1130201,
"stationName": "大崎",
"beforeStation": {
"lineName": "JR山手線",
"stationCD": 1130202,
"stationName": "五反田"
},
"afterStation": {
"lineName": "JR山手線",
"stationCD": 1130229,
"stationName": "品川"
}
}
}
}

クエリ:五反田の乗り換え駅を調べる

transferStationを追加することで、乗換駅を取得できます。beforeStationにbeforeStationを追加することで、五反田駅の乗換駅を取得します。

query stationByCD {
stationByCD(stationCD: 1130201) {
lineName
stationCD
stationName
beforeStation {
lineName
stationCD
stationName
transferStation {
lineName
stationCD
stationName
}
}
afterStation {
lineName
stationCD
stationName
}
}
}

そうすると、五反田駅の乗換駅が、東急池上線と都営浅草線があることがわかります。

{
"data": {
"stationByCD": {
"lineName": "JR山手線",
"stationCD": 1130201,
"stationName": "大崎",
"beforeStation": {
"lineName": "JR山手線",
"stationCD": 1130202,
"stationName": "五反田",
"transferStation": [
{
"lineName": "東急池上線",
"stationCD": 2600501,
"stationName": "五反田"
},
{
"lineName": "都営浅草線",
"stationCD": 9930205,
"stationName": "五反田"
}
]
},
"afterStation": {
"lineName": "JR山手線",
"stationCD": 1130229,
"stationName": "品川"
}
}
}
}

フラグメントを活用する

どの要素も、同じような属性を持っているので冗長な気がしますね。GraphQL クエリの「フラグメント」を使って共通化する事ができます。stationFというフラグメントに、3つのフィールドを集約しました。利用する側は ...stationFという形で呼び出します。

fragment stationF on Station {
lineName
stationCD
stationName
}

query stationByCD {
stationByCD(stationCD: 1130201) {
...stationF
beforeStation {
...stationF
transferStation {
...stationF
}
}
afterStation {
...stationF
}
}
}

結果はフラグメントを利用する前と同様です。

{
"data": {
"stationByCD": {
"lineName": "JR山手線",
"stationCD": 1130201,
"stationName": "大崎",
"beforeStation": {
"lineName": "JR山手線",
"stationCD": 1130202,
"stationName": "五反田",
"transferStation": [
{
"lineName": "東急池上線",
"stationCD": 2600501,
"stationName": "五反田"
},
{
"lineName": "都営浅草線",
"stationCD": 9930205,
"stationName": "五反田"
}
]
},
"afterStation": {
"lineName": "JR山手線",
"stationCD": 1130229,
"stationName": "品川"
}
}
}
}

フラグメントを活用すると、クエリ本体の密度が高まって良いですね。(SQLにもこういう文法が欲しい..)

見知ったデータで試すと意図したクエリになっているかすぐ分かるのでGraphQLの学習には向いているとおもいます。

次の拡張にむけて

現状の実装ではいくつか課題が出ることが分かっています。ざっと問題になりやすいものだけ上げておきます

  1. N+1クエリ
    • beforeStation/afterStation/transferStationなどResolverを分割したのは良いですが、gqlgen側によって1回ずつ実行されます。クエリによってはとても大きな負荷になりえます。対応策としてはDataLoaderと呼ばれるバッチ処理に対応したライブラリでサーバサイドを実装すると良いでしょう。
  2. それでも負荷が大きいクエリ対策
    • サーバに負荷を書けるようなネストが深いクエリを実行されると、1のバッチ処理対応をしても負荷が高くなります。また、今回は駅名重複や乗り換え駅でしか複数件数が取得できず、かつ件数が限られるため良いですが、データによっては最大件数の制約をかけたほうが良いでしょう。クエリの複雑とその制約に関しては Limiting Query Complexity で指定ができるようです
  3. 認証認可
    • GraphQLの仕様とは外れますが、実アプリだと認証認可が必要になってくると思います。認証についてはRecipes/Autehtificationで触れられています。認可もこのページのdirectivesという仕組みで対応できるか検討します

まとめ

GraphQL クエリを学ぶ場合、スターウォーズや英語版ポケモンに馴染みがあればそれらを用いる良いでしょう。もし駅や路線の方が理解しやすいのであれば、今回実装したサーバを用いると便利だと思います。少しでも皆さまのGraphQLライフの参考になればと思います。

GraphQLサーバを実装について。実装前はイマイチどこまでがフレームワークが担って、どこから個別実装なのかよく理解できていませんでしたが、実際に手を動かすことによって、その区別が非常にクリアになりました。仕組みが分かるとこのクエリは負荷が高そうだということもすぐ分かるようになると思います。今回は鉄道路線のデータを利用しましたが、他にも公開されているデータを用いたサンプルアプリ実装が増える流れになると良いですね。


  1. 1.Technology Innovation Groupの略で、フューチャーの中でも特にIT技術に特化した部隊です。その中でもDXチームは特にデジタルトランスフォーメーションに関わる仕事を推進していくチームです。