Gyazo の Web API の設計変更

以前の記事でも書きましたが、業務委託として Nota 社の Gyazo というサービスの実装を手伝っており、その中で Web API の設計を変更した話について書こうと思います。以前の記事というのは、以下の記事のことです。

Gyazo 開発環境の Docker 化

ここでは、Web ブラウザやその他のクライアントから HTTP を介して利用し、JSON などのデータフォーマットでクライアントアプリケーションとやり取りを行うようなエンドポイントのことを Web API と呼んでいます。

Jbuilder からの移行

これまでのコードでは、JSON を生成するために Jbuilder というライブラリを使っていました。これは DSL を用いて JSON を生成するライブラリで、Rails の場合は ActionView と協調して動きます。

rails/jbuilder

Jbuilder からの変更の理由は幾つかあるのですが、主要な理由を挙げると、以下のようなものです。

  • 実装の依存関係が分かりづらい
  • 生成結果が予測しづらい
  • 処理速度が遅い

実装の依存関係が分かりづらいというのは、Controller から使う Jbuilder は View のコンテキストで処理されるため、View で利用できる Helper などが奔放に利用されることになり、結果的にある JSON 形式の文字列を生成するのにどういうコードが利用されているのかが、我々のコードでは分かりづらくなっていたという話です。また、JSON を生成するために View のコンテキストを用意する必要がある (ようなコードが書かれることが多い) ため、再利用性の観点でも難がありました。

生成結果が予測しづらいというのは、例えばフロントエンドの開発者が API のコードを見て「このエンドポイントはどういう形式のデータを返すんだろう?」と調べようとしたときに、コードを見て推測しやすいかどうかという話です。

HTTP リクエストや Controller、View、Helper といった概念とは独立しており、入力値を元に JSON 形式の文字列を生成でき、生成結果がコードから予測しやすく、部分的に (例えば生成される JSON の Object のプロパティの1つ1つに対して) 独立した単体テストを用意できるような、これまでとは異なる実装を我々は求めていました。

Object#to_json

Jbuilder からの移行先として、ActiveSupport の提供する Object#to_json を利用した、より素朴な JSON 生成方法を自作することにしました。

render(json: object)

例えば Rails の ActionController では、上記のようなコードを書くと、object に対して to_json を呼び出した結果がレスポンスボディとして出力されます。このとき ActiveSupport の提供する Object#to_json が呼び出されることになる訳ですが、ActiveSupport の実装では、object が to_hash メソッドに対応している場合、その呼び出し結果に対して再帰的に to_json が呼び出されます。逆に言うと、to_hash を実装したオブジェクトを用意するだけで JSON を生成できます。

この仕組みを利用し、画像やユーザやコメントなど API で扱うリソースそれぞれについて「あるリソースの JSON compatible な表現を #to_hash の実装をもって表現するクラス」を用意することで、素朴なクラスを利用して JSON を生成していく環境を整えました。この手のクラスのことを、Gyazo では Representation と呼称しています。

今回はたまたま Object#to_json が便利だったので利用しましたが、ActiveSupport や Ruby に限らず、再帰的に探索して JSON を生成する仕組みが用意できるのであれば、あるいは JSON に限らず辞書型の構造を利用できる別のフォーマットを利用する場合でも、同じようなことは実現できると思います。

Representation という抽象

以下に画像のリソースを扱う Representation のコード例を挙げます。

Gyazo の画像オブジェクトを Hash 形式に変換するためのクラス

Gyazo::Representations::Base クラスで property という特異メソッドを用意しており、これを呼び出すことで、このリソースに特定のプロパティがあることを宣言します。プロパティは、#to_hash の実装に使われ、プロパティ名をキー、同名のインスタンスメソッドの呼び出し結果を値とする Hash が #to_hash の返り値になります。

上の例では、created_at, description, id, user という4つのプロパティがあります。それぞれのプロパティがインスタンスメソッドを利用して実装されていることで、プロパティごとに独立して単体テストを行うことができるという利点があります。

また、それぞれのメソッドに対して YARD でドキュメントを付けることで、期待する型を推測しやすいようにしています。発展的な課題として、この型を静的解析することで、クライアントサイドでの型付けとの互換性を検査することができるのではないかと考えています。

ちなみに親クラスである Base クラスは、以下のような実装になっています。

辞書型のデータ構造の生成に Representation を利用することで、必然的にそのデータ構造に固有の名前が付くことになるため、REST を意識した URL 設計が促進されたり、後述するクライアントサイドでの型検査と協調しやすくなったりと、適切な名前が見出されるという効果も狙っています。

ルーティングの話

URL 設計の話が出たので、ルーティングの話をします。Rails の場合、config/routes.rb を利用する訳ですが、結局のところ、どの種類の HTTP メソッドと、どのようなパターンのパスの組み合わせを、どんな名前の Controller の action に紐付けるか、という話題に集約されます。

まずどの種類の HTTP メソッドを使うかという話ですが、今回の API では、DELETE、GET、PATCH、POST の4種類のみを利用し、操作の種類に合わせて適切なメソッドを割り当てています。

次にどのようなパターンのパスを使うかという話ですが、

  • リソース名が分かるパス名にする
  • リソースのネストを許す
  • パラメータ名は種類ごとにアプリケーション内で一意にする

という指針で設計しました。「パラメータ名は種類ごとにアプリケーション内で一意にする」というのは、例えば画像の ID を表すのに /images/:id のように id という名前を与えるのではなく、/images/:image_id のように :image_id という名前を与えよう、という話です。Gyazo の API には特定の方式でエンコードされた画像 ID を利用するエンドポイントがありますが、この場合は /images/:encoded_image_id のように :encoded_image_id という名前を付け、:image_id とは異なる種類のパラメータであることを明示しています。

このようにパラメータの種類ごとに名前を使い分けることで、コードが分かりやすくなり、また、あるパラメータを利用して特定の操作を行うという処理を action 間で共通化しやすくなるという効果もあります。

最後に、どんな名前の Controller の action に紐付けるかという話ですが、Controller はリソースのネストの仕方に従った名前にしています。例えば、画像一覧を返す action は ImagesController#index、あるユーザの画像一覧を返す action は UserImagesController#index というように、ネストの仕方が Controller の名前に現れるようにしています。action には、create、destroy、index、show、update の 5種類だけを利用しています。

上記の設計を config/routes.rb にコードとして落とし込む必要があり、Rails が提供している各種 DSL ではその実現方法として幾つかの選択肢がありますが、今回は以下の記事のような方式で実装しました。

config/routes.rb の書き方を見直した

API クライアント

Web API のサーバサイド側を変更するついでに、クライアント側にも改善を加えました。クライアントサイドではこれまで HTTP クライアントとして jQuery と superagent が使われていましたが、Promise ベースであることと interceptor の仕組みがよく出来ているという理由から、axios を利用するように変更しました。

axios の interceptor は、CSRF 対策のトークンをリクエストに埋め込んだり、レスポンスを変換したりする用途で使っています。

axios/axios

同時に、axios を利用して Web API の各エンドポイントと通信を行う処理をそれぞれ状態を持たない関数にまとめ、JavaScript の静的型検査器を利用して型検査できるようなコードにしました。Gyazo では以前より Flow の導入が進められていたので、この関数の型付けにも Flow を利用しました。

Flow: A Static Type Checker for JavaScript
A Static Type Checker for JavaScriptflow.org

サーバサイドの Representation の実装を見ると、YARD で型のヒントが付けられているので、これと対応した型の情報をクライアントサイドでも用意しています。

サーバサイドとクライアントサイドで型に関する情報が重複しているので、この辺りを共通化できる仕組みを用意できると嬉しいですね。あるいは、サーバサイドの型情報は「こういう型のデータを返す」という宣言であり、クライアントサイドの型情報は「こういう型のデータが入力される前提で動作する」という表明でもあるので、この宣言と表明に齟齬が生じていないかを確認する、という観点の検査を行う仕組みがあると良いと考えています。

変更作業

Gyazo の Web サイトは Single-Page Application を意識した設計になっており、サイト内のさまざまな箇所から XHR で Web API を利用しています。ここで利用しているエンドポイント全てについて、既存実装と同じように動く新しい設計の実装を用意し、およそ 2ヶ月ほど掛けて変更作業を行いました。

具体的な作業としては、エンドポイントごとに新しい設計に移行し、それぞれ Pull Request を用意し、レビューや検証を行いながら徐々に移行を進めました。規模感としては、1つの Pull Request あたり百行から数百行程度の変更で、Pull Request の個数で言っても数十個程度の変更だったので、そこまで大きな規模の変更ではありませんでした。

苦労したところ: 使われていないコード

振り返ってみると、最も苦労を感じた点は、使われていないコードが多く残されていたことでした。既存の Web API の実装を全て移行するという観点で作業を行っていたので、使われていないコードについても移行しようとしてしまい、行き詰まった結果社内の詳しい人間に尋ねて作業内容を捨てるというシーンが頻繁にありました。

使われているコードについては、コードを見ることでいま現在使われているという事実に確証が持てますが、使われていないコードについては、本当に使われていないコードなのかどうか確証を持つのは難しいものです。継承を利用した差分プログラミングや、Convention over Configuration と呼ばれるような暗黙的な参照方法で対象のコードを利用している可能性も考慮する必要があり、単純に文字列を検索するようなコードの調査方法だけでは確実性に欠けます。

また多くの場合、もはや使われなくなったコードというのは丸々残される訳ではなく、断片的に残存するわけで、そもそもコードから得られる情報量が乏しいという難しさがあります。バージョン管理をしているとはいえ、無の背景にある履歴を紐解く作業は、存在するコードの履歴を調べる作業に比べ、難易度の高い作業です。

そういうわけで、今回の作業から得るべき教訓として、不要なコードの削除が将来の設計変更を容易にする (ので不要なコードを削除することに価値を見出そう) ということ、あるいは既存の不要なコードの存在が変更速度に影響する (のでその影響を最初からきちんと見積もろう) ということを考えさせられました。

今回の作業では、せっかく実装を大きく変更するということで、このタイミングで API に関係する全ての Controller やテストのコードの設計についても別途見直しを行えたので、負債の返済という意味でも良い機会だったのではないかと思っています。

おわり

以上、Gyazo の Web API の設計を変更した話について書きました。やりましたという体裁で書きましたが、あと1割ぐらい変更が残っています。関係者に設計意図をあらためて説明したいという目的もあり、作業が完了するより前にこの記事を書きました。

色々なやり方がある領域で、我々は今回こうしましたというだけの内容ではあるのですが、Web API の実装について悩んでいるとか、あるいは何らかの設計をベースに議論をしたい方の参考になればと思います。

自分は、来年からもしばらくはフリーランスとして活動していく予定です。Web API 周りの設計を今一度見直したいとか、あるいは徐々に人が増えつつありコードの品質向上が目下の課題になってきているとか、そういうところで相談に乗れる部分があると思うので、自分でよければぜひお声がけください。