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で公開すると思います!
flowtypeバッドノウハウ
flowtypeを使ってみて個人的にはまったところ。
v0.65.0
時点での話です。仕様なのかバグなのかわからない話も含みます。
enumの罠
flowのenum(正しくはUnion Type)は条件分岐などで、含まれないはずの値と比較しているとエラーを出してくれる。
/* @flow */ type A = "hoge" | "huga" const a: A = "hoge" if(a === "humu") { console.log("hoge") } // 7: if(a === "humu") { // ^ string literal `humu`. This type is incompatible with // 5: const a: A = "hoge" // ^ string enum
ただし、これはObjectのプロパティに対して働かない。
/* @flow */ type A = "hoge" | "huga" type Obj = { a : A } const b: Obj = { a: "hoge" } if(b.a === "humu") { console.log("hoge") } // No errors!
謎のエラーになるんじゃなくて"No errors!"になるのがたちが悪い。エラーが出ないもんだからコーディング中は型チェックされていると思い込んでしまうし、enumの定義が変わったときに検知することもできない。
解決策としては、単純に比較しないこと。例えば以下のように配列から検索したいコードを書く場合は、下記のようにすると例のごとく型チェックが働かないので、
/* @flow */ type UserState = "Active" | "Registered" type User = { state: UserState } const users: Array<User> = [ { state: "Active" }, { state: "Registered" } ] const activeUsers = users.find(user => user.state === "Actived") // No errors!
以下のように関数化して、関数に渡す段階で型を保証するとか。完全ではないけど、いろんなパターンでfindするときなんかはこういうのもアリ。
/* @flow */ type UserState = "Active" | "Registered" type User = { state: UserState } const users: Array<User> = [ { state: "Active" }, { state: "Registered" } ] const findUser = (users: Array<User>, state: UserState) => { return users.find(user => user.state === state) } findUser(users, "Actived") // Error!!!
役に立たないビルドイン関数
例えばObject.values()
。これを使いたいときはだいたい{ [string]: T }
のようなmapからTの配列を取り出したいというのに、
/* @flow */ type A = { a: string } const array: { [string]: A } = { key1: { a: "hoge" }, key2: { a: "huga" } } Object.values(array).map(value => value.a) // 10: Object.values(array).map(value => value.a) // ^ property `a`. Property cannot be accessed on // 10: Object.values(array).map(value => value.a) // ^ mixed
エラーである。まあ本来Objectのvalueなんて型を定めていないから当たり前ではあるが...。
しょうがなく型を保てる同じ関数をutility/object.js
みたいな感じで定義している。
const values = <T>(obj:{ [string]: T }): Array<T> => { return Object.keys(obj).map(key => obj[key]) } type A = { a: string } const array: { [string]: A } = { key1: { a: "hoge" }, key2: { a: "huga" } } values(array).map(value => value.a)
他にもObject.assign()
も型を壊す代表格。flowはT1 & T2 & ...
のような可変長Intersection Typeは表現できないのでしょうがない。このような型を壊す関数は不用意に使わないよう、ESLintのno-restricted-properties
で制限するのも手。
共変でない配列
これはエラーになる。
/* @flow */ type A = { price: number, a: string } type Base = { price: number } const sum = (array: Array<Base>) => array.reduce((result, e) => result + e.price, 0) const array: Array<A> = [ { price: 100, a: "hoge" }, { price: 200, a: "huga" } ] sum(array)
なぜ TypeScript の型システムが健全性を諦めているかの記事にもあるとおり、flowtypeの配列は共変でないというのが原因。TypeScriptから入ったのでよくこれに引っかかる。型がゆるふわなJavaScriptにおいては配列が共変であるメリットは大きいと思うので残念なところではあるが...
妥協案としてはこういう風にGeneric Typeを使う書き方にする。
/* @flow */ type A = { price: number, a: string } type Base = { price: number } const sum = <T>(array: Array<T>, priceDetector: T => number) => { return array.reduce((result, e) => result + priceDetector(e), 0) } const array: Array<A> = [ { price: 100, a: "hoge" }, { price: 200, a: "huga" } ] sum(array, e => e.price)
追記
これでいけると指摘をもらいました。
type A = { price: number, a: string } type Base = { price: number } const sum = <T: Base>(array: Array<T>) => array.reduce((result, e) => result + e.price, 0) const array: Array<A> = [ { price: 100, a: "hoge" }, { price: 200, a: "huga" } ] sum(array)
追記2
$ReadOnlyArrayを使ってもいけました
/* @flow */ type A = { price: number, a: string } type Base = { price: number } const sum = (array: $ReadOnlyArray<Base>) => array.reduce((result, e) => result + e.price, 0) const array: Array<A> = [ { price: 100, a: "hoge" }, { price: 200, a: "huga" } ] sum(array)
黒魔術にしか見えない定義
これだ。
type DefaultOption = { hoge: string } type CustomOption = {| ...$Exact<DefaultOption>, ...{| humu: number |} |}
Intersection doesn't work for exact object types のIssueにある方法だが、ExactTypeをIntersection Typeで結合する方法がこれしかない。これ初見で理解できる人いるんだろうか...
まとめ
よくflowtypeとTypeScriptの比較で、「flowtypeは1ファイルから導入できる」というのが利点と言われている。という自分もTypeScript派からそこに惹かれてflowtypeを使ってみたのだけど、その意見には若干同意しかねるというのが使ってみた感想。
イメージとしては、flowのカバレッジが全体の50%になるように途中から入れたとしても、恩恵としては体感20%程度、というようなイメージ。型というものは規約で縛ってレールを踏み外さないようにするもの、つまり型なしで書かれたコードは容易にレールを踏み外す。安易に型がコロコロ変わるような実装や、先のバッドプラクティスにあるようなflowの癖に合致せず暗黙のanyが生まれる実装をしてしまう。そうして結局any
だらけになったりして、得られる恩恵が少なくなる。
もちろんゼロと20%の間には大きな溝があるので、無いなら導入しかないのだが、途中から入れれるからと高をくくってないで初めから入れような!
ドキュメントを書く技術
READMEを始め、ソフトウェアのドキュメント全般を書く技術というものをもっと洗練させていきたい。要件定義書のようなものだけでなく、開発方針や設計方針、API定義などなど。
これらのドキュメントをしっかりと整備するだけで、レビューの質も上がり新しい人が入ったときもスムーズに意識のズレなく開発ができる。はずだが、なかなかドキュメントの上手い書き方や管理の仕方というものは、コーディングのそれとは違い議論が活発ではない。
最近試してみたこと
そういったドキュメントの中でも、"開発方針"や"設計思想"をどう残していくかということを考えている。それらを残しておくことで、コーディングのときも立ち戻る場所ができ、大きく道を踏み外さなくなる。
例えば、レイヤードアーキテクチャのようなものの"境界"をドキュメントにしていく。MVCでもクリーンアーキテクチャでも何でも良いけど、それらのアーキテクチャではそれぞれのレイヤの役割は定義されているものの、必ずしも理想通りにいくとは限らないし、理想を追求することだけが正ではない。そういったアプリケーション・組織独自の"境界"をドキュメントに残していく。
個人的に各レイヤ(controllersやmodelsといったディレクトリ)にひとつずつREADMEを置いていくのが好きなのだけど、最近はそこに「MUST(MUST NOT)」、「SHOULD(SHOULD NOT)」、「MAY」などを書くといいのでは?と思って書き始めている。
# Controllers ## MUST - ファイル名はXXXControllersとすること - メソッドはGETならfindXXX、POSTなら.... - このレイヤでリクエストパラメータをxx型に変換する ## SHOULD - ビジネスロジックは書くべきでないが、xxxなケースは例外とする
MUST/SHOULD/MAYの表記などはRFCでも使われていてエンジニアには比較的馴染みがある(?)はず。READMEに書いてあればレビューも通せるし、新しくプロジェクトに入った人がドキュメントの場所がわからない、ということもない。
ドキュメントの再現性
設計指針というものは、人によって判断がわかれる"迷い"から生まれると思っている。だからこそ、その迷いが出た時に「これはControllerのSHOULDじゃないの?」みたいに一種のフレームワークに当てはめると考えやすくなる。
ドキュメントを書く技術というのは、同時にドキュメントを"書かせる"技術でもある。コーディングと全く同じで、自分だけが書けても意味がなく、誰が書いても一定のレベルにすることが重要。そのためにはフォーマットを作ったりドキュメントの目的を共有したりして、適切な"枠"から無理なくドキュメントが生まれる環境を考えていきたい。
ドキュメントはコードから自動生成させていきたい
ドキュメントは腐りやすい
ドキュメントはどうやっても更新されなくなってしまう。Wikiに書こうか、Confluenceのほうがいいのか、色々置き場所を考えてはみるものの、大きく改善することはあまりない。
その理由のひとつが、コードとドキュメントのライフサイクルが違うこと。元来ドキュメントは「コーディングの前に書くもの」であったし、その意識はなくてもPull Requestに「あのドキュメントも更新しておきます」と書いている時点で、それはライフサイクルが合っていないものを、信頼性の低いプロトコルで同期を取ろうとしているようなもの。
それを解決する手段のひとつが、コードとドキュメントを同じリポジトリに置くということだけれど、READMEが大量にできるというのもそれはそれで見通しもメンテナンス性もあまり良くない。
必要とされるドキュメントの形はさまざま
それなら、とコードからドキュメントを自動生成させたいと思う。動いているコードは常に現状を正しく表しているので、そこから生成されるドキュメントも常に正しい(バグがないとは言っていない)。
JSDoc(やその源流のJavaDocなど)がそれに近いアプローチをとっているが、あまりうまく活用できたことがない。
コードは変えたけどコメントを直し忘れた、というケースがあるように、やはり「コードがコメントの通りに動いていること」は理想だけどそうならないケースがある。もちろんちゃんと運用したら良いツールなんだけど、そこが少しもったいない。
もうひとつは自由度の低さというのか、スコープの違いというのか。普段アプリケーションを作っている身としては、関数一覧が並んでいたらそんなに嬉しい?という気持ちになるし、生成されたドキュメントを見るよりコードを直接見たほうが早い。チーム外の人のためにわざわざドキュメントを生成してもほとんど見られないだろうし、なによりも別に知りたいことは関数一覧じゃなかったりする。
だからコードから生成しつつ、自由度が高い方法が欲しかった。
babylonでコードからドキュメントを生成する
とは言ってもJSDocのアプローチは捨てがたく、より面白いことができないかとbabylonに手を付けてみたら思いの外簡単だった。
babylonはあのbabelが使っているAST(Abstract Syntax Tree)を作るparser、平たく言うと「ソースコードを読み込んでその構造をオブジェクトにしますよ」というやつ。コードをbabylonにかけると、「クラス名はこれですよ」「メソッドはこんなのがありますよ」というのが簡単に取れるというもの。babelはこれを使って、コードを構造化したあと、いい感じに変換していい感じに出力している。
どれくらい簡単かというと、こういうコードがあれば
class Hoge { getMsg1() { return "hello" } getMsg2() { return "world" } } module.exports = Hoge
こういうコードを書くだけでメソッド一覧が取得できる。
const { parse } = require('babylon') const traverse = require('babel-traverse').default const fs = require('fs') const code = fs.readFileSync('./index.js', 'utf-8') const ast = parse(code) let className let methods = [] traverse(ast, { Class: path => { className = path.node.id.name }, ClassMethod: path => { methods.push(path.node.key.name) } }) console.log(`class: ${className}, methods: ${methods}`) // => class: Hoge, methods: getMsg1,getMsg2
もちろんbabelのpluginでflowやJSXも読めるし、コード上のほぼすべての情報が取れるので、例えば「Componentを継承しているクラスの一覧がほしい」とかもすぐにできる。もちろんクラスやメソッド一覧だけじゃなくて、定数一覧でも何でも。
// pluginを使う const ast = parse(code,{ plugins: ["flow"]})
これを使って例えば「外部サービスに接続している場所一覧」みたいなのをMarkdown形式でファイルに出力しておけば、自由度が高く必要な情報だけに絞ったドキュメントが自動生成される環境が作れる。
コメントも価値あるものとなる
そしてそれにdoctrineというJSDocパーサーを合わせるのも結構面白かった。結局JSDocに戻ってるじゃん、という気持ちにもなるが、例えば別ドキュメントへのリンクとか、あまり更新されないコメントであれば保守にも耐えられるだろう。たぶん。
先ほどの例でいくと、
/** * @see http://example.com */ class Hoge { getMsg1() { return "hello" } getMsg2() { return "world" } } module.exports = Hoge
というものをdoctrineで読み込むとこうなる。
const { parse } = require('babylon') const traverse = require('babel-traverse').default const fs = require('fs') const doctrine = require("doctrine") const code = fs.readFileSync('./index.js', 'utf-8') const ast = parse(code) let className let link let methods = [] traverse(ast, { Class: path => { className = path.node.id.name const comments = path.node.leadingComments.map(comment => comment.value) if (comments.length > 0) { const tags = doctrine.parse(comments[0], { unwrap: true }).tags link = tags.find(tag => tag.title === "see").description } }, ClassMethod: path => { methods.push(path.node.key.name) } }) console.log(`class: [${className}](${link})`) // => class: [Hoge](http://example.com/juuyouna/reference)
どういうドキュメントがあると嬉しいのか、それはどういうアーキテクチャなら簡単に生成できるのか、アイデアがあれば何でも出来そうな気持ちに久しぶりに楽しくなった。