twitter buttonfacebook buttongoogle plus buttonhatena bookmark buttonpocket button
ちょっと小さいのはたしかですが。
2017年4月22日

『先にヘッダを送信してから、そのあとでレスポンスボディを送信しなくてはならない』ことをコンパイル時に検証する

JavaScriptNodeNode.jspurescripthyper


hyper@2x.png

動機

Nodeで簡単なサーバサイドアプリケーションを作ることを考えてみます。

const http = require('http');

const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.write('Hello World\n');
  res.end();
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

このコードが何をやっているかは明白ですし、Nodeを知らない人でもなんとなく見当がつくでしょう。さて、HTTPのレスポンスというのは、最初にステータス行、それからヘッダが続いて、最後にレスポンスボディの順で送信するプロトコルで、例えば次のような内容になっています。

HTTP/1.0 200 OK
Content-Type: text/plain

Hello World

そういうプロトコルなので、レスポンスボディを送信したあとにヘッダを送信することはできません。たとえば、コードを次のように変更してみます。

  res.write('Hello World\n');
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end();

変更後のコードでは、writeでレスポンスボディを書いたあとに、setHeaderでヘッダを書き込んでいます。これを実行すると、実行時例外でサーバはお亡くなりになります。

http_outgoing.js:371
    throw new Error('Can\'t set headers after they are sent.');
    ^

Error: Can't set headers after they are sent.
    at ServerResponse.setHeader (_http_outgoing.js:371:11)
    at Server.http.createServer (C:\dev\nodehttptest\test.js:9:7)
    at emitTwo (events.js:106:13)
    at Server.emit (events.js:194:7)
    at parserOnIncoming (_http_server.js:563:12)
    at HTTPParser.parserOnHeadersComplete (_http_common.js:99:23)

なので、setHeaderwriteよりも手前に書くように気をつけましょう。




……何が「気をつけましょう」だよ!アホか!「気をつけましょう」なんていう心がけで本当にミスを防げたら誰も苦労しねーよ!『自動車のアクセルとブレーキを踏み間違えないようにしましょう』とか呼びかければ不幸な事故が減るとでも思ってるのか!?それに、ミスは不注意な人とか知識に乏しい人だけがするものではありません。疲労や寝不足で頭がおかしくなって訳のわからないコードを書くことは誰にでもあります。頻繁な変更が入り複雑怪奇に成り果てたコードからは、単純なミスでもそう簡単に見つけられるものではありません。ミスに対する有効な対策とは、「気をつけましょう」とかそういう心がけじゃなくて、機械がミスを発見して自動的に報告する機構を用意することでしょう。

そこで、Hyperというウェブサーバサイドフレームワークの手法を簡単に紹介します。Hyperは、『ヘッダを書き終えてからレスポンスボディを書く』という条件が満たされているかを、コンパイル時に検証してくれます。

Hyperによる解決

HyperによるHelloWorldは、例えば次のようになります。PureScriptのコードですし、コードの詳細は気にしなくていいです。フィーリングで読んでください

main = runServer defaultOptionsWithLogging {} do 
    writeStatus statusOK
    contentType textPlain
    closeHeaders
    toResponse "Hello, Hyper!" :>>= send
    end

writeStatus statusOKはNodeのres.statusCode = 200に相当する操作ですし、contentType textPlainres.setHeader('Content-Type', 'text/plain')に対応してます。それで、このコードはもちろん正常にコンパイルすることができますが、このコードを次のように書き換えてみます。

main = runServer defaultOptionsWithLogging {} do 
    toResponse "Hello, Hyper!" :>>= send
    writeStatus statusOK
    contentType textPlain
    closeHeaders
    end

つまり、sendでレスポンスボディを送ってから、writeStatusでステータスを書き込んでしまうというミスをしているわけです。これは明らかに間違いですが、これをコンパイルしようとすると、コンパイラは次のようなエラーメッセージを吐きます。この種のミスをコンパイラが発見して、事故を未然に防いでくれているのです。

Error found:
in module Main
at src\Main.purs line 15, column 13 - line 19, column 16

  Could not match type

    StatusLineOpen

  with type

    BodyOpen


while trying to match type t6 StatusLineOpen
  with type t2 BodyOpen
while checking that expression writeStatus statusOK
  has type Middleware t0
             { request :: t1
             , response :: t2 BodyOpen
             , components :: t3
             }
             t4
             t5
in value declaration main

エラーメッセージの言っていることは一見意味不明ですが、よくよく見てみるとこれはただの型エラーだったりします。コンパイラが何か特別な静的解析を働かせているとか、そういうことではありません。Could not match type StatusLineOpen with type BodyOpenとあるように、コンパイラが言っているのは、関数に与えた引数の型StatusLineOpenが、関数が求めている型BodyOpenに合わない、というだけの話です。原理的にはそれだけなのですが、この型エラーの詳細について理解するにはIndexed Monadという仕組みを学ばなくてはならず、話が長くなるので止めときます。またモナドかよ!

今回書いたコードはgistに載せておきました。

  • https://gist.github.com/aratama/1b3b9a2c1672f4ecfdf4183820cef28e

さいごに

「こんな瑣末な落とし穴、書くときに気をつければ簡単に回避できるだろ」と思ったひともいるでしょう。このヘッダの順序の問題と、Indexed Monadなどという抽象的な機能を学ぶコストを天秤にかけて、「学習コストに見合わない」というふうに考える人もいるような気がします。

たしかにこの落とし穴だけについて考えれば、そうだと思います。でも問題は、現実のコードにはこういう些細な落とし穴が山ほど隠れているということです。PureScriptのような言語を使っている人たちは、個々の落とし穴が瑣末であるからといって、その落とし穴を軽視して放置したりはしません。なぜなら、そうやって見逃されてきた単純な落とし穴も、いずれ寄り集まって複雑さを増し、やがて厄介なトラブルを山ほど生み出すとわかっているからです。些細な落とし穴だからこそ、PureScriptのような言語はそれを見逃さずに根本的な対処を与えて、そういう下らない問題で開発者の頭のなかがいっぱいにならないようにしてくれます。コンパイラがそういう下らないミスを開発者の代わりに検出してくれるからこそ、開発者は本来の問題に集中できるのです。

プログラミング上のありとあらゆる落とし穴を可能な限りコンパイル時に検出するのが、PureScriptのような言語やHyperのようなフレームワークが目指すところです。PureScriptやHyperのような道具は詳細を追い始めるとやはり複雑なところはあるのですが、それは今まで個々の開発者が神経をすり減らして『気をつけて』いたことを、Hyperのようなフレームワークが代わりに『気をつけて』くれるようになった結果です。決して闇雲に複雑にしているわけではありません。ほかにも、「どうせ実行時エラーが出るのだから、コンパイルエラーにする必要はないのでは」と思う人もいるでしょうが……そういうことを説明し出すと、また話が長(略

今回は敢えて技術的詳細の説明を控えました。どうしても詳細を知りたければ、Hyperやpurescript-indexed-monadのコードを眺めてみるといいかもしれません。ただし、今回紹介したHyperの手法を他の『普通の』プログラミング言語で実現することはまず不可能です。関数型プログラミングのテクニックを覚えて一般的なプログラミング言語で役立てるというのは、関数型プログラミングの初歩の初歩を齧っただけの人(しかも初歩しか齧っていないのに自分は関数型プログラミング全体を把握したと思い込んでいる人)が抱きがちな幻想です。HaskellやPureScript、Scalaのような本格的な関数型プログラミング言語へ移行して安全な世界で気楽に暮らすか、JavaやJavaScriptのような『普通の』プログラミング言語のまま落とし穴と隣り合わせに生きるか、どちらかしかありません。

Hyperはまだあまりに若いフレームワークですが、その方向性はPureScriptが目指しているところとぴったり一致していますし、今も活発に開発が続いていて有望なフレームワークだと思います。PureScriptの本格的なウェブサーバサイドフレームワークは、まだ片手で数えるほどしかありません。もっとPureScriptのユーザが増えて、こういう安全なフレームワークがたくさん育ってほしいと、筆者は願うところであります。

参考文献


^ 本当はもっと短く書くこともできますが、先ほどのJavaScriptでの例と対応させる感じにあえて冗長に書いています。また、インポートリストとか型注釈が長すぎるので、本質的なコードだけを抜粋してます。