読者です 読者をやめる 読者になる 読者になる

hakobera's blog

技術メモ。たまに雑談

Connect ソースコードリーディング(4) - bodyParser

bodyParser middleware

第4回目は Connect の組込み middleware である bodyParser を読んでいきたいと思います。
Node の http.ServerRequest は Java の HttpServletRequest などよりもかなりローレベルです。特に POST データはそのまま送信されてくるだけなので、プログラムで利用するためにはパースしてオブジェクト化する必要があります。そのパース処理をして、req.body プロパティに結果を格納してくれるのが、bodyParser です。Express でも bodyParser の利用を前提に設計されています。

connect/lib/middleware/bodyParser.js at 1.x · senchalabs/connect · GitHub

bodyParser が依存しているモジュール

bodyParser は以下の npm モジュールに依存しています。

Node には標準で querystring というモジュールがありますが、v0.3.x から以下のような nest 記法をサポートしなくなったので、connect では qs を利用している模様。

require('qs').parse('user[name][first]=tj&user[email]=tj');
// => { user: { name: { first: 'tj' }, email: 'tj' } }

formidable は multipart/form-madata (要するにファイルアップロード)の解析に利用されています。

bodyParser での処理の流れ

  1. req.body が既に設定済み(=パース済)であるかどうかチェック
    • req.body が既に設定済みの場合は何もせずに後続の処理へ
  2. req.method が GET or HEAD でないかチェック
    • GET or HEAD の場合は何もせずに後続の処理へ
  3. リクエストヘッダーの Content-Type から mime type を取得し、対応するパーサーを探す
    • 対応するパーサがみつからない場合は何もせずに後続の処理へ
  4. 対応したパーサで POST データを解析して、後続の処理へ

ソースとしては以下の部分に当たります。

bodyParser.js 69行目〜

exports = module.exports = function bodyParser(options){
  options = options || {};
  return function bodyParser(req, res, next) {
    if (req.body) return next();
    req.body = {};

    if ('GET' == req.method || 'HEAD' == req.method) return next();
    var parser = exports.parse[mime(req)];
    if (parser) {
      parser(req, options, next);
    } else {
      next();
    }
  }
};

なお、コメントには書いてありますが、connect.bodyParser.parser.parse[contentType] = customParserObject とやると、任意の contentType に対するカスタムパーサを bodyParser の処理フローに混ぜ込むことができます。

対応している mime type

bodyParser.parse オブジェクトにはデフォルトで以下の3つの mime type に対応するパーサが定義されています。

  • application/x-www-form-urlencoded
  • application/json
  • multipart/form-data

application/json に対応しているのが嬉しいですね。なお、mime タイプの取得には以下の mime メソッドが利用されています。

function mime(req) {
  var str = req.headers['content-type'] || '';
  return str.split(';')[0];
}

ここで 'content-type' が小文字になっているのは、Node 本体で ServerRequest を生成する時に、リクエストヘッダーは全て小文字に変換されているからです。この処理は、Node 本体の http.js 83行目、parser.onHeadersComplete メソッド内に実装されています。リクエストヘッダーを操作する何かを作る場合は注意しましょう。

Node.js lib/http.js

parser.onHeadersComplete = function(info) {
    var headers = info.headers;    
    var url = info.url;

    if (!headers) {
      headers = parser._headers;
      parser._headers = [];
    }
    
    // ... 省略

    for (var i = 0, n = headers.length; i < n; i += 2) {
      var k = headers[i];
      var v = headers[i + 1];
      parser.incoming._addHeaderLine(k.toLowerCase(), v); // <= ここ
    }

    // ... 省略
}

bodyParser.parse['application/x-www-form-urlencoded']

通常の HTML form に対応するパーサです。リクエストデータを全て読み込んだ後、qs モジュールでパースした結果のオブジェクトを req.body プロパティに設定しています。なお、特に問題になることはないと思いますが、UTF-8 以外のエンコーディングには対応していません。

exports.parse['application/x-www-form-urlencoded'] = function(req, options, fn){
  var buf = '';
  req.setEncoding('utf8');
  req.on('data', function(chunk){ buf += chunk });
  req.on('end', function(){
    try {
      req.body = qs.parse(buf);
      fn();
    } catch (err){
      fn(err);
    }
  });
};

bodyParser.parse['application/json']

application/x-www-form-urlencoded とほぼ同じですが、POST データのパースに JSON.parse メソッドを利用している点が異なります。

exports.parse['application/json'] = function(req, options, fn){
  var buf = '';
  req.setEncoding('utf8');
  req.on('data', function(chunk){ buf += chunk });
  req.on('end', function(){
    try {
      req.body = JSON.parse(buf);
      fn();
    } catch (err){
      fn(err);
    }
  });

};

bodyParser.parse['multipart/form-data']

ファイルアップロードに対応するパーサです。処理としては、まず formidable モジュールで HTML input と file のそれぞれ解析した結果を、qs モジュールでパースして、req.body プロパティに格納しています。これにより、HTML input が通常の application/x-www-form-urlencoded と同じ形式で取得できるようになっています。

exports.parse['multipart/form-data'] = function(req, options, fn){
  var form = new formidable.IncomingForm
    , data = {}
    , done;

  Object.keys(options).forEach(function(key){
    form[key] = options[key];
  });

  function ondata(name, val){
    if (Array.isArray(data[name])) {
      data[name].push(val);
    } else if (data[name]) {
      data[name] = [data[name], val];
    } else {
      data[name] = val;
    }
  }

  form.on('field', ondata);
  form.on('file', ondata);

  form.on('error', function(err){
    fn(err);
    done = true;
  });

  form.on('end', function(){
    if (done) return;
    try {
      req.body = qs.parse(data);
      fn();
    } catch (err) {
      fn(err);
    }
  });

  form.parse(req);
};

実際に以下の HTML で試してみると、

<!DOCTYPE html>                                                                         
<html>
<head>
  <meta charset="utf-8"/>
</head>
<body>
  <form action="/upload" method="POST" enctype="multipart/form-data">
    <input type="text" name="title" value=""/>
    <input type="file" name="file"/>
    <input type="submit"/>
  </form>
</body>
</html>

テストコード

var connect = require('connect'),
    util = require('util');

var bodyDump = function(req, res, next) {
  console.log(util.inspect(req.body, true, 2));
  next();
};

var server = connect.createServer(
  connect.bodyParser(),
  bodyDump,
  connect.static(__dirname + '/public')
);

server.listen(3000);

title="test", file="calc.js" を指定した場合の結果は以下のようになります。

{ title: 'test',
  file: 
   { size: 389,
     filename: [Getter],
     _writeStream: 
      { bytesWritten: 389,
        busy: false,
        encoding: 'binary',
        fd: 8,
        flags: 'w',
        mode: 438,
        path: '/tmp/7ed91b5c2bcbe9da0cc213d50e8c52e1',
        drainable: true,
        writable: false,
        _queue: [Object] },
     lastModifiedDate: Sun, 27 Nov 2011 07:50:59 GMT,
     length: [Getter],
     mime: [Getter],
     name: 'calc.js',
     path: '/tmp/7ed91b5c2bcbe9da0cc213d50e8c52e1',
     type: 'text/javascript' } }

次回予告

Session middleware の解説か、3rd Parser middleware の紹介の予定。