Connect
この文章はConnect 3.4.0を元に書かれています。
ConnectはNode.jsで動くHTTPサーバーフレームワークです。middleware という拡張する仕組みを持ち、Connectがもつ機能自体はとても少ないです。
この章ではConnectのmiddleware の仕組みについて見て行きましょう。
どう書ける?
Connectを使い簡単なEchoサーバを書いてみましょう。Echoサーバとは、送られてきたリクエストの内容をそのままレスポンスとして返すサーバのことです。
import connectfrom"connect";import httpfrom"http";import fetchfrom"node-fetch";import assertfrom"assert";const app = connect();// add Error handlingapp.use(function (err, req, res, next){console.error(err.stack); res.status(500).send(err.message); next();});// request to responseapp.use(function (req, res){ req.pipe(res);});//create node.js http server and listen on portconst server = http.createServer(app).listen(3000, request);// request => responsefunctionrequest(){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 と呼ばれる関数にはrequest
やresponse
といったオブジェクトが渡されます。このrequest
やresponse
をmiddleware で処理することで、ログを取ったり、任意のレスポンスを返すことができます。
Echoサーバではreq.pipe(res);
という形でリクエストをそのままレスポンスとして流すことで実現されています。
middlewareをモジュールとして実装
もう少しmiddleware をプラグインらしくモジュールとして実装したものを見てみます。
次のconnect-example.jsは、あらゆるリクエストに対して、"response text"
というレスポンスを"X-Content-Type-Options"
ヘッダを付けて返すだけのサーバです。
それぞれの処理をmiddleware としてファイルを分けて実装し、app.use(middleware)
で処理を追加しています。
functionsetHeaders(res, headers){Object.keys(headers).forEach(key => {const value = headers[key];if (value !==null) { res.setHeader(key, value); } });}exportdefaultfunction (){returnfunctionnosniff(req, res, next){ setHeaders(res, {"X-Content-Type-Options":"nosniff" }); next(); };}
exportdefaultfunction (text){returnfunctionhello(req, res){ res.end(text); };}
exportdefaultfunction (){returnfunctionerrorHandling(err, req, res, next){ res.writeHead(404); res.write(err.message); res.end(); next(); };}
import errorHandlerfrom"./errorHandler";import hellofrom"./hello";import nosnifffrom"./nosniff";import assertfrom"assert";import connectfrom"connect";import httpfrom"http";import fetchfrom"node-fetch";const responseText ="response text";const app = connect();// add Error handlingapp.use(errorHandler());// add "X-Content-Type-Options" to responseapp.use(nosniff());// respond to all requestsapp.use(hello(responseText));//create node.js http server and listen on portconst server = http.createServer(app).listen(3000, request);functionrequest(){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);}
基本的にどのmiddleware もapp.use(middleware)
という形で拡張でき、モジュールとして実装すれば再利用もしやすい形となっています。
middleware となる関数の引数が4つであると、それはエラーハンドリングのmiddleware とするという、Connect独自のルールがあります。
どのような仕組み?
Connectのmiddleware がどのような仕組みで動いているのかを見ていきます。
app
に登録したmiddleware は、リクエスト時に呼び出されています。そのため、app
のどこかに利用するmiddleware を保持していることは推測できると思います。
Connectではapp.stack
にmiddleware を配列として保持しています。次のようにしてapp.stack
の中身を表示してみると、middleware が登録順で保持されていることがわかります。
import errorHandlerfrom"./errorHandler";import hellofrom"./hello";import nosnifffrom"./nosniff";import connectfrom"connect";const responseText ="response text";const app = connect();// add Error handlingapp.use(errorHandler());// add "X-Content-Type-Options" to responseapp.use(nosniff());// respond to all requestsapp.use(hello(responseText));// print middleware listapp.stack.map(({handle}) =>console.log(handle));/* => [Function: errorHandling] [Function: nosniff] [Function: hello]*/
Connectは登録されたmiddleware を、サーバがリクエストを受け取りそれぞれ順番に呼び出しています。
上記の例だと次の順番でmiddleware が呼び出されることになります。
- nosniff
- hello
- errorHandler
エラーハンドリングのmiddleware は処理中にエラーが起きた時のみ呼ばれます。
そのため、通常はnosniff.js →hello.js の順で呼び出されます。
functionsetHeaders(res, headers){Object.keys(headers).forEach(key => {const value = headers[key];if (value !==null) { res.setHeader(key, value); } });}exportdefaultfunction (){returnfunctionnosniff(req, res, next){ setHeaders(res, {"X-Content-Type-Options":"nosniff" }); next(); };}
nosniff.js
は、HTTPヘッダを設定し終わったらnext()
を呼び出し、このnext()
が次のmiddleware へ行くという意味になります。
次に、hello.js
を見てみると、next()
がありません。
exportdefaultfunction (text){returnfunctionhello(req, res){ res.end(text); };}
next()
がないということはhello.js
がこの連続するmiddleware の最後となっていることがわかります。仮に、これより先にmiddleware が登録されていたとしても無視されます。
つまり、処理的には次のようにstackを先頭から一個づつ取り出し、処理していくという方法が取られています。
Connectの行っている処理を抽象的なコードで書くと次のような形になっています。
const req ="...", res ="...";functionnext(){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と同じくuse
でmiddleware を指定することからも分かりますが、Rackを参考にした実装となっています。
次は、先ほど抽象的なコードとなっていたものを具体的な実装にしながら見ていきます。
実装してみよう
Connectライクなmiddleware をサポートしたJunctionというクラスを作成してみます。
Junctionは、use(middleware)
とprocess(value, (error, result) => { });
を持っているシンプルなクラスです。
functionisErrorHandingMiddleware(middleware){// middleware(error, text, next)const arity = middleware.length;return arity ===3;}functionapplyMiddleware(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);}exportdefaultclassJunction{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(); }}
実装を見てみると、use
でmiddleware を登録して、process
で登録したmiddleware を順番に実行していきます。そのため、Junction
自体は渡されたデータの処理をせずに、middleware の中継のみをしています。
登録するmiddleware はConnectと同じもので、処理をしたらnext
を呼んで、次のmiddleware が処理するというのを繰り返しています。
使い方はConnectと引数の違いはありますが、ほぼ同じような形で利用できます。
import Junctionfrom"./junction";import assertfrom"assert";const junction =new Junction();junction.use(functiontoUpperCase(res, next){ res.value = res.value.toUpperCase(); next();});junction.use(functionexclamationMark(res, next){ res.value = res.value +"!"; next();});junction.use(functionerrorHandling(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",functionfooMiddleware(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 の形を取ることが多いためだといえます。
ミドルウェアでラップするプロセスは、概念的にたまねぎの中の層と同様の構造をもたらします。WSGI ミドルウェアより引用
この仕組みを使っているもの
- Express
- Connectとmiddleware の互換性がある
- 元々はConnectを利用していたが4.0.0で自前の実装に変更
- wooorm/retext
use
でプラグイン登録していくテキスト処理ライブラリ
- r7kamura/stackable-fetcher
use
でプラグイン登録して処理を追加できるHTTPクライアントライブラリ
まとめ
ここではConnectのプラグインアーキテクチャについて学びました。
- Connectはmiddleware を使ったHTTPサーバーライブラリである
- Connect自体の機能は少ない
- 複数のmiddleware を組み合わせることでHTTPサーバを作ることができる