Node.js x thrift@FOLIO
この記事はFOLIOアドベントカレンダー4日目、Node.jsでthriftを使うことになったという稀有な人のための記事です。
thriftとは
Apache Thriftは、RPCフレームワークです。 .thrift
ファイルにAPI定義を書くと、binary protocolで通信できるserver/clientのコードを生成できます。FOLIOでは、micro serviceが提供するAPIは基本的にはThrift
になっているので、Node.jsで書かれたBFF(Backends For Frontends)がserviceを叩くときはthrift APIを呼び出すことになります。(REST APIが少しだけゾンビのように生き残っています)
thrift(を始めとしたRPCフレームワーク)には、次のようなメリットがあります。
- binary protocolなのでJSON REST APIよりもデータ量を圧縮できる
- IDL(Interface Description Language)でAPIを定義できるので、メンテナビリティが高い
client/serverのコード生成から動かすまで
まずはthriftファイルを書きます。micro servicesのようなAPIを実装する側・叩く側がはっきりと分かれがちなアーキテクチャでは、こうしてIDLをベースに会話ができるのはとても便利です。
api.thrift
struct AddParameter { 1: required i64 a; 2: required i64 b; } service TestService { i64 add( 1: required AddParameter param ) }
thriftのコマンドを叩くだけでコードが生成されます。
$ brew install thrift $ thrift --gen js:node ./api.thrift
デフォルトで gen-nodejs
に生成されるので、これを読み込んで。
server.js
const thrift = require("thrift"); const Service = require("./gen-nodejs/TestService"); const PORT = process.env.PORT | 11111; const options = { transport: thrift.TFramedTransport, protocol: thrift.TBinaryProtocol }; thrift .createServer( Service, { add: function(params, result) { result(null, params.a + params.b); } }, options ) .listen(PORT, () => console.log(`Listen thrift sever. PORT: ${PORT}`));
これでserver側が出来上がりです。
client.js
const thrift = require("thrift"); const Service = require("./gen-nodejs/TestService"); const { AddParameter } = require("./gen-nodejs/api_types"); const PORT = process.env.PORT | 11111; const connection = thrift.createConnection("localhost", PORT, { transport: thrift.TFramedTransport, protocol: thrift.TBinaryProtocol }); const client = thrift.createClient(Service, connection); const params = new AddParameter({ a: 3, b: 4 }); client.add(params).then(result => console.log(`result: ${result}`));
client側はこんな感じ。
$ node server.js $ node client.js # result: 7
無事thrift APIを叩くことができました!
参考:Apache Thrift - Node.js Tutorial
ここからは、実際にthriftで運用していく上でのポイントを書いていきます。
型
JavaScriptといえば気になるのは型です。
TypeScriptの対応状況
なんとthrift公式で、オプションで型生成ができます。
$ thrift --gen js:ts ./api.thrift
とすることで、先のstructからは次のような型が生成されました。
declare class AddParameter { a: number; b: number; constructor(args?: { a: number; b: number; }); }
flowの対応状況
もちろんthrift公式では対応はありません。
が、npmで公開してくれているところはいくつかあります。そのうち、uber-web/thrift2flowを試してみたのですが、これasciiでreadFileしているから日本語コメントが入っているとコケることがあるという致命的な問題があったので、結局自作のライブラリを使っています(まだOSSにはできていない)。
幸いthrift-parserは使えるものがあったので、そこまで難しくありませんでした。
CIでのpublish
もともとNode用のthrift clientは、フロントエンドエンジニアが 温かみのある手作業で バックエンドの各リポジトリをgit submoduleで持ってきて生成していました。が、Backendがビルド構成を変えたことがFrontendに伝わっておらず、いつのまにかclientの生成に失敗するようになっていたという問題が頻繁に起こっていました。そのため、今ではserviceのリポジトリからCIで社内のprivate npm registryにpublishするようにしています。
これにより、BFFは使いたいバージョンのclientを npm install
するだけで済みます。各リポジトリからpublishされているため、 今どのバージョンのclientを使っているのかが一目瞭然になりました(それまでは開発中にAPIのBreaking Change等にハマってしまうことがあった)。
npm packageには
- 生成されたclient
- 型定義ファイル
- 生成元のthriftファイル(ここ大事)
が含まれるようになっています。
thrift-cli
thriftのようなAPIはメリットもあるのですが、デバッグが難しいという問題点もあります。RESTのようにcurlで誰でも叩けるわけではなく、clientを生成しないとAPIを試しに叩くこともできないため、APIのデバッグに手間取ることが何度もありました。そこで、FOLIOではthrift-cliというNode.js製の内製ツールを作成しています(gRPCにはgRPC command_line_toolというものがあるらしいです)。コマンドラインでthrift APIを実行できるようになるツールです。
# thrift-cli [(1)service] [(2)API] [(3)...arguments] $ thrift-cli userService findUser 1
先に説明したnpmにpublishされるclientを使用しており、仕組みとしては上の(1), (2)で与えられたservice名とAPI名から実行するAPIを特定、.thrift
ファイルをparseしてAPIに与えるべき引数の型を特定して、適切に変換するというものです。
下記は雰囲気を掴んでもらうためのコードの抜粋です(これより全然長い)
// .thriftファイルからmethodを抽出する import thriftParser from "node-thrift-parser"; const service = thriftParser(file); const method = service.definitions .find(d => Boolean(d.functions)) .functions.find(f => f.identifier === methodName); // .thriftファイルから型が判定できるので、変換する const normalizedArguments = method.args.map(arg => { // 例えばthrift上でstructで定義されている場合、名前から動的にrequireしてStructで初期化する const Struct = require(`${client.clientDir}/${moduleName}_types`)[structName]; return new Struct(obj); }); const service = require(`${client.clientDir}/${client.entrypoint}`); const thriftClient = thrift.createClient(service, connection); const res = await thriftClient[methodName](...normalizedArguments);
メタプロ的に動的requireを多用していることからわかる通り、publishされているthrift clientの構成に依存しています。ので、汎用的に作るのを一旦諦めて社内ツールとして作成しているので、OSS化はまだまだ先です...
生成されるclientの問題点
IDLによってAPI定義がつねに可視化されているのは良いことですが、それでもBreaking Changeには気をつけなければなりません。thriftでは基本的には、server側は更新したけどclient側の更新は忘れていた、というケースでも何事もなくAPI実行ができてしまいます(もちろんAPIが無くなっていたなどのケースではエラーになりますが)。
特に注意したいのは、thriftはvoidを返すAPIで、定義されていない例外が返ってきたときの挙動を定めていないという点です。つまりserver側で例外を追加したけど、client側の更新を忘れていたケース。どうやらScala -> ScalaのAPI呼び出しでは、未定義の例外が追加されていたとしてもclientで検知できる(generalな例外として扱われる)らしいのですが、Node -> Scalaの呼び出しだと、なんとvoidを返す関数で未定義の例外が発生した場合、成功されたことになってしまうという問題が...
引数や戻り値の変更はみんな注意して見ているのですが、例外の追加はテストでも漏れやすいのでこれは一大事です。
これは生成されるclientの実装上の問題で、例えばintを返すAPIから未定義例外を返ってきた場合は戻り値をintとしてデコードできるかで正常に終了したかを確認しているのですが、voidの場合はそもそも戻り値をデコードしないので、検知する手段が無いのです。
そのため、FOLIOでは生成されるclientにpatchを当てて対応しています。(これはどの環境でもそのまま使えるpatchのはず)
const result = text // 未定義なメッセージ(exception)を受信したときにfailedフラグをON .replace( /(_result\.prototype\.read\s+=\s+function[\s\S]*?default:\s*.*?)input\.skip\(ftype\);([\s\S]*?return;)/g, "$1input.skip(ftype);\nthis.failed = true$2" ) // failedフラグがONなら、APIをエラー扱いとする .replace( /(recv_[\s\S]*?)callback\(null\);(\s*})/g, "$1if (result.failed) { return callback(new Error('Failed: unknown result')); }\ncallback(null);$2" ); // diffがある場合のみ書き込み if (result !== text) { const [name, ext] = file.split("."); fs.writeFileSync(path.join(dir, `${name}.patch.${ext}`), result); }
finagle-thrift
FOLIOのbackend(Scala)では、finagle-thriftとフレームワークを使用しているので、分散トレーシングができるようになっています(finagle-thriftとはなんぞやという話はFOLIOアドベントカレンダーの9日目に書かれる予定のようです)。そしてBFFも、それに対応するためにthriftモジュールを生で使うのではなく、finagle対応のカスタマイズを行っています。
が、それをアドベントカレンダーに合わせて公開しようと思ったのですが、@typesのthriftの型定義が間違っていて力尽きたので間に合いませんでした...。いつかFOLIOのGitHubで公開すると思います!