そろそろやることなくなったので minify などをやることにしました。

ただ、ブログシステムの出力の最後ほうでページごとに全体を minify すると、全体としてどうしても処理に時間がかかってしまいます。要求として、キャッシュなしの状態からでも1エントリあたり0.1秒ぐらいでは処理したいので、これだと厳しい感じでした。(約7900エントリぐらいあるので、0.1s で処理しても全体のキャッシュ再構築に13分かかる計算です)

いろいろ考えたのですが (そもそも minify しないとかも)、以下のようにしました

  • エントリ本文はエントリ保存時に minify しておく (本文はDB に格納)
  • テンプレートをテンプレートの段階で minify してキャッシュしておく

minify には html-minifier を使っています。html-minifier はテンプレートを対象にした minify も一応サポートしていて、ある程度妥協すればテンプレート対象でも十分に minify できます。

テンプレートとエントリが前もってをminifyされていれば、あとは繋げて出すだけです。

テンプレートの minify

このブログシステムのテンプレートは Text::Xslate::Syntax::TTerse です。

まず、Xslate のビュー読みこみをフックして、テンプレートを動的に minify するようにしました。

{
	no warnings 'redefine';
	*Text::Xslate::slurp_template = sub {
		my ($self, $input_layer, $fullpath) = @_;
		my $source = sub {
			if (ref $fullpath eq 'SCALAR') {
				return $$fullpath;
			} else {
				open my($source), '<' . $input_layer, $fullpath
					or $self->_error("LoadError: Cannot open $fullpath for reading: $!");
				local $/;
				return scalar <$source>;
			}
		}->();
		if ($fullpath =~ /\.html$/) {
			$source = Nogag::Utils->minify($source);
			return $source;
		} else {
			return $source;
		}
	};
	$XSLATE->load_file($_) for qw{
		index.html
		_article.html
		_adsense.html
		_images.html
	};
};

Xslate には pre_process_handler というのがあって、 読みこまれたテンプレートにフィルタをかけることができます。が、この機能だとファイル名がわからないので使っておらず、slurp_template を上書きしています。

(明示的に load_file しているのは、エラーが起きるなら起動時にしたいというのと、fork 前にロードすることでメモリの節約になるからで、本題とは関係ありません)

TTerse なテンプレートに対して html-minifier を使う場合、配慮する点がいくつかあります。

全体的にHTMLとしてパースできること

TTerse の場合以下のようにも書けますが、ダブルクオートの入れ子が HTML として見ると syntax error なので html-minifier で Parse Error になります。

<meta content="[% "foo" _ "bar" %]">

属性を出しわけるとき個別に条件をつける

テンプレートの構文をタグに混ぜるには html-minifier 側にオプションを設定します (customAttrSurround)。

ただ、複数属性を囲うとうまく属性を分解できなくて死ぬっぽいので、個別に囲う必要がありました。

<!-- ダメ -->
<article
   [% IF xxx %]
   itemscope
   itemprop="blogPosts"
   [% END %]
   >
<!-- よろしい -->
<article
   [% IF xxx %]itemscope[% END %]
   [% IF xxx %]itemprop="blogPosts"[% END %]
   >

sortClassName は false にする

クラスをテンプレートで出しわけしていると死にます

実際の html-minifier へのオプション

TTerse 対応のためオプションは以下のようになりました。customAttrSurround が適切に設定されていれば、sortAttributes は true にしても問題ないようです。

function processMinify (html) {
	return Promise.resolve(minify(html, {
		html5: true,
		customAttrSurround: [
			[/\[%\s*(?:IF|UNLESS)\s+.+?\s*%\]/, /\[%\s*END\s*%\]/]
		],
		decodeEntities: true,
		collapseBooleanAttributes: true,
		collapseInlineTagWhitespace: true,
		collapseWhitespace: true,
		conservativeCollapse: true,
		preserveLineBreaks: false,
		minifyCSS: true,
		minifyJS: true,
		removeAttributeQuotes: true,
		removeOptionalTags: true,
		removeRedundantAttributes: true,
		removeScriptTypeAttributes: true,
		removeStyleLinkTypeAttributes: true,
		processConditionalComments: true,
		removeComments: true,
		sortAttributes: true,
		sortClassName: false,
		useShortDoctype: true
	}));
}

エントリ本文のポストプロセス

エントリ保存時には、minify もそうですが、ほかにも修正を加えたいことが多々あります。例えば画像を強制的に https 化して mixed content を防ぎたいとか、コードハイライトを行いたいとかです。

コードハイライトは今まで highlight.js を使い、クライアントサイドでやっていました。これも本来ダイナミックにやる必要はなくポストプロセスでできることなので、そのように変えることにしました。highlight.js をサーバサイドで行うと、付与されるマークアップ分 HTML の転送量が増えますが、highlight.js 自体が結構大きいので、かなり長いコードをハイライトしない限り、サーバサイドでやったほうが得そうです。

以前サーバサイドでMathJaxを処理するようにしましたが、この node.js の内部向けサーバをさらに拡張して、JS でいろいろなポストプロセスの処理を書けるようにしました。

これにより、クライアントサイドでやってたことをそのままサーバサイドできるようになりました。すなわち、hightlight.js をそのままサーバサイドでも動かしていますし、jsdom を使って細かいHTMLの書きかえを querySelector など標準の DOM 操作でできるようにしています。

ポストプロセス用のデーモン

前述のように node.js で動くデーモンで、ブログシステム(Perl)とは別のプロセスで動き、APIサーバになっています。

全体的には以下のようなコードです。なお書き換え部分は変な挙動をするとやっかいなので、テストを書けるようにしてあります

#!/usr/bin/env node


const jsdom = require("jsdom").jsdom;
const mjAPI = require("mathjax-node/lib/mj-page.js");
const hljs = require('highlight.js');


const minify = require('html-minifier').minify;
const http = require('http');
const https = require('https');
const url = require('url');
const vm = require('vm');


const HTTPS = {
	GET : function (url) {
		var body = '';
		return new Promise( (resolve, reject) => {
			https.get(
				url,
				(res) => {
					res.on('data', function (chunk) {
						body += chunk;
					});
					res.on('end', function() {
						res.body = body;
						resolve(res);
					})
				}
			).on('error', reject);
		});
	}
};


mjAPI.start();
mjAPI.config({
	tex2jax: {
		inlineMath: [["\\(","\\)"]],
		displayMath: [ ["$$", "$$"] ]
	},
	extensions: ["tex2jax.js"]
});


function processWithString (html) {
	console.log('processWithString');
	return Promise.resolve(html).
		then(processMathJax).
		then(processMinify);
}


function processWithDOM (html) {
	console.log('processWithDOM');
	var document = jsdom(undefined, {
		features: {
			FetchExternalResources: false,
			ProcessExternalResources: false,
			SkipExternalResources: /./
		}
	});
	document.body.innerHTML = html;
	var dom = document.body;
	return Promise.resolve(dom).
		then(processHighlight).
		then(processImages).
		then(processWidgets).
		then( (dom) => dom.innerHTML );
}




function processHighlight (node) {
	console.log('processHighlight');
	var codes = node.querySelectorAll('pre.code');
	for (var i = 0, it; (it = codes[i]); i++) {
		if (/lang-(\S+)/.test(it.className)) {
			console.log('highlightBlock', it);
			hljs.highlightBlock(it);
		}
	}
	return Promise.resolve(node);
}


function processImages (node) {
	console.log('processImages');
	{
		var imgs = node.querySelectorAll('img[src*="googleusercontent"], img[src*="ggpht"]');
		for (var i = 0, img; (img = imgs[i]); i++) {
			img.src = img.src.
				replace(/^http:/, 'https:').
				replace(/\/s\d+\//g, '/s2048/');
		}
	}
	{
		var imgs = node.querySelectorAll('img[src*="cdn-ak.f.st-hatena.com"]');
		for (var i = 0, img; (img = imgs[i]); i++) {
			img.src = img.src.
				replace(/^http:/, 'https:');
		}
	}
	{
		var imgs = node.querySelectorAll('img[src*="ecx.images-amazon.com"]');
		for (var i = 0, img; (img = imgs[i]); i++) {
			img.src = img.src.
				replace(/^http:\/\/ecx\.images-amazon\.com/, 'https://images-na.ssl-images-amazon.com');
		}
	}
	return Promise.resolve(node);
}


function processWidgets (node) {
	var promises = [];


	console.log('processWidgets');
	var iframes = node.querySelectorAll('iframe[src*="www.youtube.com"]');
	for (var i = 0, iframe; (iframe = iframes[i]); i++) {
		iframe.src = iframe.src.replace(/^http:/, 'https:');
	}


	var scripts = node.getElementsByTagName('script');
	for (var i = 0, it; (it = scripts[i]); i++) (function (it) {
		if (!it.src) return;
		if (it.src.match(new RegExp('https://gist.github.com/[^.]+?.js'))) {
			var promise = HTTPS.GET(it.src).
				then( (res) => {
					var written = '';
					vm.runInNewContext(res.body, {
						document : {
							write : function (str) {
								written += str;
							}
						}
					});
					var div = node.ownerDocument.createElement('div');
					div.innerHTML = written;
					div.className = 'gist-github-com-js';
					it.parentNode.replaceChild(div, it);
				}).
				catch( (e) => {
					console.log(e);
				});


			promises.push(promise);
		}
	})(it);


	return Promise.all(promises).then( () => {
		return node;
	});
}


function processMathJax (html) {
	console.log('processMathJax');
	if (!html.match(/\\\(|\$\$/)) {
		return Promise.resolve(html);
	}
	return new Promise( (resolve, reject) => {
		mjAPI.typeset({
			html: html,
			renderer: "SVG",
			inputs: ["TeX"],
			ex: 6,
			width: 40
		}, function (result) {
			console.log('typeset done');
			console.log(result);


			resolve(result.html);
		});
	});
}


function processMinify (html) {
	return Promise.resolve(minify(html, {
		html5: true,
		customAttrSurround: [
			[/\[%\s*(?:IF|UNLESS)\s+.+?\s*%\]/, /\[%\s*END\s*%\]/]
		],
		decodeEntities: true,
		collapseBooleanAttributes: true,
		collapseInlineTagWhitespace: true,
		collapseWhitespace: true,
		conservativeCollapse: true,
		preserveLineBreaks: false,
		minifyCSS: true,
		minifyJS: true,
		removeAttributeQuotes: true,
		removeOptionalTags: true,
		removeRedundantAttributes: true,
		removeScriptTypeAttributes: true,
		removeStyleLinkTypeAttributes: true,
		processConditionalComments: true,
		removeComments: true,
		sortAttributes: true,
		sortClassName: false,
		useShortDoctype: true
	}));
}
const port = process.env['PORT'] || 13370


http.createServer(function (req, res) {
	var html = '';
	var location = url.parse(req.url, true);
	req.on('readable', function () {
		var chunk = req.read();
		console.log('readable');
		if (chunk) html += chunk.toString('utf8');
	});
	req.on('end', function() {
		console.log('end');


		if (location.query.minifyOnly) {
			Promise.resolve(html).
				then(processMinify).
				then( (html) => {
					console.log('done');
					res.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});
					res.end(html);
				}).
				catch( (e) => {
					console.log(e);
					console.log(e.stack);
					res.writeHead(500, {'Content-Type': 'text/plain; charset=utf-8'});
					res.end(html);
				});
		} else {
			Promise.resolve(html).
				then(processWithDOM).
				then(processWithString).
				then( (html) => {
					console.log('done');
					res.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});
					res.end(html);
				}).
				catch( (e) => {
					console.log(e);
					console.log(e.stack);
					res.writeHead(500, {'Content-Type': 'text/plain; charset=utf-8'});
					res.end(html);
				});
		}
	});
}).listen(port, '127.0.0.1');
console.log('Server running at http://127.0.0.1:' + port);

デーモン利用側

利用側は単純に http リクエストを送っているだけです。ときどきパースエラーで失敗したりするので、そういう場合は元々の HTML をそのまま使うようにしています。

運用

  1. エントリ保存時にはてな記法→HTMLと上記のような処理をしてDBに保存する(キャッシュ)
  2. レンダリング結果をページキャッシュ

という2段階のキャッシュがあります。テンプレートを変更しただけの場合、レンダリング結果のキャッシュを作りなおします。これは冒頭の通り約13分程度かかります。このページ単位のキャッシュはエントリを保存すると関連するキャッシュが自動的に無効になるようになっています。この場合、次回アクセス時に再生成になります。

記法フォーマッターのコード変更や、ポストプロセス処理に変更を入れた場合には、エントリのリフォーマットが必要です。これは全てやると約10分ぐらいかかります。ただ一部エントリだけにリフォーマットをかけるようなこともできるようにしています。

  1. トップ
  2. tech
  3. ブログシステムの HTML 生成を効率化
▲ この日のエントリ