Dual Package Hazard
この記事は Node.js Advent Calendar 2019 の 11 日目の記事です。
今回は全 Node.js で ES Modules を利用するユーザーが知っておくべき Dual Package Hazard
について紹介します。
ESModules がフラグ無しでサポートに。
これまでは ES Modules は --experimental-modules
フラグが無いと使えませんでしたが、 フラグ無しで Node.js v13.2.0 から使えるようになりました。ES Modules については CodeGrid の記事で詳しく書いているのでそれを一読していただけると理解がスムーズになると思います。逆に読んだ方は Conditional Exports / Dual Package Hazard の節まで飛ばしてもらって構いません。
Node.js における ES Modules のおさらい
ES Modules と従来の CommonJS ベースの Script とは別なものです。 ES Modules は何も宣言しなくても Strict Mode になり、予約語も global に定義されている変数も異なります。Node.js ではこれらの従来の CommonJS と ES Modules を分けるためにいくつかの方法を提供しています。
- ファイル拡張子が
.mjs
になっていること - ファイル拡張子が
.js
になっている、もしくは拡張子がない場合、最も直近の親モジュールのpackage.json
に記載されているtype
フィールドがmodule
になっていること --eval
や--print
もしくは Node の標準入力から渡されるようなケースでは--input-type=module
をつけること
特にこれまでと大きく異なるのは、 ファイル拡張子です。 これまで (Node v10まで) はデフォルトで 1. のファイル拡張子が .mjs
しか認めていませんでしたが、これからは .js
でも package.json
のフィールドに type: “module”
の記述があれば ES Module として読み込まれます。
また、 ES Modules として宣言するのと同様の宣言が CommonJS でも可能です。.cjs
拡張子をつける、 type
フィールドに commonjs
と入れるなどの対応をすれば CommonJS として宣言することが可能です。
以下にフローチャートを記述します。
ES Modules の相互運用性に関して
現在の Node.js には ES Modules と CommonJS の2種類の module があります。これらの module が混在する場合はどうなるでしょうか。 Node.js はどちらであっても透過的に扱えるように互換性を取っています。この「透過的に扱えるように互換性を取ること」を “Interoperability(相互運用性)” と呼びます。
この相互運用性を保つため、 ES Modules からでも CommonJS をimportできるようになっています。CommonJS から ES Modules を呼ぶことも可能ですが、詳細は後述します。
import するとき
以下のサンプルは ES Modules から module を import する時の方法です。
// このファイルは foo.mjs として定義され、拡張子が `.mjs` になっている。 // すべての Node.js のモジュールは ES Modules としても import できるようになっている。 import fs from “fs”; // 名前付きで import する場合は以下のようになる。 import { readFile } from “fs”; // ES Modules はもちろん import できる。`.js` であっても `.mjs` であっても、特別な記述がなければ import 時は ES Modules としてロードされる。 import foo from “./foo.js”; // npm module などのモジュールもロードできるが、type フィールドがmodules じゃない場合は commonjs としてロードされる。 import _ from “lodash”; // 無理矢理呼んだ場合であっても、 package.json の type フィールドが `module` ではないため、 commonjs になる。 import _ from “./node_moduels/lodash/index.js”; // commonjs としてロードしてほしい場合は拡張子を .cjs にする。 import bar from “bar.cjs”;
CommonJS から ES Modules を呼びたい場合は、Dynamic Import 構文( import()
)を使います。
// このファイルは CommonJSとして定義されている。 const _ = requrie(“lodash”); // CommonJS から ES Modules を使う時は Dynamic Import で構文として使う。 import(“./foo.js”).then((foo) => { // foo });
相互運用性をサポートしているため、CommonJS から ES Modules を呼ぶことも、 ES Modules から CommonJS を呼び出すことも可能です。
ES Modules と CommonJS 両方に対応した package を作る
ES Modules 形式と CommonJS 形式の両方に対応した package を作る場合は以下の処理が必要です。両方に対応すると、 import
と require
の両方でロードするモジュールを作ることが可能です。
- 標準のロード形式を ES Modules 形式にするか CommonJS 形式にするか選択する。ES Modules 形式にする場合は、
package.json
のtype
フィールドをmodule
にする。 CommonJS 形式にする場合はtype
フィールドをcommonjs
にする。
// ./node_modules/es-module-package/package.json { "type": "module", // ES Modules 形式 "main": "./src/index.js" }
// ./node_modules/es-module-package/package.json { "type": "commonjs", // CommonJS 形式 "main": "./src/index.js" }
- 標準のロード形式を CommonJS 形式にした場合、ES Modules から読み込むエントリポイントを作り、そこから読み込むようにドキュメント等で記載が必要です。 ES Modules 形式にしたとしても同様で、 CommonJS から読み込むエントリポイントを作る必要があります。
// ES Modulesで読み込む用の endpoint から読み込んでもらう import foo from “foo/esm.js”;
- もしも
import
時のendpoint
をカスタマイズして使わせたい場合は package exports の機能を使います。package exports はファイルパスに対して別名をつける機能です。
// ./node_modules/es-module-package/package.json { "exports": { "./submodule": "./src/submodule.js" } }
// package.json に書いてある exports をパスとして使える。 import submodule from 'es-module-package/submodule';
これらの機能をうまく使えば、見た目上は同じく使えます。 import する際は import submodule from “es-module-package/submodule”
で、 require する際は const submodule = require(“es-module-package”);
で使えます。
ただしこれだと読み込み先のモジュールの名前をドキュメントなりで教える必要があります。そこで v13.2.0 からの experimental な機能として追加された Conditional Export という機能を使うとこの状況を改善できます。
Conditional Export
読み込み先のモジュールのエントリーポイントを用意しておき、条件に応じて切り分ける機能です。
{ "name": "test", "type": "module", "main": "./main.mjs", "exports": { ".": { "require": "./main.cjs", "default": "./main.mjs" } } }
こうしておくと、 require("test")
で呼ばれたときは main.cjs
が呼ばれ、 import "test"
で呼ばれたときは main.mjs
が呼ばれます。
まだ実験的な機能なので使うためには --experimental-conditional-exports
フラグ付きで呼び出す必要があります。
$ node --experimental-conditional-exports foo.js
このように ES Modules でも CommonJS でも呼ばれるようにする package のことを Dual Package と呼びます。
Dual Package Hazard
前置き長かったですが、やっと本編です。
Dual Package には特定のバグのリスクが伴います。このリスクのことを Dual Package Hazard と呼んでいます。きちんと理解して Dual Package 対応を進めていきましょう。
Dual Package はES Modules と CommonJS で一見同じものをロードしているようで、実はファイルが違います。この『ファイルが違う』、という事が下手をするとインスタンスの違いを引き起こし、結果として予期しない問題になり得ます。
例を挙げて解説します。 test
という Dual Package を用意します。これは import
から呼ばれたときは main.mjs
を呼び出し、 require
から呼ばれた時は main.cjs
を呼び出します。
// node_modules/test/package.json { "name": "test", "type": "module", "main": "./main.mjs", "exports": { ".": { "require": "./main.cjs", "default": "./main.mjs" } } }
package の 規定となる class を定義しておきます。これを core.cjs
として定義します。
// node_modules/test/core.cjs module.exports = class A{};
ESModules で定義されてる main.mjs
は core.cjs
をロードし、 instance 化した後で export
で公開します。
// node_modules/test/main.mjs import A from "./core.cjs"; export default new A();
CommonJS で定義されてる main.cjs
の方も同様に class A を instance 化した後で、 module.exports
で設定します。
// node_modules/test/main.cjs const A = require("./core.cjs"); module.exports = new A();
同じようなものを export してますが実体が異なります。これを同じノリで使おうとするとハマる可能性があります。
// foo.mjs // node --experimental-conditional-exports foo.mjs async function main() { const t = require("test"); t.core = false; const t2 = await import("test"); console.log(t); // A { core: false } console.log(t2.default); // A { } !! 変更が反映されていない !! } main();
この小さなパッケージでは間違えることは少なそうですが、例えば plugin 形式で context を引き継ぐような webpack, gulp, grunt のようなツールでは、ES Modules と CommonJS が同じインスタンスを指していない事で plugin 同士で context が引き継がれず思いも寄らない不具合を引き起こしかねません。
このように同じものをロードしているようで、実は実体が違うものがロードされた結果、思いも寄らない不具合を引き起こすリスクを Dual Package Hazard と呼んでいます。 Node.js の package は特に pluggable な作りのものが多いので、気をつけて使う必要があります。
Babel や esm などの transpile するやり方は CommonJS に変換して使っているものが多いため、実際にはこの問題は起きません。 Node.js が両方で読み込めるように相互運用性を高めた結果、気をつけて利用しないと、発生しうるのがこの不具合です。
Dual Package Hazard を局所化する
これに対応するのは実はそこまで難しくありません。同じインスタンスを指すようにしてしまえばよいのです。この例だけで言えば、 core.cjs
からロードしたものを instance にして返却するのではなく、 instance 化済みの状態で export し、 singleton にするという手があります。
// node_modules/test/core.cjs class A{}; module.exports = new A(); // singleton にして公開する
このようになるべく状態を作るところを一箇所にまとめ、エントリーポイントごとに状態を共通化させる事が重要です。
もしくは、 Node.js からは CommonJS 形式でのロードに集中し、 browser などの実行環境で ES Modules 形式を使うという消極的な戦略にしてしまうことも可能です。
// node_modules/test/package.json { "name": "test", "type": "commonjs", "main": "./main.cjs", "exports": { ".": { "require": "./main.cjs", "browser": "./main.mjs", // browserっていう実行環境でのみ main.mjs を読み込ませる(何らかのツールでこの情報を参照してもらう) "default": "./main.cjs" // その他の状況では commonjsにする } } }
この場合は、Nodeとbrowserで同じスタイルで読み込まれますが、そもそも実行環境がブラウザとNodeで異なるため、 CommonJS と ES Modules が同じ環境からロードされることがありません。※ browser propertyはデフォルトではブラウザが見てくれるわけではないのでなんらかのツールのアシストが必要になります。
Dual Package 自体は便利ですが、このような混乱があることを忘れずに。色々な回避策はもちろんありますが、まだベストプラクティスと呼べるほどのものが無いのが現状です。
まとめ
- Node.js ES Modules はv13.2.0からフラグ無しで使えるようになりました。
- ES Modules からも CommonJS からも両方から使えるように相互運用性をサポートしています。
- ただし、 ES Modules と CommonJS の両方に対応した Dual Package を作る時は注意が必要です。
- お互いの instance が違うことで ES Modules と CommonJS のどちらかだけでは起き得なかった不具合、 Dual Package Hazard が発生します。
- これを直そうとするといくつかのトレードオフが発生します(singletonにする、環境ごとで切り分けるなど)
- まだベストプラクティスはない状況です。積極的に使ってフィードバックするもよし、消極的にまだ運用を控えるのも良いでしょう。
参考資料
JSConf.JP を開催しました。 / We have held JSConf.JP !
Acknowledgement
色々と終わって来たので鉄は熱い内にと思ってブログを書いてます。ホント大変なこともたくさんありましたが、やりきれて本当によかったです。
We have done the rest of tasks, so I am writing a blog. I had lots of troubles, problems, issues but I am so glad that I have completed to run JSConf.JP.
これまで JSConf.JP を開催したいという声はたくさんありました。ただしこれまで開催はできませんでした。
There are lots of voice that " I would like to hold JSConf.JP ". However noone hold the event.
理由はいくつかありますが、 JSConf を開催するのに 2つレギュレーションが必要だというのが大きな理由だと思います。
There are a few reasons, but the main reason is 2 rules to hold JSConf.JP.
1つは、 JSConf に参加したことがあること、もう一つは JSConf の別なオーガナイザーからメンタリングを受けていること。
First rule is to join JSConf as attendee, 2nd rule is to have mentoring by other JSConf organizer.
1つめのルールは簡単にクリアできましたが、2つめのルールをクリアするには割と勇気がいりますね。
First rule is easy to clear, 2nd rule needs to have courage to solve the rule.
まずは JSConf Japan をやりたいと言った時に相談に乗ってくれた JSConf Colombia の Juan に感謝を言いたいと思います。彼が僕のメンターになってくれたおかげで、 JSConf.JP が開催できました。
I would like to say thank you to Juan, JSConf Colombia organizer. He gave me some advice about how to hold JSConf Japan. I could hold JSConf.JP thanks to my mentor Juan.
他にもプロジェクトマネジメントのできない僕に代わり、自分で何をするべきかを考え、色々行動してくれたスタッフ、色々と不手際がありながらも参加してくれた参加者、スポンサーの皆様にも感謝を言いたいと思います。
And I would like to say thank you all staff that they think what to do and take some actions instead of me and all attendees, and all sponsors.
JSConf Panel Talk
ユニークな試みの1つとして、 JSConf Panel Talk というパネルディスカッション形式でのトークを私がやりました。
We have an unique contents, JSConf Panel Talk. I have done panel discussion styled talk.
このセッションでは、 JSConf EU のオーガナイザーの Jan, BrooklynJS, Google DevRel の Kosamari さん, Automatic の Lena Morita さんをお呼びして、日本なりの国際カンファレンスの在り方を話し合いました。
This session is discussed about Japanese owned International Conference with Jan, JSConf EU organizer and Kosamari, Google DevRel and Lena Morita, Automatic .
トークの内容は、例えば JSConf JP にライブトランスレーションがあったほうがいいか、 JSConf JP は他の JSConf と比べてチケットが安すぎではないか?といった内容を参加者から集めて語り合いました。
For example, talked contents are JSConf.JP would be better to set up live translation? Why is JSConf JP ticket so cheap compare to other JSConf? We have collected these opinions from attendees.
どのトピックも盛り上がりました。30分では足りないくらい。
Every topic are heated up. We need more time to discuss about that.
面白かったのは、完全に海外と日本ではカンファレンスのチケットに対する考え方に差があるところです。
I have an interest in the difference of ticket thinkings between Japan and international.
海外カンファレンスのオーガナイザーは基本的に企業が従業員にチケットを買っていくもの、という考え方です。対して、日本では、個人で買うもの、という考え方です。
International Conference organizers think company buys tickets for employees. However Japanese conference organizers think an individual buys tickets for themselves.
だから数万円以上の金額になることも珍しくありません。
So their ticket price become over 100USD. This price is not so rare.
日本でもそれを念頭に置いたカンファレンスもありますが、基本的に無料だったり、安い値段になります。
some Japanese conference have the ticket price over 100USD. However basically the ticket price is free or cheap.
海外カンファレンスは高くする代わりにより設備にお金をかけます。
International conference takes more cost for equipment, facility etc.
日本だとスポンサーを増やす必要があります。
Japanese conference needs to get more sponsors if same equipment needed.
どっちがいいというわけではないですが、いま時点で日本の JSConf.JP では、例えば全セッションをライブトランスレーションを用意するなどの対応をすると、予算が足りません。
I don't know which is better, but now our JSConf.JP does not have enough budget to setup live translation in full sessions.
また、海外では hallway track を意識するとのことでした。 hallway track は廊下でのセッションで、所謂井戸端会議のようなものですね。
And International conference keeps in mind "hallway track". hallway track is a session in hallway, in Japanese Idobata-Kaigi.
つまりコミュニケーションを意識する、もっと参加者同士のつながりを持ってもらえたら、次に来るときも「あの人に会いに行きたい」という意識から来てもらえるようになる、ということでした。
They keep in mind "communication", if attendees have connections with each others, they would come in next year, they have a feelings that "I want to see that person".
Next JSConf Japan
次もやります。次は 2020 年の 9月末頃を予定しています。
We will hold JSConf.JP in next year. We are planning to hold in September 2020.
あと TC39 が 東京で9月にミーティングがあるとのことです。 TC39 のメンバーともコラボレーションしたいと思っています。
And TC39 will hold their meeting in September at Tokyo. We would like to collaborate with TC39 members.
またぜひ来てください :)
We are looking forward to seeing you in next year :)
ISUCON 9 予選に isucon_friends として参加し、予選総合3位でした。
久しぶりの本戦出場
ISUCON3 以来なので 6 大会ぶりですね。。。思えばずっと予選で負け続けてきたものです。
yosuke-furukawa.hatenablog.com
結果どうだったか
予選3位通過でした(棄権含む)。最終スコアは 27,470 ですね。 isucon.net
1位 nil 1 [1] 52,440 2位 にがり 1 [1] 36,270 3位 isucon_friends 3 [0] 27,470 ★ 4位 いんふらえんじにあー as Code 3 [0] 26,460 5位 ようするにメガネが大好きです 3 [0] 25,200
僕らよりもスコアが跳ねている、にもかかわらず、学生が1名でやっている、nil
と にがり
は本当にすごいと思います。
何をやったかまとめ
言語は Go でした。 (Node.js ではありません。)
- Index貼ったり、OR検索をUNIONに変えるなどでチューニング 2100点 => 2800点
- キャンペーン還元率を 0 => 1 に。 この時点では3300点前後になるも、ボトルネックがログインに移った。
- 3台構成にして、 nginx, app, DB にした。 この時点で 8800点程度、ただこの時点ではボトルネックはログインのまま。
- bcrypt 剥がして SHA256 に rehash することに。ベンチマークを何度も回してその都度 rehash された password の結果をdumpし、パスワードハッシュ部分だけ書き換えるように。ベンチマーク流すたびに 10000点から少しずつ伸びていくので楽しかった。この時点で14000点
- キャンペーン還元率を 1 => 2, 3, 4 と一気に変えていった。16000点に。
- /buy 内でAPI 呼び出ししてる所を並列処理化、また同時に /users/transactions.json 内で API 呼び出ししてるところを並列処理化。25000点に一気に伸びた。
- もう後は Lock wait 待ちが多くなったので、 N + 1 を改善しようとするも断念。Transaction Levelを REPEATABLE READ から READ UNCOMMITTED に変えてみたりして、悪あがき。 26000点になった。 (fail されないかビクビクしてた)
- 最後にすべてのログをオフにしてレギュレーション確認しながらベンチマークを実行、 27420点 が出たところで終了。
細かく効いたかは定かではないがやったこと
- nginxのエンドポイントを HTTP/2 にした
- Accept-Encoding が gzip だったら gzip 返すようにした (JSON のサイズが劇的に下がってた)
- キャンペーンテーブルをインメモリに持った
- 外部 API へのリクエストをする際に HTTP Agent の MaxIdleConns と MaxIdleConnsPerHost を拡張、1 ホスト 3000 コネクションまで持てるようにした。
やってみようと思いつつもできなかったこと
- N+1 の解消
- API のレスポンスが done status ならキャッシュする
この辺が思いついてはいたものの、残り時間で実装しきれず、断念した所です。 N+1 を IN 句に変えるだけでもやってもよかったかも。 この辺をやれていればもう少し伸びたかもしれません。
ISUCON9の感想
まずは予選突破できて本当に良かったです。若手や同僚にパフォーマンスの考え方を教えている身でありながら、本戦突破がそもそも1度しかできてなかったことを歯がゆく思ってました。
突破できて安心しました。
余談ですが、社内でR-ISUCONというISUCONを自分たち向けにカスタマイズした問題を作って会社全体で合宿しながら半年に一度のペースで会社全体で競っています。
今回、予選を突破している同僚が毎回社内ISUCONで一緒にしのぎを削ってるチームで、とても嬉しく思いました。
・theorem 3 [0] 12,360 ・ふんばり温泉チーム 3 [0] 12,060
R-ISUCONという社内ISUCONをやっていた結果、自分たち含めて3チームも予選を突破した事を本当に嬉しく思います。 このスタンプを社内でも使っていこうと思います。
JavaScript Registryの今後
さて、前回は tink
と yarn v2
における CLI
戦略の話でした。次は JavaScript Registry についてです。
ちなみにこの内容が今回 JSConf.EU 2019 で一番盛り上がったトピックです。
JavaScript Registry とは
JavaScript Package をバックエンドで管理しているサービスです。 npm
が管理しているものがいちばん有名です。他にも GitHub
が管理する Registry
が公開される予定です。
The economics of Package Management
slide:
video:
「Package Managementの経済」というタイトルです。 聴講者からすると、何話すのか不明でしたが、惹きつけられるものがありました。終わったあとはこれぞ「Tech Talk」という発表でした。是非時間があれば全ての動画を見てみるとよいかと思います。
筆者はここで語られていた内容を基に entropic
について紹介し、最後にこの Registry で何がやりたいのかを話します。
Successful JavaScript Registry is hard
npm
のパッケージは大きくなりました。いまや100万パッケージを超えるほどです。
一方で、 npm inc
は営利を目的にした団体です。これだけ多くのpackageがあると管理コストも高くなります。 npm inc
の経営状況がどうこうは色々な話がありますが、実際に公開されてる情報があるわけではないので、特に何かをここで言うつもりはありません。
ただ、 Registry の管理をビジネス的に成功させるのは難しい、ということです。
npm inc
の Registry は基本的に無料で使えます。無料で使う場合、すべてのライブラリは public
として公開されます。 private
や enterprise
でグループを作るなど、特別なアクセスコントロールをするライブラリを作る時には有料版を契約するというビジネスモデルです。
npm
の public
のOSSのライブラリが増えれば増えるほど、 運用コストがかかります。その運用コストを private
ライブラリや enterprise
Registry の売上で賄います。小さいサイズの Registry は運用コストがあっても寄付金で調整するのはそこまで難しくありませんが、大きなサイズになってくると寄付金だけで運用するのはやはり難しくなってきます。これをなんとかするために、 npm inc
は Venture Capital からも出資を受けています。
Venture Capital の意向が変わったら方針も変わりますし、結果としてコミュニティにとって最適な運営が行われるとは限らないわけです。
この話は npm
に限りません。 RubyGems
でも Perl CPAN
でも運営コストの話はあります。ただ、 npm
が管理している package
の数は膨大で、他の Registry よりも遥かに難しい状況になっています。
じゃあどうするか
Registry を分散して管理できるようにしていこう、というのがこの発表の内容でした。
一つの団体が一つの Registry を集中管理している状況をやめて、分散管理するシステムを作れるようにして、みんなでちょっとずつ運用コストを分散できるようにしていこうという話です。
これの構想の基になっているのが、JSConf EU のトークの中にある、 Entropic
です。
Entropic: a federated package registry for anything but mostly JavaScript
Entropic は 新しい Package Registry です。 npm
や yarn
ではなく、新しい CLI
である ds
も持っています。
entropic/cli at master · entropic-dev/entropic · GitHub
ds
は entropic
の registry
につながることはもちろん、 npm
の Registry も read only でつながります。read onlyなので、publishはできませんが、 npm
に登録されている package もダウンロードできるようになります。
ちなみにまだ experimental なので変更される可能性も大いにあります。
どうやってpackageを指定するか
ds
は domain
, namespace
, package name
すべてを指定して、ダウンロードします。
namespace@example.com/pkg-name
こういう指定方法で Package.toml
に記述があると、 package がダウンロードされます。例えば、 ds
本体をdownload したい場合は chris@entropic.dev/ds
と指定します。
ドメインを指定するので、別サーバで別DomainのRegistryであっても package の指定ができます。 (逆に言うと Domain を失効したらダウンロードができなくなるということですね。)
もしも npm
に接続したい場合は、 legacy@entropic.dev/<pkgname>
で legacy
ネームスペースを指定し、 entropic.dev
ドメインで pkgname
にアクセス先の package を指定すれば npm
からダウンロードできるようになります。
(おそらく entropic.dev
の legacy
ネームスペースは npm
への alias になってるんでしょう)
Docker の実行環境さえあれば、 Registry
を構築することは簡単にできます。
https://github.com/entropic-dev/entropic/tree/master/services/registry#running-your-own-registry
Entropic は自分たちの手元で起動し、対象となる package ダウンロード先のhost
を指定してダウンロードします。その host
に自分たちが使う package
のキャッシュが構築され、初回は Package.toml
に記述したドメインのホストからダウンロードされますが、 2回目以降は自ホストのキャッシュを利用します。
アクセス権
すべての package は public
となり、誰からでも install し、誰からでも閲覧できる形になります。 private
のアクセスをしたい場合は GitHub の Registry を併用する形になると思います。
また、一緒にライブラリを編集したい場合は publish
権限を他のユーザーに付与することも可能です。
https://github.com/entropic-dev/entropic#overview
Entropic のゴール
ここからまた少しエモい話ですが、 Entropic
は全ての JavaScript package を Entropic
上で扱おうとしてはいません。つまり、これで npm を倒すという話ではありません。
じゃあ何がゴールになるかというと、
1. 「何もしないでnpmが倒れるか、有効策が出るまで待つ」以外の選択肢を用意したい
2. 「分散管理するRegistryの運用知見」を広めたい
3. 「中央集権から分散管理」に振り子を戻したい
という話です。 npm を倒すのではなく、 npm 以外の選択肢を用意し、その運用知見を全体に広げ、分散管理していくという話は非常に分かる話で、特別な専門知識がなくても管理できるように 共通理解をみんなで深めていこうという深い話でした。
まとめ
1年前、 JSConf EU 2018 で、 Ryan Dahl は module や package について、深く色々な反省をしていました。
yosuke-furukawa.hatenablog.com
その中でもあったのが、「Nodeのモジュールの管理運営自体を private controlled にしてしまったこと」があげられていました。
1年後、 JSConf EU 2019 は節目の年です。コミュニティを代表するようなカンファレンスで Entropic
のような発表があったことは一つの epoch making な話だったと思います。来年は一旦 JSConf EUはありませんが、数年後にどうなっているか確認する、というバトンを渡されたような思いでした。
今年 JSConf JP をやる予定ですが、この辺の話もできれば幸いです。
npm, yarn による zero install 戦略
jsconf.eu 2019 に行ってきました。 特に npm や yarn の今後の話とそもそも Registry をどうしていくか、の話があったのでお知らせします。 そもそも Registry をどうしていくかについては次のエントリで話します。
tink: A Next Generation Package Manager
npm の次のコマンドラインツールである tink が紹介されていました。
presentation: github.com video:
そもそも npm の仕組み
- ローカル依存ファイルを読む (package.json, package-lock.json, shrinkwrap.json)
- 存在しないパッケージのメタデータをfetchする
- 木構造を計算して、実行する(npm v3 以降だとflattenする)
- 実際に存在しないパッケージをダウンロードする
- インストールスクリプトを実行する
今のどこがダメなのか
npm は tar ball
を fetch して、そこから gunzip
して、最後にできあがったファイルをnode_modules以下にコピーするという非常にピーキーな処理をしています。最初のfetchは network IO
に影響し、 gunzip
は CPU
に影響し、ファイルコピーは file IO
に影響します。
これを簡単にするためにキャッシュしたり、展開済みのファイルをハードリンクさせたりとチューニングをしています。 これ自身は必要な処理なのですが、実体のファイルとして作る関係上、どうしても node_modules は巨大になります。
tink sh
tink
は node
本体の代替として起動します。
$ node foo.js
で起動するのではなく、
$ tink sh foo.js
で起動します。これで何がやりたいかというと、 node_modules
を物理的なフォルダにするのではなく、仮想上のフォルダにすることを目指してます。
仮想上のフォルダになることのメリット
いくつか利点がありますが、まず実際にファイルをコピーする必要がない分、 File IO
への影響は緩和されます。
また、 tink
自身はローカル内にハッシュ値を基にしたキャッシュを作るので、ハッシュ値が一致したパッケージに関してはキャッシュが使われます。 Network IO
への影響も緩和されます。
また、最大のメリットとして、 tink sh
で実行した際にランタイムで依存モジュールを解決するため、 npm install
のコマンド実行が不要になります。
つまり、 git clone
や git pull
してから依存ファイルがなかったとしても tink sh <cmd>
で起動すれば実行時に依存モジュールを解決し、起動することができます。
この試みのことを zero install
と呼びます。
prepare && unwind
本番環境で Docker 等を使って毎回ゼロからイメージを作ってる場合はイメージ作成後のキャッシュがないため、毎回起動時にパッケージのダウンロードが開始されます。
これを解決するために事前にFetchしてくるコマンド(prepare)と事前に node_modules を作るコマンド(unwind)の2つが提供されています。
$ tink prepare # 事前にfetchしてくる $ tink unwind # node_modulesの実体を作る
load map
tink
は npm v8
で npm
コマンドに統合される予定とのことでした。
yarn v2: berry
presentation:
yarn v2
も tink
と同様の戦略です。zero install
を実現しています。 .pnp.js
というファイルを内部に作成し、そこに依存モジュールを解決できるようにしています。
開発ツールとしては tink
も yarn v2
も同じ戦略を取っていました。細かな機能の違いはあるのでいくつか紹介します。
yarn contraints
package.json と実行時の依存関係に矛盾があった場合に、警告した上でpackage.jsonを修正してくれるサブコマンドです。
一番多いユースケースは、実行時に依存してる package が package.json に書かれてなかったとか、 workspace と一緒に使った際にmonorepoのサブパッケージ間で異なるバージョンのライブラリに依存してた等ですね。
いわば、 package.json の linter みたいなものですね。
どうやって実現しているのか
tink
も yarn v2
も実態は node
のプロセスである以上、 node
側の標準モジュールでは node_modules
以下にあるファイルをロードしに行く必要があります。
これを解決するために、 tink
と yarn v2
では node
の標準APIの module
にパッチを当てています(!)
module
のファイルロードを行う箇所のメソッドを拡張し、 node_modules
がなくてもファイルをロードできるようにしています。
標準のモジュールロードからは逸脱しているため、今のところ使うのは危険ではあります。将来的に読み込み方が変わる可能性もありますし、標準の node の読み込み方を変更する可能性もあるので、まだ experimental
と言ったところでしょうか。
ただし、 electron
も同様の手法でモジュールを読み込めるように拡張しているので、そこまで変更がドラスティックに加えにくい箇所でもありますね。
実際に、 tink
の解説では、 electron
も同じことやってる(ので大丈夫)というニュアンスで発表していました。
まとめ
モジュール管理は zero install
時代になっていくと思います。事前の処理を不要にすることで開発効率を上げて、 git clone
や git pull
から何もしなくても起動できるようになっていくと思われます。ただ一方で実際に本番で zero install
にしてしまうと、起動してから実際に動作するまでのパフォーマンスは落ちる可能性もあるため、起動方法は変更になるかと思われます。
これらを解決するための本番環境用のコマンドとして、 tink prepare
や tink unwind
も公開されているので、それらを使って行くのではないかと思っています。
また一方で、 tink
も yarn v2
もほとんど同じことをしているので、どちらにも有意差があるようには見えず、状況は変わらないまま今後に突入していくのではないかと思われます。
これらを踏まえて、 Registry についての話を書きます。また次回!
JavaScript が読み込まれる前でもWeb Applicationを動かす
今回は最近取り組んでいる、 JavaScript が読み込まれる前であっても「ちゃんと」 Web Application が動作するように作る話をします。
Server Side Rendering における注意点と対策
BFFを使ってServer Side Rendering をすることに数年前から取り組んでいます。
まずはSSRをやる上での注意点と対策について紹介します。
SSRをすることはSEOのためだと思われがちですが、個人的にはSEOのためにしているわけではなく、 First View
を向上するため(特に First Meaningful Paint
を向上するため)にやっています。
SEOとSSRに関しては Google が最近出したこの記事の SEO Considerations
節が詳しいです。ここでは説明しません。
SSRをしない、Client Side Renderingのみの場合、この First Meaningful Paint
が JavaScript がダウンロードされてからになるので、遅れてしまいます。
ユーザーの環境は様々で、潤沢なwifi環境が揃っていて、最新のマシンが使えて高速という環境ばかりではありません。古いマシンを使って、インターネット環境が整備されてない状況ではJavaScriptをダウンロードする時間もJavaScriptをダウンロードしてから実行する時間も遅くなります。
色々なケースも想定し、SSR を使って First Meaningful Paint
を向上することで、表示されるまでの時間の短縮を行っています。
ただし、SSRで First Meaningful Paint
を高速化する一方で、表示が速すぎると、操作するまでの時間、 Time To Interact
までに乖離が発生します。
乖離が長ければ長いほど「見えてるのに操作できない時間」が長くなり、ユーザーのストレスになります。
これを改善するためにはページあたりに読み込まれるJavaScriptの量を減らすことで、読み込まれる時間を改善する、 Code Splitting と呼ばれる前処理をする必要があります。また、Time To Interact の時間までインジケーターを出してユーザーに処理中であることを表示するのも有効です。
ただ、Code Splitting をしたとしても分割しすぎるとページ遷移のたびに差分のJavaScriptが必要になったり、そもそも React DOM などの巨大なライブラリに依存していると全体で読み込まれるJavaScriptがgzip後で数100kbを超えることも珍しくなく、限界があります。
逆にSSRをやめて、Client Side Rendering のみにしてしまうと、「見えるまでの時間」と「操作するまでの時間」のギャップはなくなります。しかしこの場合、人間としては「見えて(認知して)から操作する」ので、ユーザーにとって最適な時間にはなりません。
JavaScript が読み込まれる前でも操作できるようにする
そこで、今取り組んでるのが、いくつかのページではJavaScriptが読み込まれる前でも普通にウェブアプリケーションとして操作できるようにしています。
HTML と Server だけでもちゃんと動くように作る
こうなると、 HTML と Server だけでもちゃんと動くように作らなければいけません。JavaScript を disabled にした状態でどこまでちゃんと動作するかを確認しながら作る必要があります。
リンクの場合
JavaScript のみで動作させる場合、リンクには click
イベントをフックするハンドラを用意して、そこで pushState
などのURL変更 API を使って遷移させることが多いです。
SSR でレンダリングした上で、リンクなどの処理は a
タグで書きつつ、 遷移先のURLを定義します。 click
ハンドラはそのままにしておけば、JavaScriptが読み込まれる前に実行してもただの a
タグでの遷移として動作します。
<a href="/foo" id="js-link" /> // JavaScript document.getElementById("js-link").addEventListener("click", (e) => { e.preventDefault(); history.pushState({}, "", e.target.href); })
この辺りはSSRを作る上ではライブラリに頼ることも多いと思うので、ライブラリが提供してくれる機能でも動作します。 react-router などのライブラリも同様の機能を提供します。
form の場合
form の場合は複雑です。formの場合はSSRとは違って、 method が POST
のケースも多いので、 POST
を受け付けられるエンドポイントを用意する必要があります。
また、 method
や action
などの form の属性もきちんと指定する必要があります。 form の method
を指定しないために、全部 GET
リクエストになってしまったり、ボタンをsubmitにしてなかったために、きちんと送信されないケースも散見されます。
また、 POST
などの更新系の操作をする場合、厄介なのは CSRF 対策もしてあげる必要があることです。 hidden
の input に対して csrf token
と呼ばれるセッションに紐づくランダムな値を設定しないといけません。XHRとは違ってカスタムヘッダを渡すことも、Cross Origin のときにpreflightチェックのリクエストが飛ぶこともないので、準備が必要です。
// form に対してmethodを指定する。 <form onSubmit={handleSubmit} method="POST"> <div> <input type="email" name="username" component={RenderInput} /> <input type="password" name="password" component={RenderInput} pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}"/> </div> {/* csrf 対策を入れる */} <input type="hidden" name="_csrf" value={csrf} /> <div> <button type="submit" disabled={submitting}> Login </button> <button type="button" disabled={submitting} onClick={reset}> Clear </button> </div> </form>
ログインのような典型的なフォームの場合はユーザーが何をするべきかが表示された段階ではわかっている事が多いため、操作するまでの時間を短縮することで、効果も大きくなります。
また、検索画面のようなフォームも入力項目が少ないため、ユーザーは表示された瞬間に迷わずにクエリーを打ち込みに行きます。このときにクエリーパラメータでURLが変わったとしても動くように作ってあげる必要があります。
このアプローチのメリットとデメリット
アプローチの内容はわかったところで、メリットとデメリットについて紹介します。
メリット
まずは性能的な面でメリットがあります。先程、操作できるようになるまでの時間と表示するまでの時間に乖離が大きいとユーザーはストレスを感じる、という説明をしましたが、JavaScriptをダウンロードするまで待つ必要はなく、「操作できるようになる時間が表示された時間とほとんど同じ」 になります。
ちょうど数ヶ月前に Netflix が React の CSR をやめて、SSRでのみReactを採用した、という記事がありましたが、そこでもパフォーマンスの改善事例として紹介されていました。
アプローチとしてはほぼ一緒です。Time to Interact にフォーカスするのであれば、 JavaScript をダウンロードされてなくても動作させる方が効果的です。
次に accessiblity としても効果があります。JavaScriptに頼らずに普通に作ると verified な HTMLを書く必要に迫られます。form の method や action もそうですが、input の name や type といった属性もきちんと書かないといけません。こうすることで、矯正ギブスのような役割を果たしてもらえます。
デメリット
一言で言うと、「実装するのが大変」というところです。一度すべてのページを JavaScript をオンにして動かせるようにしたあとで JavaScript がオフでも動くようなアプリケーションにすることは実装の観点から見るとやってやれなくはないものの、大変です。
また、完璧に JavaScript が動作するアプリと同じUXを提供することは不可能です。先程のformの例でいうと、 validation
などは input タグの pattern
属性にマッチしてるかどうかしか出すことができません。 JavaScript を使った validation
では、もう少し細かく入力値をチェックできます。例えば「パスワードは英字、数字、記号の3種類が必ず入ってて、8文字以上」などの条件の validation
も pattern
属性で正規表現で書いたとしても、「パターンにマッチしたかどうか」だけしかチェックされません。 JavaScript では、「記号が抜けてる」、「8文字以下」という細かくメッセージで何が満たせてないかを表示させることで入力を補助させることができます。
リンクならともかく、ボタンの場合や、モーダルウィンドウで確認ダイアログを出したい場合など、大体においてJavaScriptが必要なケースは多く、すべてを JS が disabled にした状態で動作させるようにするのはやはり難しいと言わざるを得ません。
個人的には、ログインやトップページの検索画面など、ユーザーがある程度最初の方に触る(JavaScriptがダウンロードされる前に触りそうな)ページを JS disabled でも動作するように作り、残りは noscript
タグで JavaScript がオンじゃないと動かない旨を出してあげるくらいでしょうか。
また、最後にどうしようもないところですが、解析系のタグが動作する前に動いてしまうので、ユーザーのトラッキングはできない可能性があります。この手の解析系がちゃんと動かないとNGのところも多いので、注意してください。
まとめ
JavaScript が読み込まれる前に SSR と HTML だけで動作するアプリを作ることに関しての紹介をしました。
最近の開発では、この方法を基本としており、SSRの弱点の一つである、 First Meaningful Paint
と Time to Interact
までの差を JavaScript が読み込まれる前でも動作させることで解消しています。
ただし、すべてのページでできているわけではなく、トップページに近いような場所でのみ、部分的に動作させています。こうすることで以下のような効果を狙っています。
- JavaScript がダウンロードされてなくてもある程度は動作することで、パフォーマンス上のメリットを狙う
- HTMLでも verified な状態にすることで、 accessibility の観点や JS がオフのユーザーであってもある程度は利用できるようにする
- すべてのUXをJSオフの状態で提供することは不可能なので、必要なページではJSがダウンロードされるまで動作しないように作る
こうすることで、SPAのUXも提供しつつ、潤沢なインターネット環境を持っていないユーザーであってもある程度高速に表示され、動作するように作っています。
再入可能なロックの話
先週、@t_wadaさんと@yosuke_furukawaさんと議論した再入可能性に関する私の経験について書きました | ロック(ミューテックス)の再入可能性 https://t.co/j3xeOUxaWt
— Yoshiki Shibata/柴田芳樹 (@yoshiki_shibata) October 22, 2018
突然のロックの話
いきなりロックの話をしましたが、10月に(なぜか)一緒に働いてるメンバーとの中で大盛り上がりした話題です。もともとはリクルートテクノロジーズで行われている、柴田芳樹さんのプログラミングGo勉強会で話題になった話です。
ここにも書いてあるのですが、 Golang では sync.Mutex
を使ったロックでは再入可能ではありません。
一方 Java のロックは再入可能です。
で、この設計に関しては合理的な解説が Russ Cox さんからされています。
意訳すると以下のような感じですね。
再入可能なロックはbad ideaだ。 mutex を利用する主な理由は mutex が不変式を保護するためだ。 不変式というのは、 例えば 円環(linkedlistのようなもの) の 全要素 p に対して p.Prev.Next == p が成立するといった内部の不変式であったり、 自分のローカル変数 x は p.Prev と等しい、といった外部の不変式を指している。 mutexのロックを取るというのは、「私は不変式を維持する必要がある」というのと、「これらの不変式を一時的に壊す」という二点の表明である。 また、 mutexのロックを開放するというのは、「不変式にはもう依存していない」というのと、「一時的に壊した不変式は元に戻っている」という表明である。 再入可能なロックを取るというのは、いったん mutex のロックを確保した状態で再び mutex のロックを取るが、この状態では不変式を一時的に壊している可能性がある。再入可能なロックは不変式を保護しない。 再入可能なロックというのは単なる間違いであり、バグの温床になる可能性がある。
この話には納得できます。ロックの再入可能可否についてはできないようにする方が良いというのは納得できます。余談ですが、プログラミング言語Go以外の本にもEffective Javaにも同様の話がありますし、詳解Unixプログラミングでもこの手の話はあります。
じゃあなぜJavaは再入可能なのか
Javaは既存のAPI資産がスレッドセーフになっているものがあり、それらの中にはメソッドの中でロックを取っているものもあります。この状況では再入可能なロックを認めざるを得なかったため、今ではバッドプラクティスとして残っています。例を挙げると、HashTableやVectorといった既存のコレクションクラスがスレッドセーフですね。
ちなみに 詳解UNIXプログラミングの中に出てくる pthread も同じく再入可能にするオプションがあります。これも既存のAPIでスレッドセーフなものが多いから渋々追加している機能です。
というわけで、そもそも再入可能なロックを提供すること自体はGoでもJavaでもUnixプログラミングでも望まれていない訳ですね。
社内で盛り上がった内容
さて、再入可能なロックを提供しないという方針については基本的に全員同意しており、これまでの話に対しては反対意見は無いですが、よくよく考えると少し腑に落ちない点があります。それは、「不変式を守るのは、ロックの有無に限らず守らないといけないのではないか?」という点です。
この不変式を守るという話でmutexの再入可能なロックを取らなくしただけではなく、本来的には壊さないように全員が気をつけるべきであり、この問題について取り組んだのが、有名な「契約による設計 (Design By Contract)」の話です。
EiffelやD言語ではこのDesign By Contract を言語仕様に取り入れているというのは有名な話ですね。
Golangでは、不変式を守るという話についてここまで説明があるにもかかわらず、Design By Contractを言語仕様には取り入れていませんし、assertすらありません。
assertを提供していない件についてはちゃんとFAQがありますが、契約による設計を取り入れてない理由も含めてGo言語がそういう取捨選択をしている理由はなんなのか、というので、話が盛り上がり、何度か柴田さんやkoichikさん、和田さんを含めて議論しました。
議論ポイントを整理すると、「ロックを再入可能にしない」という設計については納得するものの、「そこまで不変式を守ると言うなら契約による設計やassertについては入れないという選択をしたのはどうしてか」といった部分ですね。
ただどちらの主張も実は衝突するような話ではなく、「ロックを再入可能にしないのは不変式を壊したくないから、ただし、不変式を壊したくないからと行ってDesign By Contractまでのゴツい仕様は入れたくなかった」という話だろうと、古川は推測していますし、この理由である程度納得しています。
並列並行処理
色んなプログラミング言語を知っていると正解は一つではないというか多様な正解があるという事がわかります。Goの考え方はUnix哲学的なものであり、シンプルな解答を用意しつつも、バグの温床になるような言語仕様は避けようとする考え方が見られます。
一方で、この手のmutexの問題は基本的に「並列プログラミングが難しい」という問題に根ざしたものであり、これに対して何度も挑戦していないと議論ができるようなポイントに到達できないな、と改めて感じ、年末年始はこの本でも読もうかなと思いました。