第20章Node.js
ある学生が「昔のプログラマーは単純な機械とプログラミング言語を使わずに、美しいプログラムを作った。なぜ私たちは複雑な機械とプログラミング言語を使うのですか?」と尋ねました。Fu-Tzuは「昔の建築家は棒と粘土だけを使って、美しい小屋を作った」と答えました。
これまで、JavaScript言語を学び、単一の環境、つまりブラウザ内で使用してきました。この章と次の章では、ブラウザ以外でJavaScriptスキルを適用できるプログラムであるNode.jsを簡単に紹介します。これを使用すると、単純なコマンドラインツールから動的なHTTPサーバーまで、あらゆるものを構築できます。
これらの章は、Node.jsの基盤となる重要なアイデアを教え、Node.jsでいくつかの有用なプログラムを作成するのに十分な情報を提供することを目的としています。Node.jsの完全な、あるいは徹底的な扱い方をしようとするものではありません。
前の章のコードは生のJavaScriptかブラウザ用に書かれていたので、これらのページで直接実行できましたが、この章のコードサンプルはNode用に書かれており、ブラウザでは実行できません。
この章の内容に沿ってコードを実行したい場合は、まずnodejs.orgにアクセスし、お使いのオペレーティングシステムのインストール手順に従ってください。また、Nodeとその組み込みモジュールに関する詳細なドキュメントについては、そのWebサイトを参照してください。
背景
ネットワーク経由で通信するシステムを作成する際のより困難な問題の1つは、入出力、つまりネットワーク、ハードドライブ、その他のデバイスとの間のデータの読み取りと書き込みを管理することです。データの移動には時間がかかり、巧妙にスケジュールすることで、システムがユーザーまたはネットワーク要求にどれだけ迅速に応答するかに大きな違いが生じます。
入出力を処理する従来の方法は、`readFile`などの関数がファイルの読み取りを開始し、ファイルが完全に読み取られた場合にのみ戻るようにすることです。これは*同期I/O*と呼ばれます(I/Oは入出力の略です)。
Nodeは、*非同期* I/Oを簡単かつ便利にすることを目的として最初に考案されました。第17章で説明したブラウザの`XMLHttpRequest`オブジェクトなど、非同期インターフェースはこれまでも見てきました。非同期インターフェースを使用すると、スクリプトは作業を実行している間も実行を続け、完了したらコールバック関数を呼び出します。これは、NodeがすべてのI/Oを実行する方法です。
JavaScriptは、Nodeのようなシステムに適しています。I/Oを実行するための組み込みの方法がない数少ないプログラミング言語の1つです。したがって、JavaScriptは、2つの矛盾するインターフェースを持つことなく、Nodeのかなり風変わりなI/Oへのアプローチに適合させることができました。Nodeが設計されていた2009年には、人々はすでにブラウザでコールバックベースのI/Oを行っていたため、言語周辺のコミュニティは非同期プログラミングスタイルに慣れていました。
非同期性
プログラムがインターネットから2つのリソースを取得し、その結果を使用して簡単な処理を行う必要がある小さな例を使用して、同期I/Oと非同期I/Oを説明します。
同期環境では、このタスクを実行する明らかな方法は、リクエストを1つずつ行うことです。この方法には、2番目のリクエストが最初のリクエストが完了した後にのみ開始されるという欠点があります。合計所要時間は、少なくとも2つの応答時間の合計になります。これは、ネットワーク経由でデータを送受信しているときにほとんどアイドル状態になるマシンの効果的な使用方法ではありません。
同期システムにおけるこの問題の解決策は、追加の制御スレッドを開始することです。(スレッドの以前の説明については、第14章を参照してください。)2番目のスレッドが2番目のリクエストを開始し、両方のスレッドが結果が返されるのを待ってから、結果を組み合わせるために再同期できます。
次の図では、太線はプログラムが通常の実行に費やす時間を表し、細線はI/Oの待機に費やす時間を表しています。同期モデルでは、I/Oにかかる時間は、特定の制御スレッドのタイムラインの*一部*です。非同期モデルでは、I/Oアクションを開始すると、概念的にタイムラインが*分割*されます。I/Oを開始したスレッドは実行を続け、I/O自体は並行して実行され、最終的に完了するとコールバック関数を呼び出します。
この違いを表現する別の方法は、I/Oが完了するのを待つことは同期モデルでは*暗黙的*であるのに対し、非同期モデルでは*明示的*であり、私たちの制御下に直接あるということです。しかし、非同期性は両刃の剣です。直線的な制御モデルに適合しないプログラムを表現しやすくしますが、直線をたどるプログラムを表現することをより厄介にします。
第17章では、これらのコールバックはすべてプログラムにかなりのノイズと間接性を追加するという事実にすでに触れました。この非同期性のスタイルが一般的に良いアイデアかどうかは議論の余地があります。いずれにせよ、慣れるには時間がかかります。
しかし、JavaScriptベースのシステムの場合、コールバックスタイルの非同期性は賢明な選択であると主張します。JavaScriptの強みの1つはシンプルさであり、複数の制御スレッドを追加しようとすると、複雑さが増します。コールバックは単純な*コード*につながる傾向はありませんが、*概念*としては、快適にシンプルでありながら、高性能Webサーバーを作成するのに十分強力です。
nodeコマンド
Node.jsがシステムにインストールされると、`node`と呼ばれるプログラムが提供されます。これは、JavaScriptファイルを実行するために使用されます。次のコードを含む`hello.js`ファイルがあるとします。
var message = "Hello world"; console.log(message);
$ node hello.js Hello world
プログラムを実行するために、コマンドラインから次のように`node`を実行できます。
$ node > 1 + 1 2 > [-1, -2, -3].map(Math.abs) [1, 2, 3] > process.exit(0) $
Nodeの`console.log`メソッドは、ブラウザの場合と同様の動作をします。テキストの一部を出力します。ただし、Nodeでは、テキストはブラウザのJavaScriptコンソールではなく、プロセスの標準出力ストリームに送られます。
ファイル名を指定せずに`node`を実行すると、JavaScriptコードを入力してすぐに結果を確認できるプロンプトが表示されます。
$ node showargv.js one --and two ["node", "/home/marijn/showargv.js", "one", "--and", "two"]
`console`変数と同様に、`process`変数はNodeでグローバルに利用可能です。現在のプログラムを検査および操作するためのさまざまな方法を提供します。`exit`メソッドはプロセスを終了し、終了ステータスコードを指定できます。終了ステータスコードは、`node`(この場合はコマンドラインシェル)を開始したプログラムに、プログラムが正常に完了したか(コードゼロ)、エラーが発生したか(その他のコード)を伝えます。
スクリプトに与えられたコマンドライン引数を見つけるには、文字列の配列である`process.argv`を読み取ることができます。`node`コマンドの名前とスクリプト名も含まれているため、実際の引数はインデックス2から始まることに注意してください。`showargv.js`に`console.log(process.argv)`ステートメントのみが含まれている場合、次のように実行できます。
`node showargv.js one --two "three"`
`['node', '/tmp/showargv.js', 'one', '--two', 'three']`
`Array`、`Math`、`JSON`などのすべての標準JavaScriptグローバル変数も、Nodeの環境に存在します。`document`や`alert`などのブラウザ関連の機能はありません。
ブラウザでは`window`と呼ばれるグローバルスコープオブジェクトは、Nodeではより分かりやすい名前`global`になっています。
`console`や`process`など、私が言及した少数の変数を除いて、Nodeはグローバルスコープにほとんど機能を配置しません。他の組み込み機能にアクセスするには、モジュールシステムにそれを要求する必要があります。
var garble = require("./garble"); // Index 2 holds the first actual command-line argument var argument = process.argv[2]; console.log(garble(argument));
`require`関数に基づくCommonJSモジュールシステムについては、第10章で説明しました。このシステムはNodeに組み込まれており、組み込みモジュールからダウンロードしたライブラリ、独自のプログラムの一部であるファイルまで、あらゆるものをロードするために使用されます。
module.exports = function(string) { return string.split("").map(function(ch) { return String.fromCharCode(ch.charCodeAt(0) + 5); }).join(""); };
`require`が呼び出されると、Nodeは指定された文字列をロードする実際のファイルに解決する必要があります。`"/"`、`"./"`、または`"../"`で始まるパス名は、現在のモジュールのパスを基準にして解決されます。ここで、`"./"`は現在のディレクトリ、`"../"`は1つ上のディレクトリ、`"/"`はファイルシステムのルートを表します。したがって、`/home/marijn/elife/run.js`ファイルから`"./world/world"`を要求すると、Nodeは`/home/marijn/elife/world/world.js`ファイルをロードしようとします。`.js`拡張子は省略できます。
相対パスまたは絶対パスのように見えない文字列が`require`に渡されると、組み込みモジュールまたは`node_modules`ディレクトリにインストールされたモジュールを参照していると見なされます。たとえば、`require("fs")`はNodeの組み込みファイルシステムモジュールを提供し、`require("elife")`は`node_modules/elife/`にあるライブラリのロードを試みます。このようなライブラリをインストールする一般的な方法は、NPMを使用することです。これについては、後ほど説明します。
`require`の使用例を示すために、2つのファイルで構成される簡単なプロジェクトを設定してみましょう。1つ目は`main.js`と呼ばれ、コマンドラインから呼び出して文字列をgarbleできるスクリプトを定義します。
$ node main.js JavaScript Of{fXhwnuy
`garble.js`ファイルは、文字列をgarbleするためのライブラリを定義します。これは、前に定義したコマンドラインツールと、garbling関数に直接アクセスする必要がある他のスクリプトの両方で使用できます。
`module.exports`にプロパティを追加するのではなく、置き換えることで、モジュールから特定の値をエクスポートできます。この場合、`garble`ファイルをrequireした結果をgarbling関数自体にします。
例えば、NPMで見つけるモジュールの1つにfiglet
があります。これは、テキストをASCIIアート(テキスト文字で描かれた図)に変換できます。次のトランスクリプトは、そのインストール方法と使用方法を示しています。
$ npm install figlet npm GET https://registry.npmjs.org/figlet npm 200 https://registry.npmjs.org/figlet npm GET https://registry.npmjs.org/figlet/-/figlet-1.0.9.tgz npm 200 https://registry.npmjs.org/figlet/-/figlet-1.0.9.tgz figlet@1.0.9 node_modules/figlet $ node > var figlet = require("figlet"); > figlet.text("Hello world!", function(error, data) { if (error) console.error(error); else console.log(data); }); _ _ _ _ _ _ _ | | | | ___| | | ___ __ _____ _ __| | __| | | | |_| |/ _ \ | |/ _ \ \ \ /\ / / _ \| '__| |/ _` | | | _ | __/ | | (_) | \ V V / (_) | | | | (_| |_| |_| |_|\___|_|_|\___/ \_/\_/ \___/|_| |_|\__,_(_)
npm install
を実行した後、NPMはnode_modules
というディレクトリを作成します。そのディレクトリ内には、ライブラリを含むfiglet
ディレクトリがあります。 node
を実行してrequire("figlet")
を呼び出すと、このライブラリがロードされ、そのtext
メソッドを呼び出して大きな文字を描画できます。
おそらく予想外かもしれませんが、figlet.text
は大きな文字を構成する文字列を単に返すのではなく、結果を渡すコールバック関数を取ります。また、コールバックに別の引数であるerror
を渡します。これは、何か問題が発生した場合はエラーオブジェクトを保持し、すべてが正常な場合はnullを保持します。
これは、Nodeコードでは一般的なパターンです。 figlet
を使用して何かをレンダリングするには、ライブラリは文字の形状を含むファイルを読み取る必要があります。ディスクからそのファイルを読み取ることはNodeでは非同期操作であるため、figlet.text
はすぐに結果を返すことができません。非同期性は、ある意味で伝染性があります。非同期関数を呼び出すすべての関数は、それ自体が非同期になる必要があります。
NPMには、npm install
以外にも多くの機能があります。プログラムやライブラリに関するJSONエンコードされた情報(依存する他のライブラリなど)を含むpackage.json
ファイルを読み取ります。このようなファイルを含むディレクトリでnpm install
を実行すると、すべての依存関係と*それらの*依存関係が自動的にインストールされます。 npm
ツールは、ライブラリをNPMのオンラインパッケージリポジトリに公開するためにも使用され、他の人がライブラリを見つけてダウンロードして使用できるようにします。
本書では、NPMの使用法の詳細についてはこれ以上深く掘り下げません。詳細なドキュメントとライブラリの簡単な検索方法については、npmjs.orgを参照してください。
ファイルシステムモジュール
Nodeに付属する最も一般的に使用される組み込みモジュールの1つは、*ファイルシステム*の略である"fs"
モジュールです。このモジュールは、ファイルとディレクトリを操作するための関数を提供します。
たとえば、ファイルを読み取ってから、ファイルの内容をコールバックに渡すreadFile
という関数があります。
var fs = require("fs"); fs.readFile("file.txt", "utf8", function(error, text) { if (error) throw error; console.log("The file contained:", text); });
readFile
の2番目の引数は、ファイルを文字列にデコードするために使用される*文字エンコーディング*を示します。テキストをバイナリデータにエンコードするにはいくつかの方法がありますが、最新のシステムのほとんどはテキストのエンコードにUTF-8を使用しているため、別のエンコーディングが使用されていると信じる理由がない限り、テキストファイルを読み取るときに"utf8"
を渡すのは安全です。エンコーディングを渡さない場合、Nodeはバイナリデータに興味があると想定し、文字列ではなくBuffer
オブジェクトを提供します。これは、ファイル内のバイトを表す数値を含む配列のようなオブジェクトです。
var fs = require("fs"); fs.readFile("file.txt", function(error, buffer) { if (error) throw error; console.log("The file contained", buffer.length, "bytes.", "The first byte is:", buffer[0]); });
同様の関数であるwriteFile
は、ファイルをディスクに書き込むために使用されます。
var fs = require("fs"); fs.writeFile("graffiti.txt", "Node was here", function(err) { if (err) console.log("Failed to write file:", err); else console.log("File written."); });
ここでは、writeFile
は、Buffer
オブジェクトではなく書き込む文字列が指定されている場合、デフォルトの文字エンコーディング(UTF-8)を使用してテキストとして書き出すと想定するため、エンコーディングを指定する必要はありませんでした。
"fs"
モジュールには、他にも多くの便利な関数が含まれています。 readdir
はディレクトリ内のファイルを文字列の配列として返し、stat
はファイルに関する情報を取得し、rename
はファイルの名前を変更し、unlink
はファイルを削除します。詳細は、nodejs.orgのドキュメントを参照してください。
"fs"
の関数の多くには、同期と非同期の両方のバリアントがあります。たとえば、readFile
の同期バージョンであるreadFileSync
があります。
var fs = require("fs"); console.log(fs.readFileSync("file.txt", "utf8"));
同期関数は、使用する際の儀式が少なく、非同期I/Oによって提供される追加の速度が関係ない単純なスクリプトで役立ちます。ただし、このような同期操作が実行されている間、プログラムは完全に停止することに注意してください。ユーザーまたはネットワーク上の他のマシンに応答する必要がある場合、同期I/Oで立ち往生すると、迷惑な遅延が発生する可能性があります。
HTTPモジュール
もう1つの中心的なモジュールは"http"
と呼ばれます。 HTTPサーバーを実行し、HTTPリクエストを作成するための機能を提供します。
これは、単純なHTTPサーバーを起動するために必要なすべてです。
var http = require("http"); var server = http.createServer(function(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);
このスクリプトを自分のマシンで実行すると、Webブラウザをhttp://localhost:8000/helloに向けて、サーバーにリクエストを送信できます。小さなHTMLページで応答します。
createServer
に引数として渡された関数は、クライアントがサーバーに接続しようとするたびに呼び出されます。 request
およびresponse
変数は、着信データと発信データを表すオブジェクトです。前者には、リクエストに関する情報(リクエストが送信されたURLを示すurl
プロパティなど)が含まれています。
何かを送り返すには、`response`オブジェクトのメソッドを呼び出します。最初の`writeHead`は、レスポンスヘッダーを書き出します(第17章を参照)。ステータスコード(この場合は「OK」の場合は200)とヘッダー値を含むオブジェクトを指定します。ここでは、HTMLドキュメントを送り返すことをクライアントに伝えます。
次に、実際のレスポンス本文(ドキュメント自体)が`response.write`で送信されます。レスポンスを部分的に送信する場合、またはクライアントにデータが利用可能になったときにストリーミングする場合、このメソッドを複数回呼び出すことができます。最後に、`response.end`はレスポンスの終了を示します。
`server.listen`の呼び出しにより、サーバーはポート8000で接続を待機し始めます。これが、このサーバーと通信するために、`localhost`(デフォルトのポート80を使用)ではなく、*localhost:8000*に接続する必要がある理由です。
さらなるイベント(この場合はネットワーク接続)を待機しているため自動的に終了しないこのようなNodeスクリプトの実行を停止するには、Ctrl-Cを押します。
実際のWebサーバーは通常、前の例のサーバー以上のことを行います。リクエストのメソッド(`method`プロパティ)を調べて、クライアントが実行しようとしているアクションを確認し、リクエストのURLを調べて、このアクションが実行されているリソースを確認します。より高度なサーバーについては、この章の後半で説明します。
HTTP *クライアント*として機能するには、`"http"`モジュールの`request`関数を使用できます。
var http = require("http"); var request = http.request({ hostname: "eloquentjavascript.net", path: "/20_node.html", method: "GET", headers: {Accept: "text/html"} }, function(response) { console.log("Server responded with status code", response.statusCode); }); request.end();
`request`の最初の引数はリクエストを設定し、どのサーバーと通信するか、そのサーバーからどのパスをリクエストするか、どのメソッドを使用するかなどをNodeに指示します。2番目の引数は、レスポンスが返されたときに呼び出す関数です。レスポンスを検査できるオブジェクト(たとえば、ステータスコードを確認できるオブジェクト)が渡されます。
サーバーで見た`response`オブジェクトと同様に、`request`によって返されるオブジェクトを使用すると、`write`メソッドを使用してリクエストにデータをストリーミングし、`end`メソッドを使用してリクエストを終了できます。`GET`リクエストの本文にはデータを含めるべきではないため、この例では`write`を使用していません。
セキュアHTTP(HTTPS)URLにリクエストを送信するために、Nodeは`https`と呼ばれるパッケージを提供します。これには、`http.request`に似た独自の`request`関数が含まれています。
ストリーム
HTTPの例では、書き込み可能なストリームの2つの例(サーバーが書き込み可能なレスポンスオブジェクトと`http.request`から返されたリクエストオブジェクト)を見ました。
書き込み可能なストリームは、Nodeインターフェースで広く使用されている概念です。書き込み可能なすべてのストリームには、文字列または`Buffer`オブジェクトを渡すことができる`write`メソッドがあります。`end`メソッドはストリームを閉じ、引数が指定されている場合は、閉じる前にデータも書き出します。これらの両方のメソッドには、追加の引数としてコールバックを指定することもできます。コールバックは、ストリームへの書き込みまたはストリームのクローズが完了したときに呼び出されます。
`fs.createWriteStream`関数を使用して、ファイルを指す書き込み可能なストリームを作成できます。次に、結果のオブジェクトの`write`メソッドを使用して、`fs.writeFile`のように一度にではなく、一度に1つずつファイルを書き込むことができます。
読み取り可能なストリームはもう少し複雑です。HTTPサーバーのコールバック関数に渡された`request`変数とHTTPクライアントに渡された`response`変数はどちらも読み取り可能なストリームです。(サーバーはリクエストを読み取ってからレスポンスを書き込みますが、クライアントは最初にリクエストを書き込んでからレスポンスを読み取ります。)ストリームからの読み取りは、メソッドではなくイベントハンドラーを使用して行われます。
Nodeでイベントを発行するオブジェクトには、ブラウザの`addEventListener`メソッドに似た`on`と呼ばれるメソッドがあります。イベント名と関数を指定すると、指定されたイベントが発生するたびに呼び出されるように関数が登録されます。
読み取り可能なストリームには、`"data"`イベントと`"end"`イベントがあります。最初のイベントはデータが入ってくるたびに発生し、2番目のイベントはストリームが終了するたびに呼び出されます。このモデルは、ドキュメント全体がまだ利用可能でない場合でも、すぐに処理できる「ストリーミング」データに最適です。`fs.createReadStream`関数を使用することにより、ファイルを読み取り可能なストリームとして読み取ることができます。
次のコードは、リクエスト本文を読み取り、クライアントにすべて大文字のテキストとしてストリーミングするサーバーを作成します。
var http = require("http"); http.createServer(function(request, response) { response.writeHead(200, {"Content-Type": "text/plain"}); request.on("data", function(chunk) { response.write(chunk.toString().toUpperCase()); }); request.on("end", function() { response.end(); }); }).listen(8000);
データハンドラーに渡された`chunk`変数はバイナリ`Buffer`になり、`toString`を呼び出すことで文字列に変換できます。これは、デフォルトのエンコーディング(UTF-8)を使用してデコードします。
大文字化サーバーの実行中に次のコードを実行すると、そのサーバーにリクエストが送信され、受信したレスポンスが書き出されます。
var http = require("http"); var request = http.request({ hostname: "localhost", port: 8000, method: "POST" }, function(response) { response.on("data", function(chunk) { process.stdout.write(chunk.toString()); }); }); request.end("Hello server");
この例では、`console.log`を使用する代わりに、`process.stdout`(書き込み可能なストリームとしてのプロセスの標準出力)に書き込みます。`console.log`は、書き込むテキストの各部分の後に余分な改行文字を追加するため、ここでは適切ではないため、使用できません。
シンプルなファイルサーバー
HTTPサーバーとファイルシステムとの通信に関する新しく得た知識を組み合わせて、それらの間のブリッジを作成しましょう。ファイルシステムへのリモートアクセスを可能にするHTTPサーバーです。このようなサーバーには多くの用途があります。Webアプリケーションがデータを保存および共有したり、グループの人々がファイルの束を共有してアクセスできるようにしたりできます。
ファイルをHTTPリソースとして扱う場合、HTTPメソッドのGET
、PUT
、DELETE
をそれぞれファイルの読み取り、書き込み、削除に使用できます。リクエスト内のパスは、リクエストが参照するファイルのパスとして解釈します。
ファイルシステム全体を共有したくない場合、これらのパスはサーバーの作業ディレクトリ(サーバーが起動されたディレクトリ)から始まるものとして解釈します。サーバーを/home/marijn/public/
(WindowsではC:\Users\marijn\public\
)から実行した場合、/file.txt
へのリクエストは/home/marijn/public/file.txt
(またはC:\Users\marijn\public\file.txt
)を参照します。
さまざまなHTTPメソッドを処理する関数を格納するために、methods
と呼ばれるオブジェクトを使用して、プログラムを少しずつ構築していきます。
var http = require("http"), fs = require("fs"); var methods = Object.create(null); http.createServer(function(request, response) { function respond(code, body, type) { if (!type) type = "text/plain"; response.writeHead(code, {"Content-Type": type}); if (body && body.pipe) body.pipe(response); else response.end(body); } if (request.method in methods) methods[request.method](urlToPath(request.url), respond, request); else respond(405, "Method " + request.method + " not allowed."); }).listen(8000);
これは、405エラー応答(指定されたメソッドがサーバーで処理されないことを示すコード)のみを返すサーバーを起動します。
respond
関数は、さまざまなメソッドを処理する関数に渡され、リクエストを完了するためのコールバックとして機能します。引数として、HTTPステータスコード、本文、およびオプションでコンテンツタイプを取ります。本文として渡された値が読み取り可能なストリームである場合、読み取り可能なストリームを書き込み可能なストリームに転送するために使用されるpipe
メソッドを持ちます。そうでない場合、null
(本文なし)または文字列のいずれかであると想定され、レスポンスのend
メソッドに直接渡されます。
リクエスト内のURLからパスを取得するために、urlToPath
関数はNodeの組み込み"url"
モジュールを使用してURLを解析します。/file.txt
のようなパス名を取得し、それをデコードして%20
スタイルのエスケープコードを取り除き、単一のドットを前に付けて現在のディレクトリからの相対パスを生成します。
function urlToPath(url) { var path = require("url").parse(url).pathname; return "." + decodeURIComponent(path); }
urlToPath
関数のセキュリティが心配な場合は、その通りです。演習でそれに戻ります。
ディレクトリを読み取るときはファイルのリストを返し、通常のファイルを読み取るときはファイルの内容を返すように、GET
メソッドを設定します。
難しい質問の1つは、ファイルの内容を返すときにどのような種類のContent-Type
ヘッダーを追加する必要があるかということです。これらのファイルは何でもあり得るため、サーバーはすべてに対して同じタイプを返すことはできません。しかし、NPMがそれを助けてくれます。mime
パッケージ(text/plain
のようなコンテンツタイプインジケーターは*MIMEタイプ*とも呼ばれます)は、膨大な数のファイル拡張子に対して正しいタイプを知っています。
サーバースクリプトが存在するディレクトリで次のnpm
コマンドを実行すると、require("mime")
を使用してライブラリにアクセスできるようになります。
$ npm install mime@1.4.0 npm http GET https://registry.npmjs.org/mime npm http 304 https://registry.npmjs.org/mime mime@1.4.0 node_modules/mime
リクエストされたファイルが存在しない場合、返す正しいHTTPエラーコードは404です。ファイルに関する情報を検索するfs.stat
を使用して、ファイルが存在するかどうか、およびディレクトリであるかどうかを調べます。
methods.GET = function(path, respond) { fs.stat(path, function(error, stats) { if (error && error.code == "ENOENT") respond(404, "File not found"); else if (error) respond(500, error.toString()); else if (stats.isDirectory()) fs.readdir(path, function(error, files) { if (error) respond(500, error.toString()); else respond(200, files.join("\n")); }); else respond(200, fs.createReadStream(path), require("mime").lookup(path)); }); };
ディスクにアクセスする必要があるため、時間がかかる場合があるため、fs.stat
は非同期です。ファイルが存在しない場合、fs.stat
はcode
プロパティが"ENOENT"
であるエラーオブジェクトをコールバックに渡します。 Nodeが異なるタイプのエラーに対して異なるサブタイプのError
を定義していれば良いのですが、そうではありません。代わりに、あいまいなUnix風のコードをそこに配置します。
予期しないエラーはすべてステータスコード500で報告します。これは、不正なリクエストを参照する4(404など)で始まるコードとは対照的に、サーバーに問題が存在することを示します。これは完全に正確ではない状況がありますが、このような小さなサンプルプログラムでは、それで十分です。
fs.stat
によって返されるstats
オブジェクトは、ファイルのサイズ(size
プロパティ)や変更日付(mtime
プロパティ)など、ファイルに関する多くの情報を教えてくれます。ここでは、ディレクトリなのか通常のファイルなのかという kérdés に興味があり、isDirectory
メソッドがそれを教えてくれます。
fs.readdir
を使用してディレクトリ内のファイルのリストを読み取り、別のコールバックでユーザーに返します。通常のファイルの場合、fs.createReadStream
を使用して読み取り可能なストリームを作成し、"mime"
モジュールがファイル名に提供するコンテンツタイプとともにrespond
に渡します。
methods.DELETE = function(path, respond) { fs.stat(path, function(error, stats) { if (error && error.code == "ENOENT") respond(204); else if (error) respond(500, error.toString()); else if (stats.isDirectory()) fs.rmdir(path, respondErrorOrNothing(respond)); else fs.unlink(path, respondErrorOrNothing(respond)); }); };
存在しないファイルを削除しようとすると、エラーではなく204ステータスが返されるのはなぜだろうと思うかもしれません。削除されているファイルが存在しない場合、リクエストの目的はすでに達成されていると言えるでしょう。 HTTP標準では、リクエストを*冪等*にすることを推奨しています。これは、複数回適用しても異なる結果が生成されないことを意味します。
function respondErrorOrNothing(respond) { return function(error) { if (error) respond(500, error.toString()); else respond(204); }; }
HTTPレスポンスにデータが含まれていない場合、ステータスコード204(「コンテンツなし」)を使用してこれを示すことができます。エラーを報告するか、いくつかの異なる状況で204レスポンスを返すコールバックを提供する必要があるため、そのようなコールバックを作成するrespondErrorOrNothing
関数を記述しました。
methods.PUT = function(path, respond, request) { var outStream = fs.createWriteStream(path); outStream.on("error", function(error) { respond(500, error.toString()); }); outStream.on("finish", function() { respond(204); }); request.pipe(outStream); };
ここでは、ファイルが存在するかどうかを確認する必要はありません。存在する場合、上書きするだけです。ここでも、pipe
を使用して、読み取り可能なストリームから書き込み可能なストリームにデータを移動します。この場合は、リクエストからファイルに移動します。ストリームの作成に失敗した場合、"error"
イベントが発生し、レスポンスで報告します。データが正常に転送されると、pipe
は両方のストリームを閉じ、書き込み可能なストリームで"finish"
イベントが発生します。それが発生すると、204レスポンスでクライアントに成功を報告できます。
サーバーの完全なスクリプトは、eloquentjavascript.net/2nd_edition/code/file_server.jsにあります。それをダウンロードしてNodeで実行すると、独自のファイルサーバーを起動できます。もちろん、この章の演習を解いたり、実験したりするために、変更および拡張することができます。
Unixライクなシステムで広く利用可能なコマンドラインツール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
リクエストで削除した後、ファイルは再びなくなります。
エラー処理
ファイルサーバーのコードでは、処理方法がわからない例外を明示的にエラー応答にルーティングしている箇所が*6つ*あります。例外はコールバックに自動的に伝播されるのではなく、引数として渡されるため、毎回明示的に処理する必要があります。これは、例外処理の利点、つまり障害状態の処理を一元化できるという利点を完全に無効にします。
このシステムで実際に何かが例外を*スロー*すると何が起こるのでしょうか?try
ブロックを使用していないため、例外はコールスタックの最上位に伝播します。 Nodeでは、プログラムが中止され、例外に関する情報(スタックトレースを含む)がプログラムの標準エラーストリームに書き込まれます。
これは、コールバックに引数として渡される非同期の問題とは対照的に、サーバーのコード自体で問題が発生するたびにサーバーがクラッシュすることを意味します。リクエストの処理中に発生したすべての例外を処理して、必ずレスポンスを送信したい場合は、*すべて*のコールバックにtry/catch
ブロックを追加する必要があります。
これは実行可能ではありません。多くのNodeプログラムは、例外をできるだけ使用しないように記述されており、例外が発生した場合、プログラムが処理できるものではなく、クラッシュするのが正しい応答であると想定しています。
別のアプローチは、第17章で紹介されたpromiseを使用することです。これらは、コールバック関数によって発生した例外をキャッチし、それらを障害として伝播します。 Nodeにpromiseライブラリを読み込んで、それを使用して非同期制御を管理することができます。 promiseを統合するNodeライブラリはほとんどありませんが、多くの場合、それらをラップするのは簡単です。 NPMの優れた"promise"
モジュールには、fs.readFile
などの非同期関数を受け取り、promiseを返す関数に変換するdenodeify
と呼ばれる関数が含まれています。
var Promise = require("promise"); var fs = require("fs"); var readFile = Promise.denodeify(fs.readFile); readFile("file.txt", "utf8").then(function(content) { console.log("The file contained: " + content); }, function(error) { console.log("Failed to read file: " + error); });
比較のために、promiseに基づいたファイルサーバーの別のバージョンを作成しました。これは、eloquentjavascript.net/2nd_edition/code/file_server_promises.jsにあります。関数はコールバックを呼び出すのではなく、結果を*返す*ことができ、例外のルーティングは明示的ではなく暗黙的であるため、少しクリーンになっています。
プログラミングスタイルの違いを説明するために、promiseベースのファイルサーバーから数行をリストします。
このコードで使用されているfsp
オブジェクトには、Promise.denodeify
によってラップされた、いくつかのfs
関数のpromiseスタイルのバリアントが含まれています。code
およびbody
プロパティを持つメソッドハンドラから返されたオブジェクトは、promiseのチェーンの最終結果になり、クライアントに送信するレスポンスの種類を決定するために使用されます。
methods.GET = function(path) { return inspectPath(path).then(function(stats) { if (!stats) // Does not exist return {code: 404, body: "File not found"}; else if (stats.isDirectory()) return fsp.readdir(path).then(function(files) { return {code: 200, body: files.join("\n")}; }); else return {code: 200, type: require("mime").lookup(path), body: fs.createReadStream(path)}; }); }; function inspectPath(path) { return fsp.stat(path).then(null, function(error) { if (error.code == "ENOENT") return null; else throw error; }); }
inspectPath
関数は、fs.stat
の単純なラッパーであり、ファイルが見つからない場合を処理します。その場合、障害をnull
を返す成功に置き換えます。他のすべてのエラーは伝播されます。これらのハンドラから返されたpromiseが失敗すると、HTTPサーバーは500ステータスコードで応答します。
まとめ
Nodeは、ブラウザ以外のコンテキストでJavaScriptを実行できる、シンプルでわかりやすいシステムです。もともとネットワークタスク用に設計されており、ネットワークの*ノード*としての役割を果たします。しかし、あらゆる種類のスクリプトタスクに役立ち、JavaScriptを書くのが好きな人なら、Nodeで日常のタスクを自動化するのは素晴らしいことです。
NPMは、考えられるすべてのもの(そしておそらく考えもしないであろう多くのもの)のためのライブラリを提供し、簡単なコマンドを実行することでそれらのライブラリを取得してインストールできます。 Nodeには、ファイルシステムを操作するための"fs"
モジュールや、HTTPサーバーを実行してHTTPリクエストを行うための"http"
モジュールなど、多数の組み込みモジュールも付属しています。
Node.jsにおけるすべての入出力は、`fs.readFileSync`のような関数の同期バージョンを明示的に使用しない限り、非同期で行われます。コールバック関数を指定すると、Node.jsは要求したI/Oが完了した適切なタイミングでそれらを呼び出します。
練習問題
再び、コンテンツネゴシエーション
17章の最初の練習問題は、異なる`Accept`ヘッダーを渡すことで、異なる種類のコンテンツを要求する複数のリクエストをeloquentjavascript.net/authorに送信することでした。
Node.jsの`http.request`関数を使用して、これをもう一度行います。少なくとも`text/plain`、`text/html`、`application/json`のメディアタイプを要求してください。リクエストへのヘッダーは、`http.request`の最初の引数の`headers`プロパティで、オブジェクトとして指定できることを覚えておいてください。
リクエストを実際に送信するには、`http.request`によって返されたオブジェクトの`end`メソッドを呼び出すことを忘れないでください。
`http.request`のコールバックに渡されるレスポンスオブジェクトは、読み取り可能なストリームです。これは、レスポンスボディ全体を取得するのがそれほど簡単ではないことを意味します。次のユーティリティ関数は、ストリーム全体を読み取り、結果をコールバック関数に渡します。発生したエラーは、コールバックの最初の引数として渡すという、通常の パターンを使用します。
function readStreamAsString(stream, callback) { var data = ""; stream.on("data", function(chunk) { data += chunk.toString(); }); stream.on("end", function() { callback(null, data); }); stream.on("error", function(error) { callback(error); }); }
リークの修正
いくつかのファイルに簡単にリモートアクセスできるように、この章で定義されているファイルサーバーを自分のマシンの`/home/marijn/public`ディレクトリで実行する習慣をつけるかもしれません。そしてある日、誰かがブラウザに保存したすべてのパスワードにアクセスしたことがわかります。
まだわからない場合は、次のように定義された`urlToPath`関数を思い出してください。
function urlToPath(url) { var path = require("url").parse(url).pathname; return "." + decodeURIComponent(path); }
`"fs"`関数に渡されるパスは相対パスである可能性があることを考慮してください。つまり、ディレクトリを上に移動するために`"../"`を含む場合があります。クライアントがここに示されているようなURLにリクエストを送信するとどうなりますか?
http://myhostname:8000/../.config/config/google-chrome/Default/Web%20Data http://myhostname:8000/../.ssh/id_dsa http://myhostname:8000/../../../etc/passwd
この問題を修正するために、`urlToPath`を変更してください。Windows上のNode.jsでは、ディレクトリの区切り文字としてスラッシュとバックスラッシュの両方を使用できることを考慮に入れてください。
また、未完成のシステムをインターネットに公開するとすぐに、そのシステムのバグがマシンに悪影響を与えるために使用される可能性があることをよく考えてください。
両側にスラッシュ、バックスラッシュ、または文字列の末尾がある2つのドットのすべての出現を削除するだけで十分です。正規表現で`replace`メソッドを使用するのが、これを行う最も簡単な方法です。ただし、このようなインスタンスは重複する可能性があるため(`"/../../f"`など)、文字列が変更されなくなるまで`replace`を複数回適用する必要がある場合があります。また、文字列をデコードした*後*に置換を実行してください。そうしないと、ドットまたはスラッシュをエンコードすることによってチェックを回避できます。
もう1つの潜在的に懸念されるケースは、パスがスラッシュで始まる場合です。これは絶対パスとして解釈されます。ただし、`urlToPath`はパスの前にドット文字を付けるため、そのようなパスになるリクエストを作成することはできません。パス内の複数の連続したスラッシュは奇妙ですが、ファイルシステムによって単一のスラッシュとして扱われます。
ディレクトリの作成
`DELETE`メソッドはディレクトリを削除するように設定されていますが(`fs.rmdir`を使用)、ファイルサーバーは現在、ディレクトリを*作成*する手段を提供していません。
`fs.mkdir`を呼び出してディレクトリを作成する`MKCOL`メソッドのサポートを追加します。`MKCOL`は基本的なHTTPメソッドの1つではありませんが、*WebDAV*標準には同じ目的で存在します。WebDAVはHTTPの拡張機能のセットを指定し、リソースの読み取りだけでなく、書き込みにも適しています。
Web上のパブリックスペース
ファイルサーバーはあらゆる種類のファイルを配信し、正しい`Content-Type`ヘッダーも含まれているため、Webサイトの配信に使用できます。誰でもファイルの削除と置換を許可するため、興味深い種類のWebサイトになります。適切なHTTPリクエストを作成する時間をかけるすべての人が、変更、破壊、破壊できるWebサイトです。それでも、それはWebサイトです。
簡単なJavaScriptファイルを含む基本的なHTMLページを作成します。ファイルをファイルサーバーが提供するディレクトリに配置し、ブラウザで開きます。
次に、高度な練習問題または週末のプロジェクトとして、本書から得たすべての知識を組み合わせて、Webサイトの*内部*からWebサイトを変更するための、よりユーザーフレンドリーなインターフェースを構築します。
HTMLフォーム(18章)を使用して、Webサイトを構成するファイルのコンテンツを編集し、17章で説明されているようにHTTPリクエストを使用してサーバー上で更新できるようにします。
最初に、単一のファイルのみを編集可能にします。次に、ユーザーが編集するファイルを選択できるようにします。ファイルサーバーはディレクトリを読み取るときにファイルのリストを返すという事実を使用します。
ファイルサーバーのコードを直接操作しないでください。ミスをすると、ファイルが破損する可能性があります。代わりに、作業を公開ディレクトリの外に置いて、テスト時にそこにコピーします。
コンピューターがファイアウォール、ルーター、またはその他の干渉デバイスなしでインターネットに直接接続されている場合は、友人を招待してWebサイトを使用できる場合があります。確認するには、whatismyip.comにアクセスし、表示されたIPアドレスをブラウザのアドレスバーにコピーして、最後に`:8000`を追加して正しいポートを選択します。それでサイトにアクセスできれば、誰でもオンラインで見ることができます。
編集中のファイルのコンテンツを保持するために、`<textarea>`要素を作成できます。`XMLHttpRequest`を使用した`GET`リクエストを使用して、ファイルの現在のコンテンツを取得できます。実行中のスクリプトと同じサーバー上のファイルを参照するには、http://localhost:8000/index.htmlの代わりに、*index.html*のような相対URLを使用できます。
次に、ユーザーがボタンをクリックしたとき(`<form>`要素と`"submit"`イベント、または単に`"click"`ハンドラーを使用できます)、リクエストボディとして`<textarea>`のコンテンツを使用して、同じURLに`PUT`リクエストを送信してファイルを保存します。
次に、URL `/`への`GET`リクエストによって返された行を含む`<option>`要素を追加することにより、サーバーのルートディレクトリにあるすべてのファイルを含む`<select>`要素を追加できます。ユーザーが別のファイルを選択すると(フィールドの`"change"`イベント)、スクリプトはそのファイルを取得して表示する必要があります。また、ファイルを保存するときは、現在選択されているファイル名を使用してください。
残念ながら、サーバーは単純すぎるため、`GET`リクエストで取得したものが通常のファイルかディレクトリかを判断できないため、サブディレクトリからファイルを確実に読み取ることができません。これを解決するためにサーバーを拡張する方法を考えられますか?