複数のファイルに共通する部分があるとき、共通箇所をまとめて切り出しておいて、各ファイルからはそれらを参照するだけにする、というのはよくある話です。C言語なら#include <stdio.h>
という書き方をしますし、Web制作をやる人なら、CSSの@import
規則をご存じだと思います。
しかしたまに、これに似たことを静的なファイルで行って「include文の位置に参照先のファイルがそのまま埋め込まれたファイル」を作りたいという場面が出てきます。
この記事では、そんな「静的なファイルを生成するために、ソースとなるテキストファイルに書かれたinclude文をシェルスクリプトで処理して、参照先ファイルの内容をその位置に埋め込んだ結果のファイルを得たい」というニーズに対する、なるべく効率のよい実現方法を模索してみます。
やりたいこと
以前、さくらのレンタルサーバーの一番安いプランでWebサイトを公開するノウハウという記事で、「ssh接続できない月額129円の激安レンタルサーバーでも、手元にLinuxな環境があるならコマンドラインのFTPクライアントとシェルスクリプトでrsyncっぽいことができるよ! ついでに色々前処理もさせられるし、シス管系女子のサイトのようにチープな静的コンテンツだけのサイトなら全然余裕で運用できちゃう! やったね!」という事例をご紹介しました。
その際にやりたかった前処理のひとつに前述のinclude文があり、全ページで共通のヘッダやフッタを
<meta charset="UTF-8">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@piro_or">
<meta name="twitter:creator" content="@piro_or">
...
こんな風に断片ファイルとして切り出して用意しておいて、HTMLファイルの中に
<!DOCTYPE html>
<html lang="ja" xmlns:og="http://ogp.me/ns#" xmlns:fb="http://www.facebook.com/2008/fbml">
<head prefix="og: http://ogp.me/ns# fb: http://ogp.me/ns/fb# article: http://ogp.me/ns/article#">
<!--EMBED(metadata.html)-->
<title>シス管系女子って何!? - 【シス管系女子】特設サイト</title>
...
のように書いておき、アップロード直前に
<!DOCTYPE html>
<html lang="ja" xmlns:og="http://ogp.me/ns#" xmlns:fb="http://www.facebook.com/2008/fbml">
<head prefix="og: http://ogp.me/ns# fb: http://ogp.me/ns/fb# article: http://ogp.me/ns/article#">
<meta charset="UTF-8">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@piro_or">
<meta name="twitter:creator" content="@piro_or">
...
<title>シス管系女子って何!? - 【シス管系女子】特設サイト</title>
...
のように解決する、という事をしていました。
良くない実装例
最初に先の記事を公開した時の実装は、以下のようなものでした1。
#!/bin/bash
# 環境によってsedで拡張正規表現を使うためのオプションが違うので、
# egrepコマンドのように使える「$esed」を定義しておく。
case $(uname) in
Darwin|*BSD|CYGWIN*)
esed="sed -E"
;;
*)
esed="sed -r"
;;
esac
rm -rf html_resolved
cp -r html html_resolved
# include文の検出用正規表現。
# ファイル名部分は、後方参照で取り出せるように`()`で囲っておく。
embed_matcher='<!-- *EMBED\( *([^) ]+) *\) *-->'
# 処理対象のファイル(include文があるファイル)を検索する。
egrep -r \
--include='*.html' \
"$embed_matcher" \
html_resolved |
cut -d ':' -f 1 | # ファイルパスだけを取り出す。
uniq | # 1ファイルの中で何カ所も見つかる事があるので、重複を取り除く。
while read path
do
# 見つかった各ファイルに対して処理を行う。
echo "updating $path"
# ファイルを退避し、
mv "$path"{,.tmp}
# ファイルの内容を1行ずつスキャンする。
# `IFS= read -r`とすることで、行頭・行末の連続する空白文字や
# 行の中のエスケープ文字を保持する。
cat "$path.tmp" | while IFS= read -r line
do
# include文がある行だったら、
if echo "$line" | egrep "$embed_matcher" 2>&1 > /dev/null
then
# 埋め込み対象のファイルの内容をcatして、
# リダイレクトで書き出す。
parts_name="$(echo "$line" |
$esed -e "s/^.*$embed_matcher.*/\\1/")"
cat "html/_parts/$parts_name" >> "$path"
else
# それ以外の行はそのまま書き出す。
echo "$line" >> "$path"
fi
done
rm "$path.tmp"
done
これでも一応目的は達成されていたのですが、以下のような問題が残ってしまっていました。
- ソースの1行ずつに対してBashの
while
ループを回すので、とにかく遅い。 - 行の中にinclude文があると、include文の位置ではなくその次の行にファイルが埋め込まれる。
1は、処理対象のファイルの行数とファイル数が増えるごとに大きな負担となります。前の記事を書いた時には「変更が無かったファイルは無視する」という別方向からの対策を取ってみましたが、それでも全ファイルを対象に処理し直す時にはずいぶん待たされてしまいます。
2は、とりあえず今のところ問題にはなっていませんが、include文をHTMLのコメントの形式にしたので、もしかしたらこの制約の事を忘れて行中に書きたくなってしまうかもしれません。その時に「えっそんな制約あったなんて……」と戸惑う羽目になる前に、なんとかできるものならなんとかしておきたいところです。
より良い実装
その後長らくそれっきりになっていたのですが、仕事の中でまた似たようなことをやりたい場面2が出てたので、これを機にもっとマシなやり方を探してみました。すると、sed
のr
コマンドというまさにこういう事をやるためにあるような機能の情報が見つかりました。
ということで、ここからがこの記事の本題です。
マッチした箇所に他のファイルの内容を埋め込む、を高速にやる
sed
のr
コマンドは、「カーソル行の直後(次の行の直前)に別のファイルの内容を読み込んで挿入する」という物です。パターンマッチと組み合わせれば、「include文をパターンマッチで見つける→見つけたinclude文の箇所にinclude対象の外部ファイルの内容を埋め込む」という事もできるはずです。
例えば、最初に示した例をべた書きするとこうなります。
$ cat html/index.html |
sed '/<!--EMBED(metadata.html)-->/r html/_parts/metadata.html' \
> html_resolved/index.html
sed
の機能なので、Bashのwhile
ループと比べると圧倒的に高速です。これで、問題の1つ目の「とにかく遅い」という点が解消されます。やりましたね!
マッチした箇所の次の行、ではなく、マッチしたまさにその箇所に他のファイルの内容を埋め込む
ただ、まだ問題はもう1つ残っています。sed
のr
は「マッチしたまさにその箇所」ではなく「マッチした箇所が含まれる行とその次の行の間」にファイルの内容を出力するため、行の中程にinclude文があると「include文の前の文字列→include文の後の文字列→参照されたファイルの内容→次の行」という結果になってしまいます。
この問題を解消するには、include文の後で必ず改行するように事前処理してしまうのが手っ取り早いです。
$ cat html/index.html |
$esed "s/($embed_matcher)( *[^$])/\1"\\$'\n''\3/g' |
sed '/<!--EMBED(metadata.html)-->/r html/_parts/metadata.html' \
> html_resolved/index.html
何だかゴチャゴチャ書いてあって分かりにくいですが、置換の指定としては以下のような内容になっています。
- 置換前→
(<include文>)(<改行と空白以外でinclude文より後の文字列>)
- 置換後→
<マッチしたinclude文><改行文字><include文より後の文字列>
3 - 行内のマッチ箇所をすべて置換(
g
フラグ)
このように置換してからsed
のr
でinclude文を処理すれば、ちゃんと「include文の前の文字列→参照されたファイルの内容→include文の後の文字列→次の行」という順で出力されるようになるわけです。
ファイル内のすべてのinclude文を処理する
ここまでのコマンド列にはinclude文を解決するための指定をべた書きしていましたが、実際には任意のファイルでいろんなファイルに対するinclude文を処理する必要があります。後方参照でsed -r '/<!--EMBED\((metadata.html)\)-->/r html/_parts/\1'
みたいなことができると楽なのですが、残念ながらsed
のr
コマンドの読み込み対象ファイルの指定には後方参照は使えません。
解決策としては、sed
を実行するコマンド列をsed
で組み立てるという方法があります。
$ embed_matcher='<!-- *EMBED\( *([^) ]+) *\) *-->'
$ embed_mark_to_resolver="s|($embed_matcher)| -e '/\\1/r html/_parts/\\2'|"
$ cat html/index.html |
egrep -o "$embed_matcher" |
sort |
uniq
<!--EMBED(metadata.html)-->
<!--EMBED(footer.html)-->
<!--EMBED(header.html)-->
...
grep
やegrep
(grep -E
と同等)に-o
オプションを指定すると、「マッチした文字列がある行」ではなく「マッチした文字列そのもの」、ここではinclude文の部分だけが出力されます。それをsed -r "s|($embed_matcher)| -e '/\\1/r html/_parts/\\2'|"
で置換して-e 'sedスクリプト'
というコマンドラインオプションに変換すると、こうなります。
$ cat html/index.html |
egrep -o "$embed_matcher" |
sort |
uniq |
sed -r -e "$embed_mark_to_resolver"
-e '/<!--EMBED(metadata.html)-->/r html/_parts/metadata.html'
-e '/<!--EMBED(footer.html)-->/r html/_parts/footer.html'
-e '/<!--EMBED(header.html)-->/r html/_parts/header.html'
...
この出力に対してtr -d '\n'
で改行を削除し(1行に繋げ)てsed
のコマンドライン引数に指定すれば、ファイル内のすべてのinclude文を一気に処理することができます。
$ resolve_embedded_resources="sed $(cat "$path" |
egrep -o "$embed_matcher" |
sort |
uniq |
$esed -e "$embed_mark_to_resolver" |
tr -d '\n')"
$ cat html/index.html |
$esed "s/($embed_matcher)( *[^$])/\1"\\$'\n''\3/g' |
eval "$resolve_embedded_resources" \
> html_resolved/index.html
ちなみに、-e
オプションの指定の中には丸括弧など拡張正規表現では特別な意味を持つ文字があるので、これらは本来スケープする必要があります。が、$esed
ではなくsed
を使うようにすればエスケープは不要です。
$resolve_embedded_resources
と直接書くのではなく、わざわざeval
コマンドを使ってeval "$resolve_embedded_resources"
と書いているのは、組み立てたコマンドラインオプションの'
が値の一部にならないようにするためです。というのも、そのままパイプラインの中に
cat ... | $resolve_embedded_resources | ...
と書くと、シェル変数が展開されてsed -e "'/<!--EMBED(metadata.html)-->/r html/_parts/metadata.html'" ...
のように書かれた扱いとなってしまい、sed
に「'
なんてコマンドは無い」と言われてしまからです。eval
を使えば、指定文字列を改めてシェルのコマンド列としてパースするため、sed -e '/<!--EMBED(metadata.html)-->/r html/_parts/metadata.html' ...
と書いたのと同じに扱われるようになります。
また、これだけだとinclude文自体がソースの中に残ってしまうので、ついでにそれらを消す置換操作の指定も加えるとこうなります。
$ embed_mark_to_resolver="s|($embed_matcher)| -e '/\\1/r html/_parts/\\2' -e '/^ *\\1 *$/d' -e 's/ *\\1 *//'|"
1つのinclude文から3つの-e
オプションができる形ですね。
さらに、これだとマッチしたinclude文の中にファイルパスのデリミタの/
が入った時に破綻してしまうので、マッチングパターンの正規表現を囲う文字を/
から;
に変えておきます。
$ embed_mark_to_resolver="s|($embed_matcher)| -e '\\\\;\\1;r html/_parts/\\2' -e '\\\\;^ *\\1 *$;d' -e 's; *\\1 *;;'|"
s
コマンドでは単に;
で囲うだけでいいですが、r
コマンドとd
コマンドについては\;マッチングパターン;
という風に最初に\
を付ける必要があります。それをまた全体として1つの文字列の中に入れているので、エスケープがたくさん並ぶ読みにくいスクリプトになってしまいました……まぁこれはしょうがないです。
まとめ
以上を踏まえて前述のスクリプトの例を書き直すと、こうなります。
#!/bin/bash
case $(uname) in
Darwin|*BSD|CYGWIN*)
esed="sed -E"
;;
*)
esed="sed -r"
;;
esac
rm -rf html_resolved
cp -r html html_resolved
embed_matcher='<!-- *EMBED\( *([^) ]+) *\) *-->'
embed_mark_to_resolver="s|($embed_matcher)| -e '\\\\;\\1;r html/_parts/\\2' -e '\\\\;^ *\\1 *$;d' -e 's; *\\1 *;;'|"
egrep -r \
--include='*.html' \
"$embed_matcher" \
html_resolved |
cut -d ':' -f 1 |
uniq |
while read path
do
echo "updating $path"
resolve_embedded_resources="sed $(cat "$path" |
egrep -o "$embed_matcher" |
sort |
uniq |
$esed -e "$embed_mark_to_resolver" |
tr -d '\n')"
mv "$path"{,.tmp}
cat "$path.tmp" |
$esed "s/($embed_matcher)( *[^$])/\1"\\$'\n''\3/g' |
eval "$resolve_embedded_resources" \
> "$path"
rm "$path.tmp"
done
筆者が普段使用している環境で新旧それぞれのスクリプトをtime ./build.sh -f
という感じで実行して計測してみたところ、
環境 | 改修前のrealtime | 改修後のrealtime |
---|---|---|
Ubuntu on Let's note CF-SX3 | 3.217秒 | 0.644秒 |
Raspbian on Raspberry Pi2 Model B | 20.072秒 | 2.475秒 |
という感じで実時間で5~8倍の高速化となりました。ラズパイでも、カジュアルに全ファイルを処理させても気にならない程度まで高速になっています。万歳!
sed
やawk
だけでも頑張ればこういう事ができるのかもしれません。でも、ごく基本的な機能だけしか知らなくても「コマンドの実行結果でコマンド列を作る」という一工夫によってできる事の幅はかなり広がると思います4。この記事をご覧になった皆さんも、「自分はどうせ基本的な使い方くらいしか知らないから……ガッツリ覚えるつもりもないし……」と卑屈にならず、ぜひ柔軟な発想で問題を解決してみてはいかがでしょうか?
別解:Cプリプロセッサ(cpp
コマンド)を使う
ここまで「include文のようなことをsed
でやる」という事を頑張ってみましたが、C言語のプリプロセッサ向けのinclude文そのものと同じ仕様でよければ、それこそCプリプロセッサをそのまま使うという方法もあります。
C言語のプリプロセッサはcpp
というコマンドとして単体で使う事ができ、UbuntuやDebianであればその名もcpp
というパッケージでインストールできます。Gemのパッケージ等でバイナリをビルドする必要がある物をインストールする際の依存関係で既にインストールされているという人も多いのではないでしょうか。これがインストールされている環境であれば、
<!DOCTYPE html>
<html lang="ja" xmlns:og="http://ogp.me/ns#" xmlns:fb="http://www.facebook.com/2008/fbml">
<head prefix="og: http://ogp.me/ns# fb: http://ogp.me/ns/fb# article: http://ogp.me/ns/article#">
#include "html/_parts/metadata.html"
<title>シス管系女子って何!? - 【シス管系女子】特設サイト</title>
...
このようにソースに書いておいて
$ cat html/index.html |
cpp -P \
> html_resolved/index.html
と実行すれば5、まさにC言語のソースと同じ要領でinclude文を処理した結果を得る事ができます。もちろん、include以外の構文も使えます。ただ、たまたまプリプロセッサ向けの書き方と同じ文字列があると意図せず処理されてしまうというリスクはあります。自分はC言語には詳しくなくて地雷を踏むのが怖いので、しこしこsed
で頑張ろうと思います……
-
実際にはちょっと違う書き方でしたが、要旨としてはこんな感じ、ということです。 ↩
-
Firefoxの法人導入では管理者による設定を静的なJavaScriptファイルとして用意するのですが、「大部分は共通だけれども一部分だけが異なる」という設定ファイルを複数種類用意する必要が生じたのでした。共通部分を括り出すのでなく、ソースファイルに書かれたプレースホルダの位置に、環境ごとの別のソースファイルの内容を埋め込んで各環境ごとの静的なファイルをビルドしたい、という感じです。 ↩
-
sed
で置換後の文字に改行文字を含めれば行の途中で改行することができますが、それには色々と工夫が必要です。詳細はsedで改行を出力するをご覧下さい。 ↩ -
実際、この記事に「
grep
の結果をcut | uniq
で加工しなくてもgrep -l
でいける」というフィードバックを頂きましたが、これもまさに「grep
の-l
オプションを知らなくても、基本的な文字列加工コマンドの組み合わせで目的は達成できる」という事の一例と言えるでしょう。 ↩ -
cpp
コマンドの-P
オプションは、プリプロセッサの行番号情報を出力しないようにする指定です。これを指定しないと、# 1 "<command-line>"
やら# 1 "html_resolved/index.html" 1
やらといった処理中のデバッグ情報的なメッセージまでが出力に含まれてしまいます。 ↩