Dual Package Hazard

この記事は Node.js Advent Calendar 2019 の 11 日目の記事です。

qiita.com

今回は全 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 の節まで飛ばしてもらって構いません。

www.codegrid.net

Node.js における ES Modules のおさらい

ES Modules と従来の CommonJS ベースの Script とは別なものです。 ES Modules は何も宣言しなくても Strict Mode になり、予約語も global に定義されている変数も異なります。Node.js ではこれらの従来の CommonJS と ES Modules を分けるためにいくつかの方法を提供しています。

  1. ファイル拡張子が .mjs になっていること
  2. ファイル拡張子が .js になっている、もしくは拡張子がない場合、最も直近の親モジュールの package.json に記載されている type フィールドが module になっていること
  3. --eval--print もしくは Node の標準入力から渡されるようなケースでは --input-type=module をつけること

特にこれまでと大きく異なるのは、 ファイル拡張子です。 これまで (Node v10まで) はデフォルトで 1. のファイル拡張子が .mjs しか認めていませんでしたが、これからは .js でも package.json のフィールドに type: “module” の記述があれば ES Module として読み込まれます。

また、 ES Modules として宣言するのと同様の宣言が CommonJS でも可能です。.cjs 拡張子をつける、 type フィールドに commonjs と入れるなどの対応をすれば CommonJS として宣言することが可能です。

以下にフローチャートを記述します。

f:id:yosuke_furukawa:20191209230938p:plain
ES Modulesかどうか

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 を作る場合は以下の処理が必要です。両方に対応すると、 importrequire の両方でロードするモジュールを作ることが可能です。

  • 標準のロード形式を ES Modules 形式にするか CommonJS 形式にするか選択する。ES Modules 形式にする場合は、 package.jsontype フィールドを 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 で一見同じものをロードしているようで、実はファイルが違います。この『ファイルが違う』、という事が下手をするとインスタンスの違いを引き起こし、結果として予期しない問題になり得ます。

Dual Package Hazard
Dual Package Hazard

例を挙げて解説します。 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.mjscore.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にする、環境ごとで切り分けるなど)
  • まだベストプラクティスはない状況です。積極的に使ってフィードバックするもよし、消極的にまだ運用を控えるのも良いでしょう。

参考資料

nodejs.org

github.com

JSConf.JP を開催しました。 / We have held JSConf.JP !

Acknowledgement

集合写真
JSConf Japan Photo

色々と終わって来たので鉄は熱い内にと思ってブログを書いてます。ホント大変なこともたくさんありましたが、やりきれて本当によかったです。

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.

JSConf JP Best Prices
JSConf JP Best Prices

面白かったのは、完全に海外と日本ではカンファレンスのチケットに対する考え方に差があるところです。

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.

f:id:yosuke_furukawa:20191209033045p:plain

またぜひ来てください :)

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チームも予選を突破した事を本当に嬉しく思います。 このスタンプを社内でも使っていこうと思います。

f:id:yosuke_furukawa:20190908230806p:plain
本戦であいましょう

R-ISUCONについては今年の初めに 941さん と一緒にデブサミで登壇したのでその時の資料を展開しておきます。

logmi.jp

JavaScript Registryの今後

さて、前回は tinkyarn v2 における CLI 戦略の話でした。次は JavaScript Registry についてです。

ちなみにこの内容が今回 JSConf.EU 2019 で一番盛り上がったトピックです。

JavaScript Registry とは

JavaScript Package をバックエンドで管理しているサービスです。 npm が管理しているものがいちばん有名です。他にも GitHub が管理する Registry が公開される予定です。

The economics of Package Management

f:id:yosuke_furukawa:20190611135113p:plain
the economics of package management

slide:

github.com

video:

www.youtube.com

「Package Managementの経済」というタイトルです。 聴講者からすると、何話すのか不明でしたが、惹きつけられるものがありました。終わったあとはこれぞ「Tech Talk」という発表でした。是非時間があれば全ての動画を見てみるとよいかと思います。

筆者はここで語られていた内容を基に entropic について紹介し、最後にこの Registry で何がやりたいのかを話します。

Successful JavaScript Registry is hard

npm のパッケージは大きくなりました。いまや100万パッケージを超えるほどです。

一方で、 npm inc は営利を目的にした団体です。これだけ多くのpackageがあると管理コストも高くなります。 npm inc の経営状況がどうこうは色々な話がありますが、実際に公開されてる情報があるわけではないので、特に何かをここで言うつもりはありません。

ただ、 Registry の管理をビジネス的に成功させるのは難しい、ということです。

npm inc の Registry は基本的に無料で使えます。無料で使う場合、すべてのライブラリは public として公開されます。 privateenterprise でグループを作るなど、特別なアクセスコントロールをするライブラリを作る時には有料版を契約するというビジネスモデルです。

npmpublicOSSのライブラリが増えれば増えるほど、 運用コストがかかります。その運用コストを private ライブラリや enterprise Registry の売上で賄います。小さいサイズの Registry は運用コストがあっても寄付金で調整するのはそこまで難しくありませんが、大きなサイズになってくると寄付金だけで運用するのはやはり難しくなってきます。これをなんとかするために、 npm inc は Venture Capital からも出資を受けています。 

Venture Capital の意向が変わったら方針も変わりますし、結果としてコミュニティにとって最適な運営が行われるとは限らないわけです。

この話は npm に限りません。 RubyGems でも Perl CPAN でも運営コストの話はあります。ただ、 npm が管理している package の数は膨大で、他の Registry よりも遥かに難しい状況になっています。

f:id:yosuke_furukawa:20190617182135p:plain
module counts

http://www.modulecounts.com/

じゃあどうするか

Registry を分散して管理できるようにしていこう、というのがこの発表の内容でした。

一つの団体が一つの Registry を集中管理している状況をやめて、分散管理するシステムを作れるようにして、みんなでちょっとずつ運用コストを分散できるようにしていこうという話です。

これの構想の基になっているのが、JSConf EUトークの中にある、 Entropic です。

github.com

Entropic: a federated package registry for anything but mostly JavaScript

Entropic は 新しい Package Registry です。 npmyarn ではなく、新しい CLI である ds も持っています。

entropic/cli at master · entropic-dev/entropic · GitHub

dsentropicregistry につながることはもちろん、 npm の Registry も read only でつながります。read onlyなので、publishはできませんが、 npm に登録されている package もダウンロードできるようになります。

ちなみにまだ experimental なので変更される可能性も大いにあります。

どうやってpackageを指定するか

dsdomain , 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.devlegacy ネームスペースは 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回目以降は自ホストのキャッシュを利用します。

f:id:yosuke_furukawa:20190617185414p:plain
entropic download chart

アクセス権

すべての 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 以外の選択肢を用意し、その運用知見を全体に広げ、分散管理していくという話は非常に分かる話で、特別な専門知識がなくても管理できるように 共通理解をみんなで深めていこうという深い話でした。

f:id:yosuke_furukawa:20190617184648p:plain
take back the commons

まとめ

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 が紹介されていました。

github.com

presentation: github.com video:

www.youtube.com

そもそも npm の仕組み

  1. ローカル依存ファイルを読む (package.json, package-lock.json, shrinkwrap.json)
  2. 存在しないパッケージのメタデータをfetchする
  3. 木構造を計算して、実行する(npm v3 以降だとflattenする)
  4. 実際に存在しないパッケージをダウンロードする
  5. インストールスクリプトを実行する

今のどこがダメなのか

npm は tar ball を fetch して、そこから gunzip して、最後にできあがったファイルをnode_modules以下にコピーするという非常にピーキーな処理をしています。最初のfetchは network IO に影響し、 gunzipCPU に影響し、ファイルコピーは file IO に影響します。

これを簡単にするためにキャッシュしたり、展開済みのファイルをハードリンクさせたりとチューニングをしています。 これ自身は必要な処理なのですが、実体のファイルとして作る関係上、どうしても node_modules は巨大になります。

https://cdn-ak.f.st-hatena.com/images/fotolife/y/yosuke_furukawa/20180604/20180604215229.png

tink sh

tinknode 本体の代替として起動します。

$ node foo.js

で起動するのではなく、

$ tink sh foo.js

で起動します。これで何がやりたいかというと、 node_modules を物理的なフォルダにするのではなく、仮想上のフォルダにすることを目指してます。

仮想上のフォルダになることのメリット

いくつか利点がありますが、まず実際にファイルをコピーする必要がない分、 File IO への影響は緩和されます。

また、 tink 自身はローカル内にハッシュ値を基にしたキャッシュを作るので、ハッシュ値が一致したパッケージに関してはキャッシュが使われます。  Network IO への影響も緩和されます。

また、最大のメリットとして、 tink sh で実行した際にランタイムで依存モジュールを解決するため、 npm install のコマンド実行が不要になります

つまり、 git clonegit pull してから依存ファイルがなかったとしても tink sh <cmd> で起動すれば実行時に依存モジュールを解決し、起動することができます。

virtual node_modules
virtual node_modules

この試みのことを zero install と呼びます。

prepare && unwind

本番環境で Docker 等を使って毎回ゼロからイメージを作ってる場合はイメージ作成後のキャッシュがないため、毎回起動時にパッケージのダウンロードが開始されます。

これを解決するために事前にFetchしてくるコマンド(prepare)と事前に node_modules を作るコマンド(unwind)の2つが提供されています。

$ tink prepare # 事前にfetchしてくる
$ tink unwind # node_modulesの実体を作る

load map

tinknpm v8npm コマンドに統合される予定とのことでした。

f:id:yosuke_furukawa:20190610102508p:plain

yarn v2: berry

presentation:

www.youtube.com

yarn v2tink と同様の戦略です。zero install を実現しています。 .pnp.js というファイルを内部に作成し、そこに依存モジュールを解決できるようにしています。

開発ツールとしては tinkyarn v2 も同じ戦略を取っていました。細かな機能の違いはあるのでいくつか紹介します。

yarn contraints

package.json と実行時の依存関係に矛盾があった場合に、警告した上でpackage.jsonを修正してくれるサブコマンドです。

一番多いユースケースは、実行時に依存してる package が package.json に書かれてなかったとか、 workspace と一緒に使った際にmonorepoのサブパッケージ間で異なるバージョンのライブラリに依存してた等ですね。

いわば、 package.json の linter みたいなものですね。

どうやって実現しているのか

tinkyarn v2 も実態は node のプロセスである以上、 node 側の標準モジュールでは node_modules 以下にあるファイルをロードしに行く必要があります。 これを解決するために、 tinkyarn v2 では node の標準APImodule にパッチを当てています(!)

module のファイルロードを行う箇所のメソッドを拡張し、 node_modules がなくてもファイルをロードできるようにしています。

標準のモジュールロードからは逸脱しているため、今のところ使うのは危険ではあります。将来的に読み込み方が変わる可能性もありますし、標準の node の読み込み方を変更する可能性もあるので、まだ experimental と言ったところでしょうか。

ただし、 electron も同様の手法でモジュールを読み込めるように拡張しているので、そこまで変更がドラスティックに加えにくい箇所でもありますね。

実際に、 tink の解説では、 electron も同じことやってる(ので大丈夫)というニュアンスで発表していました。

f:id:yosuke_furukawa:20190610101630p:plain

まとめ

モジュール管理は zero install 時代になっていくと思います。事前の処理を不要にすることで開発効率を上げて、 git clonegit pull から何もしなくても起動できるようになっていくと思われます。ただ一方で実際に本番で zero install にしてしまうと、起動してから実際に動作するまでのパフォーマンスは落ちる可能性もあるため、起動方法は変更になるかと思われます。

これらを解決するための本番環境用のコマンドとして、 tink preparetink unwind も公開されているので、それらを使って行くのではないかと思っています。

また一方で、 tinkyarn v2 もほとんど同じことをしているので、どちらにも有意差があるようには見えず、状況は変わらないまま今後に突入していくのではないかと思われます。

これらを踏まえて、 Registry についての話を書きます。また次回!

JavaScript が読み込まれる前でもWeb Applicationを動かす

今回は最近取り組んでいる、 JavaScript が読み込まれる前であっても「ちゃんと」 Web Application が動作するように作る話をします。

Server Side Rendering における注意点と対策

BFFを使ってServer Side Rendering をすることに数年前から取り組んでいます。

まずはSSRをやる上での注意点と対策について紹介します。

SSRをすることはSEOのためだと思われがちですが、個人的にはSEOのためにしているわけではなく、 First View を向上するため(特に First Meaningful Paint を向上するため)にやっています。

f:id:yosuke_furukawa:20190210220602p:plain
First View

SEOSSRに関しては Google が最近出したこの記事SEO Considerations 節が詳しいです。ここでは説明しません。

SSRをしない、Client Side Renderingのみの場合、この First Meaningful PaintJavaScript がダウンロードされてからになるので、遅れてしまいます。

ユーザーの環境は様々で、潤沢なwifi環境が揃っていて、最新のマシンが使えて高速という環境ばかりではありません。古いマシンを使って、インターネット環境が整備されてない状況ではJavaScriptをダウンロードする時間もJavaScriptをダウンロードしてから実行する時間も遅くなります。

色々なケースも想定し、SSR を使って First Meaningful Paint を向上することで、表示されるまでの時間の短縮を行っています。

ただし、SSRFirst Meaningful Paint を高速化する一方で、表示が速すぎると、操作するまでの時間、 Time To Interact までに乖離が発生します。

f:id:yosuke_furukawa:20190210221642p:plain
Gap between FMP and TTI

乖離が長ければ長いほど「見えてるのに操作できない時間」が長くなり、ユーザーのストレスになります

これを改善するためにはページあたりに読み込まれるJavaScriptの量を減らすことで、読み込まれる時間を改善する、 Code Splitting と呼ばれる前処理をする必要があります。また、Time To Interact の時間までインジケーターを出してユーザーに処理中であることを表示するのも有効です。

ただ、Code Splitting をしたとしても分割しすぎるとページ遷移のたびに差分のJavaScriptが必要になったり、そもそも React DOM などの巨大なライブラリに依存していると全体で読み込まれるJavaScriptgzip後で数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 を受け付けられるエンドポイントを用意する必要があります。

また、 methodaction などの 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文字以上」などの条件の validationpattern 属性で正規表現で書いたとしても、「パターンにマッチしたかどうか」だけしかチェックされません。 JavaScript では、「記号が抜けてる」、「8文字以下」という細かくメッセージで何が満たせてないかを表示させることで入力を補助させることができます。

リンクならともかく、ボタンの場合や、モーダルウィンドウで確認ダイアログを出したい場合など、大体においてJavaScriptが必要なケースは多く、すべてを JS が disabled にした状態で動作させるようにするのはやはり難しいと言わざるを得ません。

個人的には、ログインやトップページの検索画面など、ユーザーがある程度最初の方に触る(JavaScriptがダウンロードされる前に触りそうな)ページを JS disabled でも動作するように作り、残りは noscript タグで JavaScript がオンじゃないと動かない旨を出してあげるくらいでしょうか。

また、最後にどうしようもないところですが、解析系のタグが動作する前に動いてしまうので、ユーザーのトラッキングはできない可能性があります。この手の解析系がちゃんと動かないとNGのところも多いので、注意してください。

まとめ

JavaScript が読み込まれる前に SSR と HTML だけで動作するアプリを作ることに関しての紹介をしました。 最近の開発では、この方法を基本としており、SSRの弱点の一つである、 First Meaningful PaintTime to Interact までの差を JavaScript が読み込まれる前でも動作させることで解消しています。

ただし、すべてのページでできているわけではなく、トップページに近いような場所でのみ、部分的に動作させています。こうすることで以下のような効果を狙っています。

  1. JavaScript がダウンロードされてなくてもある程度は動作することで、パフォーマンス上のメリットを狙う
  2. HTMLでも verified な状態にすることで、 accessibility の観点や JS がオフのユーザーであってもある程度は利用できるようにする
  3. すべてのUXをJSオフの状態で提供することは不可能なので、必要なページではJSがダウンロードされるまで動作しないように作る

こうすることで、SPAのUXも提供しつつ、潤沢なインターネット環境を持っていないユーザーであってもある程度高速に表示され、動作するように作っています。

再入可能なロックの話

突然のロックの話

いきなりロックの話をしましたが、10月に(なぜか)一緒に働いてるメンバーとの中で大盛り上がりした話題です。もともとはリクルートテクノロジーズで行われている、柴田芳樹さんのプログラミングGo勉強会で話題になった話です。

yshibata.blog.so-net.ne.jp

ここにも書いてあるのですが、 Golang では sync.Mutex を使ったロックでは再入可能ではありません。 一方 Java のロックは再入可能です。

で、この設計に関しては合理的な解説が Russ Cox さんからされています。

groups.google.com

意訳すると以下のような感じですね。

再入可能なロックは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)」の話です。

契約プログラミング - Wikipedia

EiffelやD言語ではこのDesign By Contract を言語仕様に取り入れているというのは有名な話ですね。

Golangでは、不変式を守るという話についてここまで説明があるにもかかわらず、Design By Contractを言語仕様には取り入れていませんし、assertすらありません。

assertを提供していない件についてはちゃんとFAQがありますが、契約による設計を取り入れてない理由も含めてGo言語がそういう取捨選択をしている理由はなんなのか、というので、話が盛り上がり、何度か柴田さんやkoichikさん、和田さんを含めて議論しました。

議論ポイントを整理すると、「ロックを再入可能にしない」という設計については納得するものの、「そこまで不変式を守ると言うなら契約による設計やassertについては入れないという選択をしたのはどうしてか」といった部分ですね。

ただどちらの主張も実は衝突するような話ではなく、「ロックを再入可能にしないのは不変式を壊したくないから、ただし、不変式を壊したくないからと行ってDesign By Contractまでのゴツい仕様は入れたくなかった」という話だろうと、古川は推測していますし、この理由である程度納得しています。

並列並行処理

色んなプログラミング言語を知っていると正解は一つではないというか多様な正解があるという事がわかります。Goの考え方はUnix哲学的なものであり、シンプルな解答を用意しつつも、バグの温床になるような言語仕様は避けようとする考え方が見られます。

一方で、この手のmutexの問題は基本的に「並列プログラミングが難しい」という問題に根ざしたものであり、これに対して何度も挑戦していないと議論ができるようなポイントに到達できないな、と改めて感じ、年末年始はこの本でも読もうかなと思いました。

www.oreilly.co.jp