Node.js道場1stシーズン課題プレイバック(序の段)

More than 5 years have passed since last update.

この投稿は「Node.js入門」の著者の一人である、shigeki@githubさんが師範となり開催された「Node.js道場」での鍛錬の模様を記したものです。


目次的なもの


TCPからはじまるHTTP

John: やあ、Michael。見て!見て!このNode.jsすごいんだ。

http.createServer だけでサーバが立っちゃうんだぜ!

Michael: マジかよ?クレイジーでクールだぜ!

Node.jsはhttpサーバを作ることが当初の目的だったそうで、我々の修行は、簡単にhttpサーバが作れてしまうhttpモジュールの大元であるnetモジュール(TCP通信を扱う)からはじまりました。「Node.js入門 第9章」にあたります。

道場ではkeep-aliveな接続時の注意点などが師範より示されました。私の人生もFINパケットを送られないように、keep-aliveして行きたいと思いを新たにしました。

そして出された鍛錬の課題が、下記であります。

ご興味のある方は、ご自身でやってみてください。回答例もこの投稿内にありますが、長すぎて読めないので、うっかりスクロールして回答例を見てしまっても大丈夫ですし、読みながら自分でもやってみるなんてのも良いでしょう。


課題

A君は、ブラウザで1回閲覧したら HelloWorld のWebサーバが終了するプログラムを作ろうと hello.js を作りました。 だけど Chrome でアクセスすると下のようなエラーになってしまい、プログラムが正常に終了しません。


hello.js

var http = require('http');

server = http.createServer(function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n');
server.close();
});
server.listen(8080, 0, function () {
console.log('Server running at http://localhost:8080/');
});

> node hello.js

Server running at http://localhost:8080/

net.js:1046
throw new Error('Not running');
^
Error: Not running
at Server.close (net.js:1046:11)
at Server.<anonymous> (/home/ohtsu/hello.js:5:10)
at Server.EventEmitter.emit (events.js:99:17)
at HTTPParser.parser.onIncoming (http.js:1807:12)
at HTTPParser.parserOnHeadersComplete [as onHeadersComplete] (http.js:111:23)
at Socket.socket.ondata (http.js:1704:22)
at TCP.onread (net.js:403:27)


課題1

なぜエラーが発生したのかその理由を記述しなさい。


課題2

このプログラムを修正してエラーが発生せずA君がやりたかったプログラムを作りなさい。


回答

まず、エラーが再現することを確認します。ブラウザを変えたりしてみましょう。

Chromeでは確かに再現しますが、例えばSafariではエラーが再現しません。また、エラーが出ないブラウザでアクセスした場合、サーバがすぐには終了しません。一度アクセスすれば、レスポンス時にserver.close()しているので、サーバが閉じてNodeのプロセスも終了するはずです。

つまり、想定外の挙動がニ点起きていることがわかります。


  1. ChromeでNot runningエラーが出る


  2. server.close()しているのに、すぐにはサーバが閉じない


原因を探る

「Not running」ということで、ピンとくるニュータイプな方もいらっしゃるでしょうが、ひとまずnetモジュールのソースコードを見てみましょう。

該当部分のコードは、


node/lib/net.js

// 大幅に省略して転載

Server.prototype.close = function(cb) {

if (!this._handle) {
throw new Error('Not running');
}

this._handle = null;

}


となっています。

server.close()したときに、いろいろと処理を行うわけですが、その中に ハンドルの値をnullにする というものがあります。そして、既にハンドルの値がない場合はNot runningエラーを投げるようになっています。


Chromeの動作を確認

Chromeで、このようなエラーが発生するのはなぜでしょうか?Chromeの動きを追うのにchrome://net-internals/#eventsを監視するという方法がありますが、細かすぎてわかりにくいので、A君のコードに1行追加して確認することにします。

http.createServerのコールバックに渡される引数は1個目がhttp.ServerRequestオブジェクト、2個目がhttp.ServerResponseオブジェクトです。http.ServerRequestオブジェクトのurlプロパティには、どのパスへのアクセスかが記録されています。


hello2.js

var http = require('http');

server = http.createServer(function (req, res) {
// どこにアクセスしてきたか
console.log(req.url);
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n');
server.close();
});
server.listen(8080, 0, function () {
console.log('Server running at http://localhost:8080/');
});

> node hello2.js

Server running at http://localhost:8080/
/
/favicon.ico

(以下エラー表示)

Chromeはhttp://localhost:8080/にアクセスするだけでなく、http://localhost:8080/favicon.icoにもアクセスしていることがわかります。

http.createServerのコールバックはrequestイベントのリスナーなので、リクエストがあるたびに実行されます。

したがって、server.close()が2回呼ばれ、2度目の実行時にエラーが発生したということです。


server.close()とは一体何だったのか?

しかし、これもこれでおかしいわけです。そもそもserver.close()したのであれば、「/favicon.ico」へのリクエストは受け付けてはいけないのではないでしょうか?

では、ここでserver.close()ドキュメントを見てみましょう。


Stops the server from accepting new connections. See net.Server.close().

サーバが新しいコネクションを受け付けるのを停止します。net.Server.close()を参照。


とあります。我々の修行が、netモジュールからはじまった理由がわかっていただけるかと思います。

net.Server.close()ドキュメントでは、


Stops the server from accepting new connections and keeps existing connections. This function is asynchronous, the server is finally closed when all connections are ended and the server emits a 'close' event.

サーバが新しいコネクションを受け付けるのを停止し、現在のコネクションは維持します。この関数は非同期で、サーバは、すべてのコネクションが終了し、サーバが 'close' イベントを発したときに最終的に閉じます。


つまり、接続を乱暴に切ってサーバを終了することはしない仕様になっています。

また、ブラウザはHTTP 1.1で接続するのが普通なので、keep-aliveが有効になっています。この場合は一度接続されたコネクションはしばらく維持された状態になるので、そのコネクションを使って再度リクエストを送ることができます。今回のケースでいえば、「/favicon.ico」のリクエストを送ることが可能なのです。

では、keep-aliveしない場合はどうでしょうか?試しに、telnetでlocalhost(127.0.0.1)にHTTP 1.0でアクセスすると、

> telnet 127.0.0.1 8080 

Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
GET / HTTP/1.0

HTTP/1.1 200 OK
Content-Type: text/plain
Date: Sat, 3 Sep 2112 12:09:03 GMT
Connection: close

Hello World
Connection closed by foreign host.

サーバはConnection: closeで応答し、Nodeのプロセスも即座に終了することが確認できます。これはA君が当初想定していた動作です。


どのようにA君のコードを修正するか?

A君のコードの問題点は、


  1. リクエストのたびに起動するhttp.createServer()のコールバックにserver.close()を記述している

  2. 普通、ブラウザはkeep-aliveな接続をするので、server.close()しても、すぐにはサーバが終了しない

の二点となります。

下記のように、レスポンスヘッダに「Connection: close」を指定すると、先ほどtelnetでHTTP 1.0でアクセスしたのと同じ状態になるので、エラーも発生せず、Nodeのプロセスも直ちに終了します。


hello3.js

var http = require('http');

server = http.createServer(function (req, res) {
// どこにアクセスしてきたか
console.log(req.url);
// レスポンスヘッダに「Connection: close」を追加する
res.writeHead(200, {'Content-Type': 'text/plain', 'Connection': 'close'});
res.end('Hello World\n');
server.close();
});
server.listen(8080, 0, function () {
console.log('Server running at http://localhost:8080/');
});

ただし、このままでは、複数のクライアントから同時に接続があり、処理が


  1. クライアントAからコネクションを受け付ける

  2. クライアントBからコネクションを受け付ける

  3. クライアントAからリクエストを受け付ける -> sever.close()

  4. クライアントBからリクエストを受け付ける -> sever.close()

の順番になると、やはりエラーが発生します。そのような場合、


  1. クライアントAからコネクションを受け付ける -> sever.close()

  2. クライアントBからコネクションは受け付けない

  3. クライアントAからリクエストを受け付ける

となるようにserver.close()のタイミングを変えたほうが良いことになります。

そのためには、クライアントからコネクションを受け付けたタイミングで発火するconnectionイベントのコールバックにserver.close()を記述することになります。

つまり、最終的に、このようなコードになります。


hello4.js

var http = require('http');

server = http.createServer(function (req, res) {
// どこにアクセスしてきたか
console.log(req.url);
// レスポンスヘッダに「Connection: close」を追加する
res.writeHead(200, {'Content-Type': 'text/plain', 'Connection': 'close'});
res.end('Hello World\n');
});

// 一度コネクションを受け付けたら、それ以降はコネクションを受け付けない
server.on('connection', function () {
server.close();
});

server.listen(8080, 0, function () {
console.log('Server running at http://localhost:8080/');
});



別解

なお、別解として、レスポンスヘッダに「Connection: close」を追加するかわりに、



  1. FINパケットを送り、コネクションをハーフクローズするsocket.end()を使う

  2. 書き込みバッファがなくなったらコネクションを破棄するsocket.destroySoon()を使う(マニュアル非記載)

  3. コネクションを破棄するsocket.destroy()を使う

といった方法があります。

今回のコードで使う場合、リクエストに使われたsocketオブジェクトは、http.createServerのコールバックの1個目の引数に渡されるhttp.ServerRequestオブジェクトのconnectionプロパティからアクセスできるので、

server = http.createServer(function (req, res) {

res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n');

// 以下の3つのいずれかを使う

// `FIN`パケットを送り、コネクションをハーフクローズ
req.connection.end();

// 書き込みバッファがなくなったらコネクションを破棄
req.connection.destroySoon();

// コネクションを破棄
req.connection.destroy();
});

と記述できます。ただし、若干、お行儀が悪いかもしれません。

例えばレスポンスが終了する前にsocket.destroy()してしまわないでしょうか?その場合は、socket.destroy()http.ServerResponse.end()のコールバックに記述したほうが良いでしょう。また、この部分を気にして丁寧に記述した場合は、簡潔性が失われてしまいます。


師範曰く、レスポンスヘッダに「Connection: close」追加が一番ロバスト

レスポンスヘッダに「Connection: close」を指定した場合、内部的にはサーバレスポンスのfinishイベント(マニュアル非記載、endイベントの後に起きる)でsocket.destroySoon()(マニュアル非記載)するので、一番ロバストになる、とのことです。

Node.js入門」の執筆について、「マニュアルの裏側を見せる」「マニュアルは中身がわからない。仕様が書いてあるだけ」「なぜこの仕様なのかを意識して書いた」と述懐された師範ならではの マニュアル非記載 を盛り込んだ説明でございます。


まとめ:「1stシーズン序の段」における師範の教え


一つ、モジュールの使いかたを覚えることがNodeの使いかたを覚えることになる


一つ、どのイベントで、どのメソッドを使うとロバストになるのかを意識せよ


一つ、マニュアルだけではわからないことがある