Node.js における設計ミス By Ryan Dahl
Ryan Dahl は Node.js の original author ですが、彼の作ったプロダクト deno に関するトークが jsconf.eu 2018 でありました。 Node.js にずっと関わってきた僕が見て非常に興奮するような話だったので、しばらくぶりにブログに書き起こすことにしました。
背景
Ryan Dahl は2009年に Node.js の話を初めて公の場に公開しました。その時の「公の場」というのが「jsconf.eu 2009」です。
Video: Node.js by Ryan Dahl - JSConf.eu - 2009
この発表から Node.js が広まり、今やサーバのみならず、IoTデバイス、デスクトップアプリなど、様々なところで動作しています。
で、今回はその発表から9年の歳月が経過し、Node.jsに対しての設計不備について Ryan Dahl 自ら発表したという状況です。
発表資料: http://tinyclouds.org/jsconf2018.pdf
動画:
今回の記事はこの話を超訳したものを紹介し、慣れてない方のために都度解説を挟みます。最後に古川の感想を書いて締めようかと思います。
当初のゴール
私 (Ryan Dahl) はNode.jsの初期開発と開発マネージメントを行っていました。当初の目標は「イベントドリブンなHTTP Serverを作れるようにすること」でした。 ある時点から Server side JS というゴールにスイッチしていきました。Server side JSにはイベントループモデルを取り入れることに成功しました。
WindowsでのIOCPと Linuxでのepoll、 OSXではkqueueを融和させ、libuvを作った事、それをcoreのJSレイヤでサポートしたり、npmを作ってユーザのコードを管理したりという一定の成功は得られました。
私は2012年にリーダーを引退し、Node.jsの開発を引き継ぐことにしました。『2012年の時点でもう既にゴールは達成された』と思っていました。しかし、今現在2018年にNode.jsを半年間使ってみたところそれは勘違いでした。
Node に残っていたmission criticalなタスク
2012年頃にIsaacにリーダーを引き継いだ後、実際はまだまだいくつかのcriticalなタスクが残っていました。 それらのタスクは消化され、今のNode.jsは当時よりも良いプロジェクトになっています。
- npmをnodeのcoreの中に入れること by Isaac
- N-API というbinding API
- libuv の構築 by Ben and Bert
- governance と community の管理 by Mikeal Rogers
- crypto周りのコードベースの大幅な改善 by Fedor Indutny
この他にもいろいろな物がありましたが、現メンバーの力によってかなり大幅な改善がされています。
しかし、私は「現時点の Node.js を半年間使ってみたが、自分の目的とは異なったものになってしまった」という感想を持っています。
mission critical なタスクはいくつかは解消されていましたが、いくつかのタスクはそもそもの設計の根幹に関わっており、解消しきれていませんでした。
このあとの話はそれらをリストアップし、どうやって deno
が生まれたかについての話になります。
動的型付け
動的型付け言語は科学的計算を一度だけ行うのにはベストな言語です。 JavaScript は動的型付けの言語の中でもかなり良い方の言語だと今でも思っています。
ただ Node.js においては複数回トライエラーを繰り返しながら設計されることを想定しています。設計過程で起きたNode.jsのエラーの内容はわかりにくく、エラー時の解消方法もはっきりしていません。
Node.jsのこういう側面を見るたびに黒板を爪で引っ掻いた音を聞いたときのような嫌な印象を受けました。
せめて今ならもっとよくできるのではないか、という思いを持ちました。
古川注釈: 静的型付け
おそらく静的型付け言語のがエラーの内容がわかりやすく、解消方法もわかりやすいと言いたいのではないか、特にTypeScriptのように変換する事が現代は割と一般的なので。
Promise
Promiseは2009年に一度Node coreに記述されていました。しかしながら、2010年にはそれらをすべて消すということを決定しました。
今でも愚かな決断だったと思っています。
async/await
の抽象化を行うためには Promise
は最初から入れておくべきでした。
Node.js のコアではPromiseが無いために今日まで非同期に関しての体験を悪くしてしまっています。
古川注釈: Promise
2009, 2010年のPromise騒動は覚えていて、Promiseを入れるという事が行われていたにも関わらず、当時はcallbackのほうがprimitiveでわかりやすく性能面でも利点が大きいという事になり、Promiseにするのはcoreの実装では不要という判断がされていました。
当時はここまでPromiseが今後のキーになるとRyanは思っていなかったのでしょう。
Security
V8それ自身はsecureなsandboxモデルを表現しています。この点についてもっと深く考察しておけば、もっと良いセキュリティの考察を得られ、それによって他の言語よりもより良いセキュリティを提供できたと思ってます。
例えば: ただのlinterなのに、networkアクセスやファイルへのフルアクセス権は不要ですよね。
古川注釈: Security
V8それ自身はただJavaScriptの実行エンジンであってファイルシステムへのアクセスやネットワークアクセスする機能は提供していません。標準出力を行う console.log
ですら V8の対象の外です。それに対してNode.jsは fs
や http
といったファイル・ネットワークリソースへのアクセスを提供します。ただ現在のNode.jsの幅広いユースケースを鑑みると、Linterやbuildツールとして実行しているときにネットワークリソースへのアクセス権は不要であったり、ファイルの書き込み権限は不要であったりします。
これらをsandboxの中で適宜パーミッションを得ながら実行できればもっとより良いセキュリティモデルを提供できた、と言いたいのでしょう。
Build System (GYP)
GYPは大きな失敗でした。ビルドシステムは思ったよりも難しくて重要な根幹のシステムでした。
そもそもv8がgypを採用していて、Node.jsもその仕組に乗っかりましたが、その後 gyp から gn に build システムは移り、gypユーザーは取り残されてしまいました。
gyp のインタフェースはそこまで悪いものではないですが、JavaScriptのプロジェクト内で「JSONっぽいけどJSONじゃないPythonのsyntax」を使わされ、ユーザーにとってはひどい体験だったでしょう。
このまま gyp を未来永劫使い続けるのは Node.js のコアにとっては大きな失敗です。 V8 の C++ bindings を書くなら、今は FFI (Foreign Function Interface) を推奨します。
かつて、 FFI を推奨してくれた人たちが居ましたが、当時の私(Ryan)はそれを無視しました。
(ちなみに、 libuv が autotools をサポートしたことに関しては今でも残念に思ってます。)
古川注釈: gyp
FFI と gyp に関しては FFI のがポータブルである一方で当時はgypのが高速という事が言われていました。性能を優先したデザインを取ってgypをサポートしていました。
しかし、 gyp は python2 ベースですし、そもそも Chrome や v8 開発のために作られたビルドシステムです。Chrome や v8 のビルドシステムも現在は gn や bazel というビルドシステムに置き換わっていて、 Node.js がgypを使い続けるのはアーキテクチャ上負債になっています。
コアが特定プロジェクトのビルドシステムに依存するよりは FFI のような統一的な呼び出し方法のがまだ良かった、と言いたかったのではないかと思います。
(ちなみに Ryan は常に何かのビルドシステムに乗っかっては「失敗だったから変えるわ」って言うタイプで昔はWAFというビルドシステムにのって、それからgypに変えています。)
package.json
Node.js は package.json の中にある "main" フィールドを読んでそれを Node.js に require()
でモジュールとして読み込ませることにしました。
最終的には npm は node.js の中のreleaseに含めることに成功し、それがデファクトスタンダードになっています。しかしながらそれは中央集権リポジトリを生み出してしまいました。
require("somemodule")
と記述するのは明確な特定のモジュールを示しているわけではありません。
ローカルに定義されたモジュールの可能性もあります。npmのデータベースにあるモジュールなのかローカルに定義されたモジュールなのかは実は呼び出しているだけではわかりません。
また、 package.json
はファイルを含んだディレクトリをモジュールの概念として扱っています。これは厳密的に言えば必要な抽象化ではありませんでした。 Web 上には少なくともその抽象化されたモジュールは存在しません。
また、不要な情報を多く含んでいます、ライセンス、リポジトリURL、description、こういう情報はプレーンなモジュールにおいては noise です。
import
した時にファイルとURLsが使われていれば、パスにバージョンを定義できます。依存関係のリストも不要です。
古川注釈: package.json
いくつか示唆に富んだ話ですね。解説が難しいです。ここの話からモジュールの話やpackageの話が多く存在します。
現地で聞いていたときの印象としては Ryan Dahl は module は package.json
以下にあるディレクトリを指すのではなく、コアが提供するのは単一ファイルで十分なのではないかと思っているんだと思いました。
import の時に import foo from "https://example.com/foo/v2/index.js"
とかで url で指定させたり、 import foo from "./foo/v2/index.js"
などのファイルで指定できるようにするだけでよく、ディレクトリを指定させるのはアプリケーションごとにやれば良いと思っているのかと。
node_modules
moduleの解決アルゴリズムが相当複雑になってしまいました。 vendorのモジュールをデフォルトにした事は良いことではありますが、実際には $NODE_PATH は使われていません。
ブラウザのsemanticsからも外れてしまいました。
これは私のミスです、本当に申し訳ありません。でももうやり直すことは不可能です。
古川注釈: node_modules
module の解決アルゴリズムは node_modules 内の依存関係をたどって依存解決をします。また、 moduleは指定元からの相対パスでファイルを指定しますが、 $NODE_PATH 環境変数にパスがセットされているとそこからも読み込まれます。
この他にもいくつか hacky な方法がありますが、どの方法も微妙です。この状況を招いたのは node
でもっとシンプルな方法を提供できなかったせいだと Ryan は語っていました。一方で今のNode.jsの仕組みをブラウザに逆輸入されているため、この意見については反対意見もあるようです。
Gotta disagree with Ryan here. The resolution semantics aside, local module installs are why compatibility works better in Node.js than most other platforms, and
— Mikeal Rogers (@mikeal) 2018年6月2日
aligns nicely with the cloud function future we're moving towards. pic.twitter.com/qBLGRUJqxU
".js" の拡張子なしで module を読み込ませられるようにしたこと
いわゆる require("somemodule")
と .js
の拡張子がなくても Node.js はモジュールとして読み込み可能です。
でも browser では src の指定に対して .js
を省くようなことはしません。
module loader はユーザーの意図を表現するファイルシステムへのクエリーであるべきで、明示的な指定のが良いです。
index.js
index.js は require("./") で読み込めますが、これは require("./index.js") の省略です。
最初はこれが良いアイデアだと思ってました。ブラウザも /
で /index.html
を省略できます。
しかしながら、これも module loader を多少複雑にしました。
Node.js module 設計ミスサマリ
私の失敗の多くは module に関する部分です。どうやってユーザのコードを管理するかという部分の失敗が多くありました。
なぜこの状況になったかというと、イベントI/O に集中して注力してしまい、結果としてモジュールの設計を後回しになってしまった事にあります。
この重要性に早く気づいていればもっとこの状況をよくできたと思っています。
Deno
私はこれまでの設計ミスに基づいて新しいプロダクトを開発しました。それが deno です。
最初に言っておくと、全然まだまだプロトタイプレベルです。 動かないのが普通の状況なので、 lldb でデバッグして直したりしない限り動かないし、ましてやこれでなにか作るのは強くオススメしません。 (※ ちなみに古川はまだ osx で起動どころかビルドに成功していません。)
deno は v8 で動くセキュアなTypeScript 実行環境です。
Deno のゴール: Secure Model
deno は sandbox モデルになっています。デフォルトではネットワークアクセスもなければファイルの書き込み権限もありません。
(--allow-net
--allow-write
をつけない限りはネットワークアクセスと書き込み権限が付きません。)
ユーザーが信頼していないツールをちょっと動かすみたいなケースではこれはセキュアなモデルです。(例えばlinterをちょっとだけ動かすとか)
また、 deno では「ダイレクトに任意のnative codeを実行すること」は許可していません。全ては protobuf
の呼び出しによって間接的に実行されます。
以下の図を見てください。
古川解説: Secure Model
deno は OS のカーネルのごとく、 特権モードとユーザ空間を明示的に分けてるデザインなんですね。非常に面白いです。 Goの中で v8-worker と呼ばれる worker を起動して、 worker と main process の間で protocol buffer を経由して通信して特権のコードを実行するか決めてるんですね。
(面白いけど、ここまでのセキュアなものが本当に必要なのかは不明ですね。一方で最近 npm の脆弱性も増えているので必要な面もわかります。)
Deno Goal: モジュールシステムのシンプル化
Nodeの既存モジュールとの親和性は求めてません。
importは相対パスか絶対パス、もしくはURLだけでしか指定できません。
import { test } from "https://unpkg.com/deno_testing@0.0.5/testing.ts" import { log } from "./util.ts"
import には拡張子は必要で、基本的に一度読んだらキャッシュしますが、キャッシュを強制的に開放するときは --reload
をつけて実行します。
vendoring に関してはデフォルトのキャッシュディレクトリの指定をしなければ実現できます。
古川解説: モジュールシステムのシンプル化
ファイルをモジュールの基本的な単位にするし、node.jsのnpmのことは完全に忘れて新しくするという潔さ。
また、 import
には URL かファイルの相対・絶対パスしか用意しないので、 vendoring というかバージョン管理や固定は不要ですね。ファイルならそのままgitで管理できているし、URLの場合は絶対パスなので取得先が壊れたりしない限りは(理想的には)固定されます。
まぁただ本格的に使うなら vendoringとかpackage management 用の何かの仕組みを3rd partyが作ったりするんでしょうけど。 vgo とか bundler のように。
TypeScript コンパイラ
私はTypeScriptが大好きです。
TypeScript はとても美しく、プロトタイプレベルから巨大なシステムになっても構造化を保つことができます。
denoではTypeScriptのコンパイラをモジュールの参照解決とビルド成果物のインクリメンタルキャッシュに利用します。 TypeScriptのモジュールは変更されてない限りは再コンパイルしません。 通常のJavaScriptも使えるようになります(TypeScriptはJavaScriptのスーパーセットなのでそこまで難しくはありませんが)。
スタートアップを高速化するためにv8のsnapshotも利用する予定です(まだプロトタイプには入ってません)。
古川注釈: TypeScript コンパイラ
Ryan Dahl が TypeScript が好きなのは一つ前のRyan Dahl プロダクトである propel が TypeScript なところからも感じていました。ここはそこまで驚きはないです。 何個かのアプリケーションを作るうち、Ryan Dahl も型が必要だと思ったのでしょう。
v8 snapshot は ここで解説しましたが、所謂heapのsnapshotを事前に取っておいて起動を高速化するためのテクニックですね。
single executable file と最小限のリンク
deno
それ自身が最小限の構成をすることを目指しています。 ldd
で見てみると7つ程度しかファイルがありません。
> ldd deno linux-vdso.so.1 => (0x00007ffc6797a000) libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f104fa47000) libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f104f6c5000) libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f104f3bc000) libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f104f1a6000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f104eddc000) /lib64/ld-linux-x86-64.so.2 (0x00007f104fc64000)
2018年であることを活用したモデル
Nodeのモジュールをコンパイルするのに Parcel
をバンドルツールに使っています。Nodeで行っている事よりも最大限にシンプルです。
native code の中に 良いインフラストラクチャを作っています。httpサーバについても心配する必要はありません。既に動作は確認できています。 (Nodeの時はWeb Serverは今は手で自分で作る必要がありましたが、今は違います。)
また現時点ではJS以外のパートは Go を使っています。しかし、これはGoですべてやるという事でもありません。今は色々調査しています。 Rust や C++ も良いでしょう。
その他諸々
- キャッチされない例外がPromiseで起きたら即座にシャットダウンします。
- top-level await をサポートします。
- 機能的に重なる場合はブラウザとの互換を取ります。
古川の感想
deno はまだまだプロトタイプレベルですが、今のNode.jsにもフィードバックできるところは多そうだなと思いました。そういう意味では使ってフィードバックをNode.jsにしていこうとは思いました。しかし今時点では積極的に使おうにも lldb 等のデバッガなしでは使えないので何かを作れるようになるのは先だなと思いました。
またNode.jsにおける module
とは package
のことであり、ディレクトリを指してます。一方で ES Modules や deno における module
というのはファイルの事であり、最低限の単位しか持ちません。この時点で考え方において大きな違いがあると思いました。
Node.jsがディレクトリを単位とし、 package.json
という定義ファイルがあったからこそ議論を進めた一方で、言語やコアのベーシックな機能として持つ最小限の module というのが何なのか、何であれば良かったのかについて考えさせられました。
いずれにせよ 9年越しに Ryan Dahl の話を聞けて大分面白かったです。他の jsconf と Node Collaborators Summit の話も面白かったのでいつか書きます。