マイクロという言葉は、現代のプログラミングの世界にはあふれています。マイクロフレームワーク、マイクロサービスなどなど、いろいろあります。個人的にはこの言葉は、余計なものを詰め込まずに、目の前の問題を解決してくれるという意味だと考えています。ある明確な課題の解決のためにあるのです。つまり目の前の課題に集中し、不要なコンポーネントは切り捨てるのです。
Webの世界においてNodeは、ゴルディロックスの原則(訳注:どこかにちょうど良い点がある)に当てはまる気がします。低階層のライブラリーにあるAPIセットは、小規模なWebサイトを作るのにぴったりです。低レイヤーのライブラリーのAPIは複雑すぎず、しかし低機能すぎず、Webサービスの開発にちょうど良いのです。
この記事ではNodeとGit、および若干の依存オブジェクトを使ってマイクロブログを作ります。このアプリの役割は、Gitリポジトリに保存されたファイルから静的コンテンツを呼び出してくることです。作業を通じてビルドとテストの方法を習得し、課題解決のヒントが得られるでしょう。最後には、今後ベースとして使える必要最小限のブログアプリができあがります。
マイクロブログのおもな中身
かっこいいブログを作るには次にようなものが必要です。
- HTTPメッセージを送るライブラリー
- ブログ投稿を保存するリポジトリ
- テストのためのテストランナーもしくはライブラリー
- Markdown言語のパーサー(解析・変換ツール)
HTTPメッセージの送信にはNodeを使います。サーバーからハイパーテキストを送信するのに必要な機能はこれで十分だからです。今回特に必要な2つのモジュールはhttpとfsです。
このhttpモジュールはHTTPサーバーを作るのに使用します。fsモジュールはファイルを読み出すのに使います。Nodeには、HTTPを使用したマイクロブログ構築のためのライブラリーがあるのです。
ブログ投稿を保存するリポジトリには、フル機能のデータベースではなくGitを選びました。なぜならGit自体がすでにバージョン管理を備えたテキスト文書の保管庫だからです。Gitは投稿の保管に必要な機能そのものです。別途データベースを使わないので、いくつもの課題と格闘しながらコーディングせずに済みます。
ブログ記事はMarkdown形式で記入・保管し、そのパース(解析・変換)にはmarkedを使用します。これなら、あとでサイトの内容や機能を徐々に強化したくなったときも簡単にできます。Markdownは素のHTMLを直接書く代わりに使える優れた軽量マークアップ言語です。
単体テストにはroast.itという優秀なテストランナーを選びました。理由は、外部のコードに依存することなくテストに必要な機能を満たしているからです。あるいはtaperのようなほかのテストランナーも使えますが、こちらには8つの依存オブジェクトがあります。依存先を持たないのがroast.itの良さです。
以上で、マイクロブログを作る材料はすべてそろいました。
モジュール選びは重要です。ポイントは、核となる部分に関わらないところは依存しても良いということです。たとえばテストランナーやリポジトリは自分で作らないので、上のリストに加えました。使用する依存オブジェクトのせいでプロジェクト全体やコードの自由が制限されてはいけません。だからこそ軽量なコンポーネントだけを選んで使うのです。
本記事ではNode、npm、Gitに加えてさまざまなテスト方法の知識を前提としています。記事ではマイクロブログ開発の全ステップを詳しく説明はせず、コードの特定部分に焦点を当てて解説します。自分のマシンで説明通りに試したい場合は、GitHubに全コードがあるので、記事に登場する各コードはすべて試せます。
テスト
テストによってコードの正しさが確認でき、試行と修正のサイクルが強化できます。新しいコードを書いたら、稼働まではずっと試行錯誤に時間を費やすことになるのです。Web開発では、フィードバックを得るのに多くのレイヤーを経なければなりません。たとえば、ブラウザー、Webサーバー、データベースです。複雑になればなるほどフィードバックを得るまでに何分も、あるいは何時間もかかります。単体テストなら、さまざまなレイヤーを経る必要がなくすぐに結果が得られるので、目前の課題に集中できるのです。
私はどのような開発でも簡潔な単体テストを書くところから始めます。新たにどのようなコードを書くときもテストを意識しています。roast.itの開始と実行方法は次のようになります。
package.jsonファイルに以下を加えます。
"scripts": {
"test": "node test/test.js"
},
"devDependencies": {
"roast.it": "1.0.4"
}
どのような単体テストもtest.jsファイル内に書いて実行します。たとえば、次のようにします。
var roast = require('roast.it');
roast.it('Is array empty', function isArrayEmpty() {
var mock = [];
return mock.length === 0;
});
roast.run();
roast.exit();
テストを実行するにはnpm install && npm testコマンドを入力します。うれしいのは、コードをテストするのに何重もの手続きを踏む必要はない点です。確信を持って課題に集中できます。
見てのとおり、テストランナーはroast.it(strNameOfTest, callbackWithTest)がコールされると実行される仕組みです。各テストの戻り値returnの値がtrueならテストは合格です。実際のアプリではすべてのテストを1つのファイルに書きたくないでしょう。その場合テストは別々のファイルに書き、Nodeから単体テストをrequireします。マイクロブログのtest.jsを確認すると、どのようにしているのが分かります。
ヒント:テストはnpm run testコマンドで実行しましたが、省略してnpm testまたはnpm tでも実行できます。
アプリのスケルトン
記事のマイクロブログでは、Nodeを使いクライアントからのリクエストに応答します。良い方法として、Node APIのhttp.CreateServer()を使うことです。app.jsでどのように使っているかが分かります。
/* app.js */
var http = require('http');
var port = process.env.port || 1337;
var app = http.createServer(function requestListener(req, res) {
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8'});
res.end('A simple micro blog website with no frills nor nonsense.');
});
app.listen(port);
console.log('Listening on http://localhost:' + port);
これをpackage.jsonファイルのnpmスクリプトから実行します。
"scripts": {
"start": "node app.js"
}
これでhttp://localhost:1337/がデフォルトのルートになり、クライアントに対してメッセージで応答します。さらにルートを追加して、ほかの応答、たとえばブログ投稿に対する応答などを加えるられます。
フォルダー構造
このアプリの骨組みを作るため、主要なセクションを決めました。
コードを整理するために以下のフォルダーを使います。各フォルダーの概要です。
- blog:Markdown形式で書かれたそのままのブログ投稿
- message:クライアントへの応答メッセージを作る、再利用可能なモジュール
- route:デフォルトルート以外のルート
- test:単体テストのコードを入れる場所
- view:HTMLテンプレートを置く場所
先にも書いたとおり、コードはGitHubにあるので自由に真似してください。記事に登場する各コードを試せます。
テスト用のルート
最初に、ブログの投稿のためにルートを1つ追加します。このルートはBlogRouteというテスト可能なコンポーネントに置きます。すばらしいのは、ここに依存性を注入できる点です。対象のコードと依存オブジェクトの間は「関心の分離」ができているので、単体をテストできるのです。各依存オブジェクトはコードのモックを受け取って隔離されたテストを実行します。これにより、改変不要で繰り返せる高速なテストが書けます。
コンストラクタの例は次のようになります。
/* route/blogRoute.js */
var BlogRoute = function BlogRoute(context) {
this.req = context.req;
};
効果的な単体テストは次のようになります。
/* test/blogRouteTest.js */
roast.it('Is valid blog route', function isValidBlogRoute() {
var req = {
method: 'GET',
url: 'http://localhost/blog/a-simple-test'
};
var route = new BlogRoute({ req: req });
return route.isValidRoute();
});
いまはBlogRouteはNode APIからreqオブジェクトを受け取ります。テストをパスするにはこれで十分です。
/* route/blogRoute.js */
BlogRoute.prototype.isValidRoute = function isValidRoute() {
return this.req.method === 'GET' && this.req.url.indexOf('/blog/') >= 0;
};
以上を一連のリクエスト群に含めます。app.jsを以下のようにします。
/* app.js */
var message = require('./message/message');
var BlogRoute = require('./route/BlogRoute');
// Inside createServer requestListener callback...
var blogRoute = new BlogRoute({ message: message, req: req, res: res });
if (blogRoute.isValidRoute()) {
blogRoute.route();
return;
}
// ...
あらかじめテストをしておくことで、実装に関する心配が無くなります。messageもこのあと定義します。resとreqという2つのオブジェクトはNode APIのhttp.createServer()から送られてきます。
自由にroute/blogRoute.jsの中のブログルートを確認してください。
リポジトリ
次の課題はBlogRoute.route()の中の生のブログ投稿データを読み出すことです。Nodeでは、ファイルシステムからファイルを読むためのfsモジュールが提供されています。
たとえば、次のようなものです。
/* message/readTextFile.js */
var fs = require('fs');
var path = require('path');
function readTextFile(relativePath, fn) {
var fullPath = path.join(__dirname, '../') + relativePath;
fs.readFile(fullPath, 'utf-8', function fileRead(err, text) {
fn(err, text);
});
}
このコードはmessage/readTextFile.jsファイルのものです。核となるのは、リポジトリにあるテキストファイルを読み出すことです。注目してほしいのはfs.readFile()は非同期処理だという点です。だからファイルデータと共にコールバックfnが呼ばれます。この非同期処理アプリは普通のコールバックを使うのです。
この関数がファイル入出力に必要なものを提供しています。良いところは1つの関心だけを持つ点です。ファイル読み出しのような横断的関心(クロスカット・コンサーン)モジュールを自分でテストする必要はありません。単体テストでは、他人のコードではなく自分のコードだけを隔離してテストすべきです。
理論上はメモリーにファイルシステムをモックで作り単体テストを書けますが、プログラムの関心事項が分散してプロジェクトはぐちゃぐちゃになっていくでしょう。
横断的関心事、たとえば、ファイル読み出しは自分が書くコードの管轄外です。これは直接自分でコントロールできない別システムに依存しています。これをテストに含めると安定せず、試行錯誤の時間も手間も増えてしまいます。こうした関心は、自分のアプリからは分離すべきなのです。
ここでBlogRoute.route()関数を以下のようにします。
/* route/bogRoute.js */
BlogRoute.prototype.route = function route() {
var url = this.req.url;
var index = url.indexOf('/blog/') + 1;
var path = url.slice(index) + '.md';
this.message.readTextFile(path, function dummyTest(err, rawContent) {
this.res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
this.res.end(rawContent);
}.bind(this));
};
ここでBlogRouteコンストラクタによりmessageとresが注入されていることに注目してください。
this.message = context.message;
this.res = context.res;
リクエストからreqオブジェクトを受け取り、Markdown記述のファイルを読み出します。dummyTest()については心配しないでください。いまはとりあえず、応答を処理するコールバックのように考えてください。
このBlogRoute.route()関数を単体テストするには、次にようにします。
/* test/blogRouteTest.js */
roast.it('Read raw post with path', function readRawPostWithPath() {
var messageMock = new MessageMock();
var req = {
url: 'http://localhost/blog/a-simple-test'
};
var route = new BlogRoute({ message: messageMock, req: req });
route.route();
return messageMock.readTextFileCalledWithPath === 'blog/a-simple-test.md' &&
messageMock.hasCallback;
});
これでBlogRouteにmessageオブジェクトが注入され、モックのmessage.readTextFile()を試します。これでBlogRoute.route()がパスしたことを確認できました。
プログラムから直に必要なモジュールをrequireするのは避けたいものです。依存先と切り離せなくなるからです。そして、どのようなテストも完全な結合テストになります。たとえばmessage.readTextFile()は実際のファイルを読み込むことになります。
今回の方法は依存関係の逆転の原則(dependency inversion)と呼ばれ、SOLIDの原則の1つです。単体テストはこの原則に基づき、依存オブジェクトのモックを使っているのです。たとえば、messageMock.readTextFileCalledWithPathは、ユニット単体が意図した通り動くかのみテストします。関数の境界を越えてほかには影響しません。
モックといっても心配しないでください。テスト用の軽量オブジェクトです。たとえば、sinonのようなモックテストのためのツールもあるので、使用するなら依存オブジェクトに加えてください。
カスタムモックを使えば、いろいろなケースに対応する柔軟なテストができます。メリットは、テストコード内にモックが散らからないことです。正確で分かりやすいテストができます。
MessageMockは次のようになります。
/* test/mock/messageMock.js */
var MessageMock = function MessageMock() {
this.readTextFileCalledWithPath = '';
this.hasCallback = false;
};
MessageMock.prototype.readTextFile = function readTextFile(path, callback) {
this.readTextFileCalledWithPath = path;
if (typeof callback === 'function') {
this.hasCallback = true;
}
};
コードはtest/mock/messageMock.jsにあります。
ここでモックは特に非同期処理を必要としないことに注目してください。実のところ、コールバックを呼びさえしません。テストの目的は意図したケースで動作するかどうかどうかです。message.readTextFile()が呼ばれて、正しいパスとコールバックが入っていることを確認してください。
BlogRouteに注入される実際のmessageは、message/message.jsから取得します。すべての再利用可能なコンポーネントが1つのユーティリティにまとめられています。
たとえば、次のようになります。
/* message/message.js */
var readTextFile = require('./readTextFile');
module.exports = {
readTextFile: readTextFile
};
Nodeで使える効果的なパターンです。フォルダー名に合わせてファイル名を付け、同じ所からすべてのコンポーネントをフォルダー内にエクスポートします。
この時点でアプリはすべて結合され、生のMarkdownデータを送り返す準備が整いました。動作確認のため、全体を通してテストする段階です。
npm startコマンドを実行し、別のコマンドラインウィンドウからcurl -v http://localhost:1337/blog/my-first-postを実行します。
投稿データはGitを通してリポジトリに入りました。git commitで投稿の変更を確定できます。
Markdown形式ファイルの変換
次の課題は、リポジトリにある生のMarkdown形式ファイルをHTMLに変換することです。次の2つの段階で処理します。
- viewフォルダーからHTMLテンプレートを取得
- Markdown形式からHTMLに変換してテンプレートを埋める
健全なプログラミングでは、大きな問題は小さくかみ砕いてひと口サイズにします。最初の課題は「BlogRouteの中身に基づきHTMLテンプレートを取得する方法は?」です。
1つの方法は、次のようになります。
/* route/blogRoute.js */
BlogRoute.prototype.readPostHtmlView = function readPostHtmlView(err, rawContent) {
if (err) {
this.res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
this.res.end('Post not found.');
return;
}
this.rawContent = rawContent;
this.message.readTextFile('view/blogPost.html', this.renderPost.bind(this));
};
前項で使ったdummyTestという名のダミーコールバックを置き換えます。
コールバックdummyTestを置き換えるには次のようにします。
this.message.readTextFile(path, this.readPostHtmlView.bind(this));
単体テストは次のように書きます。
/* test/blogRouteTest.js */
roast.it('Read post view with path', function readPostViewWithPath() {
var messageMock = new MessageMock();
var rawContent = 'content';
var route = new BlogRoute({ message: messageMock });
route.readPostHtmlView(null, rawContent);
return messageMock.readTextFileCalledWithPath !== '' &&
route.rawContent === rawContent &&
messageMock.hasCallback;
});
ここではうまくいくケースだけをテストしました。実際には投稿が見つからないケースもあります。すべてのBlogRoute単体テストはtest/blogRouteTestにあります。興味があれば確認してください。
この時点でテストが終わりました。すべてのリクエストを確認することは不可能ですが、次へ進んでもよいという確信が持てたでしょう。繰り返しますが、結局のところテストとはなにかをまとめると「最高の状態を保ち、課題に集中し、そしてハッピーな状態にする」ことです。プログラミングで悲しんだりイライラすべきではありません。悲しむよりも楽しむべきだと確信しています。
ここでthis.rawContentに生のMarkdown形式の投稿を収めたインスタンスがあることを確認してください。次のコールバックthis.renderPost()でさらに処理します。
もし.bind(this)に馴染みがないなら、JavaScriptでコールバック関数のスコープを限定する良い方法だと考えてください。このままだとコールバックは外からのスコープに含まれてしまい、望ましくありません。
Markdown形式からHTMLへの変換
次の「ひと口サイズの課題」は、HTMLテンプレートを取得し、生のコンテンツデータを載せることです。ここではコールバックとして説明したBlogRoute.renderPost()を使います。
次が実装例の1つです。
/* route/blogRoute.js */
BlogRoute.prototype.renderPost = function renderPost(err, html) {
if (err) {
this.res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
this.res.end('Internal error.');
return;
}
var htmlContent = this.message.marked(this.rawContent);
var responseContent = this.message.mustacheTemplate(html, { postContent: htmlContent });
this.res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
this.res.end(responseContent);
};
ここでも、うまくいくケースをテストします。
/* test/blogRouteTest.js */
roast.it('Respond with full post', function respondWithFullPost() {
var messageMock = new MessageMock();
var responseMock = new ResponseMock();
var route = new BlogRoute({ message: messageMock, res: responseMock });
route.renderPost(null, '');
return responseMock.result.indexOf('200') >= 0;
});
responseMockはどこから出てきたのかと思うかもしれません。このモックはテスト用に使用する軽量オブジェクトです。res.writeHead()とres.end()が呼ばれたのを確認するためにResponseMockを使っているのです。
モックは次のようにします。
/* test/mock/responseMock.js */
var Response = function Response() {
this.result = '';
};
Response.prototype.writeHead = function writeHead(returnCode) {
this.result += returnCode + ';';
};
Response.prototype.end = function end(body) {
this.result += body;
};
この応答モックで動作が確信できればそれでよいでしょう。確信といっても、主観的なものです。単体テストのコードを確認すれば、作り手がなにを考えているかが分かるのです。コードは第三者にもより分かりやすくなります。
コードはtest/mock/responseMock.jsにあります。
MarkdownからHTMLへの変換にはmessage.marked()を、軽量なテンプレート機能としてはmessage.mustacheTemplate()を導入したので、これらのモックも使います。
MessageMockに付加されます。
/* test/mock/messageMock.js */
MessageMock.prototype.marked = function marked() {
return '';
};
MessageMock.prototype.mustacheTemplate = function mustacheTemplate() {
return '';
};
この時点では各コンポーネントがなにを返してくるかは問題ではありません。どちらもモックの一部だということを確認してください。
良くできたモックは、繰り返し使うとさらに改良できます。バグを見つけたときにテストコードを強化してテスト条件を追加すれば、試行作業がさらに効果的になります。
以上でテストは無事に終わったので、一連のリクエストに加えます。
message/message.js内を、次のようにします。
/* message/message.js */
var mustacheTemplate = require('./mustacheTemplate');
var marked = require('marked');
// ...
module.exports = {
mustacheTemplate: mustacheTemplate,
// ...
marked: marked
};
今回選んで依存先に追加したMarkdown言語のパーサー(解析・変換ツール)がmarkedです。
package.jsonに、次のように加えます。
"dependencies": {
"marked": "0.3.6"
}
messsageフォルダー内のmessage/mustacheTemplate.jsにあるmustacheTemplateは、再利用可能なコンポーネントです。依存オブジェクトとして加えなかったのは、必要なものと比べると機能が多すぎるからです。
mustacheテンプレート関数の要点は次のようになります。
/* message/mustacheTemplate.js */
function mustache(text, data) {
var result = text;
for (var prop in data) {
if (data.hasOwnProperty(prop)) {
var regExp = new RegExp('{{' + prop + '}}', 'g');
result = result.replace(regExp, data[prop]);
}
}
return result;
}
動作するかどうかの単体テストがtest/mustacheTemplateTest.jsにありますので、自由に確認してみてください。
さらに、HTMLテンプレートないしビューを加える必要があります。view/blogPost.htmlを、たとえば、次のようにします。
<!-- view/blogPost.html -->
<body>
<div>
{{postContent}}
</div>
</body>
正しくできていたら、ブラウザーで動作デモが見られます。
試すにはnpm startを実行してhttp://localhost:1337/blog/my-first-postにアクセスしてください。
ソフトウェア開発では、コンポーネントは「モジュール化されている、テストできる、再利用できる」という原則から絶対に逸脱しないでください。もっと言えば、誰かがそれを否定するようなことを言っても相手にしてはいけません。どのようなコードでも、たとえフレームワークに強く依存していたとしても、きれいなコードで書けるはずなので、希望を捨てないでください!
さらに進める
ちゃんと動くアプリができました。ここから先、本番稼働できるようにするにはいくつもの可能性があります。
いくつか考えられる改良は、次にようなものです。
- Gitへのデプロイ、たとえば、GitFlowの使用
- クライアント側のリソースを管理する手段の追加
- 基本的なキャッシュの活用を、サーバー・クライアント双方でする
- メタデータ追加(おそらく使うのはfront matter)によるSEO強化
改良には限界がありませんので、自分のアプリを望むだけ強化してください。
最後に
Node.jsと若干の軽量モジュールだけでマイクロブログを作る方法が理解できたでしょうか。必要なのは、ほんの少しの発想と目前の課題への集中だけです。自由に使えるこれだけのAPIで、十分すばらしいものが作れます。
どのような開発にもKISSの原則(Keep It Simple, Stupid)が重要だということが身に染みたと思います。必要な課題だけを解決し、複雑さは最小限にすべきなのです。
ここで取り組んだアプリは、依存オブジェクトを合わせても容量は172KBしかありません。この軽さのアプリならおよそどのようなWebサーバーでも抜群の性能を発揮するでしょう。レスポンシブで軽量なアプリはユーザーが望んでいるものです。最高なのは、いじったり改良したりできるマイクロブログのひな形が手に入ったことです。
※本記事はMark Brown、Jani Hartikainen、Joan Yinが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitepointの査読担当者のみなさんに感謝します。
(原文:Building a Microblog Using Node.js, Git and Markdown)
[翻訳:西尾健史/編集:Livit]