「はじめて学ぶソフトウェアのテスト技法」を読んだ

よく考えてみたら「テスト」について勉強したことなかったなと思って、本を読んでみた。

本の解説

「はじめて学ぶソフトウェアのテスト技法」は、バグを効率的に発見するためにテストをどう行えばよいかという問に答える本だ。 テストコードの保守性を上げるにはどうしたらよいか、という問には答えてくれないので、その問題意識を持つ人は別の本をあたったほうが良いと思う(良い本があったら教えてください)。

本書は、以下のブラックボックステストの技法を解説している。

我々がテストを書くときに無意識に実践している項目もある。例えば「同値クラステスト」は、ほとんどのプログラマーは無意識にやっているはず。 ただ、他のテスト技法に関しては、実践したことのないものばかりだった。

これまでは、テストでバグを発見できる気がしなくてすごく気持ち悪かったんだけど、この本を読んだことである程度自信を持ってやれそうな気がしてきた。

あと、すごく重要なことなんですが、この本はかなり読みやすいです。 昔似たような本を読んだときはとにかく退屈で、10ページぐらい読んでやめてしまったんだけど、この本は一人でもサクサク読みすすめられました。

面白かったところ

同値クラス・境界値

個人的には同値クラスと境界値が大事な発想だと思った。

テストっていうのは、原始的にはありうる全ての入力パターンを試して正しく動くことを担保しないといけないと思うんだけど、現実的にはそれは無理だ。 入力がenumとかで有限のパターンしか取らないなら可能かもしれないけど、整数とか実数が入力パラメータに入ってくるとオシマイだ。 そこで導入される考え方が「同値クラステスト」と「境界値テスト」だ。

振る舞いが「非線形」に変化する領域ごとに入力をグルーピングして、そのグループの「端の値」と「代表的な値」をテストしましょうね、というのが同値クラス・境界値テストだと自分は理解した。 同値クラス・境界値の発想を理解することで、整数とか実数とかの、無数の値を取る入力に対しても網羅的にテストが書けることになる。 このことによって、逆に「網羅的にテストを書こう」という気持ちになれることを発見した。

これまでテストを書くときは、以下のパターンでテストを書くことが多かった。

  • 網羅性を気にせず、ド正常系を1つと、思いつく限りの異常系をテストする
  • 正常系に属する範囲から入力をランダムに選んでテストする

これは、どうせ網羅的にはテストを書けないのだから網羅性を担保するのを諦める、という発想が根底にある。

網羅的にテストを行う手法を理解したことで、網羅的にテストを書こうという気持ちになれる。

また、同値クラスと境界値を前提として様々なテスト技法が存在している。 例えば、デシジョンテーブル同値クラスと境界値を前提として入力の網羅性を担保したいときに使う技法だし、ドメイン分析テストは同値クラス・境界値の拡張だ。 そういう意味でも、同値クラスと境界値を理解することは重要な雰囲気を感じる。

ペア構成テスト

ペア構成テストも面白い。 これは、単体では問題なく動くコンポーネント同士を組み合わせたときにバグるのを発見する手法だ。 組み合わせるコンポーネントの種類に対して、コンポーネントの組み合わせの数は指数的に増えていく。 そのため、全てのコンポーネントの組み合わせパターンをテストするのは実質的に不可能だ。 この問題を解決するために導入される手法がペア構成テストだ。

経験則として「組み合わせでバグるのは、2つのコンポーネントを組み合わせたときにバグるのがほとんどで、3つ以上のコンポーネントを組み合わせたときだけバグるというのはほとんどない」ということが知られているらしい。 つまり、コンポーネントの全組み合わせを調べる必要はなく、全てのコンポーネントがペアを組んだことがあるような組み合わせに絞ってテストをすれば、それだけでかなりの確率でバグを発見できる、ということだ。

「全てのコンポーネントがペアを組んだことがあるような組み合わせ」を生成するソフトウェアが世の中には存在するので、それを使ってテストする組み合わせをリストアップすればいい。 これによって、全組み合わせをテストするのに比べて格段にテスト対象を減らすことができる。

ただ、自動テストをするという観点からは、多くの場合は全組み合わせをテストすることは可能だと思う。特に正常系をざっとみるだけであれば、テストケースを自動生成してエラーがthrowしないことをチェックするのは簡単なはずだ。 一方で、各組み合わせに対して詳しい挙動をチェックしようと思ったら、ペア構成テストは自動テストにおいても有効な手法だと思う。

状態遷移テスト

これまで状態遷移をテストしたことなかったけど、いざやることになったら多分どこから手を付けたらいいかわからんとなっていたと思う。 この本には状態遷移をテストする方法がいくつか紹介されていて、それぞれどのぐらい偉いテストなのかということが書いてあってよかった。 重要な状態遷移なら気合を入れて偉いテストを書き、どうでもいいやつならざっくりテストを書く、ということができると思う。

まとめ

「はじめて学ぶソフトウェアのテスト技法」は面白い本だった。 雑にテストを書いてるソフトウェア開発者の人は一度読んでみると面白いと思う。

それはそれとして「いいテストコードの書き方」「テストが書きやすい設計をする方法」とかの知見はこの本からは得られないので、オススメの本がある人は教えてください(クリーンアーキテクチャは読みました)。

これは読書ノートです: 初めて学ぶソフトウェアのテスト技法 - genya0407のメモ

NOTE:

本書はブラックボックステストの手法だけでなく、ホワイトボックステストの手法も取り上げています。 しかし、ホワイトボックステストの扱いは小さいし、「ホワイトボックステストに拘るな」みたいな記述があったし、本体コードにゴリゴリに依存したテストコードの保守性はものすごく低いことが予測され、実際に業務で書くことはあまり無いだろうと思ったので、読み飛ばした。

とはいえ、「この分岐ってテストされてないけど大丈夫かな」のような発想でテストケースに気づけることもあると思うので、なんとなく意識するぐらいのことはしてもいいのかなと思った。

Rubyで設定を書けるLinux用キーマッパー 「rumap」をRustで作った

この記事は、 CAMPHOR- アドベントカレンダー 2020の8日目の記事です。

Rubyで設定を書けるLinux用のキーマッパーをRustで実装した話をします。

Rumap

Rumap は Ruby DSLで設定を書けるLinux用のキーマッパーです(正確にはX Window System用)。

github.com

キーマッパーとは何かというと、karabinarみたいなやつです。 つまり、キーボードの入力をなにか別の入力に変換するアプリケーションです。

例えば、以下のような設定ファイルを書いたとします。

remap 'Control-BackSpace', to: 'Delete'

これを rumap に食わせて起動すると、 Control と BackSpace を同時押しすると、代わりにDeleteが入力されるようになります。

また、キー入力を変換するだけでなく、キー入力をトリガーにしてコマンドを実行することもできます。 例えば以下のように書くことで、Alt と Shift と 4を同時押しすると gnome-screenshot -a -d 0 が実行されます。

remap 'Alt-Shift-4', to: execute('gnome-screenshot -a -d 0')

詳しい使い方やインストール方法は、READMEを参照してください。

設定ファイルがRubyスクリプトである

rumapの設定ファイルはRubyスクリプトなので、いろいろと便利なことができます。

例えばこういうことができます。

%w[r z x c v w t f Return].each do |key|
  remap "Alt-#{key}", to: "C-#{key}"
end

この設定ファイルを使うと、Alt と r や z や x...を同時押ししたとき、 Ctrlとの同時押し に変換されます。

このようにプログラミング言語を設定用の言語とすることで、複雑な設定も短く書き下すことができます。

なぜ作ったのか

Linuxで動いて、かつRubyで設定ファイルを書くことができるキーマッパーがなかったので作りました...なら理由としてカッコいいですが、実はそのようなキーマッパーは存在します。

k0kubun.hatenablog.com

DSLシンタックスもほぼ同じです(というかrumapのDSLはxremapのパクリです)。一時期は僕もxremapを使っていました。

ではなぜ作ったのかという話なんですが、xremap には with_modifier オプションが当時存在しなかったからです(今はあるかも)。

with_modifier オプション

with_modifier オプションの説明をするために、with_modifier オプションがない世界線の話をします。

Ctrl+hjkl で矢印キーを入力したい気持ちになることが人類にはよくあると思います*1。 これをxremapのDSLで表現するとどうなるでしょうか

remap 'Control-h', to: 'Left'
remap 'Control-j', to: 'Down'
remap 'Control-k', to: 'Up'
remap 'Control-l', to: 'Right'

こうなります。

これでめでたくCtrl+hjklで矢印キーが入力できるようになったわけですが、ところでテキストを編集するときにマウスを使わずに範囲選択をしたくなることは、人類にはよくあります。 Ctrl+hjkl のことを考えなければ、 Shiftを押しながら矢印キーを押してカーソルを移動する ことで、上記を実現できます。

「Shiftを押しながら」 なんだか怪しい雰囲気がしてきました。

実際、当時のxremapで、 Ctrl+hjkl の設定を入れた状態でShiftを押しながらCtrl+jを入力すると何が起こったかというと、何も起こりませんでした。 上の設定は Ctrl+j の設定であって、 Ctrl+Shift+j の設定ではないからですね。

xremapの動作をもう少し詳しく説明すると、

  • xremapは起動時に、Xのサーバーに対してwatchするキーの組み合わせを設定する
  • Xは、設定された組み合わせのキーが入力されたとき、その旨をxremapに通知する
  • xremapはキー入力の通知を受けると、キーを変換してXに入力する

のように動作するのですが、上の設定だと Ctrl+j の組み合わせしかwatchしないので、Ctrl+Shift+jが入力されてもxremapに通知が来ません。

これを解決するために、rumapで導入したのが with_modifier オプションです。

remap 'Control-h', to: 'Left', with_modifier: 'Shift'
...

このように with_modifier: 'Shift' を指定すると、rumapは立ち上げ時に、Ctrl+h に加えて Ctrl+Shift+hwatchします。そして、 Ctrl+h に変換し、Shiftも押されていた場合は Shift+← に変換します。つまり、 Ctrl+h (+Shift)← (+Shift) に変換します。

これによって、 Ctrl+Shift+hjkl を同時押しすることで、テキストの範囲選択が可能になります。

なんで本家にcontributeせえへんねん

当初は、xremapにpatchを当てることによって上記の問題を解決しようとしており。PRも出すには出しました。

Pass unmatched modifiers by genya0407 · Pull Request #36 · k0kubun/xremap · GitHub

この実装は、上で説明した with_modifier オプションとは違って、 全ての modifier の組み合わせをwatchします。 そして、通知されてきた入力のうち、設定されている部分について変換処理をします。

例えば、 remap 'Control-h', to: 'Left' と設定していたら、hを含む全ての入力(hCtrl+hCtrl+Shift+hCtrl+Alt+h、、、)をwatchし、Ctrl+Alt+j が入力されてきたら、 Alt+↓に変換する、といった感じです。

これは設定が短くて済むという利点はあるのですが、PRを出した直後にバグに気づいたのでPRを取り下げました。

もう記憶があんまりないのですが、たしか Alt+jCtrl+j みたいな設定をしていると、変換後の Ctrl+jj を含んでいるため通知が飛んできて、xremapが無限ループして死ぬ、というようなバグだったと思います。

この問題を解決するには、上で説明したような with_modifier オプションをDSLに導入すればよいのですが*2

  • DSLの文法についての交渉をするのが面倒だった*3
  • 既存のコードをいい感じに修正するのが大変そうだった(たしか)
  • xremapをコードリーディングして実装を概ね把握したことで、勉強も兼ねて自分で一から実装したくなった

などの理由から自分で実装することにしました。

その他、考えたことなど

作ってるときに考えたことなどをつらつらと書いていきます

x11ライブラリ

XのAPIを叩くために、x11というライブラリを使いました。これはXlibの非常に薄いラッパーで、Cの関数を呼ぶのでunsafeな操作ばかりです*4。ただ、薄いラッパーであることによって、Xlibのドキュメントがそのまま使えたのは助かりました。

一番困ったのはXlibのドキュメント(というかチュートリアル)がないことで、仕方がないので xremap の実装を何度も読み返しました。

Linux以外のOSに(理論上)拡張できるようにした

Linux以外のOSに拡張できるように、Linux(X)に依存する部分と、そうでない "コアロジック" を分離するようにしました。

これによって、(理論上は)例えばWindowsmacOSでも使えるように、rumapを拡張できるはずです*5

ただ、コアロジックと環境依存コンポーネントをうまく分離できていないのか、あるいは分離の仕方が悪いからかもしれませんが、コアコンポーネントのコードにジェネリクスが大量に発生してしまい、非常に読みづらく、またジェネリクス関連のコピペが多くなっています。

他環境にも拡張しないとただ単にコードの複雑性を増しただけになってしまうので拡張していきたい。しかし、macにはkarabinarがあるし、Windowsを使うことは基本的にないし、なかなかモチベーションが生まれないというのが正直なところです。

Cargoのworkspaceが便利

上に説明したコンポーネントの分離を実現するために、Cargoのworkspace機能を使ったのですが結構便利でした。

レポジトリを見てもらうと、 linuxmapper という2つのディレクトリが存在し、それぞれが1つのcrateになっています*6*7。 そして、Cargo.tomlに以下のように記述することで、1つのworkspaceとしてまとめています。

[workspace]
members = [
  "mapper",
  "linux",
]

この設定により、それぞれのcrateを、依存関係をもたせながら独立にビルドすることができます。

例えば、 linux/Cargo.toml に以下のように記述することで、linux crateからmapper crateを利用することができます。

[dependencies]
mapper = { path = "../mapper" }

また、以下のようにすることで、linux crate (と、それが依存するmapper crate)のみをビルドすることができます。

$ cd linux
$ cargo build

今回の例では全てのcrateがビルドされてしまうのでありがたみがないですが、例えば macos crate を追加したとき、Linux上では macos crate のビルドが通らないとしても、 linux crate のみをビルドすることが可能です。

共通機能を持ちつつもそれを利用したアプリケーションが複数あるような場合に、とても便利そうだなと思いました。

外部のRubyコマンドに依存するのをやめたい

xremap の「Rubyで設定が書ける」という特徴は革命的に便利なので、rumapでもこの特徴は引き継ぎたいと思っていました。xremapはmrubyで実装しているからrubyのコードを評価するのは簡単ですが、Rustでそれをどう実現すればよいでしょうか。

なんとrumapは、 外部のRubyコマンドを実行する ことで、設定ファイルを評価しています。具体的には、引数に指定されたファイルをinstance_evalして結果をJSON文字列にするRubyスクリプトを書いて、rumap起動時にそのスクリプトを起動して設定ファイルをJSONに変換し、JSONを元にrumapを初期化するようにしています*8

外部のRubyコマンドに依存するのはダサいし、外部要因で動いたり動かなかったりするのはつらいので、どうにかしたいと思っています。 この記事を書いてるときに見つけた以下のcrateを使えば、いい感じにできるはず。

github.com

まとめ

Rubyで設定を書けるLinux用のキーマッパーを作りました。ほとんど xremap のパクりですが、微妙に機能が増えています。

いろいろと実装上の反省点はあるのですが、自分が便利に使えているのでひとまずはいいかなと思っています。

*1:矢印キーまで指を伸ばすのはだるいので

*2:厳密には、Ctrl+h → Ctrl+h みたいな設定をしてると無限ループは回避できないけど、そんな設定をする人類はいないと仮定する

*3:よく考えると、後方互換性を保ったままDSLを拡張できるので割とすんなり行けたんじゃないかって気はする

*4:余談ですが、unsafeな操作は上で説明したコアロジックには存在しません。なぜなら、unsafeな操作をするのはX依存のモジュールだけだからです。

*5:実際には、X固有だと思ってたロジックがコアロジックだったとか、他の環境だとコアロジックが使い物にならん、みたいなのはあるとは思いますが、そのあたりはやってみないとわからない

*6:Cargo.tomlもそれぞれ持っている

*7:linuxがX依存部分、mapperがコアロジック

*8:JSONを作れるならなんでもいいので、例えばPythonDSLを作って組み込むことも可能なはずです。

標準入力からヒストグラムを描画するCLIツールを作った

標準入力をいい感じにヒストグラムにするCLIツールを作りました。

GitHub - genya0407/hist

インストール

Releases · genya0407/hist · GitHub からお好きなバイナリをダウンロードして、適当なパスに展開してください

使い方

なんかこういう感じのテキストファイルがあるとする。

$ cat example.txt
4.486107060301375
4.400612185880518
3.1836054290123
1.8814038706949097
3.367418962291763
2.5550752855238943
2.7646969681590603
4.099374705165457
4.765991107086257
3.1929965581891215
...(略)

これをhistコマンドの標準入力に食わせると、以下のようなAAが出力される。

cat example.txt | hist
466.1|
468.3|
470.5|
472.7|
474.9|**
477.1|**
479.3|****
481.4|********
483.6|*************
485.8|************************
488.0|******************************
490.2|******************************************
492.4|*****************************************************
494.6|*******************************************************************
496.8|**********************************************************************
499.0|********************************************************************************
501.1|***************************************************************************
503.3|*************************************************************************
505.5|*****************************************************************
507.7|*******************************************************
509.9|**************************************
512.1|***********************************
514.3|*******************
516.5|***************
518.6|*********
520.8|*****
523.0|**
525.2|*
527.4|
529.6|
531.8|
     +--------------------------------------------------------------------------------+ 995 times
     +----------------------------------------+ 497 times

縦軸が「値」で、横軸が「頻度」です。上の例だと、500ぐらいの値が出る頻度が一番高くて、その頻度は1000回ぐらいであることがわかります。

ちなみに、横軸の高さを調整するオプションや、値をグルーピングする単位(いわゆる bin)を調整するオプションもあります。

contributionは大歓迎です。あと、入力の行数が少ないときにバグっぽい挙動をするので注意してください。

モチベーション

アクセスログとかDBの中身をヒストグラムにしたいことは稀によくある。例えば「ユーザーあたりのブログポスト数ってどういう分布になってるのかな?」みたいなのを知りたいなど。

継続的に見ていきたいメトリクスならGrafanaとかのキチンとしたツールで見たほうが良いと思うんですが、今この瞬間サッと見れるだけでいいんだけど、というニーズも少なからずあります。そういうときにいちいちjupyter notebookを立ち上げて、seabornの書き方を調べながら、フォント環境が壊れててイライラしたり、gnuplotでいい感じにヒストグラムを書こうとして四苦八苦するのは不毛です*1

見たい対象のデータがどこにあるのかによっても違ってきますけど*2アクセスログをテキストファイル形式で扱う場合や、MySQLにクエリを打ってデータ取ってくるときは、シェルからデータが生まれてくるわけで、シェルのなかでグラフ描画までできたら楽でいいですよね。

というわけで、「シェルから生まれてきた数字列をそれっぽいヒストグラムAAに起こす」という処理を例によってRubyワンライナーで毎度毎度書いてたんですが、ちゃんとしたヒストグラムを書くのは意外にめんどくさいのでCLIツールにしちゃおと思ったのが、今回histコマンドを作ろうと思ったきっかけです。

メイキング

Rustの良いところ

CLIツールがいい感じに作れてすごかった(小並感)。

この argopt ってcrateがめっちゃ便利。

tanakh.hatenablog.com

詳しくは上のリンクを読んでもらったら良いんですけど、例えば以下のように書いてコンパイルして実行すると、

use argopt::cmd;

#[cmd]
fn main(
    #[opt(short = "b", long = "bin")] bin: Option<f64>,
    #[opt(short = "l", long = "bar-length", default_value = "80")] bar_length: i64,
) {
  // 略
}

以下のような出力が得られます。

$ hist --help
hist 0.1.0

USAGE:
    hist [OPTIONS]

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
    -l, --bar-length <bar-length>     [default: 80]
    -b, --bin <bin>      

もちろんmain関数の引数にはいい感じに引数が渡ってきます。これはちょっと感動的に便利ですよね。 今回RustでCLI作ろうと思ったきっかけの1/4ぐらいは、argoptの紹介記事を読んだことです。

余談ですが、最新のバージョン(v0.1.1)のargoptは、dependencyとしてargoptを指定するだけじゃ動かなくて、structoptも指定してあげないとダメっぽかったので、issueを立てて報告しました(英語が拙い)。

github.com

Github Actionsがべんり

これもすごかった。

motemen.hatenablog.com

この記事を真似するだけで、

  • タグをpushすると
  • macOS/Linux/Windows用バイナリのビルドが走り
  • 各種プラットフォーム向けのバイナリ一覧を含んだリリースが作成される

という魔法みたいな状態を構築することができます。当然ビルドされたバイナリは展開してPATHに置くだけで実行できる。シングルバイナリ最高です。今まで僕がRubyCLI作って配布方法をウンウン悩んでたのは何だったんだろう...

まとめ

標準入力からヒストグラムを描画するCLIツールをRustで作りました。RustはCLIツールを作成・配布する上でとても楽な選択肢であり、オススメです。

*1:もちろん、使い慣れてる人ならそんなに大変じゃないとは思いますけど、僕はあんまり使い慣れてないので...

*2:例えば、elasticsearchにアクセスログが入ってるケースとかは、histコマンドで取り扱うのには不適切かも。素直にkibanaを導入したほうが良さそう。

scanコマンドというcliツールを作った

scanコマンドというcliツールを作った。

GitHub - genya0407/scan

scanコマンドは、標準入力の各行に対して正規表現を適用し、ほしい部分を取り出すコマンドだ。使い方は以下の通り。

$ scan --help
Usage: scan [options] OUTPUT_FORMAT
    -p [PATTERN]                     specify regexp
    -d [DELIMITER]                   specify delimiter

使用例を見てもらったほうが早いだろう。

使用例

正規表現を適用する例

例えばこういうファイルがあったとする。

$ cat data.txt
hogehoge_nyan
hohho_nyan

これに対して、アンダースコアの左だけを取り出す正規表現を適用するには、以下のようにする。

$ cat data.txt | scan -p "(.+?)_.+" {1}
hogehoge
hohho

アンダースコアの左と右を、 , で繋いで出力したいときは以下のようにする。

$ cat data.txt | scan -p "(.+?)_(.+)" {1},{2}
hogehoge,nyan
hohho,nyan

複雑な正規表現を適用するときは、名前付きキャプチャも使用できる。

$ cat data.txt | scan -p "(?<name1>.+?)_(?<name2>.+)" {name1}:{name2}
hogehoge:nyan
hohho:nyan

正規表現エンジンはRuby標準のものをそのまま使っているので、正規表現の詳しい仕様についてはリファレンスをあたってください。

正規表現 (Ruby 2.7.0 リファレンスマニュアル)

区切り文字を指定する例

また、正規表現を使うのではなく、区切り文字を指定することもできる。

例えばこういうファイルがあったとする。

$ cat hoge.csv
aaa,bbb,ccc
xxx,yyy,zzz

このとき、区切り文字として , を指定して、左から3番目のフィールドを切り出すためには以下のようにする。

$ cat hoge.csv | scan -d , {3}
ccc
zzz

また、scanコマンドに何もオプションを指定しない場合、区切り文字として \s+ つまり「1つ以上連続する空白文字列」が指定されていると解釈される。

例えばこういうファイルがあるとする。

$ cat hoge.tsv
aaa     bbb     ccc
xxx     yyy     zzz

このファイルを、オプションを何も指定しないscanコマンドに食わせると、空白文字を区切りと解釈して左からn番目の文字列を取り出すことができる。

$ cat hoge.tsv | scan {2}
bbb
yyy

インストール方法

Ruby 2.6.3 以上が動く環境を前提として、以下のファイルをパスが通った場所に配置して、実行権限を付与すれば動く。依存ライブラリとかはなく、Rubyがあれば動きます。

scan/scan at master · genya0407/scan · GitHub

もう少しいい感じのインストール方法を模索していますが、ひとまずはこれで許してください。

なぜこのコマンドを作ったのか

仕事柄(?)、バグが出たときとかにサーバーのログを漁る必要にかられることがよくある。そういうときに正規表現は便利だ。

$ cat server.log | (アクセスしたユーザーのIDをいい感じに取り出す正規表現) | sort | uniq -c
100 user_id_111 # user_id_111 が100回もアクセスしているのがわかる
 10 user_id_222
..

しかし、正規表現をシュッと書いて値を抜き出す適用するツールが見当たらなかった*1。多分awkとかperlとかのワンライナー正規表現をかけるとは思うのだが、僕はawkperlも使い方がわからないし、言語ごとに存在すると思われる正規表現の方言を覚えるのもなんだかなあという気持ちだった。

僕が一番使えるプログラミング言語Rubyなので、一時期はRubyワンライナーを書くということをやっていた。

$ cat server.log | ruby -ne 'puts $_[/user_id: (.+)\s+/, 1]' | sort | uniq -c

これも割といい線行ってるとは思うが、正規表現で値を抜き出したいだけなのに ruby -ne とか puts とか $_ とか書くのはイケてない。

次に使ってたのはrargsというツールで、これは限りなく正解に近い。

GitHub - lotabout/rargs: xargs + awk with pattern matching support. `ls *.bak | rargs -p '(.*)\.bak' mv {0} {1}`

rargsは、正規表現を指定して文字列を抜き出すことができ、抜き出した文字列を使ったコマンドを実行することができる。正規表現の代わりに区切り文字を指定することもできる。

$ ls *.bak | rargs -p '(.*)\.bak' mv {0} {1}
$ cat hoge.csv | rargs -d , echo {2}

しかし、rargsはコマンドを実行する都合上、入力される行の数だけプロセスを立ち上げる必要がある。環境にもよるが、プロセスの立ち上げはそこそこヘビーな処理で、業務で使っているMacBook Proではここがものすごく重かった*2。そのため、長めのログファイルをrargsに食わせると、ちょっと現実的ではないぐらい集計に時間がかかってしまう状態になっていた。

私が望む用途(=ログの集計)ではコマンドを実行する必要はない。そのため、rargsからコマンド実行の機能を削り、高速に正規表現を適用するだけのコマンドを作ればよいだろう、という発想に至った。

そして、今回説明したscanコマンドを作った。

まとめ

標準入力に正規表現をシュッと適用して、好きなフォーマットに整形して出力するコマンドである scan を作った。これは、ログの集計・整形などに使うことができ、実用的なレベルには高速であり、自身も便利に使っている。

*1:古参のエンジニアの皆さんはおすすめの正規表現ツールでも書いててください

*2:Instrumentsでプロファイルしたらposix_nspawnでめちゃめちゃ時間喰ってた。おそらくMacBook Proが悪いのではなく、セキュリティソフトかなにかが原因で遅いのではないかと疑っている。お家で使ってるThinkipad上のLinuxでは全然重くなかった。

画像入りzipを人物認識してエクセルに変換する「マイクロサービス」を作った

概要

例の建築家の同期が、動画に映る人の位置を1秒ごとに目視で認識するという虚無作業をしていたので、自動化するWebアプリ的なものを作りました。

github.com

使い方

まず、人物認識したい動画をお好みの間隔(1秒毎とか)で画像に切り出し、適当なフォルダにいれてzip圧縮します。 そして、今回作ったWebアプリを開きます。以下はWebアプリのスクリーンショットです。

f:id:threetea0407:20200524011510p:plain

GCPのアクセストークンを頑張って取得して、「アクセストークン」という入力欄にコピペします。そして、先程のzipファイルを選択します。最後に「変換」ボタンを押すと、以下のようなエクセルファイルがダウンロードされます。

f:id:threetea0407:20200524010137p:plain

左端の列が画像ファイルの名前を表し、その隣の2列は人物を囲う長方形の左上の点の座標を表し、その隣の2列は右下の点の座標を表しています。これによって、画像の中のどの位置に人間が写っているのかを知ることができます。

イメージとしては、以下のような写真を入れると2つの赤丸の座標が取れるという感じです。

f:id:threetea0407:20200524012615p:plain

どうやって実現しているのか

Cloud Vision APIというのが世の中にはあって、物体認識をしてくれます。

cloud.google.com

今回作ったWebアプリがやってるのは、

  • zipを解凍して画像を取り出し
  • 画像をJSONに埋め込んでAPIを叩き
  • 返ってきたデータを若干加工してCSVにする

という処理だけです。つまり、機械学習的なものを自分で実装したわけではありません。

工夫した点

動画から画像への変換をやらない

もともと同期の人がやりたかったことは、「ある街に定点カメラをおいて、その動画の中で人がどう動いているかを調べる」ということでした。

今回は、動画から画像への変換はユーザーにやってもらい、Webサービスは画像入りzipから人物を認識するだけ、というインターフェースを採用しました。

f:id:threetea0407:20200524021034p:plain
ユーザーが動画から画像を切り出す必要がある

しかしこれを例えば、ユーザーに動画をアップロードさせて、それをWebアプリ側で画像に変換し、その画像に対して人物認識をする、というようなインターフェースにすることも可能です。

f:id:threetea0407:20200524021105p:plain
動画を直接変換できる

後者のほうがユーザーの仕事が減り、一見すると便利なように見えます。しかし、このインターフェースには問題があります。

例えば、今までは1秒ごとに画像を切り出していたけど、5秒ごとに切り出すようにしたい、というような要望が出てくることは容易に想像されます。そのたびにソースコードに手を入れてパラメータを修正するのは不毛です。そのようなパラメータをWebアプリ上で設定可能にすることも考えられますが、その場合は設定項目が無数に増えていくことになるでしょう*1

また、一般にこの手の物体検出は、画像の中における物体の相対的な大きさによって、検出精度が大きく変わるようです*2

f:id:threetea0407:20200524114452p:plain
右奥に写った小さな人物は認識されない

f:id:threetea0407:20200524114522p:plain
右奥を拡大すると認識されるようになる

Jörg MöllerによるPixabayからの画像

そのため、「大きな画像に沢山の人が小さく写っている」というような画像を対象にするときは、画像を適当なサイズに分割して検出にかけた上で、その結果を統合する処理が必要になります。その場合、どのようなアルゴリズムで画像を分割するか、またどのようなアルゴリズムで結果を統合するか、ということを考える必要があります。

このように「動画を画像に変換する」という領域は、要求が変化しやすい上に、真面目に考えるとかなり複雑です。

こうしたことを考え、動画から画像への変換方法に自由度を持たせ、かつアプリケーションの複雑度を下げるため、現行のような「画像をzipで固めてアップロードする」というインターフェースを採用しました。これによって、「動画を画像に変換する」という処理はこのソフトウェアの対象領域外となり、実装も使い方も非常に簡単になりました。

また、「動画を1秒ごとに画像に切り出す」というような処理は、それ用のソフトウェアがすでに存在します。そのため、このようなインターフェースを採用しても、ユーザーの手間はそれほど変わりません。

Excel形式で出す

Excelで開けるというのはかなり重要です*3。というのも、同期の人が使っているビジュアルプログラミング環境であるGrasshopperに、「Excelからコピペでデータを貼り付けられるブロック」というのがあるからです*4。これにより、認識した人物の座標をGrasshopper上で処理することができます。

また、Excelで開ける形式であるため、表計算によるデータの加工が可能です。例えば、画像ファイルに含まれる連番の番号を文字列処理で取り出して、「この人物は動画の何秒目に写っていたのか」というデータを取り出すことができます。プログラミングに不慣れな人にとっては、表計算のほうが敷居が低いため、これも便利な点です。

同期の人がGrasshopper上で処理した例が以下です。これは、街並みの3Dモデル上に動画上の人物の位置を投影し、三次元空間上での人間の動きを分析したものです。

GCPのアクセストークンを入力させる

これはCloud Vision APIの利用料金を、利用者である同期に支払わせるための仕組みです。Cloud Vision APIは非常に廉価に利用できますが、それでも60分の動画を1秒ごとに切り出した画像を全部物体検出する、みたいな処理をすると、無視できない料金が発生します。その料金を僕が負担するのは筋違いなので、何らかの手段で同期の人に料金を支払わせる必要があります。定期的に現金ないしはLINE Payとかでお金をやり取りしても良いですが煩雑です。

そこで考え出したのが「アクセストークンを入力させる」というやり方で、これによってAPIの利用料金は同期の人のクレジットカードに直接請求されるため、私とのお金のやり取りが発生せずに済みます。なお、アクセストークンはサーバー上で保存しておらず、変換処理を行うたびに入力する必要があります*5

技術的に特筆すべきこと

Steepを使ってみた

Rubyに型をつける技術というのが最近できつつあるのですが、それを使ってみました。

github.com

上のレポジトリから例をコピーしてきますが、以下のような型定義ファイルをRubyスクリプトとは別に書いて、テストみたいな感じで型チェックを回すという感じになります。

class Person
  @name: String
  @contacts: Array[Email | Phone]

  def initialize: (name: String) -> untyped
  def name: -> String
  def contacts: -> Array[Email | Phone]
  def guess_country: -> (String | nil)
end

今回書いた型定義ファイルは以下のような感じです(抜粋)。

interface _BoxLocalizer
  def localize: (images: ::Array[Boxboxbox::BinaryImage]) -> ::Array[Boxboxbox::Box]
end

class Boxboxbox::BoxLocalizer::GoogleVisionApiOnline
  @access_token: String
  @max_results: Integer
  @min_percentage: Float
  @max_retry: Integer
  @logger: Logger

  def initialize: (access_token: String, max_results: Integer, min_percentage: Float, max_retry: Integer, ?logger: Logger) -> untyped

  def localize: (images: ::Array[Boxboxbox::BinaryImage]) -> ::Array[Boxboxbox::Box]

  (略)
end

_BoxLocalizer というインターフェースがあって、その実装として Boxboxbox::BoxLocalizer::GoogleVisionApiOnline というのがある、という気持ちです*6。ここでI/Fを切ったのは、Cloud Vision APIを叩くときに叩き方がいくつかあるし、他の物体検出系のサービスもあるので、そこを差し替えられるようにしたかったからです。

github.com

Steepを使ってみた感想は以下です

  • クラスの階層を全て明示した形で書かなきゃいけないので若干見づらい(型定義のネストができない)
  • private methodも型定義書かなきゃいけないのでつらい
  • 標準ライブラリも型定義が存在しないものがあり、自分で書く必要があったりした
  • 型を書いてるとき、Rubyスクリプトファイルと独立した全然関係ないファイルを書くことになるので、地味に虚無な気持ちになる(慣れの問題かも)
  • Rubyのダックタイピングをかなりいい感じに型に落とし込めてるという印象
  • 型がないライブラリを使うときに、自分で適当にシュッと型をつけてしまえるのは結構体験が良かった
  • 型がないライブラリの実装から型定義ファイルを自動生成するやつというのがあって、大体のケースはそれを使うとなんとなく型がついてくれるんだけど、たまになんか無理なやつがあってつらい (concurrent-rubyというgemは無理だった)

Steepを使う際にはruby-jpの#typeチャンネルの皆さんに大変お世話になりました。ありがとうございました。

今後の課題的な

同期の人の要件を満たすソフトウェアは作れたので一旦は満足なんですが、一般に公開したら誰かしら使いたがる人はいる気がしていて、公開したいという気持ちがあります。ただ、いろいろ気になるところがあって公開を見送っています。

例えば現状の実装だと、アップロードされてきたzipファイルはそのまま/tmpディレクトリ以下に書き込まれるようになっており、大容量のファイルが送られてきたり、利用者が増えてきたりすると問題になりそうです。/tmpディレクトリに置くのをやめてS3に上げるのも考えられるんですが、そうなるとファイルサイズに対して従量で料金がかかってくるわけで、テロされてAWS破産みたいな話もあり得るわけです。

あとは、GCPのアクセストークンをサーバーに送信させてるわけですが、これって大丈夫なんだっけみたいな話はあって、僕個人との信頼関係がない人が、ここにアクセストークンを書き込みたいとは思わないでしょう。そもそも、Webでこれをやる必要は一切なくて、Windowsのデスクトップアプリとして公開すれば良いはずです。今回サーバーサイドアプリケーションとして実装したのは、単純に僕にデスクトップアプリを作る技術がないからです。

ただ、デスクトップアプリとして公開する場合も、GCPのアクセストークンの取得手続きは利用者に踏んでもらう必要があり、ユーザーに一定程度難易度の高いタスクを要求することになります。そう考えると、サーバーでその処理をまるっと隠蔽してあげるというのはありうる選択肢で、ユーザーに対して適切に料金を課す手段が見つかってないことが問題なのだと考えることもできます。

というわけで、今後あり得る展開としては

  1. フリーのデスクトップアプリにして一般公開する(アクセストークンは自分で取得させる)
  2. ユーザーに料金を課すスキームを確立し、一般公開する(アクセストークンの取得をさせない)
  3. 需要がないためこれ以上何もしない。現実は非情である。

という感じです。雑にユーザーに課金させるシステムをどなたかご存知でしたら教えてください。よろしくおねがいします。

*1:しかもそれらの設定項目のうち実際に使われるのは極少数であると予想します

*2:この場合、画質の粗さは問題とならず、純粋に画像全体のなかで物体が相対的にどのぐらいの大きさで写っているのかというのが重要っぽい

*3:Excelではなく正確にはCSVファイルを出力している

*4:一般的に、Excelからコピペでデータを貼り付けられるソフトウェアは結構多い

*5:実際にはブラウザの機能で自動入力されるようです

*6:duck typingなので、明示的にinterfaceを実装しなくても、interfaceを満たしていれば実装したことになる