🤖

Node.jsのconnectをコードリーディングしてみる

2022/11/06に公開

connectは、Node.jsのHTTPサーバーにミドルウェアの機能を追加するためのライブラリです。

https://github.com/senchalabs/connect

ここでのミドルウェアは、リクエストの処理の前や後に共通処理を実行するもののことです。ミドルウェアで実行する処理には、セッションの有効化やログ出力などがあります。

connectはexpressの3系まで使われていましたが、4系になったときにexpressから削除されたようです。今回はconnectの3.7.0を読みました。senchalabs/connect at 3.7.0

まずはconnectを使ってみる

リクエストが来たらHello, worldを返すHTTPサーバーを作成しました。ログを出力するミドルウェアを2つと、レスポンスに書き込むミドルウェアの合計3つを追加しています。

playground/index.js
const connect = require("connect")

const app = connect();
app.use(function(req, res, next) {
  console.log('middleware 1');
  next();
});

app.use(function(req, res, next) {
  console.log('middleware 2');
  next();
});

app.use(function(req, res) {
  res.end("Hello, world!")
})

app.listen(3000);
$ node playground/index.js
# 別の端末でcurlを実行すると以下が表示される
middleware 1
middleware 2

$ curl localhost:3000
Hello, world!

読んでみる

エントリーポイント

connectはindex.jsのみの単一ファイルのライブラリです。requireで読み込まれるのは、index.jsでエクスポートしているcreateServerです。

createServerでは、appという関数を定義した後、appにいくつかのプロパティを追加して返却しています。

// 関係なさそうなところは省略
module.exports = createServer;

var finalhandler = require('finalhandler');
var http = require('http');
var merge = require('utils-merge');
var proto = {};

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;
}

app.handlehandleは、後ろのコードでprotoに追加されていて、それがmerge(app, proto)appに追加されています。「appの中でappを参照できるのか?」と思いましたが、再帰関数を書くときもそうすることを思い出して納得しました。

app.use(fn)

app.useは、渡したミドルウェアをstackという配列に追加します。

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

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

  // 省略

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

  return this;
};

useでは第一引数にパスを指定できますが、あまり使わなさそうな機能だったので今回は読み飛ばしました。

app.listen([...])

httpモジュールを使ってHTTPサーバーを作成して、listenで接続待ちしています。

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

http.createServerには、requestイベントに紐付けられるリスナーを渡します。thisは呼び出された関数が属するオブジェクト(実行コンテキスト)を表すので、この場合はapp関数のことです。

次の行は見慣れない処理です。ここは、app.listenに渡した引数を、そのままserver.listenに渡しています。server.listen(arguments)と書くのとほぼ同じですが、可変長引数で渡すためにFunction.prototype.applyが使われています。

app関数の中ではapp.handleが実行されているので、次はapp.handleを読みます。

app.handle

ここがconnectのメインの処理です。handleでは、ミドルウェアを順番に実行します。finalhandlerは、エラーの内容に応じたエラーレスポンスを返すためのライブラリです。

// パスに関するところは省略
proto.handle = function handle(req, res, out) {
  var index = 0;
  var stack = this.stack;

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

  function next(err) {
    // 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;

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

  next();
};

handleの中ではnextという、ミドルウェアの呼び出してポインタを1つ進める関数を定義しています。nextcallに渡され、さらにcallからミドルウェアに渡されます。ミドルウェアの中でnextを実行するので、ちょっと分かりにくいですがこれは再帰関数になっています。

call

callはミドルウェアの実行を担当する関数です。Function.prototype.lengthで実引数の個数を取得し、4個かそれ未満かどうかで分岐しています。引数が4個のミドルウェアは、エラーミドルウェアという、エラーハンドリングを担当する特別なミドルウェアです。

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);
}

ミドルウェアで例外が発生しない場合は、app.handle()next()→1つ目のミドルウェアが実行→1つ目の中でnext()→2つ目のミドルウェアが実行→2つ目の中でnext()→…というように、通常のミドルウェアがすべて実行されます。この場合は、hasErrorfalseのためエラーミドルウェアは実行されません。

□が普通のミドルウェアで、■がエラーミドルウェアです。

ミドルウェアの実行中に例外が発生した場合は、next(error)が実行され、一番近くにあるエラーミドルウェアが実行されます。ミドルウェアの実行が続くかどうかは、エラーミドルウェアの中でnext()を実行するかによります。

エラーミドルウェアでは、後続のエラーミドルウェアに引き継ぐためにnext(error)を実行するか、nextを実行しないかのどちらかになると思います。

まとめ

  • connectは、HTTPサーバーにミドルウェアの機能を提供するためのライブラリ
  • app.useで渡したミドルウェアは、順番に配列の末尾に追加される
  • ミドルウェアの引数のnextは、自身以降のミドルウェアを再帰的に呼び出す関数
    • つまり、next()以降に書いた処理は、自身以降のミドルウェアの実行が完了してから実行される
  • 引数が4個のミドルウェアは、ミドルウェアの実行中に例外が発生したときだけ呼び出される特別なミドルウェア

おまけ

nextをミドルウェアの引数に渡すところが理解しづらかったので、その部分だけ自分で書いてみました。

https://github.com/tekihei2317/playground/tree/main/middleware-pattern

GitHubで編集を提案

Discussion