Node.js

Connect ソースコードリーディング

Connectとは

Connect is a middleware layer for Node.js
https://github.com/senchalabs/connect

Connectは,Node.jsのWebアプリケーションにおいて,ミドルウェアレイヤーを提供するライブラリです.
1つのJavaScriptファイルのみで構成されたシンプルなライブラリで,MITライセンスで公開されています.
今回は,その実装を読んでいきます.

https://github.com/senchalabs/connect/blob/master/index.js

Connectの使い方

基本的な使い方は以下のようになります.

const connect = require('connect');
const app = connect();
app.use((req, res, next) => {
  console.log('common middleware');
  next();
});
app.use('/greet', (req, res, next) => res.end('Hello!'));
app.listen(3000);

http://localhost:3000/greet にアクセスすると "Hello!" と表示されるだけの実装です.
このコードの裏で,Connectが何を行っているのか確認していきます.

Connectソースコードリーディング

アプリケーションオブジェクトの生成

まずはこの行です.

const app = connect();

これにより,アプリケーションオブジェクトが生成されます.
実際のコードは以下のようになっています.

function createServer() {
  function app(req, res, next){ app.handle(req, res, next); }
  merge(app, proto);
  merge(app, EventEmitter.prototype);
  app.route = '/';
  app.stack = [];
  return app;
}

protoオブジェクトは,ミドルウェアの登録やサーバの起動等の実装を含んでいます.

つまり,以下のようなことが把握できます.

  • サーバの起動やミドルウェアの登録はアプリケーションオブジェクトを通して行う
  • EventEmitterを継承しているため,任意のイベントを発火することができる

ミドルウェアの登録

次は,ミドルウェアの登録処理について見ていきます.
サンプルコードでは,以下の行で行っていました.

app.use((req, res, next) => {
  console.log('common middleware');
  next();
});

app.use('/greet', (req, res, next) => res.end('Hello!'));

先程,アプリケーションオブジェクトを読んだ時に,protoオブジェクトを通してミドルウェアの登録を行う,ということを把握していました.
つまり,app.useは実際にはproto.useで定義されています.
実際のコードは以下のようになっています.

proto.use = function use(route, fn) {
  var handle = fn;
  var path = route;

  // default route to '/'
  if (typeof route !== 'string') {
    handle = route;
    path = '/';
  }

  // wrap sub-apps
  if (typeof handle.handle === 'function') {
    var server = handle;
    server.route = path;
    handle = function (req, res, next) {
      server.handle(req, res, next);
    };
  }

  // wrap vanilla http.Servers
  if (handle instanceof http.Server) {
    handle = handle.listeners('request')[0];
  }

  // strip trailing slash
  if (path[path.length - 1] === '/') {
    path = path.slice(0, -1);
  }

  // add the middleware
  debug('use %s %s', path || '/', handle.name || 'anonymous');
  this.stack.push({ route: path, handle: handle });

  return this;
};

proto.useroutefn を引数に取る関数です.
routeにはパスを指定しておきます.リクエストされたパスとミドルウェアのパスが一致すれば,fnに渡された関数が実行されるようになっています.
fnreqresnextを引数に取る関数,アプリケーションオブジェクトまたはhttp.Serverオブジェクトです.

なお,引数が一つしか渡されなかった場合,route/に設定され,fnに渡された関数が設定されます.
この場合,ミドルウェアは常時実行されるようになります.

fnにアプリケーションオブジェクトが渡された場合は,そのアプリケーションオブジェクトの処理を実行するようになっています.

つまり,以下のようなコードを書くと,/sub/greetにリクエストがあった際はsubAppに処理が渡り,
'Hello, sub-app'がレスポンスとして返されます.

const rootApp = connect();
const subApp = connect();

rootApp.use('/sub', subApp);
subApp.use('/greet', (req, res, next) => res.end('Hello, sub-app'));

http.Serverオブジェクトが渡された場合,requestイベントのリスナーのうち,最初のものが呼ばれます.
なお,http.createServerに渡した関数は,内部的にrequestイベントのリスナーとして登録されています.

最後に,末尾のスラッシュを除去し,ミドルウェアスタックに登録されます.
実際にミドルウェアスタックに登録されるオブジェクトは,
{route: '/', 'handle': fn} のようになります.

リクエストのハンドル

さて,最後の行です.
js
app.listen(3000);

実装は以下のとおりです.

proto.listen = function listen() {
  var server = http.createServer(this);
  return server.listen.apply(server, arguments);
};

アプリケーションオブジェクトはcreateServerに渡されるので,最終的にrequestイベントのリスナーとして設定されます.
connect()で返されるアプリケーションオブジェクトには以下のように関数が代入されています.

function app(req, res, next){ app.handle(req, res, next); }

アプリケーションオブジェクトの生成で見たとおりです.

リクエストがあった際に,この関数が呼ばれ,app.handleが実行されます.
このとき,ミドルウェアスタックに登録されたミドルウェアが探索され,リクエストにマッチするミドルウェアが実行されることになります.

では,app.handleの実装を追っていきます.
実装は以下のようになっています.

proto.handle = function handle(req, res, out) {
  var index = 0;
  var protohost = getProtohost(req.url) || '';
  var removed = '';
  var slashAdded = false;
  var stack = this.stack;

  // final function handler
  var done = out || finalhandler(req, res, {
    env: env,
    onerror: logerror
  });

  // store the original URL
  req.originalUrl = req.originalUrl || req.url;

  function next(err) {
    if (slashAdded) {
      req.url = req.url.substr(1);
      slashAdded = false;
    }

    if (removed.length !== 0) {
      req.url = protohost + removed + req.url.substr(protohost.length);
      removed = '';
    }

    // next callback
    var layer = stack[index++];

    // all done
    if (!layer) {
      defer(done, err);
      return;
    }

    // route data
    var path = parseUrl(req).pathname || '/';
    var route = layer.route;

    // skip this layer if the route doesn't match
    if (path.toLowerCase().substr(0, route.length) !== route.toLowerCase()) {
      return next(err);
    }

    // skip if route match does not border "/", ".", or end
    var c = path.length > route.length && path[route.length];
    if (c && c !== '/' && c !== '.') {
      return next(err);
    }

    // trim off the part of the url that matches the route
    if (route.length !== 0 && route !== '/') {
      removed = route;
      req.url = protohost + req.url.substr(protohost.length + removed.length);

      // ensure leading slash
      if (!protohost && req.url[0] !== '/') {
        req.url = '/' + req.url;
        slashAdded = true;
      }
    }

    // call the layer handle
    call(layer.handle, route, err, req, res, next);
  }

  next();
};

いくつかの変数初期化処理と,next関数の定義を読み飛ばすと,最後にnext関数を引数なしで実行していることがわかります.
つまり,リクエストを受信すると,next関数が実行されることになります.

next関数では,まずいくつかのチェックを行い(現段階では読み飛ばします),layerにミドルウェアを代入します.
indexは最初0で初期化されていることから,登録された順にミドルウェアが読み出されることがわかります.

次に,ミドルウェアがリクエストされたパスにマッチするかどうかをチェックしています.

if (path.toLowerCase().substr(0, route.length) !== route.toLowerCase()) 

pathはリクエストされたパス,routeはミドルウェアのパスなので,この条件分岐は「リクエストされたパスが,ミドルウェアのパスを含んでいるかどうか」と読むことができます.

いくつか例を挙げます.

ミドルウェア リクエスト マッチ?
/app /app
/app /app/path

しかし,以下のような場合もあります.

ミドルウェア リクエスト
/app/path /app/path.json
/app /apple

/app/appleは,一般的にはマッチしないのが望ましいでしょう.
この処理を行っているのが次の分岐です.

var c = path.length > route.length && path[route.length];
if (c && c !== '/' && c !== '.') {
  return next(err);
}

つまり,最後の文字が.でも/でもなければ,マッチしない判定となります.
よって,先程の例は以下のようになります.

ミドルウェア リクエスト マッチ?
/app/path /app/path.json
/app /apple

これでミドルウェアがマッチしているかどうかの判定が完了しました.
次に,リクエストされたURLから,ミドルウェアのパスを取り除きます.
こうして取り除かれたパスがreq.urlに渡されます.
ここでもいくつか例を挙げておきます.

ミドルウェア リクエスト req.url
/app/path /app/path.json /.json
/app /app/path /path

なお,req.urlにセットされるURLは必ず / から始まります.

if (route.length !== 0 && route !== '/') {
  removed = route;
  req.url = protohost + req.url.substr(protohost.length + removed.length);

  // ensure leading slash
  if (!protohost && req.url[0] !== '/') {
    req.url = '/' + req.url;
    slashAdded = true;
  }
}

ミドルウェアの実行

パスの整形が完了したら,実際にミドルウェアが呼び出されます.

call(layer.handle, route, err, req, res, next);

call関数の実装は以下のようになっています.
引数には,
* ミドルウェア関数
* ミドルウェアのパス
* エラーオブジェクト
* リクエストオブジェクト
* レスポンスオブジェクト
* next 関数

function call(handle, route, err, req, res, next) {
  var arity = handle.length;
  var error = err;
  var hasError = Boolean(err);

  debug('%s %s : %s', handle.name || '<anonymous>', route, req.originalUrl);

  try {
    if (hasError && arity === 4) {
      // error-handling middleware
      handle(err, req, res, next);
      return;
    } else if (!hasError && arity < 4) {
      // request-handling middleware
      handle(req, res, next);
      return;
    }
  } catch (e) {
    // replace the error
    error = e;
  }

  // continue
  next(error);
}

arityは,ミドルウェア関数の引数の数です.
errがtruthlyで,ミドルウェア関数が引数を4つ取る場合,またはエラーがなく,ミドルウェア関数が4つ未満の引数を持つ場合はそのまま呼び出します.

ミドルウェア関数が引数を4つ持つ場合について,ミドルウェア関数は,req, res, nextに加え,errを引数に取ることで,エラー処理用のミドルウェアを定義することが出来ます.

function (err, req, res, next) ...

なお,ミドルウェア実行時に同期的に例外が発生した場合は,その例外をキャッチし,next関数に渡します.

つまり,エラーが発生したミドルウェア以降に,エラー処理用のミドルウェアが登録されていれば,そのミドルウェアが実行されることになります.

まとめ

Connectでは,このようにミドルウェアスタックを探索していくことで,リクエストをハンドルする仕組みになっていました.
express.jsなどのもう少し高レベルなWebフレームワークでは,ルートパラメータや正規表現等,もう少し高度なルーティング機能を提供しており,実際にはそちらの方を利用することになると思います.