gRPCを用いたマイクロサービスのAPI仕様の記述

この記事はMERPAY TECH OPENNESS MONTHの10日目の記事です。

こんにちは、メルペイのバックエンドエンジニアの柴田@yoshiki_shibata)です。

メルペイのバックエンドは、Google Cloud Platform上でGoogle Kubernetes Engineを使用して、マイクロサービスアーキテクチャを採用した多数のマイクロサービスから構成されています。モノリシックなサービス実装では複数層のライブラリ(あるいはコンポーネント)から構成されるのに対して、マイクロサービスアーキテクチャでは複数層のマイクロサービスから構成されます。

どちらのアーキテクチャにおいても、偶発的プログラミング(Programming by Coincidence1を避ける2ために、注意を払って作成する必要があるのが、境界部分のAPI(Application Programming Interface)仕様です。モノリシックなサービス実装であれば、それを構成する各ライブラリ(あるいはコンポーネント)のAPI仕様ですし、マイクロサービスアーキテクチャであれば、各マイクロサービスのAPI仕様です。

この記事では、マイクロサービス間の通信に用いているgRPCのAPI仕様をどのように記述しているかを紹介します。

API仕様に求められる内容

どのようなAPI仕様であっても、そこに記述されなければならない事柄は基本的に同じです。

  • 提供される機能の説明
  • 関数呼び出し、メソッド呼び出し、あるいは、RPC呼び出しでのパラメータの意味と正当な値の範囲
  • 呼び出されるための事前の状態に何らかの制約があったり、呼び出し順序に何らかの制約があったりするのであれば、どのような制約なのか

そして、防御的プログラミング(defensive programming)の観点から、以下の事柄もきちんと仕様に記述されている必要があります。

  1. 不正なパラメータ値が渡された場合、どのようなエラーが返される(あるいは例外がスローされる)のか
  2. 不正な状態や不正な順序で呼び出された場合、どのようなエラーが返される(あるいは例外がスローされる)のか

Java言語であれば、これらはすべてJavadoc形式でソースコードに書いていきますし、1.)に対しては、NullPointerExceptionIllegalArgumentExceptionIndexOutOfBoundExceptionなどを、2.)に対してはIllegalStateExceptionなどを使います。Java言語であれば、幸いなことに、API仕様にどのような事柄を書くべきかが『Effective Java 第3版』に書かれています。

一方、メルペイのバックエンドの各マイクロサービスはGo言語を用いて実装されており、マイクロサービス間の通信はgRPCを用いています。Java言語と違って、gRPCを用いたAPI仕様の書き方の標準は存在しません。しかし、gRPCを用いたとしても書くべき事柄は同じであり、Javadocに相当する書き方の標準がないので、記述方法を工夫する必要があります。

このブログでは、私自身が工夫した記述方法を説明します。

.protoファイルに記述

gRPCは、RPCの定義を.protoファイルに書いてprotocでコンパイルしてスタブを生成します。RPCはその名前が示す通り、Remote Procedure Callであり、手続き(procedure)を定義する訳です。呼び出しのパラメータ、呼び出し結果のレスポンスなどを構造体(struct)として定義します。また、エラーを通知するためにステータスコードを返すことができるようになっています。ステータスコードは、Javaにたとえるとメソッドがスローする例外に相当します。

gRPCのAPI仕様の標準的な記述方法はありませんし、ステータスコードを.protoファイルに記述するための構文もありません。担当するマイクロサービスのAPI仕様を策定する際に、マイクロサービスのgRPCの定義(.protoファイル)とAPI仕様が乖離することを避けるために、API仕様はすべて.protoファイルにコメントとして記述することにしました。

簡単な例

gRPCのprotoファイルの例として、https://grpc.io/docs/guides/ には次のようなサンプルが掲載されています。この例を使って、採用した書き方を説明します。なお、番号(①、②、③、④)は私が説明用につけたものです。

// The greeter service definition. ①
service Greeter {
// Sends a greeting ②
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
// The request message containing the user's name. ③
message HelloRequest {
string name = 1;
}
// The response message containing the greetings ④
message HelloReply {
string message = 1;
}

serviceの説明

①には、定義するサービスの説明を書く必要があります。この例では、サービスにRPCが一つしか定義されていませんが、通常は複数のRPC定義が書かれますので、サービスが提供する機能の概要を書く必要があります。サービスによっては、数行ではなく、10行以上の説明になることもあるかと思います。また、サービスの説明だけでなく、サービスが提供するすべてのRPCで共通に返すステータスコード(エラーコード)の説明も書くことになります。

RPCの説明とエラーの記述

②には、RPCの説明が簡潔に一行で書いてあれば十分だと判断しました。なぜなら、RPCの細かな振る舞いやリクエスパラメータにおける事前条件を説明しようとすると、パラメータである構造体やレスポンスである構造体のフィールドの定義がすぐに参照できるように同じ箇所に書かれている方が都合がよいので、②に書くには不適切だと判断しました。さらに、私が担当したマイクロサービスではRPCの定義が40個以上あったことも、service定義部分で個々のRPCの説明を書くのが不適切だと判断した理由です*3

③は、RPCに対応したリクエストパラメータの構造体定義に対応する説明部分なので、そのRPCの説明(振る舞い)を書くのはこの部分が適切だと判断しました。そして、さらに、以下のことも記述することにしました。

  • リクエストの構造体の各フィールドに許される値
  • 許されない値が設定されていた場合に返されるエラー

たとえば、上記の例では、実際には何も書かれていません。nameが空でもよいのか、空を許すとしたらそれは何を意味することになるのか、空を許さないとしたら空の場合どのステータスコードが返されるのかが何も書かれていません。また、RPCを呼び出すために何らかの認証が必要なのかどうかも記述されていません。そのようなエラーの記述は、RPCおよびリクエストの各フィールドの説明と同じ部分に書くのが都合がよいので、③の部分に書くことにしました。

不正なパラメータの場合、単純にリクエストのフィールドの値が仕様で要求される形式や取り得る値の範囲を満たさないのであれば、ステータスコードはInvalidArgumentでよいかと思います。そうではなくて、たとえばリクエストのフィールドで指定されたデータがデータベースに無いのであれば、NotFoundかもしれません。どちらであっても、どのような場合に、どのステータスコードが返されるかをきちんと記述する必要があります。

gRPCには成功のOKを含めて標準のステータスコードが17個定義されています(Go言語用の定義はこちら)。どのような場合に、どのようなステータスコード(エラーコード)を返すかは、きちんと設計し、かつ、API仕様に明確に記述しなければなりません。つまり、③の部分に明確に記述する必要があるということです*4

④のレスポンスについては、その内容を理解するために必要な説明を書く必要があります。ただ、定義から自明の場合には、何も書かなくてもよいかもしれません。

実際に私が英語で書いた、あるマイクロサービスの.protoファイル(1個のservice、46個のrpc)はAPI仕様も含めて2000行以上あり、企業秘密なのでここで示すことはできません。しかし、例として、上記のGreeterを例として書き直したとしてたら次のようになります。

/**
 * Greeter service provides a way to say a greeting message to a user and 
 * returns a message from the user.
 *   - Each RPC may return Internal but it is not listed in the [ERRORS] section for brevity.
 */
service Greeter {
// Sends a greeting message to a user and returns a message from the user.
rpc SayHello (HelloRequest) returns (HelloResponse) {}
}
/**
 * Hello sends say a hello message a user and returns a message from
 * the user.
 * - the returned message may be empty.
 *
 * [ERRORS] 
 * - InvalidArgument:
 *   - name is empty or too long
 * - NotFound:
 *   - the user specified by name is not found in the system
 */
message HelloRequest {
string name = 1;
}
message HelloResponse {
string message = 1;
}

UnknwonInternal

API仕様には、gRPCで提供するサービスが提供する機能に応じて、どのようなステータスコードが返されるかを記述することが重要だと述べました。ただし、UnknownとInternalに関しては注意が必要です。

Go言語のgRPC用のミドルウェアでは、statusパッケージを使わずに生成したerrorを返すと、コードとしてUnknownが返されます。たとえば、データベースへアクセスした結果のerrorをそのままRPCのerrorとして返すと、コードはUnknownとなります。つまり、Unknownとは適切にステータスコードが指定されなかったことを意味しますので、API仕様でUnknownを返すことがあるとは決して書かれるべきではありません。

返すステータスコードとしてのInternalは、呼び出した側の問題ではなく、呼び出された側の何らかの不変式(invariant)が成立していないときに返します。言い換えると、設計上のバグと考えられるものはInternalを返すことになります。Java言語で言えばAssertionErrorが明示的にスローされるような場合です。ソフトウェアを完璧に作るのは容易でありませんので、設計上の誤りと想定される事象が起きたときはInternalを返すことになりますが、すべてのRPCに共通することなので、サービスの説明に書いておくだけでよいと思います。もちろん、UnknownInternalは、サービスインの前にすべて発生しないようにテストを実施していることが理想です。

エラー翻訳

前述のUnknownの例だけでなく、一つのマイクロサービスが依存している他のマイクロサービスが返すエラーをそのまま伝搬して返すと、不適切なエラーとなることが多いです。RPCが提供する機能に対する適切なステータスコードを返すためには、「エラー翻訳」(Javaで言うところの「例外翻訳」*5)を行う必要があります。適切なエラー翻訳を行うためには、RPCが提供する機能に対して概念的に正しいステータスコードを仕様に定義して、マイクロサービスの実装でそのステータスコードへ変換する必要があります。

README.mdの自動生成

メルカリ/メルペイの.protoファイルは、GitHub上のあるリポジトリで管理されており、.protoファイルが更新されると、自動的にREADME.mdファイルが生成されるように設定されています。コメントとしてAPI仕様を書く場合には、.protoファイルからコメントを抽出して、オンラインドキュメントを生成してくれるツールが求める形式で書くのがよいと思います。たとえば、ツールとしてはprotoc-gen-docなどがありますので、それを使用するのであれば、ツールに合わせてAPI仕様を書くのがよいかと思います。

まとめ

きちんとしたAPI仕様を書いていない場合、そのAPIのテストコードは正常ケースだけだったり、不正なパラメータが渡されたときにどのように振る舞うかは、実装のソースコードを見ないと分からなかったりします。さらに、「エラー翻訳」(「例外翻訳」)を行っていない実装は、いつのまにか返されるエラーが変わってしまっていることも起こり得ます。

不備が多いAPI仕様は、不具合が多いAPI実装を生み出し、結果として長期的な開発コストを増加させます。これは、サードパーティへ提供するソフトウェアではなくても、会社内で閉じているソフトウェアでも、長期的な開発コストを増加させる結果となります。

(Javadocのような)API仕様記述の業界標準が存在しないからといって、gRPCの定義でAPI仕様をを何も書かなくてもよいわけではありません。何らかの工夫をして書く必要があります。私が担当したマイクロサービスでは、.protoファイルにすべてコメントとして記述することを選択しましたが、よりよい方法が存在するかもしれませんし、今後登場するかもしれません。

*1:Andrew Hunt、David Thomas 『達人プログラマー』(オーム社)

*2:『達人プログラマー』の「Tip 44 偶発的プログラミングを行わないこと」

*3:SpannerのAPIのようにすべてここに書かれているものも存在します。

*4:SpannerのAPIでは残念ながらどのようなステータスコードが返されるかは、書かれていません。

*5:『Effective Java 第3版』の「項目 73 抽象概念に適した例外をスローする」

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