CUBE SUGAR CONTAINER

技術系のこと書きます。

Re:VIEW で書いた本に記載するコマンドライン操作をテストする方法について考えた

今回は、Re:VIEW で記述している技術書に記載するコマンドライン操作がちゃんと動くか確認する方法について考えてみた話。 このエントリでは、コマンドライン操作を記述しているテキストファイルをシェルスクリプトに変換して実行する方法を提案する。 なお、Re:VIEW にはさほど慣れていないので、もっと良いやり方があれば教えてもらいたい。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ review version
3.2.0
$ sed --version | head -n 1
gsed (GNU sed) 4.7
$ bash --version
GNU bash, version 3.2.57(1)-release (x86_64-apple-darwin18)
Copyright (C) 2007 Free Software Foundation, Inc.

もくじ

技術書にコマンドライン操作を記載する際の課題について

技術書にコマンドライン操作を記載する場合、その内容が実機でちゃんと動作するか確認する必要がある。 それも、動作確認は一回やって終わりではなく、少なくとも書籍が完成するまでは継続的に実施することになる。 なぜなら、記載する内容は随時修正される可能性があるから。 ある箇所を修正したら、別の箇所で整合性が取れなくなっていないかレグレッションテストしなければならない。 また、出版してからも、使っているシステムやツールの更新などによって動作しなくなっていないか確認することが望ましい。

しかし、上記の作業を手作業で実施することには困難が伴う。 内容を修正する度に、本から一行ずつコマンドをコピペしていく作業は考えただけで気が遠くなる。 そのため、なるべく一連の作業は自動化することが望ましい。

また、コマンドライン操作特有の要件として、以下のようなものがある。

  • 実行して得られる出力も記載したい
    • 入力だけを羅列すれば良いわけではない
    • 出力はコマンドによって毎回内容が異なる可能性がある (ping のレイテンシなど)
  • 自動化する工程を環境構築用にも流用したい
    • たくさんのコマンドライン操作を読者に強要することを避けるため

今回は、上記を満たせるように Re:VIEW において自動化する方法を考えてみた。 具体的には、一連のコマンドライン操作をプリプロセッサが解釈する命令を頼りにシェルスクリプトに変換する。 シェルスクリプトの形になっていれば、実機で実行することも比較的容易といえるはず。

サンプルとなる Re:VIEW のプロジェクトを用意する

ここからは、実際に Re:VIEW を使って課題を解決するまでの流れを解説する。 なお、Re:VIEW の環境構築などについては以下のエントリを参照のこと。

blog.amedama.jp

まずはサンプルとなるプロジェクトを用意しよう。 ここでは example-book という名前で Re:VIEW のプロジェクトを作成する。

$ review-init example-book

すると、次のように必要なファイル一式が用意される。

$ cd example-book
$ ls
Gemfile     config.yml  images      sty
Rakefile    doc     layouts     style.css
catalog.yml example-book.re lib

上記の中で example-book.re ファイルがデフォルトで組み込まれるマークアップテキストファイルとなる。

Re:VIEW にコマンドライン操作を記載する方法について

Re:VIEW でコマンドライン操作を示すには、ブロック命令 cmd を用いる。 以下では、実際に cmd 命令を使っていくつかのコマンドライン操作を示すように、マークアップテキストファイルを編集している。

$ cat << 'EOF-' > example-book.re
= Hello, World

//cmd{
$ echo 'Hello, World'
Hello, World
$ cat << 'EOF' > greet.txt
Hello, World
EOF
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
//}

EOF-

それでは、実際に上記をテキストフォーマットにビルドしてみよう。

$ rake text

ビルドすると、次のような結果が得られた。 コマンドラインの操作が本の中に組み込まれていることがわかる。

$ cat book-text/example-book.txt 
■H1■第1章 Hello, World

◆→開始:コマンド←◆
$ echo 'Hello, World'
Hello, World
$ cat << 'EOF' > greet.txt
Hello, World
EOF
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

プロプロセッサ命令を使って外部のファイルを取り込む

先ほどのやり方では、コマンドライン操作を直接マークアップテキストファイルに記述した。 このやり方は単純で分かりやすい反面、記述した内容を変更する際のメンテナンス性が悪い。 そこで、Re:VIEW ではプリプロセッサを使ってファイルのファイルに記述された内容を取り込むことができる。

例えば、次のように commands.txt という名前でコマンドライン操作を記述したテキストファイルを用意する。

$ cat << 'EOF-' > commands.txt
$ echo 'Hello, World'
Hello, World
$ cat << 'EOF' > greet.txt
Hello, World
EOF
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
EOF-

そして、マークアップテキストファイルでは上記のファイルをプリプロセッサ命令の #@mapfile で読み込むように指定する。

$ cat << 'EOF' > example-book.re
= Hello, World

//cmd{
#@mapfile(commands.txt)
#@end
//}

EOF

準備ができたら rakepreproc タスクを実行する。

$ rake preproc

すると、プリプロセッサ命令の中にファイルの内容が取り込まれる。

$ cat example-book.re 
= Hello, World

//cmd{
#@mapfile(commands.txt)
$ echo 'Hello, World'
Hello, World
$ cat << 'EOF' > greet.txt
Hello, World
EOF
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
#@end
//}

もちろん、ビルドした後の成果物にはプリプロセッサ命令は残らない。

$ rake text
$ cat book-text/example-book.txt 
■H1■第1章 Hello, World

◆→開始:コマンド←◆
$ echo 'Hello, World'
Hello, World
$ cat << 'EOF' > greet.txt
Hello, World
EOF
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

プロプロセッサ命令を使って外部のファイルの一部を取り込む

先ほどのやり方も、直接マークアップテキストファイルにコマンドライン操作をベタ書きするよりはだいぶメンテナンス性がよくなる。 ただ、本の中で取り扱うコマンドライン操作が多いと、大量のファイルを扱うことになってこれまたメンテナンス性が悪い。 そこで、Re:VIEW にはファイルの一部だけを取り込むプリプロセッサ命令も用意されている。

例えば、それぞれのコマンドライン操作をプリプロセッサ命令を使って別々に取り出すことを考えてみよう。 この場合、次のように外部のファイルにもプリプロセッサ命令を記述する。 具体的には #@range_begin(<id>) ~ #@range_end(<id>) を使って取り出す範囲を指定する。

$ cat << 'EOF-' > commands.txt

#@range_begin(whole)

#@range_begin(echo)
$ echo 'Hello, World'
Hello, World
#@range_end(echo)

#@range_begin(cat)
$ cat << 'EOF' > greet.txt
Hello, World
EOF
#@range_end(cat)

#@range_begin(ping)
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
#@range_end(ping)

#@range_end(whole)

EOF-

コマンドライン操作を記述したファイルが用意できたら、それをマークアップテキストファイルに取り込む。 取り込むときは #@maprange(<filepath>, <id>) ~ #@end を使って取り出す内容を指定する。

$ cat << 'EOF' > example-book.re
= Hello, World

//cmd{
#@maprange(commands.txt,echo)
#@end
//}

//cmd{
#@maprange(commands.txt,cat)
#@end
//}

//cmd{
#@maprange(commands.txt,ping)
#@end
//}

//cmd{
#@maprange(commands.txt,whole)
#@end
//}

EOF

プリプロセスを実行する。

$ rake preproc

すると、次のようにファイルの一部分が取り込まれる。

$ cat example-book.re           
= Hello, World

//cmd{
#@maprange(commands.txt,echo)
$ echo 'Hello, World'
Hello, World
#@end
//}

//cmd{
#@maprange(commands.txt,cat)
$ cat << 'EOF' > greet.txt
Hello, World
EOF
#@end
//}

//cmd{
#@maprange(commands.txt,ping)
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
#@end
//}

//cmd{
#@maprange(commands.txt,whole)

$ echo 'Hello, World'
Hello, World

$ cat << 'EOF' > greet.txt
Hello, World
EOF

$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms

#@end
//}

ビルドした結果は次の通り。

$ rake text
$ cat book-text/example-book.txt 
■H1■第1章 Hello, World

◆→開始:コマンド←◆
$ echo 'Hello, World'
Hello, World
◆→終了:コマンド←◆

◆→開始:コマンド←◆
$ cat << 'EOF' > greet.txt
Hello, World
EOF
◆→終了:コマンド←◆

◆→開始:コマンド←◆
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

◆→開始:コマンド←◆

$ echo 'Hello, World'
Hello, World

$ cat << 'EOF' > greet.txt
Hello, World
EOF

$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

プリプロセッサ命令を頼りに外部のファイルをシェルスクリプトに変換する

さて、これでようやく前提知識の共有が終わったので本題に入る。 基本的なコンセプトとしては、コマンドライン操作を記述したファイルの内容をシェルスクリプトに変換したい。 シェルスクリプトに変換できれば、あとはそれを実機上で実行するだけで済む。

しかし、コマンドライン操作を記述したファイルはそのままだと使えない。 例えば先ほどのファイルの内容を見るとコマンドラインの操作を実行して得られた標準 (エラー) 出力の内容も含まれている。 また、ヒアドキュメントや複数行のコマンドライン操作も存在して話をややこしくしている。

そこで、実際には使わないプリプロセッサ命令を使って「ここがコマンドラインで実行したい箇所ですよ」と印をつけることにした。 論よりソースということで、以下のコマンドで生成されるファイルを見てほしい。 この中には exec という ID を使って、実際に実行したいコマンドライン操作の前後にプリプロセッサ命令が記述されている。

$ cat << 'EOF-' > commands.txt

#@range_begin(whole)

#@range_begin(echo)
#@range_begin(exec)
$ echo 'Hello, World'
#@range_end(exec)
Hello, World
#@range_end(echo)

#@range_begin(cat)
#@range_begin(exec)
$ cat << 'EOF' > greet.txt
Hello, World
EOF
#@range_end(exec)
#@range_end(cat)

#@range_begin(ping)
#@range_begin(exec)
$ ping \
    -c 3 \
    8.8.8.8
#@range_end(exec)
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
#@range_end(ping)

#@range_end(whole)

EOF-

上記のように、実際には使われないプリプロセッサ命令が含まれていたとしても、無視されるだけで成果物のビルドには影響を与えない。

$ rake preproc
$ rake text
$ cat book-text/example-book.txt 
■H1■第1章 Hello, World

◆→開始:コマンド←◆
$ echo 'Hello, World'
Hello, World
◆→終了:コマンド←◆

◆→開始:コマンド←◆
$ cat << 'EOF' > greet.txt
Hello, World
EOF
◆→終了:コマンド←◆

◆→開始:コマンド←◆
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

◆→開始:コマンド←◆

$ echo 'Hello, World'
Hello, World

$ cat << 'EOF' > greet.txt
Hello, World
EOF

$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

しかし、命令を頼りにコマンドライン操作で実行したい箇所を次のように取り出すことはできる。

$ sed -n '/^#@range_begin(exec)$/,/^#@range_end(exec)$/p' commands.txt
#@range_begin(exec)
$ echo 'Hello, World'
#@range_end(exec)
#@range_begin(exec)
$ cat << 'EOF' > greet.txt
Hello, World
EOF
#@range_end(exec)
#@range_begin(exec)
$ ping \
    -c 3 \
    8.8.8.8
#@range_end(exec)

なお、今回は GNU sed を使ってシェルスクリプトに変換してみた。 とはいえ、別に何を使っても良いと思う。

$ brew install gnu-sed
$ alias sed=gsed

こんな感じでシェルスクリプトに変換できる。 set -e しておくと、コマンドライン操作で非ゼロ (エラー) が返ったときに止まるので異常に気づきやすくなる。

$ cat commands.txt | sed -n '
    /^#@range_begin(exec)$/,/^#@range_end(exec)$/p
  ' | sed -e '
    1i#!/usr/bin/env bash
    1iset -Ceuxo pipefail
    /^#@range_.*$/d
    s/^$ //g
  '
#!/usr/bin/env bash
set -Ceuxo pipefail
echo 'Hello, World'
cat << 'EOF' > greet.txt
Hello, World
EOF
ping \
    -c 3 \
    8.8.8.8

変換したスクリプトを、ファイルとして書き出してみよう。

$ cat commands.txt | sed -n '
    /^#@range_begin(exec)$/,/^#@range_end(exec)$/p
  ' | sed -e '
    1i#!/usr/bin/env bash
    1iset -Ceuxo pipefail
    /^#@range_.*$/d
    s/^$ //g
  ' > commands.sh

できたシェルスクリプトを実行してみる。

$ bash commands.sh
+ echo 'Hello, World'
Hello, World
+ cat
+ ping -c 3 8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=56 time=9.517 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=56 time=15.521 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=56 time=9.736 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 9.517/11.591/15.521/2.780 ms

ちゃんと実行できた。

今回のサンプルだと、スクリプトに set -C しているので、リダイレクトでファイルが上書きできないようにしてある。 なので、もう一度実行してみたときコマンドライン操作の中でファイルを上書きしてしまっているということにも気づける。

$ bash commands.sh
+ echo 'Hello, World'
Hello, World
+ cat
commands.sh: line 4: greet.txt: cannot overwrite existing file

もし、一時的にチェックを無効にして回避するならコマンドライン操作を記述したファイルに set +C を追加する。

$ cat << 'EOF-' > commands.txt           

#@range_begin(whole)

#@range_begin(echo)
#@range_begin(exec)
$ echo 'Hello, World'
#@range_end(exec)
Hello, World
#@range_end(echo)

#@range_begin(exec)
$ set +C  # temporarily disable overwrite check
#@range_end(exec)

#@range_begin(cat)
#@range_begin(exec)
$ cat << 'EOF' > greet.txt
Hello, World
EOF
#@range_end(exec)
#@range_end(cat)

#@range_begin(exec)
$ set -C  # enable overwrite check
#@range_end(exec)

#@range_begin(ping)
#@range_begin(exec)
$ ping \
    -c 3 \
    8.8.8.8
#@range_end(exec)
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
#@range_end(ping)

#@range_end(whole)

EOF-

シェルスクリプトを作り直す。

$ cat commands.txt | sed -n '
    /^#@range_begin(exec)$/,/^#@range_end(exec)$/p
  ' | sed -e '
    1i#!/usr/bin/env bash
    1iset -Ceuxo pipefail
    /^#@range_.*$/d
    s/^$ //g
  ' > commands.sh

実行すると、今度は失敗しない。 スクリプトに冪等性が得られた。

$ bash commands.sh
+ echo 'Hello, World'
Hello, World
+ set +C
+ cat
+ set -C
+ ping -c 3 8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=56 time=9.741 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=56 time=9.680 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=56 time=10.080 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 9.680/9.834/10.080/0.176 ms

このやり方ならコマンドラインの操作とテストする内容が二重管理にもならないところが良いかなーと思っている。 ただ、コマンドライン操作を記述するファイルがちょっとごちゃごちゃするね。 あと、解決できていない課題もある。 例えば、ユーザの入力を伴うインタラクティブな操作があるときどうするかとか。 あと、複数のターミナルにまたがった操作をどうするか、とか。

いじょう。