私はAccel Recordという、TypeScript用ORMライブラリを開発しています。
Accel Recordは他のTypeScript/JavaScript用ORMライブラリとは異なり、非同期APIではなく同期APIを採用することにしました。
この記事では、Accel Recordが同期APIを採用するに至った経緯と理由を整理します。
作ろうとしたORM
『TypeScriptで書ける型安全なRuby on Railsを求め、ORMの開発を始めた』という記事で、TypeScript用のORMライブラリを作り始めたことを紹介しました。
私の希望は「TypeScriptにもRuby on Railsくらい開発効率の高いフレームワークが欲しい」というものです。そのために、RailsのActive Recordのような機能を持つORMをTypeScriptで作れないか試すことになりました。ですので、新しく作るORMはまずActive RecordのAPIを真似て実装を始めました。
非同期APIの問題
JavaScript/TypeScriptではDBアクセスはPromiseやcallbackを使った非同期APIで行われます。DBアクセスのためのライブラリも各処理でPromiseを返すため、新しいORMでも各APIは自然と非同期APIとして実装されました。
非同期APIの実行時にはawait
を付けて順番に処理するのが一般的です。
例えばUserモデルに関してCRUD操作を行う際は以下のような利用方法になります。
await User.create({ name: "Foo" }); // Create
const user = await User.find(1); // Read
await user.update({ name: "Bar" }); // Update
await user.delete(); // Delete
それぞれにawait
を記述するのは若干面倒ですが、ここまではそこまで大きな問題だとは感じませんでした。
しかし、アソシエーション関連の操作を行う際には問題が徐々に大きくなってきました。
例えば、UserモデルがhasOneアソシエーションでSettingモデルと関連付けられている場合を考えてみます。
問題の例 1: 関連の更新
更新処理を行う際、Active Recordのインターフェースにならって以下のように書きたいと考えました。
const setting = Setting.build({ theme: "dark" });
await user.setting = setting; // この書き方はできない
ここでawait
を付けている理由は、この処理によってDBアクセスが発生する可能性があるためです。RailsのActive Recordの場合、このsetterでuserかsettingのどちらかに変更が発生するとDBアクセス(保存処理)が行われます。
しかし、TypeScriptのsetterは非同期処理にできないため、このような書き方はできません。代わりに、別のインターフェースを検討する必要がでてきました。
問題の例 2: 関連の読み込み
関連の取得操作を行う際には、毎回await
を書く必要があります。DBアクセスが発生する可能性があるためです。
const theme = (await user.setting).theme;
Active Recordでは関連が遅延ロードされるため、関連の取得時にDBアクセスが発生する場合があります。既にuserインスタンスがsettingをキャッシュとして持っている場合はDBアクセスは発生しませんが、持っていない場合はDBアクセスが発生します。
こちらに関してもそのままでは使い勝手が悪いため、別のインターフェースを検討する必要がでてきました。
上記のような問題の一つ一つは、インターフェースを工夫すればある程度は解決できると思います。しかし、そのような調整を繰り返すうちに、ライブラリの使い勝手が理想のものからどんどん離れていってしまうと感じました。非同期APIによって、ライブラリのインターフェースが制限されてしまうのです。
同期APIという発想の転換
RailsのActive Recordは、テーブルとモデルクラスを対応させDBアクセス周りの処理を抽象化しています。しかし、これらのAPIを非同期にしてしまうと、常にDBアクセスのタイミングを意識せざるを得なくなります。これは抽象化を妨げ、アプリケーションの開発効率を低下させます。
このまま非同期APIでORMの実装を進めると、Active Recordのような抽象化を実現するのは難しいと感じました。最初の目的である高い開発効率を実現することも困難です。
APIのデザインを見直すことも考えましたが、ここでもっと別のアプローチがあるのではないかと気付きました。
「JavaScript/TypeScriptのDBアクセスAPIは、非同期APIである」という常識を疑ってみることにしたのです。
Promiseを使わない同期APIの呼び出しは、await
をつける必要がありません。各APIを非同期APIではなく同期APIとして実装できれば、上述のような問題は発生しないことになります。メソッドを呼ぶたびにawait
をつけるかどうかを気にする必要もなくなり、もっとActive Recordに近い開発体験を実現できると考えました。
そうして、次に私がすべきことは以下の問いに対する答えを見つけることになりました。
- なぜJS/TSのDBアクセスライブラリは非同期APIを採用しているのか?
- 必ず非同期APIにする必要があるのか?
- 同期APIを使ったORMは作れないのか?
Node.jsのイベントループと同期処理
今回のORMライブラリは、Ruby on RailsのActive Recordのような開発体験を提供することを目指しています。そのため、フロントエンドでの利用は想定せず、サーバーサイドでの利用を前提とします。
サーバーサイドでのTypeScript(JavaScript)の実行環境としては、Node.jsが最もメジャーです。ですので、Node.jsにおいて同期APIを利用する際の課題について調査しました。
Node.jsの公式資料で言うと、以下のページが最も関連が高そうでした。
Node.jsはシングルスレッドで動作する非同期I/Oモデルを採用しており、これにより高いパフォーマンスを実現しています。非同期処理を行うことで、I/O待ちの時間を他の処理に活用できるためです。
JavaScriptやNode.jsにはイベントループという概念がありますが、同期処理を行うとイベントループがブロックされ、その間は他の処理を行えません。非同期処理を利用する際と比べてシステムのパフォーマンスが低下する可能性があるということでした。
同期処理を使うデメリットと回避策
例えばWebアプリケーションでHTTPリクエストを同期APIのみで処理すると、リクエストが完了するまで他のリクエストを受け付けることができなくなります。
ただし、このような動作は他の言語では一般的です。例えばRuby on Railsでも、通常は一つのリクエストが完了するまでそのプロセスは他のリクエストを処理することができません。
ですので、同期APIを使うと非同期APIを使ったNode.jsアプリケーションよりはパフォーマンスが下がりますが、他言語のアプリケーションと比較すると必ずしもパフォーマンスが劣るとは言えないということです。
また、ここで述べられているのはあくまで「1スレッドあたりの」パフォーマンスになります。逆に言えばプロセスやスレッドを並列に並べれば、システム全体のパフォーマンスとしては大きく下がらない可能性もあります。
Webアプリケーションにおいてサーバーサイドの処理をマルチプロセスで並列化することは、極めて一般的です。
例えばRubyではunicornなどのアプリケーションサーバーを使って複数のプロセスを立てる構成がよくあります。
Node.jsであってもプロセスを並列に立てることができますし、一部の処理をイベントループをブロックしない別スレッドで動かす仕組みも存在します。1
Node.jsで同期処理を用いるとスレッドあたりのパフォーマンスは下がる可能性がありますが、システム構成の工夫によってシステム全体としてはパフォーマンスの低下を回避することも可能そうということです。
さらに別の観点ですが、最近はAWS Lambdaなどサーバーレス環境で処理を動かすケースも多いのではないでしょうか。その場合は、1プロセス(コンテナ)で複数リクエストを同時に処理するケースがそもそも無く、同期処理でもパフォーマンスに影響が出ない場合も多そうです。
システムのパフォーマンスより重視したいこと
最終的な私の希望は「TypeScriptにもRailsくらい開発効率の高いフレームワークが欲しい」というものです。そもそも重視しているのはシステムのパフォーマンスではなく、アプリケーションの開発効率です。
システムの(スレッドあたりの)パフォーマンスよりも、開発効率を重視したいプロダクト開発現場も多いはずだと私は考えています。
そうでなければサーバーサイドの開発においては必ずCなどの高速な言語が選ばれるはずですが、実際はPHPやRubyなど比較的高速でない言語も人気です。
これは、その言語やFW等のライブラリによってより効率的な開発環境が用意できると判断されているからだと理解しています。
新しいORMでは同期APIを採用することにした
調査内容を踏まえて、途中の問いかけに対する回答を整理します。
- なぜJS/TSのDBアクセスライブラリは非同期APIを採用しているのか?
- →JavaScriptのイベントループをブロックしないためです。イベントループをブロックすると、システムのパフォーマンス低下につながることがあります。
- 必ず非同期APIにする必要があるのか?
- →必ずしも非同期APIにする必要はありません。スレッドあたりのパフォーマンスの低下を許容できるなら、同期APIでも良いはずです。(そして、このデメリットはシステム構成の工夫によってある程度回避が可能そうでした。)
- 同期APIを使ったORMは作れないのか?
- →JavaScriptやNode.jsの制約として作れない理由は無さそうでした。
仮にORMを同期APIで設計したとしても、デメリットは(スレッドあたりの)パフォーマンスの低下がメインだと理解しました。しかもそれは上で述べたようにシステム構成による回避策もあります。
ですので、そのようなデメリットと、ライブラリ利用者の開発効率向上のメリットを比較した結果、同期APIを採用するメリットの方がデメリットよりも大きいと判断しました。
こうして新しいORMであるAccel Recordは、TypeScript用のライブラリでありながら同期APIを採用することになりました。
まとめ
この記事では、Accel Recordが同期APIを採用するに至った経緯と理由を整理しました。
まず、非同期APIでは理想のORMのインターフェースを実現するのが難しかったという点があります。非同期APIによって、ライブラリのインターフェースが制限されてしまうことがわかりました。
それを受けて、ライブラリに同期APIを採用できないかを調査しました。システムパフォーマンスへの影響という懸念はありましたが、今回の目的と照らし合わせて、理想のインターフェースを実現して得られるメリット(開発効率向上)が大きいと判断しています。
Accel Recordが同期APIを採用することでどのようなインターフェースを実現できているか『Active Recordパターンを採用したTypeScript用ORM「Accel Record」の紹介』やREADME(日本語)で是非チェックしてみてください。
(この記事は『Why We Adopted a Synchronous API for the New TypeScript ORM』の原文です)