Scalaは実はRPCを実装するのに適した言語

Taro L. Saito氏:もともと何をしたかったかというと、「Scalaでクライアントもサーバーも実装できるんだったら、Scalaでそのまま通信できないのか?」というところが出発点になって、今新しいフレームワークを整えているところです。

ScalaはFunctional(関数型)で、かつstatically typed(静的型付き)、というobject orientedな言語になっているのがいいところです。

これよく考えてみると、Scalaの関数をそのままRPCのメソッドにできるよね。Scalaのオブジェクトには、ちゃんと型がついているし、パラメータ名もついている。これもそのままRPCのリクエストやレスポンスタイプに応用できるよね。そうすると、ScalaはRPCを実装するのに実は非常に適した言語なんじゃ? なにもRESTとかProtocol Bufferを持ち出さなくても、Scalaをそのまま使えばいいんじゃ? というところで始まったアプローチが「Airframe RPC」、Scalaファーストな実装です。

まず、RESTとかProtocol Bufferのことはすべて忘れてください。Scalaのトレイトでメソッドを定義する。モデルクラス・ケースクラスでネットワーク中に渡すデータを定義する。

このRPCのインターフェイスをScalaで書くことによって、例えばサーバーの実装をそのままScalaで書けるし、クライアントの実装のためのコードを生成してあげて、Scala.jsだったりWebアプリケーションで動くようなサービスが簡単に作れるんじゃないかということです。

さらに、Airframe RPCはなにもScala.jsだけに限ったわけではなくて、例えばScala同士の通信も使える、応用することができます。

バックエンドサーバーもpluggableになっていて、今のところ実装はHTTP/1ベースのFinagleとHTTP/2ベースのgRPCが使えるようになっていますし、クライアント側もFinagleのクライアントだけじゃなくて、gRPCクライアント、さらによく使われているWebクライアントであるOkHttpだったり、もしpure JavaでいきたいんだったらJavaのURLConnection、標準ライブラリに入っているような簡易なURLConnection clientを使うこともできます。

RPCフレームワークの実装に必要なもの

こういうRPCのフレームワークを実装するときに何かが必要になってくるかというのを噛み砕いてみると、だいたい4つあります。

ネットワーク中にデータを流さなくてはいけないので、まず必要なのは、メッセージのシリアライザ・デシリアライザ。

さらに、どんなバイナリフォーマットでデータを表現しなければならないのかというところが肝になってきます。RESTの場合はJSONを使いますし、gRPCの場合はProtocol Buffer。Airframe RPCの場合は、次で紹介しますが、MessagePackを使っていきます。

そしてRPCのインターフェイスの言語としては、ここではもうScalaを使っていこうと。

4番目のクライアント・サーバーの実装については、コード生成したりとかライブラリのほうで自動的にサーバーを立ち上げられるような工夫がいくつかなされます。

シリアライゼーション

では、シリアライゼーションについて見ていきます。基本的にはScalaのオブジェクトをMessagePackに変換したいわけですけれども、じゃあそもそもMessagePackって何かというと、JSONと比較してもらうとすごくわかりやすくて、MessagePackはいわばコンパクトなJSONのバイナリフォーマットだと思ってください。

例えばここに例があります。JSONにおいて27バイトで表されるこういうデータです。パラメータを2つもっているデータをMessagePackにすると、18バイトの小さなバイナリで表すことができます。

MessagePackは、基本的にprefix、データの型と長さを表したフォーマット。ここで「82」というのはエレメントを2つ持ったMap型です。次の「A7」というのは7バイトのStringを表すprefixで、次にデータが続きます。Booleanは1バイトで表せますし、Integerも1バイトで表す。そういうよく使うデータを短く表す工夫が組み込まれているデータフォーマットです。

Treasure DataではMessagePackをすごくよく使っていて、これを使ってScalaのオブジェクトのシリアライゼーションをやるとすごく効率がいいんじゃないかというところで、airframe-codecというインターフェイスを導入しました。

基本的には、入力オブジェクト、すなわちインプットからMessagePackに変換する方向を「Pack」、その逆方向、すなわちMessagePackからデータに戻すほうを「Unpack」というインターフェイスを定義して、Scalaに適用しています。

Scalaはいろいろなデータタイプがあるので、そのあらゆるデータタイプに対して、predefined、すなわちあらかじめコーデックを定義してあげます。

例えばPrimitiveタイプ、すなわちByteやChar、Integer、それからLong、Double、String。そういう基本的なデータ型に対してのコーデックを定義してあげたり、Collection、Array、Sequence、List、そういうScalaのコレクションに対応したコーデックを定義して、複雑なデータ構造でもMessagePackに変換できるような工夫がなされています。

その他、Java特有のデータに対するコーデックなども定義されているので、ほぼ基本的にケースクラスを定義したらほとんどMessagePackに変換していけます。

さらに複雑なデータ構造の場合、例えばケースクラス。ここではケースクラスのAというものがありますけれども、これにはport、name、timeoutという3つのパラメータがあります。その各々のパラメータに対応したコーデックを組み合わせることで、ケースクラスのような複雑にネストしたデータ構造(data structure)でもMessagePackに変換できるようになります。

ここの例ではMessageCodec.of[A]というAirframeコーデックが定義されているメソッドを呼び出すと、オブジェクトAをMessagePack経由で読み書きするコーデックが生成されます。

ここではわかりやすさのためにJSONを紹介していますけれども、基本的にJSONはMessagePackにダイレクトに変換できるデータで、ここではJSONをMessagePackに変換してコーデックを通して元のオブジェクトに戻すという、そういう操作がなされています。逆方向も当然できる。なので、MessagePack経由でScalaの複雑なデータタイプを変換することが可能になっています。

RPC用インターフェイス

シリアライザができたところで、RPC用のインターフェイスをどうすればいいかというところを見ていきます。

Airframe RCPではScalaのトレイトに対して「@RPC」というアノテーションを付けてあげます。「@RPC」というこのRPCのアノテーションがついたトレイトに定義されているパブリックなfunctionは、すべてRPCのエンドポイントになります。

関数に複雑なケースクラスのデータが入っていた場合は、それがそのままデータ構造を定義するスキーマ言語(schema language)になる。このScalaで書かれたインターフェイスをバックエンド側・クライアント側で共有して実装を進めていくという流れになる。

関数呼び出しを実際にネットワーク上で送るにはどうすればいいかというと、例えばこのpersonとmessageという2つの引数があるhelloという関数をRPCで実行するときには、この関数呼び出しの引数に対応するMap型、すなわちScalaのMapのデータ構造を作ってあげます。

personというargument、すなわち引数にはPersonのケースクラスのオブジェクト、messageには例えば”Hello RPC!”というメッセージを入れたMapを作ります。このMapをAirframeコーデックを使ってMessagePackに変換してあげる。そしてHTTPのPOSTメソッドを使ってサーバー側に送ってあげるようにします。

基本的にこの関数に対応するエンドポイントのマッピングは、Scalaで定義されたパッケージ名。例えば「hello.api.v1」というパッケージでこの関数が定義されていたら、そのあとに「/」をつけて「hello」というマッピングをしています。contentのbodyには、先ほどシリアライズしたMapのfunctionのargumentのMapデータが入る。こういうかたちでリモートの関数を呼び出すデータを伝えられます。

サーバーサイドの実装はRPCのアノテーションがついたトレイトを単純に自然に実装するだけで済みます。MyServiceというトレイトがあったら、それを実装したMyServiceImplというのを普通のScalaのコードを書くように実装するだけでサーバーの実装が可能になります。

airframe-httpではこの今実装したRPCのimplementationをWebサーバーにする仕組みが備わっていて、FinagleをバックエンドにしたWebサーバーに立ち上げるのがこれぐらいのコード量で実現できます。Finagleというのは、Twitterが作っている高性能なHTTPサーバーのライブラリです。

例えばRouterというのはRPCのエンドポイントをルーティングするための機能なんですけれども、そこに先ほどのRPCの実装を追加する。ここには複数のRPCの実装を追加できます。Finagle.server.withRouterで今ここで定義したrouterを登録してあげて、任意のポートナンバー、ここでは8080番を定義して、まずサーバーをスタート。これだけでWebサーバーが立ち上がる。RPC用のWebサーバーが立ち上がる。

また、別の実装の仕方として、gRPCを使ったバックエンドも同様に実装できます。先ほどと同様に、例えばRPCの「sayHello」というトレイトを定義して、これを素直に継承して実装する。

これも先ほどと同様にRouterに登録するんですけれども、違いは、先ほどFinagleだったところをgRPCにするだけです。そうすると、今度はHTTP2ベースの高速なgRPCベースのバックエンドのWebサーバーが立ち上がる。

gRPCはHTTP2ベースで非常に高速で、その高速になっている理由というのは、1つのHTTPコネクションの中に複数のリクエストをmultiplex(多重化)して詰め込むことができるからです。よりローレイテンシ(低遅延)なリクエストハンドリングが可能になっています。

ここで見てほしいのは、Protocol Bufferがどこにも出てこないところです。gRPCは基本的にProtocol Bufferで使うのが主流なんですけれども、ここではなにもProtocol BufferなしでもScalaのインターフェイスだけあればgRPCのサービスが使えてしまうというところがおもしろいところです。

より少しアドバンスドなトピックとして、実際にどうやってgRPCを拡張しているのか。

Airframe RPCはgRPC-JavaというJava版のgRPCの実装をベースに実装されています。でも、gRPCそのものは実はProtocol Bufferを使わなくてもいい。データフォーマットagnostic、すなわちデータフォーマットに依存しないフレームワークになっていて、例えばScalaのインターフェイスをエンドポイントにしたり、MessagePackをProtocol Bufferの代わりに使いたいという拡張をしたいときには、2か所拡張してあげれば実現できるようになっています。

RPCのエンドポイントを拡張するのに必要なのがMethodDescriptor。MethodDescriptorに対してScalaのインターフェイスにマッピングするエンドポイントを定義してあげる。

さらに、どのようにデータをシリアライズするか。それをgRPCではMarshallerといっていますけれども、そのMarshallerのカスタマイズもできるようになっている。ここでMarshallerの実装として、MessagePackをやりとりするMarshallerをここに埋め込んでいます。

HTTP1とHTTP2との性能差

おもしろいところなんですけれども、じゃあHTTP1とHTTP2ベースのサーバーを使ってどれぐらい性能差があるかというのを見てみました。

ここで比較しているのはAirframe RPCのFinagleバックエンドとgRPCバックエンド。それからScalaPBというProtocol BufferのデータをScalaケースクラスにマッピングするライブラリですね。これもgRPC-Javaベースの実装になっている。

あとはgRPC-Javaをそのまま使った場合です。これはProtocol BufferのデータをJavaのクラスにマッピングする実装になっています。gRPC-JavaはgRPC界隈で最も速い部類の実装になっていて、これと同じぐらいの性能が出ていたら非常に優秀ということなんですけれども、FinagleとgRPCを比べてみて非常に性能差がある。倍以上の開きが出ている。

この青い部分がsynchronized、RPCのリクエストを1個1個処理した場合の性能です。Finagleだと1秒間にだいたい5,000ぐらいのRPCが処理できる。gRPCベースになると、8,000とか9,000とかそれぐらいのRPCを1秒間に処理できるようになっています。この性能自体は、僕が持っているMac Bookでローカルに実行したものです。

赤い部分が非同期に実行した場合です。非同期(asynchronous)というのはどういうことかというと、RPCのリクエストを同時にたくさん投げて、でも結果は、戻ってきた順に処理する。結果を1個1個待ったりはしない実装になっているので、asynchronousにするとすごく速く処理できます。gRPCベースのものは、ScalaPBでも1秒間に4万以上のRPC、Airframe gRPCだと4万6,000、gRPC-Javaだと5万2,000、非常に高いスループットを出してくれます。

ちょっとおもしろかったので、なぜScalaPBがほかの実装より遅いのかというのを気になって調べてみたんですけれども、ScalaのFutureを使うところで10パーセントぐらいオーバーヘッドがあったり、Protocol BufferをScalaのケースクラスにマッピングするところでオーバーヘッドがあるようでした。

でも、ScalaPBの作者に確認したところ、それほどに最適化やパフォーマンスはがんばっていないらしいので、まだ最適化の余地があるところのようです。でも、基本的にgRPC-Javaと比べても10パーセントか15パーセントぐらいのオーバーヘッドしかないので、非常に実用的なパフォーマンスだと思います。

RPCのクライアントの作り方

では今度は、RPCのクライアントをどう作っているかというところを紹介します。

基本的にはこのRPC、Scalaで書かれたインターフェイスの定義を読み取って、テンプレートベースのコード生成をしています。その生成するためにsbt-airframeというプラグインを作成しました。これを使うとgRPCのクライアントを生成したり、Scala.jsクライアントを生成したりなどもできますし、OpenAPIのスキーマも生成できます。

例えばOpenAPIを生成すると何がいいかというと、YAMLのAPIのspecを生成するとき、例えばSwagger Editorなどを使ってAPIのドキュメントをきれいに表示できたり、まだ試していないんですけれども、例えばクロスランゲージなRPCクライアント、つまりScala以外の言語からAirframe Rpcのサーバーにアクセスするためのクライアントも、もしかしたら生成できるかもしれない。

全体の流れ

それでは実際に今まで話してきたことを全部つなげてみます。このMyServiceというRPCがあって、それに対して生成されたクライアントがServiceSyncClient。これにairframe-httpで定義されている標準的なWebクライアントを渡してあげる。これでクライアントができる。

クライアントからmyServiceのインターフェイスを呼び出して、helloというメソッドに対してPersonというケースクラスを渡してあげる。これで実際裏側では、プログラムからクライアントへ渡って、裏側でMessagePackに変換されてサーバーに渡って、サーバーでこのmyServiceの実装を実行して、結果をMessagePackに変換して、またMessagePackからオブジェクトに戻して変換してプログラムに渡す。この一連の動作が全部この短い行数の中に収まっているというところですね。

ここでも裏側ではMessagePackだったりgRPCだったりFinagleだったりいろいろな技術が使われているんですけれども、全部忘れてScalaにフォーカスして開発を続けることができるようになっている。

(次回につづく)