Rails
api
microservices

ロバストネス原則とマイクロサービス(2)

ロバストネス原則をマイクロサービスの側面からもう少し丁寧に掘り下げてみます。マイクロサービスの最も一般的な統合方法である、Web APIでの統合を考えます。つまり、Consumerが(通常HTTPの)リクエストを送り、Providerがレスポンスを返すパターンです。リクエスト・レスポンス系では、丁寧に書くと以下のように整理されます。

Consumer (1.送信) => (2.受信) Provider
Consumer (4.受信) <= (3.送信) Provider

このうち、前回の記事では、4のConsumerの受信のところのみ言及しました。他の部分はどうでしょうか。

前回特に言及しませんでしたが、ロバストネス原則のもう片方、「送信するものは保守的に(仕様に正しく則って)」というものは、これは当然守られるべきです。上の中では、Consumerのリクエストの送信、Providerのレスポンスの送信は、APIの仕様に則って正しく送信されるべきです。特に、Providerの送信について、APIのレスポンスの型を定めそれをProviderが守ることなどが必要です。軽視すると後々辛くなります。JSONを返すであれば、OpenAPIやJSON Hyper-schemaなどを組み合わせて、型チェック出来るようにしつつ同時に自動ドキュメント生成するのが良いです。もちろんgRPC + Protocol Buffersで型をカッチリさせるのも有力です。

Providerの受信とロバストネス原則

次に、 Providerの受信はロバストネス原則を守るべきでしょうか?
これについては議論がありそうです。
少なくとも、きちんとやるべきなのはValidationで、基本的には未知の値が来たら400 Bad Requestにするのが普通でしょう。知らない値が来ても未定義の動作をするのは良くありません。

では、RFC 1122で言及されていたような、列挙型のパターンはどうでしょうか。例えば、あるtypeを受け取ってフィルタリングして配列を返すようなAPIを考えます。そこに未知のtypeが来た場合は、400 Bad Requestにするべきか、200 OKにして空配列を返すべきでしょうか?

これは、よほど200を返すのがしっくりくるAPIを除いて、400にするのが良いでしょう。その理由は2つあります。

まず、未定義値も受け入れて値を返すとき、寛容に受け入れるところまでは良いが、自身の送信(レスポンス)に対して、正しさを保証できない場合があります。
例えば、空配列を返せば、Consumerは正常系として処理を進めるでしょう。その結果として特に問題なく処理が進んでしまった結果、本来は失敗するべきだった処理が行われてしまうかもしれません。

もう一つは、Consumer-Providerの関係性と信頼の度合です。
RFC 1122では他のノードは「悪意をもつ可能性」を考えていたのに対し、マイクロサービスではそれはない(信頼度がそれよりは高い)ので、サービスの進化による拡張を考えます。
しかし、基本的にConsumer-Providerの関係では、何か拡張するとしたらProviderが先です。Consumerが突然新しい未定義値を送りたくなる、ということは通常考えにくいのです。
逆に、この種のものは最初からエラーになっていれば普通本番より前の環境で気づくが、一度通しておいたが後からエラーになるというようなケースのほうが被害が広がりそうです。

適当な実世界の例をまた出しますが、本来3日前に依頼されるべき仕事を、「この仕事今日中にお願い」と無茶な依頼をされたら、どうするでしょうか。
「受信するものには寛容に」OKですよと答えるでしょうか(200 OK)。それは寛容ではありますが、返答に責任を持っているとはいえず、「行うことには保守的に」の原則に反しています。
したがって、自分の行動に責任をもつべく、「3日前に言われなかったので(バリデーションの理由)、できません(400 Bad Request)」と答えるのが正しいでしょう。
もちろん、実際には今日中にはできるのなら、「やりますよ(200 OK)、次から3日前に依頼して下さいね(WARNログを吐く)」とすれば、両方満たせるので、ロバストネス原則に照らし合わせれば最も正しい返答となります。

Consumerの受信をロバストにするためのチェックリスト

1日目に色々書きましたが、あまり実際の例には踏み込めませんでした。ここでは、実際にリモートAPIを呼び出すConsumerを実装する上で気をつけることを列挙してみました。

ロバストネス原則に反した(多くの古い)RPCはそもそも使わない

あまり古いRPC実装に明るくないので軽くスルーしますが、Martin FowlerのTolerantReaderや、Sam Newmanのマイクロサービスアーキテクチャでも繰り返し警告されています。

フィールドの追加によって壊れないように注意する

これはロバストネス原則を持ち出すまでもなく広く合意されていることですが、意外と注意が必要です。
取得したjsonを、例えばハッシュのような生に近い状態のまま取り扱っていると、事故が起きやすくなります。

以下はRuby on Railsでのサンプルです。

class AbcRepository
  def self.abc
    res = some_http_client.get 'https://abc.com/path/to/abc'
    # res.body is a json, expected { "hoge": 123, "fuga": "aaa" }
    Abc.new(res.body)
  end
end

class Abc
  include ActiveModel::Model
  attr_accessor :hoge, :fuga
end

要は、返ってきたレスポンスのjsonを直接Abc.newに突っ込んでいます。これは現在は動きますが、例えば未知のフィールドpiyoが入ってきたときに、エラーになってしまいます。

# 期待する挙動
Abc.new({ "hoge": 123, "fuga": "aaa" })
#=> #<Abc:0x007f95d80b75a0 @hoge=123, @fuga="aaa">

# フィールドが増えるとエラーに
Abc.new({ "hoge": 123, "fuga": "aaa", "piyo": 1 })
#=> ActiveModel::UnknownAttributeError: unknown attribute 'piyo' for Abc.

気をつけていないと起きそうです。対策としては、何らかの方法できちんと必要なフィールドを明示します。

    # Abc.new(res.body)
    Abc.new(hoge: res.body["hoge"], fuga: res.body["fuga"]))

多ければフィールド名をまとめて指定して絞るなどします。Railsなら slice 、JavaScriptのlodashなら _.pick などが相当します。

使っていないフィールドの削除によって壊れないようにする

1日目の記事で書いたことです。これも上と同じように必要なフィールドでフィルタしていれば問題ないでしょう。

未知の列挙値が来たときに、正常系として処理する

はじめのAPIでは、typeが"hoge"または"fuga"と決められていたのに、後から"piyo"が追加されるケースは、破壊的変更でしょうか。
モノによっては破壊的変更に近いものもあるかもしれませんが、一般的にはサービスの進化の過程で普通に起きることなので、これによって破壊されないようにきちんと対処しましょう。

APIを呼び出し未知のtypeを受け取ったRepositoryが取りうる行動は、いくつかあります。
モデルに「その他」のようなデフォルトの属性があれば、そういうモデルを作って返せばよいでしょう。
それがなければ、nullを返すか、または、特別に定義された例外を返し、メソッドの呼び出し側に必ずハンドルさせるようにするのも良いでしょう。例えばJavaであれば検査例外(RuntimeでないException)を使います。とにかく、アプリケーションとしてそういうケースがありうると想定して設計するのが重要です。