テックタッチアドベントカレンダー 18 日目担当の yokochin です。
今年になって JavaScript の新しいランタイムである Bun をよく目にするようになりました。
Node.js、Deno に続く JavaScript ランタイムの新勢力となるわけですが、それぞれどのように違うのか、それぞれが生まれた背景やコンセプトから理解していこう!というのがこの記事の趣旨です。
Node.js 開発の背景
Node.js は 2009 年にリリースされ、現在最も広く使われている JavaScript ランタイム(以降 JS ランタイム)です。
Node.js 開発の最初のモチベーションは「ノンブロッキングなネットワークプログラミングをサーバー開発に熟知していないユーザーにも可能にすること」でした。 ノンブロッキングという概念が重要になりますが、その前に Node.js が採用しているイベントループという実行モデルについて説明します。
イベントループとはメインスレッドと呼ばれる 1 つのスレッドがイベントを待ち受けていて、イベントを感知したら処理を実行し、実行が終わったらまたイベントを待ち受けるというループを回し続ける機構です。
基本的にイベントループは 1 スレッドですべての処理を実行するのですが、サーバーにおいては 1 つの HTTP リクエストを処理している間にほかのリクエストに対応できないのでは使い物になりません。ここで I/O(ディスクやネットワークとのやりとり)を並列に実行できるノンブロッキングが重要になります。
ノンブロッキングな処理は I/O の応答を待つ間にイベントループを動かし続けることができます。たとえば、サーバーから DB にクエリを発行すると DB からの応答を待つことになります。この待ち時間の間にイベントループを再開し、ほかのリクエストを受け付けるなど別の処理ができます。DB からの応答が返ってきたら、後続の処理を再開します。こうすることにより 1 スレッドでも待ち時間を持て余すことなく効率良く処理を実行できます。
ノンブロッキングなプログラミングをサーバー開発に取り入れたかった背景は 2 つあって、1 つはサーバーの傾向として起動時間に対して I/O の待機時間の占める割合が大きいため 1 スレッドで効率良く処理できるノンブロッキング、イベントループと相性が良かった点。2 つ目は I/O を並列化するための技術(epoll や kqueue といったシステムコール)は 1980 年代からあったものの、どのプログラミング言語からも容易に使えるインターフェースがなく、実用的にサーバー開発で使うにはハードルが高かった点です。
そこで Node.js はサーバーサイド開発でノンブロッキングなプログラミングを容易にするインターフェースとともにリリースされました。 Promise
( 現代は async / await
) のようなシンプルな構文でノンブロッキング、イベントループを利用できるのは実はありがたいことなんですね。
イベントループをサーバーサイド開発に取り入れたいのであれば、新しいプログラミング言語を作るという手もありましたが、JavaScript を選んだのは I/O に関する定義がない ECMAScript を拡張することができ、かつ JavaScript の普及とともに Node.js を認知する機会を得ることができる、というメリットがあったからのようです。
余談:ブロッキングとSSR
Node.js の背景から脱線しますが、最近のトレンドを踏まえてノンブロッキングをもう少し掘り下げてみます。
前述の通り Node.js はノンブロッキングによって I/O の待機時間を利用して 1 スレッドで効率よく処理できますが、 I/O ではなく CPU 負荷の高いスレッドを専有される処理には弱いです。
たとえば JSON.parse()
で巨大な JSON をパースするとイベントループはブロッキングされて、その間はほかのタスクが実行できなくなってしまいます。
最近では Next.js などで一般的になった SSR(サーバーサイドレンダリング)ですが、コンポーネントをまとめあげ HTML としてレンダリングするという処理はイベントループをブロッキングする処理であるため、実は Node.js が得意な領域ではありません。にもかかわらずここまで SSR が発展しているという現状はおもしろいですね。
実行効率よりもフロントエンドとサーバーサイドのコンテキストスイッチを少なく開発できるという開発体験が重要視されている点と、この弱点を補えるスケーラブルな実行環境(Vercel のようなエッジでスケールする環境や Kubernetes などのコンテナオーケストレーションによる水平スケーリングが容易な環境)が整ってきていることが背景にある気がします。
Node.js の後悔と Deno の登場
Deno は Node.js の開発者である Ryan Dahl 氏によって開発が進められている JS ランタイムで、 2018/08 のバージョン 0.1.0 からおよそ 2 年の開発を経て、 2020/05にバージョン 1.0 に到達しました。
Node.js を開発した本人がなぜ新しい JS ランタイムを作ろうとしたのか。JSConf EU 2018 で Ryan Dahl 氏は Node.js の 10 の後悔を述べていて、最も大きな後悔としてモジュールシステムの失敗を挙げています。
Deno のモジュールシステム
Node.js が登場したときにはモジュールシステムの仕組みがなかったため、CommonJS と呼ばれるモジュールシステムを採用しましたが、後に ES6 で標準化されたモジュールシステム(ES Module)は CommonJS とは異なるものでした。
ES Moduleでは
import { myFunction } from './libs/mymodule.js'
のように import ~ from …
の構文で拡張子まで含めた明示的なパスを要求しますが、CommonJS では
const { myFunction } = require('mymodule')
のように require
で外部スクリプトの識別子を要求し、内部的に mymodule
が node_modules 配下にあるかなどを解決しにいきます。このように構文としてもモジュール解決の仕組みとしても ECMAScript の進化から Node.js は外れてしまっている、ということを後悔として挙げています。
そもそも外部のスクリプトを使いたいのであれば、ES Moduleのように直接スクリプトのパスを指定すれば良く、ディレクトリに「モジュール」という新しい概念を与える必要はなかったし、それを管理するための package.json や npm リポジトリさえ生み出してしまうことはなかったと述べています。(今振り返ってもっとこうしたかった、というのはわかりますがここまで npm のエコシステムが大きくなっている現状を見ると少し悲観的すぎる気はしますが。)
npm リポジトリにはすでに多くの資産があり、後戻りすることはできないため、JS ランタイムを 1 から作り直そうというのが Deno 開発のモチベーションになりました。
リリース当初の Deno は既存の npm との互換性を切り捨てていて、モジュールへの依存も以下のように直接スクリプト内パスを指定する方式をとりました。
import * as R from "https://deno.land/x/ramda@v0.27.2/mod.ts";
外部のスクリプトを利用するのに npm, package.json, node_modules は不要で、 deno run
でスクリプトを実行すると依存するモジュールがダウンロードされます。
そのほかの特徴
Node.js の後悔の 1 つにセキュリティの問題を上げていました。Node のプログラムを実行するとあらゆるリソース(ファイルシステムやネットワーク)にアクセスできてしまいます。ですが、 linter を使うのにネットワークアクセスやファイルの書き込み権限は不要なはずです。そこで deno では実行時に
--allow-net
(ネットワークアクセスの許可)--allow-write
(ファイル書き込み権限の許可)
のようなオプションをつけない限りはリソースへアクセスできないようにすることで、サードパーティのスクリプトを安全に実行できる機構を提供しています。
また、Node.js との違いとしてはデフォルトで TypeScript をサポートしている点です。TypeScript のコンパイルがなくとも deno run
で直接 TypeScript を実行できます。
Bun の登場
Node.js や Deno と違って Bun は課題を起点に生まれたというよりは、Node.js や Deno を Bun に置き換えるという意欲から出発しています。
Bun のパフォーマンス
後発で登場するからには Node.js から乗り換えてもらうメリットがないといけないわけですが、Bun はとにかくパフォーマンスを押しています。
Bun の公式サイトのトップページを見ると 2022/12/14 時点では React の SSR が Node.js の 4 倍以上速いというベンチマークの結果を載せています。
この SSR のベンチマークでは Hello world レベルの 1 コンポーネントしかないので、React で SSR する部分はわずかで、stream を使ってレスポンスを返す部分がベンチマークスクリプトの重たい部分となっています。この stream の実装が Node.js と Deno は JavaScript で実装されていて、Bun は Zig で実装されているため、言語の違いでパフォーマンスの差が出ていると考えられます。数十〜数百のコンポーネントを使ったケースでは Node.js、Bun ともに JavaScript による処理の比重が大きくなるため、4 倍ほどの差は開かないと思います。
Bun はとにかく知名度を上げて使ってもらうためにも勝てるフィールドでのベンチマーク結果を押し出していっているという状況です。
そのほかの特徴
公式サイトで「all-in-one JavaScript runtime」を謳っているように、ランタイムでありながらもバンドラ、トランスパイラなどが付属してきます。TypeScript のトランスパイラも付いているため、Deno 同様 TypeScript を実行できます。
また、npm クライアントを内蔵しているため npm パッケージを利用することもできます。
JS ランタイムの互換性
さてここまで 3 つの JS ランタイムの背景やコンセプトについてお伝えしました。
さっそく既存の Node.js プロジェクトから移行して試したくなりますが、Deno、Bun は Node.js とまったく同じ構文を解釈、実行できるわけではないので簡単には移行できません。とはいえ、Deno、Bun としては Node.js の利用者に使ってほしいので移行コストを下げる努力をしています。
Deno の Node.js 互換
Deno のモチベーションの 1 つは Node.js で失敗したモジュールシステムを作り直すことでしたが、2022/11/14 v1.28 で正式に npm をサポートすることになりました。やはりこれまでの npm パッケージを使いたいという多数の声は無視できなくなったようです。競合である Bun が登場とともに npm サポートを謳っていたのも背景にあるとは思います。
Deno v1.28.3 では Node.js の API の互換性のために std/node
という組込みライブラリがあり、これが Node.js API の fs
や http
などの Polyfill となるようです。また、CommonJS のモジュールを Deno に読み込んでくれるようです。
Bun の Node.js 互換
前述の通り npm パッケージは利用できます。 Bun v0.3.0 時点では Node.js の API は 90%ほど実装していると言っています。 ES Module、CommonJS 両方にもサポートしているようです。
終わりに
Deno、Bun の互換性を上げる取り組みは絶賛進行中なので既存のプロジェクトを置き換えるハードルはまだ高そうです。そのため Node.js がシェアを占める状態はすぐには変わらないと思いますが、新規プロジェクトについては Deno、Bun の導入を検討して見ても良いかもしれません。
私自身はちょっとしたスクリプトであれば Deno を使って書くことがありますが、やはりゼロコンフィグで TypeScript が使える、package.json や node_module で汚れない、スクリプト 1 枚で共有できるなどメリットは大きいので新しい JS ランタイムの方が Node.js を上回るシーンは出始めていると実感します。
継続的に JS ランタイム事情をウォッチしつつ、適材適所で使い分けていきたいですね!
参考
- fukabori.fm
- Node.js に関する 10 の反省点
- Ryan Dahl: Original Node.js presentation
- 東京 Node 学園祭 2011
- Big Changes Ahead for Deno