第4版が公開されています。こちらで読んでください

第20章Node.js

ある生徒が尋ねた。「昔のプログラマは単純な機械しか使わず、プログラミング言語もなかったのに、美しいプログラムを作った。なぜ私たちは複雑な機械とプログラミング言語を使うのか?」 傅子(ふし)は答えた。「昔の建築家は棒と粘土しか使わなかったが、美しい小屋を作ったのだ。」

元馬大師(げんばたいし)、プログラミングの書
Picture of a telephone pole

これまで、私たちはJavaScriptを単一の環境、つまりブラウザ内で使用してきました。本章と次の章では、ブラウザの外でJavaScriptスキルを適用できるプログラムであるNode.jsを簡単に紹介します。Node.jsを使えば、小さなコマンドラインツールから、動的なウェブサイトを支えるHTTPサーバーまで、あらゆるものを構築できます。

これらの章では、Node.jsが使用する主な概念を教え、それを使って便利なプログラムを作成するための十分な情報を提供することを目指しています。プラットフォームの完全な、あるいは徹底的な解説を意図したものではありません。

前の章のコードは、生のJavaScriptかブラウザ向けに書かれていたので、これらのページで直接実行できましたが、本章のコードサンプルはNode用に書かれており、多くの場合、ブラウザでは実行されません。

本章のコードを実際に実行したい場合は、Node.js バージョン10.1以降をインストールする必要があります。https://node.dokyumento.jpにアクセスして、ご使用のオペレーティングシステムのインストール手順に従ってください。Node.jsに関するさらなるドキュメントもそこで見つけることができます。

背景

ネットワークを介して通信するシステムを作成する際のより困難な問題の1つは、入出力、つまりネットワークやハードドライブとの間のデータの読み書きを管理することです。データの移動には時間がかかり、それを巧みにスケジュールすることで、システムがユーザーやネットワークリクエストにどれだけ迅速に応答できるかが大きく変わってきます。

このようなプログラムでは、非同期プログラミングがしばしば役立ちます。これにより、複雑なスレッド管理や同期を行うことなく、プログラムは複数のデバイスとの間で同時にデータの送受信を行うことができます。

Nodeは当初、非同期プログラミングを容易かつ便利にすることを目的として考案されました。JavaScriptはNodeのようなシステムに適しています。JavaScriptは、入出力を実行するための組み込みの方法を持たない、数少ないプログラミング言語の1つです。そのため、JavaScriptをNodeのやや独特な入出力アプローチに適合させることができました。2つの矛盾するインターフェースができてしまうことはありませんでした。Nodeが設計された2009年当時、人々は既にブラウザでコールバックベースのプログラミングを行っていたので、その言語を取り巻くコミュニティは非同期プログラミングスタイルに慣れていたのです。

nodeコマンド

Node.jsがシステムにインストールされると、JavaScriptファイルを実行するために使用するnodeというプログラムが提供されます。例えば、次のコードを含むhello.jsというファイルがあるとします。

let message = "Hello world";
console.log(message);

プログラムを実行するには、コマンドラインから次のようにnodeを実行します。

$ node hello.js
Hello world

Nodeのconsole.logメソッドは、ブラウザで行うことと同様の動作をします。テキストを出力します。しかしNodeでは、テキストはブラウザのJavaScriptコンソールではなく、プロセスの標準出力ストリームに出力されます。コマンドラインからnodeを実行する場合、それはログされた値がターミナルに表示されることを意味します。

ファイルを与えずにnodeを実行すると、JavaScriptコードを入力してすぐに結果を確認できるプロンプトが表示されます。

$ node
> 1 + 1
2
> [-1, -2, -3].map(Math.abs)
[1, 2, 3]
> process.exit(0)
$

consoleバインディングと同様に、processバインディングはNodeでグローバルに利用可能です。これは、現在のプログラムを検査および操作するためのさまざまな方法を提供します。exitメソッドはプロセスを終了し、終了ステータスコードを与えることができます。これは、nodeを起動したプログラム(この場合はコマンドラインシェル)に、プログラムが正常に完了した(コード0)か、エラーが発生したか(その他のコード)を知らせます。

スクリプトに与えられたコマンドライン引数を見つけるには、文字列の配列であるprocess.argvを読み取ることができます。nodeコマンドの名前とスクリプトの名前も含まれていることに注意してください。そのため、実際の引数はインデックス2から始まります。showargv.jsにステートメントconsole.log(process.argv)が含まれている場合、次のように実行できます。

$ node showargv.js one --and two
["node", "/tmp/showargv.js", "one", "--and", "two"]

ArrayMathJSONなどの標準的なJavaScriptグローバルバインディングも、Nodeの環境に存在します。documentpromptなどのブラウザ関連の機能は存在しません。

モジュール

consoleprocessなどのバインディング以外にも、Nodeはグローバルスコープにいくつかの追加バインディングを配置します。組み込み機能にアクセスしたい場合は、モジュールシステムに要求する必要があります。

require関数に基づくCommonJSモジュールシステムについては、第10章で説明しました。このシステムはNodeに組み込まれており、組み込みモジュールからダウンロードされたパッケージ、そして独自のプログラムの一部であるファイルまで、あらゆるものをロードするために使用されます。

requireが呼び出されると、Nodeは指定された文字列を実際にロードできるファイルに解決する必要があります。/./../で始まるパス名は、現在のモジュールのパスを基準に解決されます。ここで、.は現在のディレクトリ、../は1つ上のディレクトリ、/はファイルシステムのルートを表します。そのため、ファイル/tmp/robot/robot.jsから"./graph"を要求すると、Nodeはファイル/tmp/robot/graph.jsのロードを試みます。

.js拡張子は省略でき、そのようなファイルが存在する場合はNodeによって追加されます。要求されたパスがディレクトリを参照している場合、Nodeはそのディレクトリにあるindex.jsという名前のファイルのロードを試みます。

相対パスでも絶対パスでもない文字列がrequireに渡されると、組み込みモジュールか、node_modulesディレクトリにインストールされているモジュールを参照していると見なされます。例えば、require("fs")はNodeの組み込みファイルシステムモジュールを提供します。また、require("robot")node_modules/robot/にあるライブラリのロードを試みる可能性があります。このようなライブラリをインストールする一般的な方法はNPMを使用することであり、これは後で改めて説明します。

2つのファイルからなる小さなプロジェクトを設定してみましょう。最初のファイルはmain.jsと呼ばれ、コマンドラインから呼び出して文字列を反転できるスクリプトを定義しています。

const {reverse} = require("./reverse");

// Index 2 holds the first actual command line argument
let argument = process.argv[2];

console.log(reverse(argument));

ファイルreverse.jsは文字列を反転するためのライブラリを定義しており、このコマンドラインツールと、文字列反転関数を直接使用する必要がある他のスクリプトの両方で使用できます。

exports.reverse = function(string) {
  return Array.from(string).reverse().join("");
};

exportsにプロパティを追加すると、モジュールのインターフェースに追加されることを覚えておいてください。Node.jsはファイルをCommonJSモジュールとして扱うため、main.jsreverse.jsからエクスポートされたreverse関数を取得できます。

これで、ツールを次のように呼び出すことができます。

$ node main.js JavaScript
tpircSavaJ

NPMを使ったインストール

第10章で紹介したNPMは、JavaScriptモジュールのオンラインリポジトリであり、その多くはNode用に特別に記述されています。コンピュータにNodeをインストールすると、このリポジトリとやり取りするために使用できるnpmコマンドも取得します。

NPMの主な用途はパッケージのダウンロードです。第10章ではiniパッケージを見ました。NPMを使用して、そのパッケージをコンピュータに取得してインストールすることができます。

$ npm install ini
npm WARN enoent ENOENT: no such file or directory,
         open '/tmp/package.json'
+ ini@1.3.5
added 1 package in 0.552s

$ node
> const {parse} = require("ini");
> parse("x = 1\ny = 2");
{ x: '1', y: '2' }

npm installを実行した後、NPMはnode_modulesというディレクトリを作成します。そのディレクトリ内には、ライブラリを含むiniディレクトリがあります。開いてコードを確認できます。require("ini")を呼び出すと、このライブラリがロードされ、そのparseプロパティを呼び出して設定ファイルを解析できます。

デフォルトでは、NPMは中央の場所ではなく、現在のディレクトリ下にパッケージをインストールします。他のパッケージマネージャーに慣れている場合は、これは珍しいように見えるかもしれませんが、利点もあります。各アプリケーションはインストールするパッケージを完全に制御し、バージョン管理とアプリケーションの削除時のクリーンアップを容易にします。

パッケージファイル

npm install の例では、package.json ファイルが存在しないという警告が表示されることがあります。手動で、または npm init を実行することで、各プロジェクトにこのようなファイルを作成することをお勧めします。このファイルには、プロジェクト名やバージョンなどのプロジェクトに関する情報と、その依存関係がリストされています。

第7章のロボットシミュレーションは、第10章の練習問題でモジュール化されていますが、次のような package.json ファイルを持つ場合があります。

{
  "author": "Marijn Haverbeke",
  "name": "eloquent-javascript-robot",
  "description": "Simulation of a package-delivery robot",
  "version": "1.0.0",
  "main": "run.js",
  "dependencies": {
    "dijkstrajs": "^1.0.1",
    "random-item": "^1.0.0"
  },
  "license": "ISC"
}

インストールするパッケージ名を指定せずに npm install を実行すると、NPM は package.json にリストされている依存関係をインストールします。依存関係としてまだリストされていない特定のパッケージをインストールすると、NPM はそれを package.json に追加します。

バージョン

package.json ファイルには、プログラム自身のバージョンと、その依存関係のバージョンが両方リストされています。バージョンとは、パッケージが個別に進化するという事実に対処するための方法であり、ある時点で存在したパッケージで動作するように記述されたコードは、後で修正されたパッケージのバージョンでは動作しない可能性があります。

NPM は、そのパッケージが *セマンティックバージョニング* と呼ばれるスキーマに従うことを要求します。これは、バージョン番号にどのバージョンが *互換性がある* か(古いインターフェースを壊さない)という情報をエンコードします。セマンティックバージョンは、ピリオドで区切られた3つの数字で構成され、2.3.0 のようになります。新しい機能が追加されるたびに、中央の数字を増分する必要があります。既存のコードがパッケージを使用する際に互換性が壊れ、新しいバージョンでは動作しなくなるたびに、最初の数字を増分する必要があります。

package.json の依存関係のバージョン番号の前にキャレット文字(^)があると、指定された番号と互換性のある任意のバージョンをインストールできることを示します。たとえば、"^2.3.0" は、2.3.0 以上 3.0.0 未満の任意のバージョンが許可されることを意味します。

npm コマンドは、新しいパッケージまたはパッケージの新しいバージョンを公開するためにも使用されます。package.json ファイルがあるディレクトリで npm publish を実行すると、JSON ファイルにリストされている名前とバージョンを持つパッケージがレジストリに公開されます。誰でもNPMにパッケージを公開できますが、既存のパッケージをランダムな人が更新できるのはかなり危険なので、まだ使用されていないパッケージ名でのみ公開できます。

npm プログラムは、オープンシステム(パッケージレジストリ)と通信するソフトウェアであるため、その動作に固有のものはありません。NPM レジストリからインストールできる別のプログラムである yarn は、やや異なるインターフェースとインストール戦略を使用して、npm と同じ役割を果たします。

この本では、NPM の使用方法の詳細についてはこれ以上詳しく説明しません。詳細なドキュメントとパッケージを検索する方法については、https://npmjs.org を参照してください。

ファイルシステムモジュール

Node で最も一般的に使用される組み込みモジュールの 1 つは、ファイルシステムを表す fs モジュールです。ファイルとディレクトリを操作するための関数をエクスポートします。

たとえば、readFile と呼ばれる関数はファイルを読み取り、ファイルの内容を含むコールバックを呼び出します。

let {readFile} = require("fs");
readFile("file.txt", "utf8", (error, text) => {
  if (error) throw error;
  console.log("The file contains:", text);
});

readFile の第2引数は、ファイルを文字列にデコードするために使用される *文字エンコーディング* を示します。テキストをバイナリデータにエンコードする方法はいくつかありますが、ほとんどの最新のシステムでは UTF-8 を使用しています。したがって、別のエンコーディングが使用されていると信じる理由がない限り、テキストファイルを読み取る際には "utf8" を渡してください。エンコーディングを渡さないと、Node はバイナリデータに関心があると想定し、文字列ではなく Buffer オブジェクトを返します。これは、ファイル内のバイト(データの8ビットチャンク)を表す数値を含む配列のようなオブジェクトです。

const {readFile} = require("fs");
readFile("file.txt", (error, buffer) => {
  if (error) throw error;
  console.log("The file contained", buffer.length, "bytes.",
              "The first byte is:", buffer[0]);
});

同様の関数である writeFile は、ファイルをディスクに書き込むために使用されます。

const {writeFile} = require("fs");
writeFile("graffiti.txt", "Node was here", err => {
  if (err) console.log(`Failed to write file: ${err}`);
  else console.log("File written.");
});

ここでは、エンコーディングを指定する必要はありませんでした。writeFile は、Buffer オブジェクトではなく文字列が渡されると、デフォルトの文字エンコーディング(UTF-8)を使用してテキストとして書き込む必要があると想定します。

fs モジュールには、他にも多くの便利な関数があります。readdir はディレクトリ内のファイルを文字列の配列として返し、stat はファイルに関する情報を取得し、rename はファイルを名前変更し、unlink はファイルを削除します。詳細は、https://node.dokyumento.jp のドキュメントを参照してください。

これらのほとんどは、最後のパラメーターとしてコールバック関数を取得し、エラー(最初の引数)または成功した結果(2番目の引数)のいずれかで呼び出します。第11章で見たように、このプログラミングスタイルには欠点があります。最大の欠点は、エラー処理が冗長になり、エラーが発生しやすくなることです。

Promise はしばらくの間 JavaScript の一部でしたが、執筆時点では Node.js への統合はまだ進行中です。バージョン 10.1 以降の fs パッケージからエクスポートされる promises オブジェクトには、fs とほとんど同じ関数がありますが、コールバック関数ではなく Promise を使用します。

const {readFile} = require("fs").promises;
readFile("file.txt", "utf8")
  .then(text => console.log("The file contains:", text));

非同期性が不要で、邪魔になることがあります。fs の多くの関数には、同期バリアントもあります。これは、最後に Sync を追加した同じ名前を持っています。たとえば、readFile の同期バージョンは readFileSync と呼ばれます。

const {readFileSync} = require("fs");
console.log("The file contains:",
            readFileSync("file.txt", "utf8"));

このような同期操作を実行している間は、プログラムが完全に停止することに注意してください。ユーザーまたはネットワーク上の他のマシンに応答する必要がある場合、同期アクションで停止すると、迷惑な遅延が発生する可能性があります。

HTTP モジュール

もう1つの重要なモジュールは http と呼ばれます。HTTP サーバーを実行し、HTTP 要求を行うための機能を提供します。

これは HTTP サーバーを開始するために必要なものです。

const {createServer} = require("http");
let server = createServer((request, response) => {
  response.writeHead(200, {"Content-Type": "text/html"});
  response.write(`
    <h1>Hello!</h1>
    <p>You asked for <code>${request.url}</code></p>`);
  response.end();
});
server.listen(8000);
console.log("Listening! (port 8000)");

自分のマシンでこのスクリプトを実行する場合は、Web ブラウザーを http://localhost:8000/hello にポイントして、サーバーに要求を送信できます。小さな HTML ページが返されます。

createServer に引数として渡された関数は、クライアントがサーバーに接続するたびに呼び出されます。requestresponse バインディングは、入出力データを表すオブジェクトです。前者には、要求に関する情報が含まれており、その url プロパティは、要求が行われた URL を示しています。

したがって、ブラウザでそのページを開くと、自分のコンピューターに要求が送信されます。これにより、サーバー関数が実行され、応答が送信され、ブラウザで確認できます。

何かを送信するには、response オブジェクトのメソッドを呼び出します。最初の writeHead は、応答ヘッダーを出力します(第18章を参照)。ステータスコード(この場合は「OK」の 200)と、ヘッダー値を含むオブジェクトを渡します。この例では、Content-Type ヘッダーを設定して、HTML ドキュメントを送信することをクライアントに知らせます。

次に、実際の応答本文(ドキュメント自体)が response.write で送信されます。利用可能になり次第クライアントにデータのストリームを送信するために、応答を少しずつ送信したい場合は、このメソッドを複数回呼び出すことができます。最後に、response.end は応答の終わりを示します。

server.listen を呼び出すと、サーバーはポート 8000 で接続を待つようになります。そのため、デフォルトのポート 80 を使用する localhost ではなく、localhost:8000 に接続してこのサーバーと通信する必要があります。

このスクリプトを実行すると、プロセスはそのまま待ち状態になります。スクリプトがイベント(この場合はネットワーク接続)を待機している場合、node はスクリプトの終わりに達しても自動的に終了しません。終了するには、control-C を押します。

実際のWebサーバーは、通常、例にあるものよりも多くのことを行います。クライアントが実行しようとしているアクションを確認するために要求のメソッド(method プロパティ)を確認し、このアクションが実行されるリソースを確認するために要求のURLを確認します。より高度なサーバーについては、この章の後半で説明します。

HTTP *クライアント* として機能するには、http モジュールの request 関数を使用できます。

const {request} = require("http");
let requestStream = request({
  hostname: "eloquentjavascript.net",
  path: "/20_node.html",
  method: "GET",
  headers: {Accept: "text/html"}
}, response => {
  console.log("Server responded with status code",
              response.statusCode);
});
requestStream.end();

request の最初の引数は要求を設定し、Node にどのサーバーと通信するか、そのサーバーからどのパスを要求するか、どのメソッドを使用するかなどを伝えます。2番目の引数は、応答が入力されたときに呼び出される関数です。ステータスコードを確認するなど、応答を検査するために使用できるオブジェクトが渡されます。

サーバーで見た response オブジェクトと同様に、request によって返されたオブジェクトを使用すると、write メソッドを使用してデータのストリームを要求に送信し、end メソッドを使用して要求を終了できます。この例では、GET 要求は要求本文にデータを含めるべきではないため、write は使用していません。

https: URL に要求を行うために使用できる https モジュールに同様の request 関数があります。

Node の生の機能を使用して要求を行うことは、かなり冗長です。NPMには、はるかに便利なラッパーパッケージがあります。たとえば、node-fetch は、ブラウザから知っている Promise ベースの fetch インターフェースを提供します。

ストリーム

HTTP の例では、サーバーが書き込むことができる response オブジェクトと、request から返された request オブジェクトの 2 つの書き込み可能なストリームのインスタンスを確認しました。

書き込みストリームはNode.jsで広く使われている概念です。このようなオブジェクトは、ストリームに何か書き込むために文字列またはBufferオブジェクトを渡すことができるwriteメソッドを持っています。そのendメソッドはストリームを閉じ、オプションで閉じる前にストリームに書き込む値を取ります。これらのメソッドの両方に、追加の引数としてコールバックを渡すことができ、書き込みまたは閉じられた後にコールバックが呼び出されます。

fsモジュールのcreateWriteStream関数を使用して、ファイルを参照する書き込みストリームを作成できます。その後、結果のオブジェクトのwriteメソッドを使用して、writeFileのように一度にではなく、ファイルを一度に一部分ずつ書き込むことができます。

読み込みストリームは少し複雑です。HTTPサーバーのコールバックに渡されたrequestバインディングと、HTTPクライアントのコールバックに渡されたresponseバインディングの両方が読み込みストリームです。サーバーはリクエストを読み取ってからレスポンスを書き込み、クライアントは最初にリクエストを書き込んでからレスポンスを読み取ります。ストリームからの読み取りは、メソッドではなくイベントハンドラーを使用して行われます。

Node.jsでイベントを発生させるオブジェクトには、ブラウザのaddEventListenerメソッドと同様のonというメソッドがあります。イベント名と関数を渡すと、指定されたイベントが発生するたびにその関数を呼び出すように登録されます。

読み込みストリームには"data""end"イベントがあります。最初のイベントはデータが入力されるたびに発生し、2番目のイベントはストリームの終端に達するたびに呼び出されます。このモデルは、全体のドキュメントがまだ利用できない場合でも、すぐに処理できるストリーミングデータに最適です。ファイルは、fscreateReadStream関数を使用して読み込みストリームとして読み取ることができます。

このコードは、リクエストボディを読み取り、すべて大文字のテキストとしてクライアントにストリーミングするサーバーを作成します。

const {createServer} = require("http");
createServer((request, response) => {
  response.writeHead(200, {"Content-Type": "text/plain"});
  request.on("data", chunk =>
    response.write(chunk.toString().toUpperCase()));
  request.on("end", () => response.end());
}).listen(8000);

データハンドラーに渡されるchunk値はバイナリBufferになります。これは、そのtoStringメソッドを使用してUTF-8エンコード文字としてデコードすることにより、文字列に変換できます。

次のコードは、大文字変換サーバーがアクティブな状態で実行すると、そのサーバーにリクエストを送信し、受信したレスポンスを出力します。

const {request} = require("http");
request({
  hostname: "localhost",
  port: 8000,
  method: "POST"
}, response => {
  response.on("data", chunk =>
    process.stdout.write(chunk.toString()));
}).end("Hello server");
// → HELLO SERVER

この例では、console.logを使用する代わりに、process.stdout(プロセスの標準出力。書き込みストリームです)に書き込んでいます。レスポンスが複数のチャンクとして届く可能性があるため、console.logは使用できません。console.logは書き込むテキストの後に改行文字を追加するため、ここでは適切ではありません。

ファイルサーバー

HTTPサーバーとファイルシステムの操作に関する新しく得られた知識を組み合わせて、この2つをつなぐ橋渡しとなるものを作成しましょう。つまり、ファイルシステムへのリモートアクセスを許可するHTTPサーバーです。このようなサーバーには様々な用途があります。ウェブアプリケーションがデータの保存と共有を可能にしたり、複数の人が複数のファイルに共有アクセスできるようにしたりできます。

ファイルをHTTPリソースとして扱う場合、HTTPメソッドGETPUTDELETEを使用して、それぞれファイルの読み取り、書き込み、削除を行うことができます。リクエストのパスは、リクエストが参照するファイルのパスとして解釈されます。

ファイルシステム全体を共有したくはないでしょうから、これらのパスはサーバーの作業ディレクトリ(サーバーが起動されたディレクトリ)から始まるものとして解釈します。/tmp/public/(またはWindowsではC:\tmp\public\)からサーバーを実行した場合、/file.txtのリクエストは/tmp/public/file.txt(またはC:\tmp\public\file.txt)を参照することになります。

様々なHTTPメソッドを処理する関数を格納するmethodsオブジェクトを使用して、プログラムを段階的に構築します。メソッドハンドラーは、リクエストオブジェクトを引数として受け取り、レスポンスを記述するオブジェクトを解決するPromiseを返す非同期関数です。

const {createServer} = require("http");

const methods = Object.create(null);

createServer((request, response) => {
  let handler = methods[request.method] || notAllowed;
  handler(request)
    .catch(error => {
      if (error.status != null) return error;
      return {body: String(error), status: 500};
    })
    .then(({body, status = 200, type = "text/plain"}) => {
       response.writeHead(status, {"Content-Type": type});
       if (body && body.pipe) body.pipe(response);
       else response.end(body);
    });
}).listen(8000);

async function notAllowed(request) {
  return {
    status: 405,
    body: `Method ${request.method} not allowed.`
  };
}

これは、405エラーレスポンス(サーバーが特定のメソッドの処理を拒否するために使用されるコード)を返すだけのサーバーを起動します。

リクエストハンドラーのPromiseが拒否されると、catch呼び出しによって、エラーがまだオブジェクトでない場合、エラーがレスポンスオブジェクトに変換され、サーバーはクライアントにリクエストの処理に失敗したことを知らせるエラーレスポンスを送信できます。

レスポンス記述のstatusフィールドは省略できます。その場合、デフォルトで200(OK)になります。typeプロパティのコンテンツタイプも省略でき、その場合、レスポンスはプレーンテキストと見なされます。

bodyの値が読み込みストリームの場合、読み込みストリームから書き込みストリームにすべてのコンテンツを転送するために使用されるpipeメソッドがあります。そうでない場合、null(ボディなし)、文字列、またはバッファーのいずれかと見なされ、レスポンスのendメソッドに直接渡されます。

リクエストURLに対応するファイルパスを特定するために、urlPath関数はNode.jsの組み込みurlモジュールを使用してURLを解析します。パス名("/file.txt"など)を取得し、デコードして%20スタイルのエスケープコードを取り除き、プログラムの作業ディレクトリを基準に解決します。

const {parse} = require("url");
const {resolve, sep} = require("path");

const baseDirectory = process.cwd();

function urlPath(url) {
  let {pathname} = parse(url);
  let path = resolve(decodeURIComponent(pathname).slice(1));
  if (path != baseDirectory &&
      !path.startsWith(baseDirectory + sep)) {
    throw {status: 403, body: "Forbidden"};
  }
  return path;
}

ネットワークリクエストを受け入れるプログラムを設定するとすぐに、セキュリティについて心配し始めなければなりません。この場合、注意しないと、ファイルシステム全体をネットワークに誤って公開してしまう可能性があります。

ファイルパスはNode.jsでは文字列です。このような文字列を実際のファイルにマッピングするには、かなりの解釈が必要です。たとえば、パスには親ディレクトリを参照する../が含まれる場合があります。そのため、/../secret_fileのようなパスのリクエストは、明らかな問題の原因となる可能性があります。

このような問題を回避するために、urlPathpathモジュールのresolve関数を使用し、相対パスを解決します。その後、結果が作業ディレクトリのにあることを確認します。process.cwd関数(cwdは「現在の作業ディレクトリ」の略)を使用して、この作業ディレクトリを見つけることができます。pathパッケージのsepバインディングはシステムのパスセパレータです。Windowsではバックスラッシュ、その他のほとんどのシステムではフォワスラッシュです。パスがベースディレクトリで始まらない場合、関数は、リソースへのアクセスが禁止されていることを示すHTTPステータスコードを使用して、エラーレスポンスオブジェクトをスローします。

GETメソッドを、ディレクトリを読み取る場合はファイルのリストを返し、通常のファイルを読み取る場合はファイルのコンテンツを返すように設定します。

ファイルのコンテンツを返す際に、どのようなContent-Typeヘッダーを設定するかは難しい問題です。これらのファイルは何でも良いので、サーバーはすべて同じコンテンツタイプを返すことはできません。ここで再びNPMが役立ちます。mimeパッケージ(text/plainのようなコンテンツタイプインジケーターはMIMEタイプとも呼ばれます)は、多数のファイル拡張子の正しいタイプを知っています。

サーバースクリプトが存在するディレクトリで、次のnpmコマンドを実行すると、特定のバージョンのmimeがインストールされます。

$ npm install mime@2.2.0

リクエストされたファイルが存在しない場合、返す適切なHTTPステータスコードは404です。ファイルに関する情報を調べるstat関数を使用して、ファイルが存在するかどうか、およびディレクトリかどうかを確認します。

const {createReadStream} = require("fs");
const {stat, readdir} = require("fs").promises;
const mime = require("mime");

methods.GET = async function(request) {
  let path = urlPath(request.url);
  let stats;
  try {
    stats = await stat(path);
  } catch (error) {
    if (error.code != "ENOENT") throw error;
    else return {status: 404, body: "File not found"};
  }
  if (stats.isDirectory()) {
    return {body: (await readdir(path)).join("\n")};
  } else {
    return {body: createReadStream(path),
            type: mime.getType(path)};
  }
};

ディスクにアクセスする必要があるため時間がかかる可能性があるため、statは非同期です。コールバックスタイルではなくPromiseを使用しているため、fsから直接ではなくpromisesからインポートする必要があります。

ファイルが存在しない場合、statcodeプロパティが"ENOENT"のエラーオブジェクトをスローします。これらのやや分かりにくいUnix風のコードは、Node.jsでエラーの種類を認識する方法です。

statによって返されるstatsオブジェクトは、ファイルのサイズ(sizeプロパティ)、変更日時(mtimeプロパティ)など、ファイルに関する多くの情報を教えてくれます。ここでは、isDirectoryメソッドが教えてくれる、それがディレクトリか通常のファイルかという点に関心があります。

readdirを使用してディレクトリのファイルの配列を読み取り、クライアントに返します。通常のファイルの場合、createReadStreamを使用して読み込みストリームを作成し、mimeパッケージがファイル名に与えるコンテンツタイプとともに、それをボディとして返します。

DELETEリクエストを処理するコードは少し簡単です。

const {rmdir, unlink} = require("fs").promises;

methods.DELETE = async function(request) {
  let path = urlPath(request.url);
  let stats;
  try {
    stats = await stat(path);
  } catch (error) {
    if (error.code != "ENOENT") throw error;
    else return {status: 204};
  }
  if (stats.isDirectory()) await rmdir(path);
  else await unlink(path);
  return {status: 204};
};

HTTPレスポンスにデータが含まれていない場合、ステータスコード204(「コンテンツなし」)を使用してこれを示すことができます。削除に対するレスポンスは、操作が成功したかどうか以外に情報を送信する必要がないため、ここで返すのは理にかなっています。

存在しないファイルを削除しようとした際に、エラーではなく成功ステータスコードが返される理由について疑問に思われるかもしれません。削除しようとしているファイルが存在しない場合、リクエストの目的は既に達成されていると言えるでしょう。HTTP標準では、リクエストを *べき等* にすることを推奨しています。これは、同じリクエストを複数回実行しても、1回実行した場合と同じ結果になることを意味します。既に存在しないものを削除しようとしても、行おうとしていた効果は達成されています—そのものはもう存在しないのです。

これはPUTリクエストのハンドラーです。

const {createWriteStream} = require("fs");

function pipeStream(from, to) {
  return new Promise((resolve, reject) => {
    from.on("error", reject);
    to.on("error", reject);
    to.on("finish", resolve);
    from.pipe(to);
  });
}

methods.PUT = async function(request) {
  let path = urlPath(request.url);
  await pipeStream(request, createWriteStream(path));
  return {status: 204};
};

今回はファイルが存在するかどうかを確認する必要はありません—存在する場合は上書きするだけです。ここでは再びpipeを使用して、読み取り可能なストリームから書き込み可能なストリームに、この場合はリクエストからファイルにデータを移動します。しかし、pipeはプロミスを返すように書かれていないため、pipeの呼び出しの結果をラップするpipeStreamというラッパーを作成する必要があります。

ファイルを開く際に問題が発生した場合でも、createWriteStreamはストリームを返しますが、そのストリームは"error"イベントを発生させます。ネットワークがダウンするなど、リクエストからのストリームも失敗する可能性があります。そのため、両方のストリームの"error"イベントを接続してプロミスを拒否します。pipeが完了すると、出力ストリームが閉じられ、"finish"イベントが発生します。これがプロミスを正常に解決できる(何も返さない)時点です。

サーバーの完全なスクリプトはhttps://eloquentjavascript.dokyumento.jp/code/file_server.jsで入手できます。それをダウンロードし、依存関係をインストールした後、Nodeで実行して独自のファイルサーバーを起動できます。そしてもちろん、この章の練習問題を解いたり、実験したりするために、修正したり拡張したりすることもできます。

Unix系システム(macOSやLinuxなど)で広く利用可能なコマンドラインツールcurlを使用して、HTTPリクエストを行うことができます。次のセッションでは、サーバーを簡単にテストします。-Xオプションはリクエストのメソッドを設定するために使用され、-dはリクエストボディを含めるために使用されます。

$ curl http://localhost:8000/file.txt
File not found
$ curl -X PUT -d hello http://localhost:8000/file.txt
$ curl http://localhost:8000/file.txt
hello
$ curl -X DELETE http://localhost:8000/file.txt
$ curl http://localhost:8000/file.txt
File not found

file.txtへの最初のリクエストは、ファイルがまだ存在しないため失敗します。PUTリクエストによってファイルが作成され、次のリクエストで正常に取得されます。DELETEリクエストで削除した後、ファイルは再び存在しなくなります。

概要

Nodeは、非ブラウザコンテキストでJavaScriptを実行できる、小さく優れたシステムです。もともとはネットワークタスクのために、ネットワークの *ノード* の役割を果たすように設計されました。しかし、あらゆる種類のスクリプトタスクに適しており、JavaScriptの記述を楽しんでいるのであれば、Nodeを使用したタスクの自動化はうまく機能します。

NPMは考えられるあらゆるもの(そしておそらく考えつかないような多くのもの)のパッケージを提供しており、npmプログラムを使用してそれらのパッケージを取得してインストールできます。Nodeには、ファイルシステムを操作するためのfsモジュールや、HTTPサーバーを実行しHTTPリクエストを行うためのhttpモジュールなど、いくつかの組み込みモジュールが付属しています。

Nodeにおけるすべての入出力は、readFileSyncなどの関数の同期的なバリアントを明示的に使用しない限り、非同期的に実行されます。このような非同期関数を呼び出す際には、コールバック関数を提供し、Nodeは準備が整ったときにエラー値と(存在する場合は)結果を伴ってそれらを呼び出します。

練習問題

検索ツール

Unix系システムには、正規表現を含むファイルをすばやく検索するために使用できるgrepというコマンドラインツールがあります。

コマンドラインから実行でき、grepと somewhat 似ているNodeスクリプトを作成します。最初の コマンドライン引数を正規表現として扱い、それ以降の引数を検索対象のファイルとして扱います。内容が正規表現に一致するファイルの名前を出力する必要があります。

それが機能したら、引数の1つがディレクトリの場合、そのディレクトリとそのサブディレクトリ内のすべてのファイルを検索するように拡張します。

必要に応じて、非同期または同期のファイルシステム関数を使用します。複数の非同期アクションを同時に要求するように設定すると、少し高速化される可能性がありますが、ほとんどのファイルシステムは一度に1つのものしか読み取れないため、大幅な高速化にはなりません。

最初の コマンドライン引数である正規表現は、process.argv[2]にあります。入力ファイルはその後に続きます。RegExpコンストラクタを使用して、文字列から正規表現オブジェクトに変換できます。

readFileSyncを使用して同期的に行う方が簡単ですが、プロミスを返す関数を得るために再びfs.promisesを使用し、async関数を作成すると、コードは似て見えます。

何かがディレクトリかどうかを判断するには、再びstat(またはstatSync)とstatsオブジェクトのisDirectoryメソッドを使用できます。

ディレクトリの探索は分岐プロセスです。再帰関数を使用するか、作業(まだ探索する必要があるファイル)の配列を保持するかして行うことができます。ディレクトリ内のファイルを見つけるには、readdirまたはreaddirSyncを呼び出すことができます。奇妙な大文字と小文字の使い分け—Nodeのファイルシステム関数の命名は、すべて小文字の標準的なUnix関数(readdirなど)をベースにしていますが、大文字のSyncを追加しています。

readdirで読み取られたファイル名から完全なパス名にするには、ディレクトリ名と結合し、間にスラッシュ文字(/)を置く必要があります。

ディレクトリの作成

ファイルサーバーのDELETEメソッドはrmdirを使用してディレクトリを削除できますが、現在、ディレクトリを *作成* する方法は提供されていません。

fsモジュールからmkdirを呼び出すことでディレクトリを作成するMKCOLメソッド(「コレクションの作成」)のサポートを追加します。MKCOLは広く使用されているHTTPメソッドではありませんが、HTTPの上に一連の規則を指定し、ドキュメントの作成に適したWebDAV標準で同じ目的のために存在します。

DELETEメソッドを実装する関数を、MKCOLメソッドの青写真として使用できます。ファイルが見つからない場合は、mkdirを使用してディレクトリを作成してみてください。そのパスにディレクトリが存在する場合は、ディレクトリの作成リクエストがべき等になるように、204レスポンスを返すことができます。非ディレクトリファイルが存在する場合は、エラーコードを返します。コード400(「不正なリクエスト」)が適切です。

ウェブ上の公開スペース

ファイルサーバーはあらゆる種類のファイルを配信し、適切なContent-Typeヘッダーも含まれているため、ウェブサイトを提供するために使用できます。誰でもファイルの削除と置換が許可されるため、興味深い種類のウェブサイトになります。適切なHTTPリクエストを作成する時間をかけるすべての人によって変更、改善、破壊行為が行われる可能性のあるウェブサイトです。

シンプルなJavaScriptファイルを含む基本的なHTMLページを作成します。ファイルをファイルサーバーによって提供されるディレクトリに配置し、ブラウザで開きます。

次に、高度な練習問題、または週末プロジェクトとして、本書で得られたすべての知識を組み合わせて、ウェブサイト(*ウェブサイトの内側* から)を変更するためのよりユーザーフレンドリーなインターフェースを作成します。

第18章で説明されているように、HTMLフォームを使用してウェブサイトを構成するファイルの内容を編集し、HTTPリクエストを使用してサーバー上で更新できるようにします。

最初は1つのファイルのみを編集可能にします。次に、編集するファイルを選択できるようにします。ファイルサーバーがディレクトリを読み取るときにファイルのリストを返すという事実を使用します。

間違えると、そこに存在するファイルを破損する可能性があるため、ファイルサーバーによって公開されているコードで直接作業しないでください。代わりに、作業を公開アクセス可能なディレクトリの外部に保持し、テスト時にそこにコピーします。

編集中のファイルの内容を保持する<textarea>要素を作成できます。fetchを使用してGETリクエストを行うことで、ファイルの現在の内容を取得できます。実行中のスクリプトと同じサーバー上のファイルを参照するには、http://localhost:8000/index.htmlではなく、index.htmlなどの相対URLを使用できます。

次に、ユーザーがボタンをクリックすると(<form>要素と"submit"イベントを使用できます)、同じURLに<textarea>の内容をリクエストボディとして持つPUTリクエストを行い、ファイルを保存します。

次に、URL/へのGETリクエストによって返された行を含む<option>要素を追加することで、サーバーのトップディレクトリ内のすべてのファイルを含む<select>要素を追加できます。ユーザーが別のファイルを選択すると(フィールドの"change"イベント)、スクリプトはそのファイルを取得して表示する必要があります。ファイルを保存する際には、現在選択されているファイル名を使用します。