MTC2018 カンファレンスLPの裏話 〜GraphQL編〜

こんにちは、メルペイのライブラリとか作るおじさんの @vvakame です。

インフラ編に続きGraphQL(API)編です。
MTC2018のカンファレンスLPのGraphQLによるAPI実装について紹介していきます。

リポジトリをこちらで公開しているので気が向いたら見ていってください。
Playgroundもあって、しばらくは生きている状態のままだと思います。

GraphQLやっていき

筆者は最近GraphQL、特にGo言語用のライブラリであるgqlgenに入れ込んでいます。
そこで、APIの実装にGraphQLを使うことによりgqlgenの使い方を社内に示し、ついでクライアント側の人たちにGraphQLの良さを体感してもらおう!という思惑です。
MTCでもCSToolをgqlgenでやっていき!という話題がありましたし!

…と思ったらアプリの人たちはFlutterを選び、Dartにはまだロクな型安全みを感じられるコード生成系ライブラリがなくて特に気持ちよくならなかったという… 仕方ないね!
代わりにReactを使っているWebフロントエンドではなかなか良い開発体験が提供できたように思います!

あとは、個人的に技術書典5GraphQLサーバをGo言語で作るという本を書き、このAPI開発で得た知見などを扱っているのでぜひ見ていってください。

サーバ側の実装の話

まずはGraphQLサーバを作りました。
gqlgen自体は既に個人的に使っていたので特に不安はありません。
やっていきましょう。

GraphQLの何が良いと考えているのか?

GraphQLの何が良いのかという話をまず最初にしておくと、なんといっても型付けされたクエリ言語であり、Introspectionという普通のプログラミング言語でいうリフレクション相当の機能があることです。
これにより、Webフロントエンドにおける開発では、クエリを書き、そのクエリが正しいかを静的に検証し、さらにはある特定のクエリに対するレスポンス専用の型のコードも生成することができます。

気になった人はとりあえずGitHubのGraphiQLを触って、定義にジャンプできるやん!とかドキュメントががっちりバンドルされてるやん!などの感動を体験しておいてください。

なぜGo言語でGraphQLか?

個人的な理由と会社的な理由があります。

まず個人的な理由から。
筆者はGoogle App Engine/Standard Environmentの信者であり、それ以外のプラットフォームを使う気は今の所ありません。
Python,Java,Node.js,PHP,Goのうち、静的型付け言語であるのはJavaとGoで、インスタンスのスピンアップが早いのはGoです。
TypeScriptを使えばNode.jsも選択肢に入りますが、登場が遅く既存資産はだいたいGoだしgoroutineやdefer便利だしでGoに落ち着きます。

会社的な理由として、Mercari Tech Confなどで触れられている通り、Microservice+Goを今後基本的なアーキテクチャにしていく予定です。

よって、個人的にも会社的にもGoを選ぶ条件が整っています。

GoでGraphQLサーバを組む場合、今まであまり良いライブラリがなかったのですが、最近はgqlgenが出てきました。
gqlgenはスキーマファーストで、型付けがキチンとされたコードが生成されるライブラリです。
まずは公式サイトを一通り眺めて、Getting Startedをやってみてください。

Go言語の場合 interface {} がポンポン出てくる地雷原を歩かされるか、ひたすらめんどくさいボイラープレートコードを血反吐を吐くまで書かされるか、ということも多いのですがコード生成系ツールはそういった苦労を避けられる場合が多いです。話のわかるやつだ。

gqlgenを用いた開発のススメ方

gqlgenは スキーマ定義 → コード生成 → 自分で実装を書く という流れで開発を行います。
Webフロントエンドの開発は スキーマ定義 → クエリ書く → 型定義の生成 → 該当データを使う実装を書く という流れになるでしょう。
つまり、分業体制におけるGraphQLの開発では最上流にスキーマ定義が来ることになります。
これがもし サーバ側のコード実装 → スキーマ定義の生成 という流れだと開発の中にブロッカーが生まれてしまうのでちょっとお辛いですね。

mtc2018-webでも、基本はスキーマ定義である schema.graphql を手書きし、prettiergraphql-schema-linterでフォーマット&チェックをするようにしました。
わるいにんげんなので、descriptionをちゃんとチェックしろというルールをオフにしていますが真っ当なエンジニアの皆さんはちゃんとオンにして運用するようにがんばりましょう。

今回のリポジトリでは、schema.graphqlもサーバ側のコードもフロントエンド側のコードも同じリポジトリに入れています。
今まで筆者は小規模なチームで開発をすることが多かったのでこの構成になったのですが、実際に開発する時は分割したほうがいいかもしれません。
スキーマ定義とコードを別のリポジトリに分け、更新について独立のタイミングで開発を進めることができるようにする。
後方互換性のためにBreaking Changeは慎み、必要であれば code>@deprecated ディレクティブなどをしっかり利用しコミュニケーションを取る、というのが正しいやり方かもしれません。

今回は schema.graphql を触りたがる人がまぁだいたいGoもTypeScriptも普通にできる人ばかりだったので困りが顕在化しなかった可能性があります。

GraphQLとOpenCensus+DataDogによるTracing

GraphQLサーバはざっくりとResolverの集合体ですが、これのTraceを取ることができると楽しそうです。
なので、適当にやってみます。

gqlgenはOpenTracing派のようですが、筆者はGCP贔屓なのでGoogle発のOpenCensusでやってみることにします。
Cloud Spannerのクライアントライブラリも内部でOpenCensus使っているので統一したほうが面倒がないですからね。
また、Traceの出力先は会社がデフォルトで使っているDataDogにします。

gqlgenでは、 RequestMiddlewareResolverMiddleware が存在し、それぞれOperationを受け取った時と、各Resolverが動作する時に処理にフックを噛ませることができます。
gqlgenはOpenTracing用の実装を持っているので真似すればサクサクできるでしょ…と考えたのが案外大変でした。

まず、Traceするために必要な次の条件が満たされる必要があります。

  • Middlewareで変更を加えた context.Context が次のResolverやMiddlewareに渡されること
  • 親となるSpanは子のSpanが閉じられるまで開かれていること

残念ながら、gqlgenはこれらの条件を満たしていません。

1つ目の条件について、当初はMiddlewareが次の処理に送る context.Context を変更することができませんでした。
僕が最初に送ったPRはBreaking Changeが含まれていたため、別の方法で解決されました。

1つ目が直った後に実際にコードを書いて試してみた結果、2つ目の条件も満たす必要があり、そしてそれは満たされていないことがわかりました。
このブログ記事を書く時に直ってたらかっこいいなーと思ったんですが、どういう直し方をするのが良いのかが難しくてまだやりきっていません。
一応ここで相談&進行中なのでやりきったら褒めてください。

この辺をやりきると、現状ではこんなトレースだったのが…

f:id:vvakame:20181022115622p:plain

こういう感じのきれいな山になる!

f:id:vvakame:20181022115640p:plain

予定です。

ゴールデンテスティングの話

ゴールデンテスティングは特定の入力に対して想定される出力をファイルに保存しておき、実際に同じ出力が得られたか見るテスト技法です。
"想定される出力"がまだ存在しない場合、入力に対して得られた出力をファイルに保存し、"想定される出力"にします。

このテスト技法の便利なところは、とりあえず入力データを量産してやるとテストケースを簡単に増やすことができ、変更を加えた時に出力がどう変わるのかを確かめ、それに納得してから想定される出力を変更できる点です。
GraphQLは入力となるクエリの中にコメントを書くことができるため、この技法と親和性が高いと感じています。

実際の実装例はここにあります。
入力出力を並べてみると、どういう感じかわかりやすいでしょう。

ゴールデンテスティングは便利なんですが、もちろんこれだけで全てを賄えるわけではありません。
ディレクティブの実装やモデル周りの構造についてはしっかり単体テストを書きましょう。
また、ゴールデンテスティングの場合、変化しやすいデータ(データの作成日時とか)をテストするのは難しいです。
今回はDBに対する操作を行うコードがほぼ含まれていないので、テストはかなりサボっています。

また、ゴールデンテスティングに用いるデータに(めんどくさがって)本番用データを使ってしまいました。
これにより講演情報にアップデートがあると出力が変わりテストが壊れる…という良くない流れが一部ありました。
テストは実装が壊れた時にちゃんとそれを検出できて、かつリファクタリングをするときにテストの修正が負担になりにくい構造にするべきです。
手を抜かずにキチンとやって、一度書いたテストをストレス少なく有効に活用していきたいところです。

go.modの話

Go 1.11からGo Moduleが登場しました。
とはいえ、今はまだ GO111MODULE=on にするか、GOPATHの外で(暗黙的にでも) GO111MODULE=auto でなければ有効ではありません。

過去、vgoは辛いからやめとけと言ったことがあるんですが、 go mod は使って見るとなかなか良いのでとりあえず使いはじめてみるのがよいと思います。

mtc2018-webでも、もちろん Go Bold に使いはじめてみました。
普通に良かったんですが、ちょいちょい辛い箇所がありました。

今まではdepを使っていて、そこに golint などのツール的な依存関係も含めていました。
これはプロジェクト内で全員同じ環境で作業するためには必須で、depでダウンロードしてビルドして使う、という流れです。
go mod では、Issueで紹介されていたハックを使って解決しました。

// +build tools
package server
// from https://github.com/golang/go/issues/25922#issuecomment-412992431
import (
_ "github.com/99designs/gqlgen"
_ "github.com/rakyll/statik"
_ "golang.org/x/lint"
_ "golang.org/x/tools"
)

こういうコードを置いておくことによって go mod tidy とかで拾えるようにしておくわけです。
あとはsetupでバイナリを作ってテストの時にパスを通すことでみんなで同じツールを使えるようにしました。

これ以外の辛かったポイントとして、gqlgenなどのコード生成系ツールがまだ go mod に対応していなくて、なおかつ既存のやり方と両方対応するのが難しいという問題がありました。
筆者も一回挑戦してみたのですが、あまりのめんどくささに敗北しました。
プロジェクト毎にどっちを使うかが変わるので、ツールとしては両方に対応したバイナリをリリースするべき、となるのが辛いところです。

mtc2018-webでは、CI上で go generate ./... するのが辛かったので開発者のローカルで生成するようにしてしまいました。
開発者の環境では go mod 関係なく、GOPATHにもだいたい似たようなパーツが揃っているので… というクソみたいな解決方法です。
このあたりは vendor/ を使って誤魔化すなどのワークアラウンドがありそうな気はします。

辛い点もありますが、開発時の体験はかなり良くて、GoLandは既に go mod のサポートがあるため、プロジェクトが本当に依存しているコードに対してジャンプしたりデバッガを使ったりできます。
デバッガビリティという意味では go mod を導入することで本当に体験が良くなりました。

メルカリのMicroservice社内テンプレートリポジトリの話

メルカリはMicroserviceをGoでやる!と言っているだけあって、社内にテンプレートとするリポジトリがあります。
mtc2018-webもそのリポジトリを下敷きにして立ち上げているため、ロギング周りの構成や設定値の読み込みかた、プロジェクトのディレクトリ構成などにそのテンプレートプロジェクトの色が反映されています。

だいぶ僕好みに魔改造した気がしなくもないですけど…。
メルカリ社内の様子を垣間見るには、まぁ役に立つかも?どうだろう?

やりかけ

やりたかったけどやれなかったネタをここで供養しておきます。

DBを絡めたトレースとcomplexityの計算

SpannerをDBとして、Traceに加えたりしたかった…。
Spannerは内部的にOpenCensusでTraceを吐いてるのできれいなグラフが得られるはずなんですよね…。
あとQuery Complexityをちゃんと計算してリミットかけたりログ取ったりしたかった…。
現状オンメモリのデータしか扱ってないのでComplexityとか計算しても仕方ないんですよね…。
つらみ。

Subscriptionを活用する

セッションにLikeをつけられるようにして、それをSubscriptionさせて画面側に反映する…みたいなのをやりたかった!というネタがありました。
Relay Global Object Identificationをそれなりに真面目に実装してあったので、これをちゃんとやるとSubscriptionの恩恵を受けて画面内の関係ない箇所も自動更新できるのでは…?みたいなところの検証やりたかった…。

フロントエンド側の話

フロントエンド側についても多少GraphQLの知見が得られたので書いておきます。

Apollo vs Relay

まず、Webフロントエンド側で利用するGraphQLクライアントに何を使うか?という議論がありました。
Apolloは既に広く使われていて面白みに欠けるが、我々の経験値はまだゼロである。
RelayはLanguage plugin supportが最近追加され、TypeScriptでも使えるようになりました。
Relayの評価もしてみました。

結果的にApolloとRelay両方のオープンソースコミュニティに対する向き合い方の差を考え、Apolloを採用することにしました。

最終的に後悔したりはしなかったのでApolloは普通に使える!という気持ちになりました。
@adwd さんがNode.jsでSSRもやってくれたし…!

ComponentとQuery/Fragmentの対比と現状のベストプラクティス

Reactを使ってComponentを組み上げ、それでページを作っていました。
僕はあまりフロントエンド側の設計に噛んでいなかったのですが、ざっくり Page を担当するComponentとそれ以外のサブComponentに分類できる構成にしていたようです。

これはGraphQL的には非常によい設計です。
GraphQLのクエリを投げる回数は少なければ少ないほどよく、1画面表示するのに1回のクエリを投げればOK!というのが基本的には一番良いでしょう。
とすると、前述のPageを担当するComponentがクエリを投げ、それ以外のComponentはクエリを投げない、という構成にしたいところです。

GraphQLはこれを実現するために、Fragmentという仕組みがあり、各Componentには自分をレンダリングするために要求したい項目をFragmentとしてまとめ、これを集約しPageのComponentが取りまとめて単一のクエリを投げます。

少し長いですが、LPが実際に投げているGraphQLクエリを掲載します。
Pageがqueryを発行し、各Componentは自分が欲しい要素をFragmentとして定義しています。

query Top {
...NewsListFragment
...ContentGridFragment
...TimetableSectionFragment
}
fragment NewsListFragment on Query {
newsList(first: 100) {
nodes {
id
date
message
messageJa
link
__typename
}
__typename
}
__typename
}
fragment ContentGridFragment on Query {
sessionList(first: 100) {
nodes {
...ContentGridItemFragment
__typename
}
__typename
}
__typename
}
fragment ContentGridItemFragment on Session {
id
sessionId
title
titleJa
startTime
endTime
type
place
outline
outlineJa
tags
speakers {
id
speakerId
name
nameJa
position
positionJa
__typename
}
__typename
}
fragment TimetableSectionFragment on Query {
sessionList(first: 100) {
...TimetableRowFragment
__typename
}
__typename
}
fragment TimetableRowFragment on SessionConnection {
nodes {
...TimetableContentSlotFragment
__typename
}
__typename
}
fragment TimetableContentSlotFragment on Session {
id
sessionId
lang
tags
title
titleJa
speakers {
name
nameJa
__typename
}
__typename
}

この構成にすると、Componentは自分が描画に使いたいもののみを要求し、子Componentのことは子のFragmentさえ取り込むようにすれば後は何も気にする必要がありません。
結局、どの値をどのComponentが必要としているのか?というのが管理できないとリファクタリング時に要素を削ることができなくなってしまいます。
これを避けるために、このような自分の面倒は自分だけが見る構成を取るのは分割統治の方法として優れていると考えています。

細かいルールは当時のPRを参照してみてください。

おわりに

GraphQLやっていき!
これからもgqlgenに必要なものがあったらバリバリ実装していく覚悟なのでご期待下さい。

GraphQLが社内で市民権を得られるかはまだまだこれからなのですが、やっていきたい人がいたら一緒にやりましょう!おー。

jp.merpay.com

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加