概要

先日、ひさしぶりに読書をしたぜ。

その勢いでもうひとつ読書をしよう。今度は Node.js っつうプログラミング言語の技術書を読んでみるぜ。まったく知らねえけれども、 JavaScript は知っているからなんとか読めるだろ!

今回の本は "ハンズオン" という名のとおり、実践しながら読み進めることを推奨しているけれども、ゴメン……ぼくは読み物として楽しみたいだけだからそれはいいや。

 

Node.js って何? に答えるシリーズ

 

とりあえずこれを言えれば格好はつくぜリスト

  • Node.js は JavaScript の実行環境のひとつだ。もともとブラウザ環境でだけ動く言語だったけれど、 Node.js のおかげでどこでも動くようになった。
  • JavaScript は Stack Overflow Developer Survey において「もっともよく使われる言語」8年連勝している。
  • JavaScript の仕様は "ECMAScript 標準" で決まっている。
    • これは ECMA International という団体の TC39 という技術委員会が作っている。
  • ES1, ES11, ES2020 というふうにバージョン数がある。
    • いや2020ってなんだよ。
    • 1997年に ES1 が出て、2015年に ES6 が出たわけなんだが、 ES6 で「西暦のほうがよくね?」と気づいた奴がおり、 ES6=ES2015 以降は西暦でバージョン名がつくようになっている。
  • ECMAScript 標準で決まっているからといって、ブラウザの JavaScript で有効とは限らない。だってブラウザ側が対応しているかは別の問題だから。
  • というわけでブラウザの JavaScript にはブラウザ環境独自の標準がある。たとえば DOM の API はブラウザ独自のものだから ES には関係ないからね。
    • ブラウザ環境での仕様は W3C とか WHATWG という組織が作っている。
  • JavaScript は変数、関数をグローバルスコープに配置するため、何が何に依存するのかわかりづらいヒッドい状態だった。 Node.js はモジュールシステムによってそれを解決している。
  • このモジュールシステムを "CommonJS module" という。こいつはスゴイぜ。節を設けてサマるぞ。
  • 他の言語が並行処理をするときはマルチスレッドを使っていたのだが、 Node.js はシングルスレッドで実現している。こいつもスゴイぜ。節を設けてサマるぞ。

 

これを言えれば Node.js のパッケージ管理システムについては格好がつくぜリスト

  • Node.js のパッケージ管理ツールは Node Package Manager (npm) だ。Isaac Z. Schlueter が作った。公開されたデータベース (レジストリという) にパッケージが保存されている。
  • 自分の npm プロジェクト (パッケージというみたい) のメタデータは package.json にある。
    • name: 1〜214文字。スコープ付きパッケージなら @SCOPE_NAME/ という名前。
    • version: セマンティックバージョニング v2.0.0 の仕様を満たさないとダメ。 [メジャーバージョン].[マイナーバージョン].[バッチバージョン] 互換性のない変更.互換性を保って機能追加.互換性を保って不具合修正
    • description, keywords: 検索に使われる。
    • main: パッケージのエントリポイント。
    • author, contributors: 作者。
    • license, private: もし非公開にするなら "UNLICENSED", true とする。
    • dependencies: 依存先パッケージが書かれる。 "^ などを使って範囲をもたせてバージョンを指定するほうが多くの場合好ましいです"
    • devDependencies: テストやビルドのためのパッケージを書く。 npm install --save-dev PACKAGE_NAME
    • peerDependencies: 依存してないが欲しいもの。どーゆーときかっつーと、「何かのライブラリのプラグインを作ってる」場合。
    • optionalDependencies: dependencies と同じようにインストールされるけど、たといインストールに失敗してもエラーにならない。いや、そんなパッケージあるかよ。
    • bundledDependencies: 公開されたパッケージを一部改変して利用していたりする場合に使おう!
    • scripts: 任意のスクリプトを記述。 npm run SCRIPT_NAME で実行可能。
    • bin: ここに書いとくと、コマンドが使えるようになる。ああー、 npm install したり pip install したりするとコマンドとして使えるやつは、こういうふうに実装されてたんだ。
    • repository: このパッケージのコードを管理するリポジトリを書く。
    • engines: そのパッケージの動作に必要な Node.js と npm のバージョン。 npm config set engine-strict true しておくと、不一致の環境で npm install するとエラーになる。
    • acceptDependencies: なんか……これが利用可能ならこれも利用可能だよみたいなやつを書く。 (よくわかってナイ)
    • type: この項がなければ js ファイルを CommonJS module として扱う。 module であれば js ファイルを ES module として扱う。ただ js ではなく cjs だったら CommonJS module だし、 mjs であれば ES module として扱うことになる。拡張子が優先だ。
  • peerDependencies に関連して、 UNMET PEER DEPENDENCY の警告が出ることがある。これはだな、複数のパッケージが、違うバージョンの peerDependencies に依存していることを示す。この警告を出すために peerDependencies に定義しているのだ。 npm v6 だと "警告" なんだけど npm v7 だと "エラー" になる。バージョンが違うのに読み込んでしまうことを防ぐためのものだ。
  • npm のコマンド: npm install
    • node_modules を削除せず、未インストールのものだけインストールする。 package-lock.json がなければ作る。
  • npm のコマンド: npm ci
    • node_modules を削除し、 package-lock.json の基づいてパッケージをインストールする。 npm ci は npm install の倍の速度で完了することもある。イチオシ。
  • npm のコマンド: npm ls --all (all は npm v7 から有効)
    • インストールしたパッケージ一覧。
    • この一覧に deduped という表示があるときは、 "複数パッケージの依存パッケージだが、このバージョンだけのインストールで済みました" の意味。
    • deduped のときは node_modules 直下に置かれるけれど、 deduped できなくて複数バージョンがインストールされたときはネストして配置される。
  • パッケージ管理には npm じゃなくて yarn を使ってもいい。 2016年に Facebook が作ったもので、インストールが速い。
    • npm との違いは……実はあんまりない。しかし yarn が出た当時は package-lock.json がなかったので、 yarn.lock を持つ yarn はみんなから歓迎された。
  • yarn はバージョンに注意。 yarn 1 と 2 はかなり違くて、別の名前のツールとしてリリースしてはどうか、と議論があったほど。

 

これを言えれば Node.js のモジュールシステムについては格好がつくぜリスト

  • モジュールシステムといえば "CommonJS module" と "ES module" がある。
    • "ES module" は実験的な機能なので忘れていい。 "CommonJS module" を極めよう。
  • CommonJS module では各 js ファイルが CommonJS moduleとなる。 Python と同じだね。
  • 書いただけでモジュールになるわけじゃなくて、 module.exports を通して外部に公開し、 require() でロードする。
// cjs-math.js でこうやって公開し……
module.exports.add = (a, b) => a + b;

// こうロードする。拡張子は省略可能。 Python とおんなじだね。
const math = require('./cjs-math');
// こう使える。 Python の import とほとんど同じで使いやすいね。
math.add(1, 2);

// CommonJS moduleは関数を export するだけでなく、 json をそのままロードすることもできる。
// key-value.json
{ "key": "value" }

// ロードするときはこう。
require('./key-value');
  • なんとなく require らへんはビルトイン関数なのかなと思ってるけど、これは "モジュールスコープ変数" と呼ばれているっぽい。
// cjs-filename-dirname.js
module.exports = { __filename, __dirname };

// 見てみる。
require('./cjs-filename-dirname');
{
    __filename: '/path/to/dir/cjs-filename-dirname.js',
    __dirname: '/path/to/dir'
}
  • ここでスゲー大切なことだが、 CommonJS module ではつねにファイルのてっぺんに 'use strict' をつけること。これは緑さんのような超 A 型には必須ですわ。
  • 'use strict' は ES5 (西暦になるひとつ前) から導入された。
'use strict'
let myString = 'a';
myStrng = 'b';  // 変数名のタイプミス
// ここで ReferenceError: myStrng is not defined が出るようになる。
// てか出ないのが異常やろ。

 

これを言えれば Node.js の非同期処理については格好がつくぜリスト

  • Node.js は並行処理をシングルスレッドで実現している (イベントループというらしい)。
  • というかこれが Node.js 開発の動機だと Ryan Dahl サンは申している。
  • 並行処理の動機は、時間のかかる処理……ブロッキングという……に対応することだ。フツーはマルチスレッドで対応する。
  • これまで技術書は何冊か読んで、マルチスレッドの問題点は何度も読んだ。マルチスレッドの問題点は、どの本でも同じように語られている。すなわち……
    • メモリをたくさん消費するし、
    • Web アプリの場合マルチスレッド (= クライアント) が1万個になるとレスポンスが激重になる現象……C10K問題という……が発生するし、
    • スレッドセーフのプログラムを書かないといけなくて面倒。
  • で、 Node.js はこれらデメリットをイベントループで解決している。が、これはこれでデメリットが……
    • コールバック・ヘルのリスクがあったり、
    • 同期処理と非同期処理を混ぜないよう気を遣う手間があったり、
    • Promise がクソムズであったり。
  • ところでマルチプロセスってのもあるな。マルチプロセス間では値のやり取りをコピーで実現する。一方マルチスレッドではメモリを共有できるので、値を直接やり取りできるぞ。
  • 同期処理と非同期処理の例として、本書が用意しているレンチンのコードがわかりやすいのでノートしておく↓。
const 金額 = バーコードリーダー.読む(弁当);  // <-- 同期処理。
電子レンジ.チン(  // <-- 非同期処理。レンチンは時間のかかる処理だけどブロッキングしない(ノンブロッキングという)。
  弁当,
  温まった弁当 => 商品を渡す(温まった弁当)
);
レジ.会計する(金額);
  • 非同期処理の関数には規約がある。実例として JSON をパースする非同期処理をノートしておく。
    • また、 "同期処理と非同期処理を混ぜない" についても併記する。
//                             ↓規約ひとつめ。コールバックはパラメータの最後にする。
function parseJSONAsync(json, callback) {

  // もし結果がキャッシュに入ってる場合は、1秒かけずにすぐに結果を返したい。
  // が、同期的に return してはいけない。これが "同期処理と非同期処理を混ぜない"
  // そういうときは待ち時間0秒で setTimeout したり……
  setTimeout(() => callback(cached.err, cached.result), 0);
  // setTimeout がダサいと思うならこうしたり……
  process.nextTick(() => callback(cached.err, cached.result));
  // ブラウザ環境なら nextTick は使えないのでこうする……。
  queueMicrotask(() => callback(cached.err, cached.result));

  setTimeout(() => {
    try {
      //        ↓規約ふたつめ。コールバックのパラメータは (エラー, 処理結果) とする。
      callback(null, JSON.parse(json));
    } catch (err) {
      callback(err);
    }
  }, 1000);  // 処理に1秒かかる重い処理を再現しているだけ。
}
parseJSONAsync('不正なJSON', (err, result) => {
  console.info('parse結果', err, result);
});
  • コールバック・ヘルを防ぐための async await は ES2017 でようやく追加された。
async function asyncFunc(input) {
  try {
    const result1 = await asyncFunc1(input)
    const result2 = await asyncFunc2(result1)
  } catch (e) {
    // エラーハンドリング
  }
}

 

これを言えれば Node.js のユニットテストについては格好がつくぜリスト

  • ユニットテストはソフトウェアテストの中で最も投資効率が高い。
  • Node.js でやるなら、
    • フレームワークには Mocha、
    • アサーションには assert, Chai、
    • テストダブルには Sinon.JS, testdouble.js、
    • カバレッジには Istanbul、
    • それらすべての機能をもつフルスタックフレームワーク: Jest、
    • ……を使う。
  • テストダブルっていうのが緑さんにとっては目新しいな。ひとつユニットテストを書くとして、ユニットテストの依存先がすでに十分テストされたものである場合、テスト済みのものに対して重複したテストを書くことになってしまう。テストダブルを使って重複を排除しよう。
  • Jest は人気。テストを並列に実行するので高速であることと、フルスタックなので手順や設定が少なくて済むこと。もうこれ一択じゃん。
  • Mocha との違いは次のとおり。
    • テストの関数名が it に加え test も使える、
    • before -> beforeAll, after -> afterAll という関数名で提供される、
    • describe() 外に書いたフックは、 Mocha では全テストファイルに対するグローバルなフックとなるが、 Jest ではそのファイル内の全テストケースに対するフックとして機能する、
    • Mocha みたいな assert.sameDeepMembers() がないので、配列を比較するときはソートしないといけない、
    • Jest のテストダブルは Sinon でいうところのスタブであるが、名称はモックである、
    • ……などなど。
  • Web API をテストするときは、 Mocha にも Jest にもその機能がない。そのときは supertest モジュールを使う。
  • デバッグのためにあちらこちらに console.log() 書くのは非効率的。 Node.js のインスペクタ機能を使え。

 

これを言えれば Node.js のコンパイルについては格好がつくぜリスト

  • ガンバって ES2015 (ES6) JavaScript を書いたとしても、実行環境が ES5 止まりのヘボ環境だったら動かない。そこで、コンパイルして ES5 コードに変換しよう。 Babel ツールを使う。
  • Babel はもともと "6to5" という名前だった。もちろん、 ES6 コードを ES5 に変換するという意味だ。
  • あと TypeScript で書いてしまった場合も JavaScript へ変換する必要がある。それは TypeScript のコンパイラがやる。
    • TypeScript は Microsoft が2012年に始めた言語だ。
npm install -D typescript @types/node

# typescript をインストールすると tsc コマンドが有効になる。
# これで tsconfig.json ができる。
npx tsc --init
  • コンパイルだとか変換だとか軽く言っているけれど実際何をしているんだ?
    • ソースコードから Abstract Syntax Tree (AST、抽象構文木) を生成 (パースという) して、
    • AST に変更を加え (変換) て、
    • AST からソースコードを生成する、
    • ……という手順である。
  • 具体的には、たとえば ES2015 のアロー関数は ES2015 未対応の環境では動かないので、次のように変換しなければならん。
    • [1, 2].map(n => n * 2) -> [1, 2].map(function (n) { return n * 2 })
  • これは AST でいうと……
    • ArrowFunctionExpression -> FunctionExpression に変更して、
    • FunctionExpression の body に BlockStatement, ReturnStatement を追加、
    • ……することである。
  • あとファイルの先頭に 'use strict' つけたり import 文を require() に変換したりする。

 

Node.js の構文でオモロかったところリスト

関数について。

// 知らなかった関数定義の方法。
const add = function addFn(a, b) { return  a + b };
add.name;  // --> 'addFn' と出る。知らなかった。 add じゃないのか。

// ES2015 で追加されたアロー関数が導入された。意味は↑と同じ。
// ヤメようや、いくつもやり方を作るのは……。
const add1 = (a, b) => { return a + b };
const add2 = (a, b) => a + b;
const add3 = a => a + 1;

// ぼくが個人的によく使う関数定義。
// これは "関数宣言" ではなくて "関数式" っていうらしい。
const add = function(a, b) { return a + b };

// こっちが "関数宣言"。
// 関数宣言では hoisting (巻き上げ) が発生する。宣言前に参照できる現象だ。
function add(a, b) { return a + b };

スプレッド構文について。

// オブジェクトのコピーとか、コピーしつつ要素を追加できるやつか。これは良いね。
const obj2 = { ...obj1, propC: 3 };

// 配列にも使えるヨ。
const arr3 = ['a', ...arr2, 'b'];

レスト構文について。

// obj2 から propA が削除されたものが obj3 に格納される……
// 何これクッソ読みづらくない? ヤメようや。
const { propA, ...obj3 } = obj2;

// 配列にも使えるヨ。いや、レスト構文は読みづらすぎるのでヤメます。
const [head1, head2, ...arr4] = arr2;

クラスについて。 JavaScript のクラスなんて見たことないわ。

// クラスに定義したメソッド、コンストラクタ、 getter, setter は prototype に追加される。
// どういうことかっていうとこういう↓ことだ。
fooInstance.__proto__ === Foo.prototype;

// 実は instanceof は __proto__ と prototype を比較している。ほー、なるほど。

ジェネレータについて。へー、 JavaScript にもジェネレータあるんだ。

function* generatorFunc() {
    yield 1
    yield 2
}
const generator = generatorFunc();
generator.next();  // --> { value: 1, done: false }
generator.next();
generator.next();  // --> { value: undefined, done: true }
generator.next(true);  // --> カウンタがリセットされる。えぇwマジかw

「2GBのファイルを読み込んで別ファイルに書き込もうとしたら、メモリが圧迫されて大変だったってことあるやろ?」ねえよ。「そういうときは全部読み込んでから書き込むんじゃなくて、ストリームを使って、読み込んだはしから書き込んでいこう!」ねえよそんなコト。

fs.createReadStream(src)
  // 下の方にも on error があるけど、ここにも挟まないとエラーが伝播しちゃう。
  .on('error', err => console.info('エラーイベント', err.message))
  // 読み込んだやつを暗号化してみる。
  .pipe(crypto.createHash('sha256'))
  // 読み込みストリームから書き込みストリームへ pipe する。
  .pipe(fs.createWriteStream(dest))
  .on('finish', () => console.info('コピー完了!'))
  // エラーにも備えるぜ!
  .on('error', err => console.info('エラーイベント', err.message))

// でもこっちでいいです。最初に言え。
stream.pipeline(
  // pipe() したい2つ以上のストリーム。
  fs.createReadStream('no-such-file.txt'),
  fs.createWriteStream('dest.txt'),
  // コールバック。
  err => err
    ? console.error('エラー発生', err.message)
    : console.info('正常終了')
);

 

Node.js で Web アプリ作るってなったとき知っていたらよさげなコトたち

 

サーバサイドでもクライアントサイドでも同じ言語を使えるってヤバくね?

  • 言われてみればヤバくね? 複数の言語を勉強する必要がないというのもあるし、以下の条件を満たすと "ユニバーサル Web アプリケーション" を実現できる。
    • クライアントサイドが Single Page Application (以下 SPA) であり、
    • クライアントサイドが HTML のレンダリングをする Client Side Rendering (CSR) …… ではなく、
    • サーバサイドがレンダリングをする Server Side Rendering (SSR) であり、
    • そのレンダリングの結果を Static Site Generation (以下 SSG) の仕組みを使ってキャッシュしておき、
    • かといってクライアントサイドの仕事がなくなるわけではなくてこちら側では HTML と JavaScript オブジェクトを紐付ける処理を行う (ハイドレーションという)、
  • ……という条件を満たすことで "ユニバーサル Web アプリケーション" となる。は……? なんか超大変そう。
  • なんか超大変そうだが、ちゃんとフレームワークがある。 React.js でユニバを作るなら Next.js を使えばいいし、 Vue.js でユニバを作るなら Nuxt.js を使おう。
  • 大袈裟杉内? と思ったときは http モジュールとか Express モジュール? フレームワーク? を使うこと。
  • http は低レベルなので普通は Express を使いましょう。こんな感じ↓
'use strict'
const express = require('express');
const router = express.Router();
const app = express();

// Express の機能を拡張するためにはミドルウェアを使う。
// 静的ファイルの配信は static ミドルウェア関数を使う。
app.use(express.static('public'));

// リクエストボディをパースするためのミドルウェア。
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Cookie の取得にはこのミドルウェア。
app.use(require('cookie-parser'));

function expressMiddleware (req, res, next) {
    // ... (ミドルウェアの処理)
    next(); // 後続のミドルウェアに処理を受け渡す場合はこれが必要。
}

// こんなふうに使う。
app.get(
    '/api/todos',
    expressMiddleware,  // <- これは汎用的なミドルウェアと呼ぶ。
    (req, res) => {  // <- これも実はミドルウェア。だけど汎用的ではない。ルートハンドラという。
        // ...
    }
);
  • ああ、 Django ではやたらと「この middleware はこの middleware の後に定義……」とか決まりがある。それは、 middleware は next によって次の middleware へ処理を委譲していくものだから、だったのか。

 

時代はリアルタイム Web じゃね?

  • だって、ウェブページを開いたまま10分経過したら、その情報はもう古くなってるかもしれないじゃん? 新しい情報が投稿されたら、それをすぐにウェブページに表示してほしいよね?
  • それを実装するためのもっともシンプルな手段がポーリング。定期的にサーバにリクエストする。いやいや……こんなの、あるか分からない振り込みを確認するためにいちいちコンビニの ATM に行くアホな人じゃん?
  • ずっと http 接続を保持したままにしておき、データ更新があったときサーバがクライアントへデータを送信するのが Server Sent Events (SSE) だ。えっ、それいいじゃん。
    • といっても長時間送信がないと接続がタイムアウトしちまうので、定期的にコメントだけのメッセージを送ってタイムアウトを防ぐ。
  • サーバからクライアントへの一方通行だけでなくて、クライアントからサーバへのメッセージ送信も行いたいなら WebSocket を使う。 http でハンドシェイクしてから、 WebSocket プロトコルで通信するものだ。ただ、プロキシやファイアウォール、アンチウィルスソフトウェアによって通信が妨害されることもあるので、サーバからクライアントへの一方向の通信で事足りるなら SSE 使っとけ。
    • WebSocket 使うならサーバ側で socket.io モジュールを使い、クライアント側で socket.io-client モジュールを使う。
    • こちらがサーバ側コードの例だ↓
'use strict'
const http = require('http');
const Server = require('socket.io');
const server = http.createServer((req, res) => {}).listen(3000);
const io = Server(server, { origins: allowed.origin.com });

io.on('connection', socket => {
    // socket を roomA に入れる。
    socket.join('roomA');
    // roomA に存在する socket にデータを送信
    io.to('roomA').emit('someEvent', 'foo');
    // socket を roomA から出す
    socket.leave('roomA');
});

 

データストレージの設計はこれでヨシじゃね?

  • データストレージに対する操作を設計するときは "開放/閉鎖原則" に沿ってやること。
    • ひとつ、 "拡張に対して開いていること"。別のデータストレージに切り替えやすいということ。
    • ひとつ、 "修正に対して閉じていること"。別のデータストレージを切り替えるとき、メインのコードを修正する必要がないこと。
  • データベースにはこんなものがある……
    • リレーショナル・データベース (RDB)。行と列で構成されるテーブルに保存され、 SQL によって作成、取得、更新、削除される。
    • NoSQL。 Not only SQL の略で、ようは RDB じゃないデータベースということだ。たとえば Key-Value Store とか、 XML とか JSON で保存するドキュメントストアとか、ワイドカラムストアとか、グラフデータベースとかがある。

 

2021年ともなれば Docker 使うのがキホンじゃね?

  • Node.js の npm プロジェクトを Docker で環境構築するときは、こんな Dockerfile を用意するぞ。
FROM node:14-alpine

ARG NODE_ENV=development
ENV NODE_ENV=${NODE_ENV}

WORKDIR /usr/src/app

# package.json, package-lock.json に変更があるときのみ npm ci をやり直します。
# 変更がないなら Docker のキャッシュが使われるのでビルドが高速になる!
# 疑問: docker-compose up でもそうなの? build だけ?
COPY package*.json ./
RUN npm ci

# たとえばこのあとに npm ci しちゃうと、つねに npm ci が実行されて時間がかかる。
COPY . .

EXPOSE 3000
CMD [ "npm", "start" ]
  • docker-compose はこう用意するぞ。
version: '3'
services:
    web:
        build: .
        volumes:
            - .:/usr/src/app
            - /usr/src/app/node_modules/
        ports:
            - 3000:3000
        command: npm run dev
  • .dockerignore はこう用意するぞ。
node_modules
  • 起動コマンドはこうだ。
docker-compose up
docker-compose down

# 本番環境向けのイメージをビルド
docker build -t node-hello-world --build-arg NODE_ENV=production .
docker run -p 3000:3000 -d node-hello-world

 

ざっくり読了

というわけで500ページ級の読書、終了だ。楽しめる読書だったぜ。11章からなる大物を3記事に整理するのは骨が折れたが、11章あるからといって11記事書いていたら、ただの書き写しだものな。これまでの技術書読書では、章ごとに書いたり、1記事にまとめたりしてきた。その経験から、数記事にまとめるがもっとも性に合っていると感じた。

ちなみに本書 "今村謙士 『ハンズオン Node.js』 2020年11月13日 初版第1刷発行" には誤植がいくつかあるぞ。

  • 36ページ: "これららにはそれぞれ、そのモジュールのファイル名"
  • 345ページ: "(Docker Hub等" 閉じカッコがない。
  • 350ページ: "Dokcer"