Connect

この文章はConnect 3.4.0を元に書かれています。

ConnectはNode.jsで動くHTTPサーバーフレームワークです。 middleware という拡張する仕組みを持ち、Connectがもつ機能自体はとても少ないです。

この章ではConnectの middleware の仕組みについて見て行きましょう。

どう書ける?

Connectを使い簡単なEchoサーバを書いてみましょう。 Echoサーバとは、送られてきたリクエストの内容をそのままレスポンスとして返すサーバのことです。

import connect from "connect";
import http from "http";
import fetch from "node-fetch";
import assert from "assert";
const app = connect();
// add Error handling
app.use(function (err, req, res, next) {
    console.error(err.stack);
    res.status(500).send(err.message);
    next();
});
// request to response
app.use(function (req, res) {
    req.pipe(res);
});
//create node.js http server and listen on port
const server = http.createServer(app).listen(3000, request);

// request => response
function request() {
    const closeServer = server.close.bind(server);
    const requestBody = {
        "key": "value"
    };
    fetch("http://localhost:3000", {
        method: "POST",
        body: JSON.stringify(requestBody)
    })
        .then(res => res.text())
        .then(text => {
            assert.deepEqual(text, requestBody);
        }).then(closeServer, closeServer);
}

このEchoサーバに対して、次のようなリクエストBodyを送信すると、レスポンスとして同じ値が返ってきます。

{
    "key": "value"
}

app.use(middleware) という形で、 middleware と呼ばれる関数にはrequestresponseといったオブジェクトが渡されます。このrequestresponsemiddleware で処理することで、ログを取ったり、任意のレスポンスを返すことができます。

Echoサーバでは req.pipe(res); という形でリクエストをそのままレスポンスとして流すことで実現されています。

middlewareをモジュールとして実装

もう少し middleware をプラグインらしくモジュールとして実装したものを見てみます。

次のconnect-example.jsは、あらゆるリクエストに対して、 "response text"というレスポンスを"X-Content-Type-Options"ヘッダを付けて返すだけのサーバです。

それぞれの処理を middleware としてファイルを分けて実装し、app.use(middleware)で処理を追加しています。

function setHeaders(res, headers) {
    Object.keys(headers).forEach(key => {
        const value = headers[key];
        if (value !== null) {
            res.setHeader(key, value);
        }
    });
}
export default function () {
    return function nosniff(req, res, next) {
        setHeaders(res, {
            "X-Content-Type-Options": "nosniff"
        });
        next();
    };
}
export default function (text) {
    return function hello(req, res) {
        res.end(text);
    };
}
export default function () {
    return function errorHandling(err, req, res, next) {
        res.writeHead(404);
        res.write(err.message);
        res.end();
        next();
    };
}
import errorHandler from "./errorHandler";
import hello from "./hello";
import nosniff from "./nosniff";
import assert from "assert";
import connect from "connect";
import http from "http";
import fetch from "node-fetch";

const responseText = "response text";
const app = connect();
// add Error handling
app.use(errorHandler());
// add "X-Content-Type-Options" to response
app.use(nosniff());
// respond to all requests
app.use(hello(responseText));

//create node.js http server and listen on port
const server = http.createServer(app).listen(3000, request);

function request() {
    const closeServer = server.close.bind(server);
    fetch("http://localhost:3000")
        .then(res => res.text())
        .then(text => {
            assert.equal(text, responseText);
            server.close();
        })
        .catch(console.error.bind(console))
        .then(closeServer, closeServer);
}

基本的にどの middlewareapp.use(middleware)という形で拡張でき、モジュールとして実装すれば再利用もしやすい形となっています。

middleware となる関数の引数が4つであると、それはエラーハンドリングの middleware とするという、Connect独自のルールがあります。

どのような仕組み?

Connectの middleware がどのような仕組みで動いているのかを見ていきます。

appに登録した middleware は、リクエスト時に呼び出されています。そのため、appのどこかに利用する middleware を保持していることは推測できると思います。

Connectではapp.stackmiddleware を配列として保持しています。次のようにしてapp.stackの中身を表示してみると、 middleware が登録順で保持されていることがわかります。

import errorHandler from "./errorHandler";
import hello from "./hello";
import nosniff from "./nosniff";
import connect from "connect";

const responseText = "response text";
const app = connect();
// add Error handling
app.use(errorHandler());
// add "X-Content-Type-Options" to response
app.use(nosniff());
// respond to all requests
app.use(hello(responseText));

// print middleware list
app.stack.map(({handle}) => console.log(handle));
/* =>
 [Function: errorHandling]
 [Function: nosniff]
 [Function: hello]
*/

Connectは登録された middleware を、サーバがリクエストを受け取りそれぞれ順番に呼び出しています。

上記の例だと次の順番で middleware が呼び出されることになります。

  • nosniff
  • hello
  • errorHandler

エラーハンドリングの middleware は処理中にエラーが起きた時のみ呼ばれます。

そのため、通常は nosniff.jshello.js の順で呼び出されます。

function setHeaders(res, headers) {
    Object.keys(headers).forEach(key => {
        const value = headers[key];
        if (value !== null) {
            res.setHeader(key, value);
        }
    });
}
export default function () {
    return function nosniff(req, res, next) {
        setHeaders(res, {
            "X-Content-Type-Options": "nosniff"
        });
        next();
    };
}

nosniff.jsは、HTTPヘッダを設定し終わったらnext()を呼び出し、このnext()が次の middleware へ行くという意味になります。

次に、hello.jsを見てみると、next()がありません。

export default function (text) {
    return function hello(req, res) {
        res.end(text);
    };
}

next()がないということはhello.jsがこの連続する middleware の最後となっていることがわかります。仮に、これより先に middleware が登録されていたとしても無視されます。

つまり、処理的には次のようにstackを先頭から一個づつ取り出し、処理していくという方法が取られています。

Connectの行っている処理を抽象的なコードで書くと次のような形になっています。

const req = "...",
    res = "...";
function next(){
    const middleware = app.stack.shift();
    // nextが呼ばれれば次のmiddleware
    middleware(req, res, next);
}
next();// 初回

このような middleware を繋げたものをmiddleware stackと呼ぶことがあります。

middleware stack で構成されるHTTPサーバとして、PythonのWSGI middlewareやRubyのRackなどがあります。 ConnectはRackと同じくusemiddleware を指定することからも分かりますが、 Rackを参考にした実装となっています。

次は、先ほど抽象的なコードとなっていたものを具体的な実装にしながら見ていきます。

実装してみよう

Connectライクな middleware をサポートしたJunctionというクラスを作成してみます。

Junctionは、use(middleware)process(value, (error, result) => { });を持っているシンプルなクラスです。

function isErrorHandingMiddleware(middleware) {
    // middleware(error, text, next)
    const arity = middleware.length;
    return arity === 3;
}
function applyMiddleware(error, response, middleware, next) {
    let errorOnMiddleware = null;
    try {
        if (error && isErrorHandingMiddleware(middleware)) {
            middleware(error, response, next);
        } else {
            middleware(response, next);
        }
        return;
    } catch (error) {
        errorOnMiddleware = error;
    }
    // skip the middleware or Error on the middleware
    next(errorOnMiddleware, response);
}

export default class Junction {
    constructor() {
        this.stack = [];
    }

    use(middleware) {
        this.stack.push(middleware);
    }

    process(initialValue, callback) {
        const response = {value: initialValue};
        const next = (error) => {
            const middleware = this.stack.shift();
            if (!middleware) {
                return callback(error, response);
            }
            applyMiddleware(error, response, middleware, next);
        };
        next();
    }
}

実装を見てみると、usemiddleware を登録して、processで登録した middleware を順番に実行していきます。そのため、Junction自体は渡されたデータの処理をせずに、 middleware の中継のみをしています。

登録する middleware はConnectと同じもので、処理をしたらnextを呼んで、次の middleware が処理するというのを繰り返しています。

使い方はConnectと引数の違いはありますが、ほぼ同じような形で利用できます。

import Junction from "./junction";
import assert from "assert";
const junction = new Junction();
junction.use(function toUpperCase(res, next) {
    res.value = res.value.toUpperCase();
    next();
});
junction.use(function exclamationMark(res, next) {
    res.value = res.value + "!";
    next();
});
junction.use(function errorHandling(error, res, next) {
    console.error(error.stack);
    next();
});

const text = "hello world";
junction.process(text, function (error, result) {
    if (error) {
        console.error(error);
    }
    const value = result.value;
    assert.equal(value, "HELLO WORLD!");
});

どのような用途に向いている?

ConnectやJunctionの実装を見てみると分かりますが、このアーキテクチャでは機能の詳細を middleware で実装できます。そのため、本体の実装は middleware に提供するインタフェースの決定、エラーハンドリングの手段を提供するだけでとても小さいものとなっています。

今回は紹介していませんが、Connectにはルーティングに関する機能があります。しかし、この機能も「与えられたパスにマッチした場合のみに反応する middleware を登録する」という単純なものです。

app.use("/foo", function fooMiddleware(req, res, next) {
    // req.url starts with "/foo"
    next();
});

このアーキテクチャは、入力と出力がある場合にコアとなる部分は小さく実装できることが分かります。

そのため、ConnectやRackなどのHTTPサーバでは「リクエストに対してレスポンスを返す」というのが決まっているので、このアーキテクチャは適しています。

どのような用途に向いていない?

このアーキテクチャでは機能の詳細が middleware で実装できます。しかし、多くの機能を middleware で実装していくと、 middleware 間に依存関係を作ってしまうことがあります。

この場合、use(middleware) で登録する順番により挙動が変わるため、利用者が middleware 間の依存関係を解決する必要があります。

そのため、プラグイン同士の強い独立性や明確な依存関係を扱いたい場合には不向きといえるでしょう。

これらを解消するためにコアはそのままにして、最初から幾つかのmiddleware stackを作ったものが提供されるケースもあります。

エコシステム

Connect自体の機能は少ないですが、その分 middleware の種類が多くあります。

また、それぞれの middleware が小さな単機能となっていて、それを組み合わせて使うように作られているケースが多いです。

これは、 middleware が層として重なっている作り、つまり middleware stack の形を取ることが多いためだといえます。

pylons_as_onion

ミドルウェアでラップするプロセスは、概念的にたまねぎの中の層と同様の構造をもたらします。 WSGI ミドルウェアより引用

この仕組みを使っているもの

  • Express
    • Connectと middleware の互換性がある
    • 元々はConnectを利用していたが4.0.0で自前の実装に変更
  • wooorm/retext
    • useでプラグイン登録していくテキスト処理ライブラリ
  • r7kamura/stackable-fetcher
    • useでプラグイン登録して処理を追加できるHTTPクライアントライブラリ

まとめ

ここではConnectのプラグインアーキテクチャについて学びました。

  • Connectは middleware を使ったHTTPサーバーライブラリである
  • Connect自体の機能は少ない
  • 複数の middleware を組み合わせることでHTTPサーバを作ることができる

参考資料

results matching ""

    No results matching ""