APIのバージョニングは限局分岐でやるのが良い

ちょっと前にTwitterAPIのバージョニングをどうやるかみたいな話をしていたのですが、そのへんもやもやしているので少し整理しておきたいなと。

さて、これについて色々と異論・反論も含めた意見が出たのですが、まずは、大昔にURL方式(=コントローラ分割)でやってきて後悔したぼくが、(5年ぐらい前から)現在はどうやってAPIのバージョンを管理しているか?について紹介します。

基本原理としては、コピペが多発する根っこで分岐(=コントローラ分割)じゃなくて、必要最小限のところで限局的に分岐するのがいい、という考え方に基づきます。

一言でいうと、「パラメータとしてAPI versionを渡し、それをリクエスト単位でスレッドローカル変数に保存し、必要に応じて分岐する」というやり方です。

API versionの渡し方

具体的なAPI versionの渡し方としては、おおまかに二種類あります。

まずは、ログインしてセッションを生成する段階でパラメータとして渡す方法。ものすごく簡素化していうと

/api/session/create?id=foo&password=bar&api_version=1

みたいな感じで、認証に成功したら短命なsession_tokenを受け取り、以降はサーバ側で保存されたAPI versionが適用されるので、クライアント側からはapi_versionのような付帯情報を毎回送る必要がなく、session_tokenだけをキーとして送ればよくなります。

このやり方は、グループチャットのLingrや対戦ゲームインフラのPankiaのような、オンライン・オフライン状態の識別がクリティカルなサービスの場合に有効です。

もう一つは、API Versioning - O'Reilly Broadcastでも紹介されていますが、毎回HTTP Headerにapi_versionを埋め込む方法です。

X-Api-Version: 1

このやり方は、データベースのリモートバックアップサービスDumperで採用していますが、毎回送るべき情報が少なく、オンライン・オフラインの区別がほとんどないサービスに有効です。OAuthベースのシステムとも親和性が高いでしょう。

リクエスト・コンテキスト

さて、なんらかの方法でAPI versionがリクエストにのってサーバに受け渡されたとして、それを使って分岐する可能性のある場所は、コントローラに限りません。データベースの構造が変わった場合や、JSONの構造が変わった場合など、モデルやその他のクラスでもAPI versionを参照する必要がでてきます。

そこで、リクエスト単位をライフサイクルとするスレッドローカルな変数を使い、コードのどこからでもAPI versionにアクセスできるようにします。

具体的にRailsのコードで見ていきましょう。まずは、「APIというコンテキスト」を扱うオブジェクトを定義します。ここではOpenStructを使って、ApiContext.api_version = 1のような形でアサインすると、自動でスレッドローカルに保存してくれるようにします。以下のコードをlib/api_context.rbに置きます。

require 'ostruct'

class ApiContext
  class << self
    def method_missing(method_name, *args)
      Thread.current[:api_context] ||= OpenStruct.new
      Thread.current[:api_context].send(method_name, *args)
    end
end

そして、app/controllers/application_controller.rb(あるいはapp/controllers/api/base_controller.rb)に

class ApplicationController < ActionController::Base
  before_action :clear_context_variables

  def clear_context_variables
    Thread.current[:api_context] = nil
  end
end

のようにしてグローバルなbefore_actionを定義してやり、リクエスト毎に必ずスレッドローカル変数がクリアされるようにしておき、

ApiContext.api_version = request.headers['x-api-version'].to_i

のような形でアサインします。以降はこのApiContext.api_versionを参照して、コントローラやモデルなど、どこにでも分岐を記述できるようになり、

if ApiContext.api_version > 1
  { result: [{ name: 'foo', age: 20 }] }
else
  { result: { name: 'foo', age: 20 } }
end

のように局所的に分岐できるようになります。

ところで余談ですが、スレッドローカル変数はいわゆる(スレッドセーフな)グローバル変数なので、条件反射的に「使うべきではない」という反応をする人たちがいます。しかしRails本体でも、たとえばi18nでリクエストごとの言語ロケールをセットするところなどはスレッドローカルで実装されており、リクエストをライフサイクルとする広域変数にスレッドローカル変数を使うのはむしろ定番です。

ただし、当然ながら副作用としてスコープリークが発生し、MVCの分離が甘くなるので、ApiContextの使用は必要最小限にとどめ、テストの記述にも注意が必要です。

議論

さて、ここまでの実装をふまえて、個々に議論していきたいと思います。

まさしくその通り!

つまり、コントローラ分割だと、よほど根本的で大きな変更がないかぎり、v2を導入することができなくなります。言い換えると、99.9%のサービスでは、v2を導入する日は永遠にやってきません。

ぼくがやりたいのは逆で、もっと気軽にAPI versionを使って、APIの質をどんどん改善していくことです。非互換な変更を入れなければいけないときというのは、よっぽど慎重にデザインしていても、やっぱり発生します。そういうときに、互換性を壊さないために「改善しない」という選択をするのではなく、「古いクライアントはそのまま動く、新しいクライアントではより良く動く、時間とともに良いクライアントが増える、いつかは古いバージョンをディスコン」というオペレーションです。

これもわかります。というか、その昔にぼくがURLで丸ごと切り替えるという判断をした理由もこれでした。

とにかくコードは一行でもいじると壊れる可能性があるので、互換性を最大限に維持したいなら、一切いじらなくてすむのが一番。だから古いコードはさわらず、新しくv2として書き始める。

と考えたのですが、これには大きな罠があります。

それは、アプリが進化する限り、下回りで依存しているライブラリ(Rails本体、Gemなど)もアップデートし続ける必要があり、それにあわせて結局v1のコードも書き換え続ける必要がある、ということです。

v1, v2, v3と増えるごとに、書き換える対象が増え続ける、これぞまさしく「コピペが増える」という表現で伝えたかったことです。

これは結局、シングルソースの限局分岐をメンテするよりも多大な労力がかかるので、結果的に後方互換性を壊してしまうリスクは同等かそれ以上にある、ということになります。

それだったらば、ライブラリにあわせての変更は一箇所で済み、分岐が限局されているやり方のほうが、結果的に品質も高くなるのではないか、という考え方もあるかなと思います。

そうそう、極めつけはこれだと思います。

せっかくコントローラを分けても、モデル層やJSON生成層その他で発生した非互換な変更には無力なので、結局ぼくがやってるのと似たような仕組みでMVCを超えたバージョンの受け渡しが必要になります。コントローラだけを大局分岐してバージョン管理できた気になるというのは、やはり悪手だろうと思います。

なんかすごそうなことをやってる。。。もうちょっとkwsk!というわけで、33の続報をRebuildできけることに期待。

いかがでしょうか。

ぼくなりの現時点でのテンポラリな結論としては以上なのですが、MVCがリークしているのでテストの書き方が難しいとか、まだまだ満足のいく水準にあるとはいえず、これという正解が見つかってない世界だと思います。

このやり方でうまくいってるという方法があれば、ぜひ教えてください。