手作業のドキュメントとコードとは乖離する

本田雄亮氏:今回、「Goaを使ってAPIサーバー開発してみた」というタイトルでお話ししたいなと思います。

まず自己紹介です。プラットフォーム部というところで基盤システムの開発をしています。バックエンドのエンジニアです。名前は本田です。興味あるのは、Goとかアーキテクチャ。DDDとかがけっこう好きなので、もし懇親会に参加される方がおられたら、Goaだけの話じゃなくて、Go全般だったりアーキテクチャ、DDDまわりでもお話できたらなと思っています。

さっそくメインテーマのGoaです。なぜ僕たちのチームでGoaを使うことに至ったか、その経緯からお話していきたいと思います。

まず今のプロジェクトからお話します。先ほど基盤システムを作っていると言ったんですが、今はけっこう特殊なプロジェクトに参加していて、新規でネイティブアプリの開発をしています。僕は、そのバックエンドのAPIサーバーを作っています。

このAPI自体は、外部公開とか社内の他のサービスへの公開とかはないものになっています。一応クライアントとサーバーサイドでチームが分かれていて、クライアントのほうが先に開発がスタートしました。先に始まっていて、RESTと言うといろいろありますが、RESTful APIを使用して開発が進んでいました。

僕たちサーバーサイドも、遅れて技術選定が始まりました。選定において大事にしていることは、「まずドキュメント」です。ドキュメントと言っていますが、APIのインプットとアウトプットの定義のことを指しています。なんでドキュメントを大事にしているかという話ですが、これは当たり前なことなんですが、利用者はまずドキュメントを見る。利用者からすれば、ドキュメントがすべてというところがあります。

だからドキュメントが大事。これはもちろん当たり前です。ただ、人はミスする生き物なので、必ずしもドキュメントが常に最新かはわからないというのがよくあると思います。これまでのつらい経験。僕もいろいろ経験して、ドキュメントにないフィールドが返ってきたり、ドキュメントと違う型が返ってくる。がんばってリクエストを投げたのに、そんなの知らないよというパラメータを要求されるみたいなことがよくあると思います。

こういったことがあると、結局デバッグに時間が掛かったりとかバックエンドに問い合わせに行ったりとか、余分な確認時間が発生してしまいます。なので「こういうのはもったいないよね」というところがありました。つまりドキュメントとコードは乖離するという話をしていたんですが、これは手で作るドキュメントとコードは乖離するというのが正しいかなと思います。

ドキュメントを大事にすれば、手戻りや開発待ちも減る

なので僕たちは、手作業は排除して、ある情報源から生成しようと考えていました。ある情報源から生成というのは、コードからドキュメントを生成する、ドキュメントからコードを生成する、または、中間言語みたいなものを挟んでコードとドキュメントをそこから生成する、みたいなことをすれば、ドキュメントとコードは乖離しないんじゃないかと考えて、こういったものを選定において大事にしたいねという話をしていました。

ドキュメントを大事にした理由は、他にもあります。実装前にドキュメントをレビューすることで、実装に入ってからの手戻りを減らしたかったというのがあります。本格的に実装に入る前にバックエンドもクライアント側もみんなでドキュメントをレビューすることで、手戻りを減らそうということを考えていました。

それからもう1つ、APIができていないことによってクライアントで開発の待ちが発生するということをなくしたかったというのもあります。冒頭にも述べた通り、先にクライアントから開発が始まっていました。なのでどうしてもAPIができてないことによって、開発に待ちが発生してしまいます。このとき、ドキュメントさえあればクライアントでモックを作ることができるので、そういった待ち時間も解消できると考えていました。

ドキュメントを大事にできるフレームワークを選定したい

これまでの話をまとめます。フレームワークを選んでいく上で、2つのことを大事にしていました。1つ目として、実装よりも前にまずドキュメントを作れること、そして2つ目として、ある情報源からドキュメント、またはドキュメントとコードとかを生成できる。つまり、絶対にドキュメント=コードの状態を保てるということです。

これらを踏まえて、今回は4つの候補を挙げています。他にもいろいろ考えていたんですが、今回はこの4つに絞って話を進めていこうかなと思います。

まずOpenAPI。OpenAPI自体はREST APIの仕様の記述方式を形式化したもので、このルールに沿って書けば、グラフィカルなUIでドキュメントを閲覧できたり、またそのドキュメントから実際にAPIを叩くこととかできます。

ご存知の方も多いかなと思うんですが知らない方のためにもうちょっと補足しておくと、OpenAPIの仕様を書くのがopenapi.ymlだったりopenapi.jsonみたいなファイルです。書いた時点では、その名の通りYAMLだったりJSONだったりのただのファイルなんですが、これをあるツールに通してやると、ドキュメントとしてグラフィカルなUIで見ることができます。

ご存知ない方は、今回はopenapi.ymlといったらドキュメントとほぼイコールなんだなということを思ってもらえればと思います。

さっそく1つ目から見ていきます。go-swaggerですね。go-swaggerは、まずopenapi.ymlを書いて、そこからGoのコードを生成します。つまり、ドキュメントからGoのコードを生成するタイプになります。ただ、ドキュメント、YAMLを書くことになります。これはチーム内でもつらいという話が出ました。

もちろんGUIからポチポチして最終的にopenapi.ymlという形式で出力するツールはいくつでもあるんですが、あまりたくさんのツールを使うというのも管理が大変なのでやめておきたいというのがあって、いったん見送りました。

次にswagです。これはGoコード内のコメントからopenapi.ymlを生成します。ただ、このコメントは、コードとは必ずしも結びついていないので、コードとドキュメントが乖離する可能性があります。そもそもコメントを書くので、コメントがかなり膨大な数になってしまいます。こういうのがあまりイヤだったので、こういうのも見送りました。

Goaはいったん飛ばして、ProtocolBuffersです。gRPCですね。こちらは有名なのでみなさんご存知だと思うんですが、IDLからGoのコードを生成できます。gRPC UIを使えば、そのprotoの内容をドキュメント化することもできます。そもそもgRPCはprotoからクライアントのコードを生成できますよね。なのでこれはいいなと思いました。ただ、RPCなんですよね。

冒頭にも述べた通り、クライアント側はRESTfulなAPIを使って開発を進めていたので、今からRPCの対応はできない。grpc-gatewayというツールを使えば対応はできるんですが、先ほども言ったように、あまりツールを増やし過ぎたくないという思いからいったん見送りました。個人的には本当はけっこうgRPCを使いたかったです。

最終的にGoaになったわけですが、Goaについてはこれ以降に詳しく述べていくので、いったんここは飛ばします。

Goaについてもう少し詳しく解説

ではGoaについてもう少し詳しく。GoaはDSLというものを記述することで、APIのインプットとアウトプットを定義します。DSLを元にopenapi.yml、つまりドキュメントを生成できて、さらにGoの雛形のコードも生成できます。このDSLの学習コストが高いんじゃないかと思われる方もいると思うんですが、DSL自体はGoで書けます。なのでOpenAPIに関する知識があれば、だいたいは書くことができます。

これがGoaの公式のホームページなんですが、Design firstと書かれていて、僕たちがまさに求めていたものです。

Goaで生成されるコードに関してなんですが、ここはクリーンアーキテクチャだったりオニオンアーキテクチャの単語が入ってきてしまうんですが、クリーンアーキテクチャとかに関しては後ほど岡崎さんの発表があると思うので、そちらでまた思い出していただければなと思います。

Goaで生成されたコードというのは、あくまでこの赤色のところのプレゼンテーション層です。他にもUI層だったりインタフェース層と呼ばれる層ですね。 あくまでここに相当するコードしか生成されません。ビジネスロジックはユースケース層やドメイン層に入っています。なのでGoaで生成されるコード自体は、ビジネスロジックには一切介入してきません。

次にGoaの注意点に関してです。Goaで現在使用できるのは、OpenAPIのv2だけです。issueが立っていて、v3対応に向けて作業中です。

デザインから実装まで

実際にどう開発しているか、僕たちのチームでどう開発しているかです。まずデザインを作ります。デザインファイルの一部を掲載していますが、この.Payload()というのがリクエストのパラメータです。このパラメータでは、nameというものがStringであるとか、年齢ageがIntであるということを定義しています。ageに関しては.Description()で年齢ですよとか、.Example()で18歳ですよと記述することができます。

こういったデザインファイルを作ってから生成用のコマンドを叩くと、genというディレクトリが作成されて、その配下にopenapi.yml(が作られます)。JSONバージョンもあります。このドキュメントだったり、雛形のコードを生成することができます。

生成したら、それをクライアントチームもサーバーサイドもみんな集まってレビューします。レビューのときはデザインファイルを見るわけではなくて、Swagger UIを使ってブラウザで閲覧しながらレビューしていきます。レビューのときは、S3にホスティングしたSwagger UIで確認するようにしています。ただしローカルに関しては、Docker ComposeでSwagger UIのイメージがあるので使って見るようにしています。

これを簡単に説明しておくと、genの配下のopenapi.yml、つまり生成されたopenapi.ymlですね。これをコンテナ上の適当なところにマウントして、そのマウントしたパスをSWAGGER_JSONという環境変数に渡してやることで、作成したopenapi.ymlの中のドキュメントを簡単に閲覧することができます。

先ほども述べたんですが、みんなでレビューするときは、いちいちこのローカルに落としてくるということをせずに、S3にホスティングしているものを見に行きます。実際にどんなドキュメントが見られるのかというと、こういったドキュメントになります。今回はユーザしか作ってなくて、しかもGETとPOSTしかないのであまり見栄えしませんが、こういったドキュメントを簡単に見ることができます。

今言っていたS3のみんなへの共有方法に関しては後ほど詳しく述べていきます。

レビューが通ったら、最後に本実装していきます。生成されたコードをビジネスロジックと紐付けるところです。これがプレゼンテーション層のコードです。このCreate()がハンドラにあたるところです。ここでGoaで生成した.CreatePayloadと、.CreateResult。これらは(.CreateResultが)レスポンスの中身と.CreatePayloadがリクエストパラメータの中身を定義したものです。

このようにインとアウトが確定しているので、ドキュメントと乖離することなくコードを書いていくことができます。

その他工夫したところ

では他に工夫しているところは何かあるかなというところを話していきます。まず自動生成したファイルの管理についてです。これはGitでの管理はしていません。なぜかというと、誰が生成しても同じファイルができあがるからです。この生成したファイルも含めてしまうと、かなりの量があるのでプルリクレビュー時に余分な差分が出ます。そういうのもイヤで管理はしていません。

生成にかかる時間も数秒程度です。なのでCI時も、いちから生成しています。現状、CIにめちゃくちゃ時間がかかるとかがないので、今のところは何も問題なくこの運用で進んでいます。

先ほど言っていたレビュー時のドキュメントをどうやって公開するかというところです。先ほども述べた通り、S3でSwagger UIをホスティングしています。デフォルトでは、開発の最新のブランチとなるdevelopブランチを常に閲覧できるようにしています。

レビューのときはどうするんだという話ですが、ブランチ名のプリフィックスをdesignにすることで、CIにて生成したopenapi.ymlが、自動でS3にアップロードされるようにしています。

このアップロードしたやつをどうやって見るかという話なんですが、Swagger UIのヘッダー部分に、こんな検索窓が存在しています。この検索窓は、見たい対象のopenapi.ymlへのパスを入れてやれば、そのパスにあるopenapi.ymlを見に行ってくれるものです。今ここにdevelopと入っているんですが、ここを今作業していたブランチ名に変更してやれば、そのブランチのドキュメントを閲覧できるようになります。

レスポンスのカスタマイズもしています。デフォルトだとこの下のところに載っているようなエラーレスポンスです。これだとちょっと僕たちには必要がないものが多かったので、レスポンスの形式を変えました。具体的には、Goを使っている人じゃないとわかりにくいコードになってしまうんですが、このコードはエンドポイントとハンドラを結び付けてくれるようなところだと思っていただければと思います。

注目していただきたいのはこのオレンジで囲っているところだけです。エラーハンドラとフォーマットを引数で渡せるようになっています。なので自分たちで作ったerrhandlerであったりformatterというのを渡してあげれば、それに従ってエラーレスポンスの形式を変えてくれます。

これは一例なんですが、エラーコードにオリジナルのエラーコードを付けてあげたりメッセージを付けてあげたりということが可能になります。

次にgolintの遵守です。Goa公式のサンプルコードに沿って開発していると、ドットインポートをしているためにgolintに怒られてしまいます。ドットインポートというのは、このimportしている部分にあるドットですね。ドットインポートをしていないバージョンがこれです。DSLパッケージをインポートして、使うときはdsl.Service、dsl.Descriptionみたいにしないといけません。

ドットインポートしていると、さっきのdsl.の部分を消すことができます。なので記述するときにはかなり便利なんですが、これ自体はgolintに怒られてしまいます。そこで僕たちのチームではドットインポートは使わないようにしています。このデザインファイル自体のlintというのは正直どうでもいいことなので、ここでルールを曲げるよりかはちゃんと本実装のコードでルールを守りたいということでドットインポートはしないようにしています。

正直面倒くさいです。面倒くさいですが、やっぱり本実装のコードを守りたいという意識のほうが強いというわけです。

Goaでドキュメント中心の開発を実現

まとめです。今回はGoaを使ってドキュメント中心の開発を実現しています。コードとドキュメントは今のところは乖離が一切ないと考えていますし、実際そうですね。ドキュメントのレビューによって本実装に入る前にレビューをしているので、手戻りを削減できていると考えています。それからもう1点、クライアントとサーバーサイド間で認識合わせにもなるので、これはかなり有益だなと思っています。

最初にも言っていたAPI開発遅れによるクライアントサイドでの待ち時間も削減できているかなと思っています。

まとめのあとに余談なんですが、僕はgRPCを使いたいなという話をしていました。gRPCに乗り換える準備自体はできていて、先ほども述べた通り、Goaというのはプレゼンテーション層にしか現れません。つまりさっきの図でいうとこんな感じですね。なのでこの黄色でGoaとなっている部分をgRPCに置き換えてやるだけで、あとはビジネスロジックに関しては一切触ることなく修正できます。

gRPCに完全に置き換えなくても並存もできるので、乗り換えは全然簡単にできると考えて思っています。

以上で発表を終わります。お聞きいただきありがとうございました。