たまに18歳未満の人や心臓の弱い人にはお勧めできない情報が含まれることもあるかもしれない、甘くなくて酸っぱくてしょっぱいチラシの裏。RSSによる簡単な更新情報を利用したりすると、ハッピーになるかも知れませんしそうでないかも知れません。
の動向はもえじら組ブログで。
宣伝。日経LinuxにてLinuxの基礎?を紹介する漫画「シス管系女子」を連載させていただいています。 以下の特設サイトにて、単行本まんがでわかるLinux シス管系女子の試し読みが可能!
シェル芸アドベントカレンダー2019に空きがあったので、最近やったシェルの話で参加させていただきます。
自分は日経Linux誌でシス管系女子というシェルの解説記事(マンガ)を連載させていただいてます。その2020年1月号掲載分の回において、「劇中の架空の会社で、アダムズ方式を使って各部署から代表者を何名かずつランダム且つ公平な感じで選ぶシェルスクリプトを作る」という話をやっています。
アダムズ方式では、前の計算の結果を使って次の計算を行うというステップが何度か出てきます。今までは「1画面分だけの画面出力」が必要なケースが多かったので都度てきとうにその場の気分で偽の画面をデッチ上げていたものの、こう複雑な話になってくると、それぞれの画面間で数字に矛盾が無いようデッチ上げるのはなかなか大変です。なので、実際にそれっぽい「社員名簿」のダミーデータのCSVを作って、その処理結果をそのまま使うことにしました。
こういう場面でのダミーデータの作り方はいろいろあると思いますが、描いているのがシェルの解説なので、シェルでやってみました。というのがこの記事の内容です。
まんがでわかるLinux シス管系女子の74ページで、仮想端末(本編中ではtmux)の使用時に複数の画面間でコマンド履歴が共有されない問題の解決策として~/.bashrc
にこういう記述を追加すると良いという話を書きました。
function share_history {
# 最後に実行したコマンドを履歴ファイルに追記
history -a
# メモリ上のコマンド履歴を消去
history -c
# 履歴ファイルからメモリへコマンド履歴を読み込む
history -r
}
# 上記の一連の処理を、プロンプト表示前に(=何かコマンドを実行することに)実行する
PROMPT_COMMAND='share_history'
# bashのプロセスを終了する時に、メモリ上の履歴を履歴ファイルに追記する、という動作を停止する
# (history -aによって代替されるため)
shopt -u histappend
ところが、これを使用しているとコマンド履歴に同じ項目がどんどん溜まっていくという問題が起こるようになります。自分の場合だと、git commit -p
してからgit push
するとか、その前にgit pull
するとかいう操作が多く、コミットの粒度も細かくするようにしているので、コマンド履歴があっという間にこれらのコマンド列で埋まってしまいます。
Ubuntuの場合だと~/.bashrc
には最初からHISTCONTROL=ignoreboth
という記述がありますが、この指定は「直前のコマンド列と同じコマンド列の実行時にはコマンド履歴を残さない」という物です(正確には、これはHISTCONTROL=ignorespace:ignoredups
の省略形で、重複を記録しないという動作はignoredups
の効果です)。似た指定でHISTCONTROL=erasedups
という指定もあり、こちらは直前のコマンド列に限らずコマンド履歴全体の中で重複を排除する物です。しかしどちらを指定しても、上記の設定と組み合わせると期待通りの結果を得られません。何故なのでしょうか。
これは、HISTCONTROL
の指定が一体何に対して作用するのかという事を考えると分かります。GNUのbashのバージョン4.4.18のソースコードを見てみると、ignoreboth
やerasedups
はどちらもメモリ上のコマンド履歴に対して、新しい項目を追加する時にメンテナンスを実行するようになっています。しかし、上記の設定を使用している場合、せっかくそのようにメンテナンスしたコマンド履歴はhistory -c
で消去されてしまって、その後、重複を含んだままの履歴ファイルからhistory -r
で履歴を読み込み直しています。これではignoreboth
もerasedups
も全く効果を得られなくて当たり前です。
ではどうすればよいかという話なのですが、とりあえずの解決策としてはこういう方法があります。
function share_history {
history -a
tac ~/.bash_history | awk '!a[$0]++' | tac > ~/.bash_history.tmp
[ -f ~/.bash_history.tmp ] &&
mv ~/.bash_history{.tmp,} &&
history -c &&
history -r
}
PROMPT_COMMAND='share_history'
shopt -u histappend
tac
コマンドというのは、cat
の逆で最後の行から最初の行に向かってファイルの内容を出力するコマンドです。これを使ってファイルを逆順出力してから、重複行の最初の1行目を残して残りを削除して、それをまた逆順にして出力し直した後、出力結果で~/.bash_history
を置き換えています(1行の中でそのままリダイレクトでファイルを置換しようとすると元ファイルが消えてしまうので要注意!)。[ -f ~/.bash_history.tmp ]
で一時ファイルがちゃんと作成されていることを確認して(何らかのエラーで一時ファイルが作成されなかった場合、そのまま続行するとコマンド履歴が消失して終わりになってしまいます!)、history -c
でメモリ上のコマンド履歴を消去してhistory -r
で読み込み直すようにするわけです。
これにより、同じコマンド列を何度も実行しても最新の1つだけが履歴に残るようになります。やりましたね!
もっとスマートなやり方もあるんだろうとは思いますが(例えば、BashではなくZshを使えば最初からこういう挙動になっているそうです)、自分がパッと思いつく解決策はこういう物でした、ということで主にシス管系女子読者の方向けの3年越しのフォローアップ記事として公開しておきます。
tac
の代用tac
はGNU coreutilsのコマンドの一つなのですが、macOSなどのBSD系の環境だとコマンドが存在しません。そのためtail -r
で代替する必要があります。
WindowsでのFirefoxのビルドに使用するMozillaBuildだと、tac
もtail -r
もどちらも使えません。このような環境では、sed
での代替実装を使って以下のようにtac
コマンドの代わりの関数を定義しておくとよいでしょう。
function tac {
exec sed '1!G;h;$!d' ${@+"$@"}
}
このエントリはQiitaとのクロスポストです。
diff
コマンドを使うと、ファイルの中で変更があった箇所を簡単に調べる事ができます。
ただ、たまにその逆の事をしたくなる場合があります。つまり、2つのファイルの中で変更があった部分は無視して、同じ行があったらそこを列挙して欲しい、という場面です。
例えば、多言語対応のための言語リソース(ロケール)について、未訳箇所は原語のままにするというルールで運用している場合に、原語のロケールと比較して未訳箇所を調べたいというような場合がこれにあたります。
en-US.properties:
menu.new.label=New File
menu.save.label=Save
menu.close.label=Close
menu.properties.label=Properties
menu.exit.label=Exit
button.new.label=New File
button.new.tooltip=Create new file
button.save.label=Save
button.save.tooltip=Save (Overwrite) this file
button.close.label=Close
button.close.tooltip=Close this file
button.properties.label=Properties
button.properties.tooltip=Show detailed information of this file
button.exit.label=Exit
button.exit.tooltip=Exit without save
ja.properties:
menu.new.label=新規作成
menu.save.label=保存
menu.close.label=閉じる
menu.properties.label=Properties
menu.exit.label=終了
button.new.label=新規作成
button.new.tooltip=ファイルを新しく作る
button.save.label=保存
button.save.tooltip=ファイルを上書き保存する
button.close.label=閉じる
button.close.tooltip=ファイルを閉じる
button.properties.label=Properties
button.properties.tooltip=Show detailed information of this file
button.exit.label=終了
button.exit.tooltip=ファイルを保存せず終了する
こんな感じの2つのファイルがあったとして、menu.properties.label=Properties
などの箇所が未訳ということになりますが、あちこちに散らばっているこのような未訳箇所がどれだけあるかをパッと調べたい、ということです。
「inverted diff」「opposite diff」「reversed diff」「diffの逆」のようなキーワードで検索すると、以下のようなやり方が紹介されている事が多いです。
comm -1 -2 oldfile newfile
:2つの入力の共通行を調べるcomm
コマンドを使った例。ただし、これは両方のファイルの内容がソート済みである必要がある。fgrep -x -f oldfile newfile
:指定文字列にマッチする行を出力するfgrep
(grep -F
と同じ。マッチングパターンを正規表現ではなく静的な文字列として扱うgrep
)の-f
オプション(マッチングパターンをファイルで指定するオプション。ファイルの1行が1つのマッチングパターンになる)と-x
オプション(マッチングパターンに行全体がマッチする場合にのみマッチしたとみなすオプション)を使い、片方のファイル内の各行について、もう片方のファイルのいずれかの行と内容が完全一致する行を出力する。他にもPerlを使う例もありますが、これらのやり方はいずれも、「2つのファイルで内容が同じ行を出力する」という物です。
それに対し、diff
では変更があった箇所を示すと同時にその前後の変更が無かった箇所も出力する、つまり前後の文脈を見る事ができます。「diffの逆」というなら、「変更が無かった箇所の前後の文脈」も見たくなって当然でしょう。
また、行単位で見れば内容が同じでも、出現位置が違うので全体としては意味が違う、という事もあります。前述の例はいずれも、このようなケースを検出できません。
diffは、「ファイルを先頭から比較していき、内容が変化していない部分は飛ばして、内容が違う部分があったらそこを詳細に出力する」という事をします。
その反対となる逆diffには「ファイルを先頭から比較していき、内容が変化していない部分を詳細に出力して、内容が違う部分があったらそこは飛ばす」という事をして欲しいわけです。
ということで、それらしい事をやるシェルスクリプトを書いてみました。
inverted-diff.sh:
#!/bin/bash
context_lines=3
while getopts c: OPT
do
case $OPT in
c)
context_lines=$OPTARG
shift 2
;;
esac
done
case $(uname) in
Darwin|*BSD|CYGWIN*)
esed="sed -E"
;;
*)
esed="sed -r"
;;
esac
oldfile="$1"
newfile="$2"
diff --new-line-format='+%L' --old-line-format='-%L' --unchanged-line-format=' %L' \
<(cat "$oldfile" | tr '\r' '\n') \
<(cat "$newfile" | tr '\r' '\n') |
paste -s -d '\r' - |
$esed -e "s/^([-+][^\r]*\r)*(([-+][^\r]*\r){$context_lines})([^-+])/...\r\2\4/g" \
-e "s/(\r[^-+][^\r]*)((\r[-+][^\r]*){$context_lines})(\r[-+][^\r]*)+(\r?)$/\1\2\r...\5/g" \
-e "s/((\r[-+][^\r]*){$context_lines})(\r[-+][^\r]*)+((\r[-+][^\r]*){$context_lines})(\r[^-+]|$)/\1\r...\4\6/g" |
tr '\r' '\n'
実際にこれを先の例のファイルに対して実行すると、こんな結果になります。
$ ./inverted-diff.sh en-US.properties ja.properties
...
+menu.new.label=新規作成
+menu.save.label=保存
+menu.close.label=閉じる
menu.properties.label=Properties
-menu.exit.label=Exit
-button.new.label=New File
-button.new.tooltip=Create new file
...
+button.save.tooltip=ファイルを上書き保存する
+button.close.label=閉じる
+button.close.tooltip=ファイルを閉じる
button.properties.label=Properties
button.properties.tooltip=Show detailed information of this file
-button.exit.label=Exit
-button.exit.tooltip=Exit without save
+button.exit.label=終了
...
ちなみに、前後の行をもっと出力したい場合は./inverted-diff.sh -c 4 en-US.properties ja.properties
のように行数を-c
オプションで指定できるようにしてあります。
キモになるのは、省略せずに全行の差分を出力する方法です。diff
に--new-line-format='+%L' --old-line-format='-%L' --unchanged-line-format=' %L'
という具合に「追加された行」「削除された行」「変更が無かった行」それぞれの出力フォーマットを個別に指定すると、変更が無かった部分が長く続いてもその部分が省略されていない出力結果を得る事ができます。
後の部分は、その出力に対して「変更が連続している部分をそれらしく省略する」という加工を施すための物です。以下、シス管系女子シリーズの読者の方向けに、本の中で解説していないテクニックについてもうちょっと詳しく解説しておきます。
while getopts
で名前付きの引数をオプションとして受け取る定番の方法を使って-c 4
のような行数指定を受け付けていますが、オプションの検出後にshift 2
で引数のリストの先頭から-c
と4
を取り除く事で、その後の$1
と$2
が確実に比較対象のファイルを示すようにしています。これは、名前付きオプションと名前無しの引数を併用する形のスクリプトを書く時に気をつけないといけないポイントです(shift 2
を忘れると引数の順番がズレてしまいます)。case $(uname)
からのブロックは、環境によってsed
で拡張正規表現を使うためのオプションが違うという問題を回避するために自分がよく使っているやり方です。<(cat "$oldfile" | tr '\r' '\n')
というのは、「cat "$oldfile" | tr '\r' '\n'
の実行結果の出力を内容としたファイルを、そこで指定した事にする」という書き方(bashのプロセス置換という機能)です。sed
で複数行に渡る置換を行うために、一旦全行をpaste -s -d '\r' -
で「\r
区切りの1行の文字列」として結合しています。paste
を使った行の結合については別の記事で解説していますので、併せてご覧下さい。(パターン){N}
と指定すると「そのパターンがN回繰り返された場合」を示す事ができます。これを使って、変更があった行が何行以上連続していたら間を省略する、ということをやっています。この実装ですが、やっつけ仕事なのでアラが色々あります。例えば以下のような具合です。
diff
の出力を一旦1行に繋げているので、入力が大きいとメモリを使いすぎる(多分)。CR
かLF
かの違いが無視される。CRLF
であるファイルは改行が二重に表示される。diff -r
のような再帰的な複数ファイルの比較に対応していない。自分はここまででやる気が尽きてしまいましたので、どなたかやる気に溢れた方のアドバイスをお待ちしております。
このエントリはQiitaとのクロスポストです。
適当な空きポートをlistenしたい場面があったのですが、「空きポート 探す」みたいなキーワードで検索しても「あるポートが空いているかどうか(そのポート番号についてファイアウォールでブロックされていないかどうか)を調べる」や「あるポートを誰かがlistenしているかどうかを調べる」という例はすぐに見つかっても、「誰も使っていないポートをランダムに1つ抽出する」という例はパッと出てきませんでした。なので書き留めておきます。
/proc/sys/net/ipv4/ip_local_port_range
でエフェメラルポートの開始番号と終了番号が得られる。shuf -i [start]-[end] -n 1
で与えられた範囲の数字の中から1つをランダムに選べる。netstat -a -n | egrep ':[port] .+LISTEN'
に成功した場合は誰かがそのポートをlistenしていて、失敗した場合は誰もそのポートをlistenしていない(ポートが空いている)。という所から、空いているポートをランダムに1つ選ぶシェルスクリプトはこんな感じに書けました。
find_free_port.sh:
#!/bin/bash
available_port_range="$(cat /proc/sys/net/ipv4/ip_local_port_range | cut -f 1,2 --output-delimiter='-')"
while true
do
port="$(shuf -i $available_port_range -n 1)"
netstat -a -n |
egrep ":$port .+LISTEN" 1>/dev/null 2>&1 ||
break
done
echo $port
nc
で一時的なサーバーを立てるとかそういう場面で使えるんじゃないでしょうか。
このエントリはQiitaとのクロスポストです。
複数のファイルに共通する部分があるとき、共通箇所をまとめて切り出しておいて、各ファイルからはそれらを参照するだけにする、というのはよくある話です。C言語なら#include <stdio.h>
という書き方をしますし、Web制作をやる人なら、CSSの@import
規則をご存じだと思います。
しかしたまに、これに似たことを静的なファイルで行って「include文の位置に参照先のファイルがそのまま埋め込まれたファイル」を作りたいという場面が出てきます。
この記事では、そんな「静的なファイルを生成するために、ソースとなるテキストファイルに書かれたinclude文をシェルスクリプトで処理して、参照先ファイルの内容をその位置に埋め込んだ結果のファイルを得たい」というニーズに対する、なるべく効率のよい実現方法を模索してみます。
以前、さくらのレンタルサーバーの一番安いプランでWebサイトを公開するノウハウという記事で、「ssh接続できない月額129円の激安レンタルサーバーでも、手元にLinuxな環境があるならコマンドラインのFTPクライアントとシェルスクリプトでrsyncっぽいことができるよ! ついでに色々前処理もさせられるし、シス管系女子のサイトのようにチープな静的コンテンツだけのサイトなら全然余裕で運用できちゃう! やったね!」という事例をご紹介しました。
その際にやりたかった前処理のひとつに前述のinclude文があり、全ページで共通のヘッダやフッタを
html/_parts/metadata.html(共通パーツ):
<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ファイルの中に
html/index.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>
...
のように書いておき、アップロード直前に
html_resolved/index.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#">
<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>
...
のように解決する、という事をしていました。
最初に先の記事を公開した時の実装は、以下のようなものでした(実際にはちょっと違う書き方でしたが、要旨としてはこんな感じ、ということで)。
build.sh:共通パーツを埋め込む:
#!/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
これでも一応目的は達成されていたのですが、以下のような問題が残ってしまっていました。
while
ループを回すので、とにかく遅い。1は、処理対象のファイルの行数とファイル数が増えるごとに大きな負担となります。前の記事を書いた時には「変更が無かったファイルは無視する」という別方向からの対策を取ってみましたが、それでも全ファイルを対象に処理し直す時にはずいぶん待たされてしまいます。
2は、とりあえず今のところ問題にはなっていませんが、include文をHTMLのコメントの形式にしたので、もしかしたらこの制約の事を忘れて行中に書きたくなってしまうかもしれません。その時に「えっそんな制約あったなんて……」と戸惑う羽目になる前に、なんとかできるものならなんとかしておきたいところです。
その後長らくそれっきりになっていたのですが、仕事の中でまた似たようなことをやりたい場面(Firefoxの法人導入では管理者による設定を静的なJavaScriptファイルとして用意するのですが、「大部分は共通だけれども一部分だけが異なる」という設定ファイルを複数種類用意する必要が生じたのでした。共通部分を括り出すのでなく、ソースファイルに書かれたプレースホルダの位置に、環境ごとの別のソースファイルの内容を埋め込んで各環境ごとの静的なファイルをビルドしたい、という感じです)が出てたので、これを機にもっとマシなやり方を探してみました。すると、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文より後の文字列>
g
フラグ)sed
で置換後の文字に改行文字を含めれば行の途中で改行することができますが、それには色々と工夫が必要です。詳細はsedで改行を出力するをご覧下さい。
このように置換してからsed
のr
で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つの文字列の中に入れているので、エスケープがたくさん並ぶ読みにくいスクリプトになってしまいました……まぁこれはしょうがないです。
以上を踏まえて前述のスクリプトの例を書き直すと、こうなります。
build.sh:共通パーツを埋め込む(改良版):
#!/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
だけでも頑張ればこういう事ができるのかもしれません。でも、ごく基本的な機能だけしか知らなくても「コマンドの実行結果でコマンド列を作る」という一工夫によってできる事の幅はかなり広がると思います。実際、この記事に「grep
の結果をcut | uniq
で加工しなくてもgrep -l
でいける」というフィードバックを頂きましたが、これもまさに「grep
の-l
オプションを知らなくても、基本的な文字列加工コマンドの組み合わせで目的は達成できる」という事の一例と言えるでしょう。
この記事をご覧になった皆さんも、「自分はどうせ基本的な使い方くらいしか知らないから……ガッツリ覚えるつもりもないし……」と卑屈にならず、ぜひ柔軟な発想で問題を解決してみてはいかがでしょうか?
cpp
コマンド)を使うここまで「include文のようなことをsed
でやる」という事を頑張ってみましたが、C言語のプリプロセッサ向けのinclude文そのものと同じ仕様でよければ、それこそCプリプロセッサをそのまま使うという方法もあります。
C言語のプリプロセッサはcpp
というコマンドとして単体で使う事ができ、UbuntuやDebianであればその名もcpp
というパッケージでインストールできます。Gemのパッケージ等でバイナリをビルドする必要がある物をインストールする際の依存関係で既にインストールされているという人も多いのではないでしょうか。これがインストールされている環境であれば、
html/index.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#">
#include "html/_parts/metadata.html"
<title>シス管系女子って何!? - 【シス管系女子】特設サイト</title>
...
このようにソースに書いておいて
$ cat html/index.html |
cpp -P \
> html_resolved/index.html
と実行すれば、まさにC言語のソースと同じ要領でinclude文を処理した結果を得る事ができます(cpp
コマンドの-P
オプションは、プリプロセッサの行番号情報を出力しないようにする指定です。これを指定しないと、# 1 "<command-line>"
やら# 1 "html_resolved/index.html" 1
やらといった処理中のデバッグ情報的なメッセージまでが出力に含まれてしまいます)。
もちろん、include以外の構文も使えます。ただ、たまたまプリプロセッサ向けの書き方と同じ文字列があると意図せず処理されてしまうというリスクはあります。自分はC言語には詳しくなくて地雷を踏むのが怖いので、しこしこsed
で頑張ろうと思います……
このエントリは以下のエントリのフォローアップです。
(また、Qiitaにもクロスポストしています。)
結論を先に書くと、シェルスクリプトの中で普通のプログラミング言語で文字列を区切り文字で分割して配列にする操作、いわゆるsplit()
相当の事はtr '区切り文字' '\n'
でできます。その逆の、配列を結合して1つの文字列にする操作、いわゆるjoin()
相当の事はpaste -s -d '区切り文字' -
と覚えておくのが筆者的にはオススメです。
(ちなみに、GNU coreutilsのコマンドでjoin
という物がありますが、これは配列のjoin()
ではなく、SQLで言うところのINNER JOIN
とかOUTER JOIN
とかの方の文脈の「join」に対応する物です。この記事の話とは関係ないので、忘れて下さい。)
以下、どういう場合にそれが言えるのかという事と、その理由の解説です。
このエントリはShell Script Advent Calendarとのクロスポストです。(→Qiitaの方の投稿)
前回は自作のBashスクリプト製Twitterクライアントをネタに実装を解説しましたが、今日は他の言語で多少のプログラミング経験はあるんだけど、どうにもシェルスクリプトは苦手だ……という人のための、シェルスクリプトによるプログラミングの勘所を解説してみようと思います。多分、プログラミング入門レベルの人や上級レベルの人よりは、中級レベルにいる人や、初級から中級以上に成長したいという感じの人にとって役に立つ話だと思います。
結論から先にいうと、「引数」と「配列」の事を忘れればシェルスクリプトはグッと書きやすくなります。というおはなしです。タイトルからして期せずして前の日の方の話の全否定になっちゃいました。ごめんなさい、他意は無かったんです……
このエントリはチャットボット Advent Calendarとのクロスポストです。(→Qiitaの方の投稿)
チャットボット Advent Calendarをご覧になっているような方々はフレームワークやニューラルネットでディープラーニングなAIといった最新のbot事情に関心の強い方が多いと思うのですが、今日の記事は時代に逆行しまくって、Bashスクリプトで昔ながらの人工無脳botを作りましたというお話です。
この記事では以下のことを書いています。
このエントリはShell Script Advent Calendar 2016とのクロスポストです。(→Qiitaの方の投稿)st
Linux Advent Calendarの方にGUIアプリのスクショを定期的にSlackに流すシェルスクリプトの話でエントリーしたのですが、Shell Script Advent Calendar的にはそれをこっちを投稿した方が良かったかもと今更思いつつ、今日は別の話題です。
シェルスクリプト製Twitterクライアントには恐怖!小鳥男やtweet.sh(同名の別実装)などいくつか実装例がありますが、自分も2015年末頃からtweet.shという汎用のTwitterクライアントを開発しています。この記事ではtweet.shをネタに、以下の事を解説します。
nkf
といくつかの一般的なコマンドでURLエンコードするキー1=値1,キー2=値2,...
の形に変換するこのエントリはさくらのアドベントカレンダー(その2)とのクロスポストです。(→Qiitaの方の投稿)
この記事では自分で自由に使えるLinuxなサーバーかPCがあるという事を前提として、さくらのレンタルサーバーのライトプランで静的コンテンツだけのWebサイトを公開・運用する際のノウハウをご紹介します。
なお、自分では調べていませんが、スクリプト内で使用しているlftp
等のコマンドがHomebrew等でインストール可能なのであれば、macOS(OS X)でもこの方法をそのまま使えるかもしれません。