シェルスクリプトで高い移植性と生産性を両立させるシリーズ
タイトル | |
---|---|
第一弾 | なぜシェルスクリプトはPOSIXに準拠しても環境依存が激しいのか? |
【第二弾】 | 高い移植性と生産性を両立するソフトウェアを書くのに必要な知識と考え方 |
第三弾 | 中〜大規模シェルスクリプトのためのメンテナンス性の高いディレクトリ構造 |
第四弾 | シェルスクリプトの互換性と生産性の問題を解決する高度なプログラミング技術 |
第五弾 | (タイトル未定) |
はじめに
この記事は「シェルスクリプトで高い移植性と生産性を両立させるシリーズ」の第二弾です。この記事の第一章では移植性と生産性を両立させることの重要性を説明しています。第二章では高い移植性と生産性を持つソフトウェアを作るのに必要な知識と考え方を解説しています。シェルスクリプトの話がメインですが考え方自体は他の言語にも当てはまると思います。第三章では実際に多くのシェルと環境で動作検証を行っている ShellSpec をどのような考えで設計・開発したかを具体的に解説します。またこの記事は ShellSpec の設計方針を伝え、あわよくば誰かが開発に参加してくれたらなーという目的もあります。そのため多少具体的すぎる話や移植性と生産に関係ない話が出てきますがご了承ください。
第一弾の振り返りとこの記事の注意点
第一弾では POSIX のシェルとコマンドについての移植性に関する問題点として POSIX に準拠したシェルスクリプトを書いたとしてもすべての環境で動くわけではなく、どちらにしろ自分で環境の違いに対応したコードを書かなければいけないという点を指摘し、その解決方法の一つとして GNU Coreutils をインストールすることで移植性、つまり OS や環境によるコマンドの違いを吸収した方が良いと提案しました。この記事でもそれを踏まえて話をしていますが、その方法だけでは 30 年前の POSIX シェルに対応するシェルスクリプトを作ることは出来ません。実の所この記事にはシェルスクリプトの移植性の問題を解決するための 2 つの異なるアプローチが含まれています。一つはより一般的なソフトウェアのための方法でそれが GNU Coreutils やオープンソースソフトウェアを使うという方法です。この方法では 30 年前の POSIX シェルに対応するのは難しいと思いますが、そもそも 30 年も前の POSIX シェルに対応したいことはないと思います。つまり**この記事の第一章・第二章はより現実的な問題を前提として書いています。そして第三章では ShellSpec の話をしていますが GNU Coreutils を使うという案は採用していません。**それは ShellSpec があらゆる環境で動くことを目標とした特殊なソフトウェアだからです。そのため第三章ではそれまでの話と一部矛盾するような方法が登場します。これによって読者を混乱させてしまう可能性があるため、本来なら ShellSpec の話は別の記事に分けるべきなのかもしれませんが、いくつかの理由があってあえて一つの記事にまとめることにしました。第三章の「注意書き」でも説明してますがこの点には注意して下さい。
ShellSpecとは
この記事で実例として挙げている ShellSpec はシェルスクリプト用のユニットテストフレームワークです。すべての POSIX シェル(dash, bash, ksh, zsh, 等)とそれらのシェルが動くすべての環境(実際のテスト環境)で動作することを目標としています。現時点でソースコードの量は合計 1 万行(空白行含む)、テストコードは別に 1 万 3000 行(テスト数は 1600 以上)とシェルスクリプト製のツールとしては大規模なソフトウェアですがプログラミング言語としての設計技術と実装技術を応用して開発しており、高い移植性と生産性だけでなくテストフレームワークとして重要な高い信頼性・機能・パフォーマンス等を両立させながら開発を行っています。
そして少し自慢をさせて下さい!とうとう同ジャンルのソフトウェアの中で GitHub のスターの数が 3位 になりました!(1 位 bats-core、2 位 shunit2)3 位になった理由の一つはプロジェクトが終了した bats を省いているからだったりしますが bats-core が事実上の後継プロジェクトなので問題ないでしょう。最初に公開したのが 2019 年 2 月なのでおよそ 2 年半かかりました。これからもメンテナンスを続けていけば数年後には shunit2 を追い越して 2 位にはなれるんじゃないかなと思っています(bats-core は遥か彼方ですが・・・)。対応シェルや機能の多さではすでに追い越していますがさすがに数年のアドバンテージと知名度の差は大きいです。
30年前のシェルに対応とは?
ShellSpec が最初の POSIX シェルである ksh88 でも動くことを指して言っています。ksh88 の最初のバージョンは 1988年 リリースなので 33 年前ですが ksh88 には細かいバージョンがあって実際に動作確認したバージョンは M-11/16/88i です。このバージョンは SunOS 5.6 (1997 年) にはじめて搭載されたものであるため、正確には 24 年前のシェルです。また 動作確認は Solaris 10(2005 年)上で行っています。これより前のバージョンの ksh88 は現時点でテスト環境(現在準備中)がないためまだ動作確認は出来ていませんが対応は可能なんじゃないかなーということで一応 30 年前ということにしていますw。Linux 環境では Docker を使って古い環境で定期的にテストを行っており最も古い環境は 2000 年にリリースされた Debian 2.2 でシェルは bash 2.03 (1999 年)や zsh 3.1.9 (2000 年) や pdksh 5.2.14 (1999 年) です。
第一章 なぜ移植性と生産性を両立させるのが重要なのか?
Linux の一人勝ちにより移植性が重要ではなくなっている(UNIX 哲学「効率より移植性」)
移植性が高いということは様々な環境でソフトウェアが動作することを意味します。しかしながら商用 Unix や BSD は開発したソフトウェアを動かす環境としては使われなくなってきており事実上 Linux の独占状態です。今でも重要な移植性には、デスクトップアプリ(Windows、Linux、macOS で動くこと)、スマホアプリ(iOS、Android で動くこと)、ブラウザアプリ(Chrome、Edge、Firefox、Safari などで動くこと)がありますがこれらのソフトウェア(特に GUI 部分)はシェルスクリプトでは作ることが出来ないのでこの記事の対象外です。Linux は WSL を介して Windows でも動きますし、Docker の登場により Windows でも macOS でも Linux 用ソフトウェアの開発が簡単に行えるようになりました。OS の多様性が失われつつあるのは残念ですが(GUI アプリ以外は)Linux に対応すれば十分というのが実情です。
「UNIXという考え方」という本で紹介されている UNIX 哲学には「効率より移植性」という定理があります。この本は Unix という OS の開発にまつわる話で、この定理で言う効率とは(開発効率ではなく)実行速度、移植性とは(別の OS ではなく)別のハードウェアへの移植性のことです。当時は特定のベンダーのハードウェア専用にソフトウェアを開発することもあり、そうすると実行速度は速くなるが別のハードウェアへの移植性は下がるので効率よりも移植性を優先しようという話です。Linux は移植性に関する部分をドライバで吸収することで多くのハードウェアで動作します。そのため Linux に対応したソフトウェアを開発するだけでも UNIX 哲学の「効率より移植性」は達成できます。つまりハードウェアの移植性が重要であっても Linux だけに対応すれば十分になってしまっているわけです。
それでも私は他の OS との移植性を大事にしたいと思っており可能なかぎり Linux 以外にも対応させるつもりです。しかしながら(あまり重要でなくなった)他の OS との移植性を重視するあまり生産性が悪くなったり特定の機能を実装することが出来なければ本末転倒です。仮に Linux にしか対応してないけど必要な機能を実装しているソフトウェアと、どの OS でも動くけど必要な機能が実装されてないソフトウェアが有った場合、ユーザーは間違いなく前者を選ぶでしょう。ほとんどのユーザーは Linux しか使わないからです。クラウドの時代となって随分経ちますが仮想マシンもコンテナもほとんどが Linux ベースです。異なる環境へのシステム変更が行われる機会ことは殆どありません。移植性があったとしても実際に移植される機会がなければその価値はないのと一緒です。今どき移植する機会なんて歴史的な理由で商用 Unix や BSD を使い続けておりシステム刷新で Linux にリプレースする時ぐらいではないでしょうか?
そういう中でいろんな環境で動くことの価値を見出してもらうのは難しいことです。移植性が高いだけでソフトウェアを選んでもらえる事はまずないでしょう。だからこそ生産性が重要になってきます。もし必要とされる機能を犠牲にすること無くどこでも動くソフトウェアを実現したならば、それが選ばれる可能性はずっと高くなります。一般的にソフトウェアは開発完了(納品)したら開発終了となることはありません。移植が行われる機会は少ないですが機能追加や改修は何度も行われます。ソフトウェアは成長するものであるため生産性の高さは常に必要となります。高い移植性よりも高い生産性の方がはるかに重要です。
なお、この記事ではこれ以降、単に「移植性」と書いた場合は別のハードウェアではなく別の環境(OS)との移植性という意味であることとします。
POSIX の幻想と真実
POSIX は移植性のための標準規格ですが POSIX コマンドの移植性は高いとは言えません。第一弾の記事で詳しく書きましたが多くの POSIX コマンドは標準化されていたとしても動作が一つに統一されていません。POSIX シェルはまだマシですがそれでもシェルによって動作が異なります。環境毎に動作が違うものを使って移植性があるソフトウェアを開発するのは困難です。POSIX コマンドのもう一つの問題点は最低限の機能(少ないコマンドとオプション)しか標準化しなかったことです。POSIX で標準化されたコマンドの中には OS を管理するコマンドやネットワークにアクセスするコマンドすらありません。POSIX コマンドだけでは Unix システムの管理はできませんし Unix が最低限備えていなければいけない POSIX の機能ですら全ては使えないのです。POSIX コマンドは 30 年前に決められたときからほとんど改善されていません。シェルスクリプトの生産性を上げるには POSIX から脱却しなければいけません。その答えの一つが第一弾の GNU Coreutils やオープンソースコマンドのインストールです。
もし POSIX で規定されているコマンドだけを使わないと移植性が確保できないと思っているのであればそれは大きな間違いです。POSIX というのは基本的に C 言語用のインターフェースです。GNU Coreutils や多くのオープンソースのソフトウェアは POSIX(C 言語のインターフェース)を直接または間接的に使って実装されており実際に多くの環境に移植されています。移植性が高いコマンドを使っていれば、それらを使ったシェルスクリプトも高い移植性が実現できます。問題になるとしたらそのコマンドがあらかじめインストールされているかどうかでしかありません。その問題はインストールするだけで解決します。またオープンソースであれば今後も半永久的に使い続けることが出来ます。仮に開発元がなくなっても自分でソースコードからビルドする事ができます。
そもそも UNIX 哲学には移植性のために POSIX コマンドだけを使おうという発想はありません。POSIX が登場した時期は UNIX 開発全盛期の終わりです。Unix では数多くのコマンドが生まれましたが POSIX では数少ないコマンドだけしか標準化されませんでした。標準化したというよりもどこでも同じように動きそうなコマンドをリストアップしただけというのが適切だと思います。Unix と POSIX はコマンドや移植性に関する考え方が一致しているわけではありません。
移植性と後方互換性の違い(UNIX 哲学「90 パーセントの解を目指す」)
「移植性」と「後方互換性」は勘違いされやすい概念なので補足しておきます。移植性が低い(例 Linux でしか動かない)というのは、環境をアップデート(つまり Linux のバージョンのアップデート)したときに動かなくなる可能性が高いという意味ではありません。「移植性」は別の環境との互換性のことであり、同じ環境の新しいバージョンとの互換性である「後方互換性」は別の概念です。Linux では多くの場合 GNU 製のソフトウェアを使っていますが、GNU 製のソフトウェアは高い後方互換性を維持し続けています。Linux (GNU) と BSD のコマンドは POSIX で規定されてない部分まで含めると機能が異なり移植性が低いといえますが、だからといって同じ環境内でのバージョンアップで動かなくなることはまずありません。GNU、BSD、それぞれの環境内での後方互換性は高いレベルで保たれています。
もちろん後方互換性は 100% ではないかもしれません。しかし POSIX で規定されてい部分も 100% の移植性はありません。どちらにしろ移植性も後方互換性も 100% のものはありません。100% じゃないから意味がないと結論を出そうとしている人に紹介したいのが UNIX 哲学の「90 パーセントの解を目指す」という考え方です。
どんなことであれ、100パーセントうまくやることは大変だ。90パーセントのことだけをうまくやれるようにするほうが、はるかに能率的であり費用対効果も最も高い。UNIX開発者は、対象ユーザーの90パーセントが満足する解を目指す。残りの10パーセントには自分で何とかしてもらうしかない。
どこでも動くようにするならば POSIX に準拠してない環境(組み込み等)にも対応しなければいけませんが、そういう環境に対応する必要はあるでしょうか? GNU 製品やオープンソースのソフトウェアが使えない環境もありますが、開発するソフトウェアはそういう環境で動かすのでしょうか? 完全な互換性が実現できるのであればそれは良いことですが 100% の移植性と後方互換性を目指すために費用対効果を悪くしてしまっては元も子もありません。「90 パーセントの解を目指す」というのは費用対効果を考えて決定しましょうという話です。
他の言語の方が移植性も生産性も高い
移植性が高い言語というのは、移植性を気にせずコードを書くことができ単一のコードでどの環境でも動く言語のことです。今ではほとんどの言語が移植性が高い言語に当てはまります。それに引き換えシェルスクリプトは どこでも同じように動くようにするならばシェルやコマンドの違いを自分で回避してコードを書かなければいけません。移植性があるシェルスクリプトを書くには多くの無駄知識(バッドノウハウ)が必要です。Unix 全盛期には C 言語とシェルスクリプトぐらいしか Unix で実用的に使える言語はありませんでしたが今は多くの言語があります。それらの言語の多くはオープンソースで移植性も高くどこでも動く言語です。そしてそれらの言語を使うと単一のコードでどの環境でも動くソフトウェアを開発することができ高い移植性と生産性を両立することができます。Unix 全盛期時代は C 言語より移植性が高いと言われたシェルスクリプトも今となっては他の言語よりも移植性も生産性も低い言語となってしまいました。シェルスクリプトでソフトウェアを作るということは、簡単に高い移植性と生産性を実現できる他の言語と争わなければいけないということです。シェルスクリプトで高い移植性と生産性を目指すならばシェルスクリプトという言語をより詳しく知って自分の力で高い移植性と生産性を実現しなければいけません。
シェルスクリプトの適用範囲
一般的に他の言語を使う方が移植性も生産性も高くなりますが、それでもシェルスクリプトの方が生産性が高い場合は存在します。それはコマンドを利用して何かを処理する場合です。Unix / Linux には数多くの便利なコマンドが存在します。シェルスクリプトが得意なのはこれらのコマンドを組み合わせて目的を実現することです。(POSIX コマンドに限らず)多くのコマンドを使うことでシェルスクリプトの価値は高まります。(可能な場合は)GNU Coreutils や様々なオープンソースのコマンドを使い、もしなければ自分で(シェルスクリプトだけでなく他の言語で)コマンドを作り、そしてそれらをうまく組み合わせることがシェルスクリプトのメリットを活かす一般的な使い方です。
とは言っても、何にでも例外はあります。ShellSpec がその一つです。どこでも(その環境のデフォルト状態で)動くシェルスクリプト用のテストフレームワークの開発が目的であるため、移植性が重要であるにもかからわず GNU Coreutils などを使って解決することができません。この状態で考えもなしに開発を行うと生産性は大きく下がってしまいます。(次の第二章ではなく) 第三章では ShellSpec の場合にどういう風に考えて設計したかを解説しています。
第二章 高い移植性と生産性を両立するソフトウェアを作るのに必要な知識と考え方
言語(シェルスクリプト)を学び、シェルスクリプトで出来ることを理解する
GNU Coreutils を使うと環境ごとのコマンドの違いがなくなり単一のコードで高い移植性と生産性を実現することが出来るようになりますが、必ずしもその方法が使えるとは限りません。その方法が使えない場合は自分で高い移植性と生産性を両立させる必要があります。そのためには一般的な(書き捨てに近い)シェルスクリプトでは使わないようなテクニックが必要となります。よく誤解されていますがシェルスクリプトは最初からプログラミング言語として開発された言語です。多くの人が考えるよりももっと高度なことが実現出来ます。プログラミング言語としてのシェルスクリプトを学ぶことで高い移植性と生産性を両立させられるようになります。具体的なテクニックについては第三弾で解説します。
高い移植性と生産性を両立するにはシェルスクリプト以外の言語の知識も必要
シェルスクリプトをプログラミング言語として扱っている例が少ないため、他の言語で使われてる一般的なソフトウェア開発技術(例えばデザインパターン、リファクタリング等)をシェルスクリプトに応用する例は多くありません。これらの技術はシェルスクリプトで高い移植性と生産性を両立させるのに重要にも関わらずシェルスクリプトだけを学んでいては身につかないので他の言語から知識を学ぶ必要があります。実のところ ShellSpec で使用している設計技術・実装技術は新しく考え出されたものではなく他の言語で使われてる技術の応用でしかありません。シェルスクリプトではそんな事ができると思われていなかった(少なくとも私はそれまで知らなかった)ことが、シェルスクリプトを学ぶうちに実現可能であることに気づいてそれらをシェルスクリプトに応用していくことで ShellSpec は実現しました。もし私が他の言語の経験がなく一般的なソフトウェア開発技術を知らなければシェルスクリプトで高い移植性と生産性を両立させることはできず、いつまでも環境によるコマンドの違いに悩まされていたことでしょう。
移植性や生産性を高めるためにシェルスクリプト以外の言語も使う
シェルスクリプトの記事で他の言語を使うという話をするのは、シェルスクリプトで利用するコマンドは(シェルスクリプトだけでなく他の言語で)自分で作ることが出来るからです。せっかくコマンドを呼び出すのが得意なシェルスクリプトを使ってるのに、そのコマンドを自分で作らないというのはもったいない話です。そもそもシェルスクリプトと POSIX で規定されたコマンドだけでは Unix のすべての機能は使えませんから、それらの機能が欲しければ必然的にコマンドを作らなければなりません。既存のオープンソースのコマンドで使えるものがあればもちろんそれを使っても良いです。自分でコマンドを作る場合は C 言語を使ってもいいですが周りを見渡せばもっと移植性や生産性が高い言語が見つかります。
一部の言語は国際な標準化団体によって言語が標準化されており高い移植性を実現することが出来ます。例えば Java は標準化プロセスの JCP - Java Community Process によって標準化されていますし、JavaScript、C#、Ruby なども 国際的な標準化団体によって標準化されています。Ruby は日本発のプログラミング言語で初の国際標準となった言語です。これらの言語は複数の環境 (OS) で動作しますし実装も複数あります。例えば Ruby の場合、本家の Ruby (CRuby) の他に、JRuby や mruby などがあります。オープンソースであればプロジェクト終了の心配はほぼありませんが、Java 有償化(誤解が多いので下記の補足参照)を根拠に複数の実装があることがリスク分散になると考えてる人には複数の実装があることは重要な点かもしれません。
複数の実装があったとしても、それらに完全な互換性があるかどうかという問題は残りますが、それを言うならシェルスクリプトや POSIX コマンドの方が別の実装との互換性は低いです(その上出来ることも少ないです)。ちなみに複数の実装に完全な互換性がないということは、実装が多い=テストしなければいけない環境が多いということです。シェルやコマンドは実装が多く互換性が低いためためどこでも動くと証明するためには多くの環境でテストする必要があります。逆に実装が一つまたは少ない方がテストしなければいけない環境が少なくなります(つまりどの環境でも GNU Coreutils をインストールして使うというのは、対応する実装を減らすという意味があります)。互換性が低い場合、実装が多いことは必ずしもメリットになるわけではありません。ブラウザ間の互換性が低い時代に多くのブラウザで多数のテストが必要だったことを思いだします。
プログラミング言語とは少し異なりますがリレーショナルデータベースで使われる SQL は学ぶのにおすすめの言語です。こちらも標準 SQL 規格があり、基本を覚えるだけでいくつものリレーショナルデータベースで応用することが出来ます。例えばコマンドラインからでも SQL を使うことができます。サーバーが不要で CLI からの実行も速い SQLite はシェルスクリプトから使うのに向いていますし、CSV や TSV を SQL で操作する事ができるq というコマンドもあります。場合によっては sed
や awk
を駆使するよりも簡潔なコードで柔軟なテキスト処理が可能になるでしょう。クラウドなど SQL が使われている場面は多く応用範囲が広いのでぜひ覚えておきたい言語の一つです。
Java 有償化についての補足: Java は以前からオープンソース(OpenJDK)で、有償化したのは Oracle 社の JDK サポートだけです。Linux は無料だけど RedHat Linux は有料というのと同じ扱いで、無料で使いたいなら Debian を選ぶのと同じように Oracle JDK ではなく AdoptOpenJDK を選べば無料で LTS(長期サポート)が得られます。
ライブラリ・コマンドを使う(UNIX 哲学「ソフトウェアを梃子として使う」)
生産性を高めるためにライブラリやコマンドを使うというのは一般的な考え方です。「UNIXという考え方」(P69) でも「6. ソフトウェアを梃子(てこ)として使う」としてライブラリやコマンドを使うことの重要さが解説されています。P9 より引用しますと
再利用可能なモジュールの重要性について、たいていのプログラマは表面的にしか分かっていない。プログラムの再利用は、ソフトウェアの梃子を最大限に活用した強力な考え方だ。UNIXの開発者たちは、この考え方に従って、非常に多くのアプリケーションを比較的短期間に開発してきた。
P69 にはさらに詳しく説明されており「よいプログラマはよいコードを書く。偉大なプログラマは良いコードを借りてくる」「独自技術を避ける」「コードを他者が梃子として使うのを認める」「すべてを自動化する」といった見出しで解説されています。生産性に関する話をいくつか引用します。
(P70) 他人のコードを梃子に使うことは、プログラマ個人にとっても強力な武器になる。すべてのコードを自分で書くことが職の安定につながると考えるプログラマもいる。「良いコードを書くから、必ず仕事がある」というわけだ。しかし、ここで問題は、良いコードを書くには時間がかかることだ。アプリケーションで使用するコードの一行一行を全部自分で書いているのでは、仕事が遅く効率の悪いプログラマとみなされかねない。本当の職の安定は、あちこちのモジュールを効率よく切り貼りできるプログラマのものだ。短時間で大量のソフトウェアを生み出せるので、会社から不可欠の存在と扱われることだろう。
(P71) あるグループが他グループのアプリケーションの価値を認めない。市販品を使うよりゼロからの開発を好む、他人が書いたソフトウェアは、他人が書いたものだから使わない・・・このような症状が見られたら独自技術症候群を疑ったほうがいい。
(P71) 一般に信じられているところとは反対に、独自技術症候群は創造性を伸ばさない。他人の仕事を見て、自分のほうがうまくできると主張してみてもそれだけで創造性が増えるわけではない。既存のアプリケーションをゼロから設計し直すことは模倣ではあっても創造とは言わない。むしろこれを避けることで、新しい、わくわくするような設計世界への扉が開かれる。既存のルーチンを書き直さずにすむためにできた余裕で新しい機能を開発できる。
(P75) ソフトウェアの梃子を効果的に利用する方法の一つは、マシンをより激しく働かせることだ。コンピュータにできることを人間が手作業で行うのは時間の無駄だ。
このように「UNIX 哲学」においてもライブラリやコマンドを使うことの重要性は詳しく述べられています。ただシェルスクリプトにおいてはコマンド(もちろん POSIX で規定されてないのものも含みます)はたくさんありますが、シェル関数ライブラリは少ないんですよね・・・。コマンドよりもシェル関数の方が速くコマンドでは出来ないこと(シェル変数へのアクセスなど)も出来るので、もっとあればといいと思うのですが。
コマンドはフィルタとして実装する
「UNIXという考え方」でも「すべてのプログラムをフィルタにする」という定理として登場します。シェルスクリプトもプログラムなのでフィルタとして実装することが出来ます。ShellSpec も内部的に使用するコマンド(トランスレータ等)はフィルタとして実装しています。
コマンドを作成する場合はフィルタとして実装すると他のコマンドとの連携がしやすくなります。フィルタとは標準入力からデータを入力し標準出力にデータを出力するものです。フィルタになっているコマンドの例としては sed
、tr
、bc
、フィルタではないコマンドの例としては basename
、dirname
、expr
などがあり、これらは引数でデータを渡します。一行一データのテキスト形式である方が grep
や sed
といったよく使わるコマンドと連携するには都合が良いですが、必ずしもそうする必要はありません。compress
、uncompress
のように一行一データではなくテキスト形式でもないフィルタも存在します。
データはストリーミング(前から来たデータをその都度処理できる)形式にするのがベストです。そうすることでデータが全て揃わなくても処理を開始することができコマンドをパイプで繋ぐことでそれぞれのコマンドを並列で実行することが出来ます。省メモリで実行できる場合も多いです。ただし sort
コマンドのように本質的にデータが全て揃わないと処理を行うことが出来ないものも存在します(ちゃんと確認はしていませんが、ソート自体はデータが発生するたびに行われていて最終的な結果の出力がデータが全て揃ってから行われるはず)。
コマンドの設計については「シェルスクリプトのための良いデザイン ~ expr と bc から知る設計の違い ~」でも解説しています。
特殊なデータフォーマットには汎用コマンドを使う(「車輪の再発明をしない」)
「UNIXという考え方」を読んでいて疑問になったのが JSON などの構造化テキストデータについてはどう考えればいいのだろうか?ということです。おそらく当時そのようなものはなかったので「UNIXという考え方」では明確に述べられていません。一応「数値データはASCIIフラットファイルに保存する」(P57)にて「データファイルはところどころ改行文字(略)によって区切られたバイトストリームでなければならない。」と書いてあるため一行一データ形式を想定しているのだと思うのですが、説明内容はテキスト形式 vs バイナリ形式の話となっており、テキスト形式の利点のほとんどは構造化テキストデータにも当てはまるように思えます。(当てはまらないのは sed
や awk
などの JSON 形式非対応のコマンドで簡単に処理できないという点のみで、JSON 形式対応のコマンドを使えばいいだけの話なので単に POSIX のコマンドが現実の問題に対して不足しているという問題でしかない)
JSON 形式は標準化(RFC 8259)されており移植性が高いフォーマットです。jq
コマンドをフィルタとして使えば他のコマンドと連携することが出来ます。一行一データ形式で出力すれば sed
や awk
と連携することだって出来ます。しかしながら当時は存在しないデータフォーマットとコマンドを使うわけで、特殊なデータフォーマットとして考えるのがこの本の読み方としては妥当なのだろうと思います。そしてその場合に P24 の内容が適用されるでしょう。
特殊フォーマットが必要な場合は、他の汎用プログラムを使うといい。さもなくば、よく言われる通り、全ての新しいプログラムが「車輪を再発明する」のと同じになってしまうだろう
ようするに私が言いたいのは特殊なデータフォーマットはそれを扱うコマンドが用意されているので「車輪の再発明」をせずに他の汎用プログラム(jq
のようなコマンド)を使いましょうと「UNIXという考え方」には書かれていますよという話です。他には PDF ファイルからテキストや画像を抜き出したりするコマンドもありますね。テキスト以外を扱いコマンドも今では充実しています。車輪の再発明をせずこれらのコマンドを使うことも生産性のために重要なことです。
勉強のための「車輪の再発明」は製品には採用しない
「車輪の再発明をしない」という話をすると、どうもある種の反論?が度々登場するようなので私の考えを簡単に書いておきます。
まず既存の製品と同じようなものを作ったとしても、既存の製品では実現できないことを達成できるのであれば「車輪の再発明」であったとしても重要な意味があると思っています。例えばクローズドな製品のオープンソース版を実装する場合は、全く同じ機能を持っていたとしても既存の製品はソースの公開ができないのでオープンソース版を実装することには重要な意味がります。また同じジャンルの製品でも設計が全く異なっており実現する機能も違えばそれは別製品です。bats-core や shunit2 に対する ShellSpec がそうです。どれもシェルスクリプト用のテストフレームワークですが、全く異なる設計を使っており実現している機能も異なります。
既存のよく知られた製品が使えるのに「車輪の再発明」をする場合、なぜ既存の製品ではだめかという合理的な理由が言えなければいけません。それがなければやってはいけない「車輪の再発明」です。例えば「他人が書いたものだから使わない」「中にで何をやってるかわからないので使わない」みたいな理由は合理的な理由ではありません。
ちなみに私は「車輪の再発明をすると勉強になる」という理由は再発明をする合理的な理由だと思っています(参考 「車輪の再発明の効用」)。アルゴリズムの実装など自分でやってみると深い理解を得られます。ただし勉強のための「車輪の再発明」は実際の製品に使ってはいけません。勉強のための「車輪の再発明」は勉強することが目的であって役目はそこまでです。勉強中の人(その技術の知識が浅い人)が作ったものが、既存のよく知られた製品を超えることはありません。修行中の板前が練習で作った料理を客に出さないのと同じことです。勉強のために「車輪の再発明」をするのはいいことですが、実際の製品には既存のよく知られた製品を使うべきです。
信頼性が高いライブラリを見つける技術
再利用可能なモジュールを使うことは重要なことですが、専門家だけが UNIX を使っていた時代とは違って、誰でも簡単にソフトウェアを書くことができ、再利用可能なモジュールも簡単に公開できるようになった現代では信頼性が低いモジュールも多く登場してしまっています。信頼性のない再利用可能なモジュールに依存してしまうことで、逆に自分のソフトウェアの信頼性や生産性を落としてしまうという新たな問題が発生しているように思えます。
例えば検索して見つかったサンプルコードを理解せずに切り貼りして使うようのは一般的に良くない行為です。コード自体に信頼性があるのであればそれをそのまま使えばいいでしょうが、サンプルコードはバグがあったり、使われてない(実績があるかわからない)コードばかりです。サンプルコードは正しく理解してテストしてから自分のものとして使わなければいけません。
もう一つの問題の例は npm の left-pad 問題 です。これはコード自体に信頼性の問題があるのではなくプロジェクトの信頼性の問題です。何が起きたかはリンク先を参照していただくとして、コードに問題がなかったとしても依存するプロジェクトによっては、そのプロジェクトの政治問題やユーザーのことを考えない仕様変更などで間接的に自分のソフトウェアの信頼性や生産性下げてしまう可能性があります。
だからといって再利用可能モジュールをまったく使わずに全部自分で実装していたら、同じように信頼性や生産性は下がってしまいます。それを避けるにはコードとプロジェクトの両方に信頼性があるかを評価できなければいけません。他の人が開発したモジュールを適切に評価できることもプログラマにとって必要な技術の一つです。とはいっても大抵のプロジェクトの場合はそんなに問題はありません。「有名なプロジェクトで長くメンテナンスされていてみんなが使ってるものなら大丈夫だろう」という考えでだいたい大丈夫です。シェルスクリプト関連の話で言えば GNU Coreutils がそれに該当します。jq や curl等も十分その条件を満たすでしょう。例外はもちろんありますが有名でみんなが使っていればなにか問題が起きても直ぐに解決されます。逆に誰も使ってないようなプロジェクトは問題があっても誰も気づきません。プロジェクトの利用者数は重要な指標です。
で、この話をすると、じゃあ ShellSpec はどうなるんだ?使われてないだろ?とブーメランになってしまいますw。徐々に使われだしているとは思うのですが、現時点で信頼性があるか判断は難しいと思います。スターの数こそ 3 位になりましたが、スターの数は話題性や知名度だけで簡単に増えてしまうので信頼性の点からはあまり役に立ちません。このままだと ShellSpec は選択してはならないということになってしまうので、あまり有名でないライブラリやフレームワークを選ぶ時の(私の)基準を紹介します。
まずプロジェクトは活発に開発が続けられているかを見ます。最終コミット日付、それまでの修正の歴史。そこから大体の活発度がわかります。ただどれくらいあれば OK かはプロジェクトによって異なり小さい機能だけを提供しているようなものは更新が少なくても問題ない可能性があります。Issues や Pull Requests がどれだけ処理されているかからも活発度はわかります。簡単な Issue や Pull Request が長く放置されていたらプロジェクトの信頼性は低いとみなすべきでしょう。Issue や Pull Request がまったくない(またはプロジェクト関係者のものだけ)というのも注意が必要です。他の人が誰も使ってない可能性が高いからです。幸か不幸か、どんなプロジェクトでも使われていれば Issue ではない Issue (つまり単なる質問)が来るものです。オープンソースのものであれば英語の README.md 用意されていることも重要です。世の中すべてが英語であるべきだとは思いませんが、少なくとも多くの人に使ってもらおうと思ってるソフトウェアであれば README.md を英語で書くしかありません。もちろん追加で日本語版を用意するのは OK です。コードの品質の面ではテストコードがあるのはほぼは必須です。ただし一般的な話としてカバレッジは 100% である必要はありません。テストしている部分としてない部分が明確になってることが重要です。どんなに問題なく動くと主張したからと言っても、その根拠(テストコード)がなければ信用できません。手動テストの結果も意味がありません。他の人が自分の手でテストを実行できることが重要です。ソフトウェアはバグや機能追加で修正を行いますが、テストコードなしではその修正によって他が壊れてないことが保証できません。開発者本人ならまだしも他の人はコードを安心して修正することは出来ません。そのようなコードを修正して使うぐらいなら私は別のプロジェクトを探します。あとは知ってる言語であればコードを読んで基本なことができてるかスタイルは一貫性があるかコードの重複はないか lint ツールは使われているかなどからソフトウェアの品質や方針を読み取ることが出来ます。現時点でこれらが 100% できている必要はないしソフトウェアにバグがあったとしても大きな問題とはなりません。重要なのはプロジェクトの開発者がこれからどうしていきたいと思っているかです。やる気を感じられればプロジェクトを応援(バグの報告や修正)する気になります。
オープンソースを使う
前項の内容と少しかぶりますが、他人のソフトウェアを使う場合、オープンソースのものにするのをおすすめします。
一般的にクローズド(もしくは特定の人にしか公開されてなかったり利用者がコードを自由に修正できないもの)の製品を使うと移植性が下がったり製品の寿命が短くなります。その製品は特定の環境でテストされてないかもしれませんし、その製品を使う場合にライセンス契約をしなければいけないならば、契約を打ち切ったら使えなくなります。クローズドであるとユーザー数は減るので実績も少ないのが一般的です。
クローズドな製品を使うということは自由がないということです。その製品(例えばコマンド)に新たな機能が欲しいと思っても開発元が認めなければ追加されません。オープンソースであれば自分でフォークして機能追加することが出来ます。それを派生版としてオープンソースでリリースすることも出来ます。もしその機能が人気があれば開発元に取り込まれるかもしれません。しかしクローズドではすべて相手の判断で決まってしまいます。それに対してオープンソースには本当の意味での独裁者はいません。それが重要なポイントです。
「オープンソースソフトウェアの育て方 - 第4章 プロジェクトの政治構造と社会構造」より
プロジェクトが分裂すること、いやむしろその可能性と言った方がよいでしょう。 この可能性こそが、フリーソフトウェアプロジェクトに本当の意味での独裁者が存在しない理由になっています。 これはオープンソースプロジェクトで "独裁者" とか "暴君" と呼ばれる人がいるとよく聞くことを思えば、突飛な主張かもしれません。 しかし、この手の "暴政" という言葉は特別なもので、伝統的に理解されている暴政の意味とは違うものです。 いつでも自分の王国をコピーでき、 いいように支配するためにそのコピーを持ち歩ける家来がいる王様を想像してみてください。 そんな王様が、自分が何をしようと家来が自分の支配下にいると決まっている王様と同じ振舞いをするでしょうか? するはずがないですよね。
中略
優しい独裁者は普通多く指示を出したりはしません。 むしろいつでも可能な限り、議論や実験を行う作業を開発者に任せておきます。 優しい独裁者は議論そのものには参加しますが、普通の開発者として、 自分より優れた技能を持つメンテナーの領域では、たびたび彼らに従います。 結論が出ず、ほとんどのグループが開発を続けるために誰かが判断の指針を示すことを明確に 望んでいる 場合だけ、 彼らは敢えて異を唱えてこういうのです。「これがあるべき方向性だ」と。 命令することで決定するのを我慢するのは、 成功している優しい独裁者に事実上共通する特性です。 これが、彼らが優しい独裁者という役割を維持している理由のひとつなのです。
念の為に言っておくと私はクローズドを完全に否定してはいません。それが本当に素晴らしい製品であり依存する価値があるのであれば使っていいと思います。しかしその製品を作っている会社がいつまでも存在しているとは限りませんし、急にライセンス料金を変えたりしてくるかもしれません。特殊な技術があるように言っていたとしても、大抵はただのセールストークでしょう。その技術が第三者の手によって追試検証されているかを確認して下さい。見つからなければ誰も使っていないということでしょう。
クローズドな製品を使わせる会社はあの手この手で引き留めようとしてきます。例えば特殊なツール(コマンド)を使ってシステムを開発させ、独自の独特な開発手法を押し付けてきたりします。もちろん契約を打ち切ったらそれらのツールは使えません。独自技術はそれを知っている人は限りなく少なくなるため属人化しやすく技術を身に着けている人が会社を去れば、誰もメンテナンスできないシステムの出来上がりです。このように特定の会社固有のソフトウェアや開発手法を導入して、その会社の独自の仕様に依存した設計になってしまい、他のソフトウェアや設計への変更が困難になってしまうことをベンダーロックインといいます。
クローズドな製品を使う場合は代替製品を探しておいていつでも乗り換えられるようにしておくほうが良いでしょう。第三弾では信頼性が低いソフトウェアを使う場合の方法として、処理を抽象化したりやリファクタリングする技術も説明しますが、それらの技術はクローズドな製品への依存度を下げて別の技術や言語に乗り換えるときにも応用することができます。
高い移植性と生産性を両立するにはプログラミング言語以外の知識も必要
高い移植性と生産性を両立するのに必要な知識はプログラミング言語(シェルスクリプト)だけではありません。生産性というのはコードだけによるものではないからです。バージョン管理、テスト、コンテナ・仮想化技術、リリース作業、そういった開発全般の知識も必要です。またそれらを自動化しなければ生産性は下がってしまいます。ShellSpec の開発では直接使用していないためこの記事では省略しますが、ソフトウェア開発に必要な知識は、データベース、ネットワーク、セキュリティ、脆弱性、ソフトウェアライセンス等いくつもあります。これらの知識をつけるのは大変ですが、それこそがプログラマとしての必要な技術です。シェルスクリプトは得意だけどシェルスクリプト以外は苦手という人にはならないようにソフトウェア開発全般の知識をつけましょう。
ソフトウェア開発全体をサポートするバージョン管理ソフト
世の中にはバージョン管理を専用のツールを使わずにディレクトリコピーとかでやる人がいるらしいですが、それはただのバックアップでバージョン管理ではありません。これは私の持論ですがバージョン管理ソフトはソフトウェアのバージョンを管理するソフトです。バージョンというのは複数の機能(コミット)の集まりです。そのコミット単位で管理できるのがバージョン管理ソフトです。例えばソフトウェア開発では 1.1 系の開発中にリリース済みの 1.0 系のバグ修正を行うなど複数のバージョンを並行して開発することになります。1.0 系のバグ修正は当然 1.1 系に反映させたりしなければいけません。また複数の人によって複数の機能が並行して開発されます。開発を一人やることはまずありません。他人の成果(コミット)を取り込む必要があります。そういった一連の開発の中で出てくる多数のコミットを適切な単位で取り込んだり場合によっては取り消したりと自由自在に素早く扱うツールがバージョン管理ソフトです。管理というのは過去の自分の作業履歴を見るだけではなくコミット単位で操作することです。コミットを管理するにはコミットに(チーム全体で)一意の ID が必要です。コミットにはコメントを書ける必要があります。どこから分岐しどういう理由でマージされたのかの情報もわからなければいけません。ディレクトリコピーではそれらを効率的に行うことは出来ません。
またバージョン管理をするというのはプロジェクトを進める意味でも価値があります。詳しい話はオープンソースソフトウェアの育て方 - 第4章 プロジェクトの政治構造と社会構造に譲りますが「バージョン管理を行なうと堅くならずに済む」ので勢いがある開発が行えるということです。プロジェクトの大きさや方針によって開発のスタイルは変わると思いますが、例えばある機能を追加する場合に関係者全員と議論を交わし合意を取るような官僚的な手続きが必要だと開発が進むまで時間がかかってしまいます。そもそも実際にやってみなければ判断がつかない場合もあります。バージョン管理を行っていない場合、ある決定を戻すのは大変な作業になりますが、バージョン管理されていると簡単に決定を戻すことが出来ます。
バージョン管理というのは単にソースコードを管理する以上の大きな役目を果たしています。プログラミングを始めて数ヶ月程度の人ならともかく、何年もやってる人がディレクトリコピーでバージョン管理してたら、その人はソフトウェア開発の事を何も知らないと言っていいレベルでしょう。
覚えるのではなく理解する
どうも世の中には「新しい知識を勉強したくない」という人がいるようです。手持ちのカード(今持っている知識)だけで何とかやろうとして無駄に時間をかけるのです。新しい知識を得ることで生産性を上げることが出来るのですが、どうやら彼らの理屈は「勉強してもその技術が古くなって使えなくなったら意味がない。無駄になるかもしれない知識を覚えたくない。」ということのようです。無駄になる「かも」というのなら無駄にならないかもしれないわけだし、無駄になるまではそれで生産性を上げられるわけで、勉強しない理由がわかりません。何も勉強しなければ永遠に低い生産性のままです。
確かにバージョン管理ツールは cvs から svn、svn から git へと変わりました(他には mercurial もあります。私は Visual SourceSafe も使ったことがあります。cvs は使った記憶はあるのですがそれ以外全く覚えていません)。しかし基本的な考え方が無駄になったとは思いません。昔も今もバージョンを管理するためのツールです。知識は応用が効きます。新しものに変わったとしても基本を理解していればその差を覚えるだけですみます。ウェブのフロントエンドでは様々なフレームワークが登場し消えていますが、基本を理解している人はフレームワークが変わってもあっという間に使えるようになります。理解してる人は新しいフレームワークの使い方を「勉強」しません。今までの知識の応用で使い方(のほとんど)は最初から理解していて、それを新しいフレームワークでどうやるのか「調べる」だけです。
新しいことを勉強したくないという人はおそらく本質を理解せずにコマンドや言語やライブラリやフレームワークの使い方を「覚えている」だけなのだろうと思います。覚えるのに苦労した。変わると覚えた使い方全てが無駄になる。全部無駄になるのは嫌だから覚えない。理解したこと、つまり基本は変わらないのですが、ツールの使い方を覚えるだけで理解をしてないからツールが変わると何も残らないのでしょう。
ただ漠然とコマンドや言語やライブラリやフレームワークの使い方を覚えるのではなく、どうしてそうなっているのか?なぜこれが必要なのか?と深く考えて理解することが重要です。覚えることも重要ですが覚えなくても調べればわかりますし普通に使っていればそのうち勝手に覚えます。
本当は怖い「KISS の原則」
KISS の原則 というのをご存知でしょうか?ソフトウェア業界では「シンプルにしておけ!この間抜け」という意味で使われています。UNIX哲学でも「スモール・イズ・ビューティフル」として知られている考え方です。たまたまこの「KISS の原則」の語源を調べていて本来(?)は少し異なる意味であることを知りました。
この原則の実例として次のような逸話がある。ジョンソンが設計チームに一握りの工具を手渡して、平凡な整備員が戦闘状況で、この工具だけを使って修理ができるようなジェット戦闘機を開発しろと課題を出したのである。
この言葉をそのままソフトウェア業界にあてはめると「誰でも修正できるわかりやすいコードを書け」になるでしょうか。一見この言葉は正しいように思えるのですが実は危険な考え方です。「誰でも」の誰とは一体誰のことを指しているのでしょうか?もちろん特定の人しか修正できないような属人的なコードは良くないのですが「誰」の中に未経験者に近い人が含まれていたらどうなるでしょうか?プログラミング言語はバージョンアップすると新しい文法(≒高度な文法)が追加されることがあります。それらは生産性をあげたりするためという理由があって新しく導入されるものですが、未経験者に近い人はそれを知らないでしょう。「誰でも修正できる」を満たそうとするとそういった高度な文法は使えなくなってしまいます。プログラミング言語だけでなくいろいろな開発ツール(例 Docker 等)もそうです。未経験者は知らないので未経験者を基準としてしまうと何もかも使えないということになってしまいます。
そして数年後、未経験者は成長するでしょう。「誰でも修正できるコード」を満たすためにいつまでたっても未経験者でもわかるコードしか書くことが出来ません。そうやって未経験者を基準にしてしまうと経験者のストレスをため生産性を下げてしまいます。出来る人は見切りをつけその現場を去ってしまいます。「誰でも修正できる」ようにすることで技術レベルも生産性も下がってしまうのです。ではなぜジョンソンはそのようなことを言ったのか?ここでカギとなるのは「平凡な整備員が戦闘状況で」という所でしょう。そうです。戦闘状況で修理する現場の平凡な整備員は、成長する機会もなくすぐに入れ替わってしまうという前提なのです。「悲しいけどこれ戦争なのよね・・・」
戦争では隣りにいた仲間が明日もそこいるとは限りませんし、まともな道具もない状況で作業しなければならないので、一握りの工具だけで修理ができるというのは合理的な判断です。しかし現代のソフトウェア開発の現場で誰でも修正できるようにしろと、現場の意見も聞かずにむやみに色んなもの(新しい文法や技術など)を制限しだしたら危険信号とみていいでしょう。そこは人が成長すること無く入れ替わってしまうという前提なのです。中には成長することに興味がなくずっと同じやり方でやりたいという人もいますが、個人的には 10 年後も今と同じやり方を続けてたいとは思いません。10 年の間にでる技術を取り入れればもっと良くなるからです。もし上の立場の人間が新しい技術に興味がなければ、そこはまともな開発環境も与えられず最新の開発ツールが使えないかもしれません。git を使うなとかいい出したら、その現場には git 使えるような人はいないのかもしれません。それらは俺より高い技術を身につけるなという圧力なのかもしれません。恐ろしいですね。
話を戻すとソフトウェア業界では「KISS の原則」は「シンプルにしておけ!この間抜け」という意味です。高度な文法やツールを使わずにシステムを開発しろという意味ではありません。
関数で処理を抽象化し共通処理のコードの重複を避ける
一般的な考え方なのであえて説明するまでもない気もしますが、生産性を上げるには処理を抽象することが重要です。これは構造化プログラミングの基本です。抽象化とは一連の処理にわかり易い名前をつけて関数にすることです。勘違いされやすいですが関数はコードを再利用するために作るものではありません。わかりやすくするために作るのです。再利用はどちらかといえば二次的なメリットです。例えば次のコードは何をしているかわかるでしょうか?
echo "$str" | sed 's/&/\&/g; s/</\</g; s/>/\>/g; s/"/\"/g; s/'"'"'/\'/g'
&
や "
から推測できると思いますが HTML エスケープです。気づく人は気づくでしょうが、ぱっと見ではわかりませんよね?人間はこの処理を HTML エスケープであると認識します。その認識通りの適切な名前をつけるのが抽象化です。具体的な処理(sed
+ 引数)に対して htmlescape
という名前をつけるのです。もしコマンドの引数が複雑でコメントを書きたくなったら、それは関数にするべきというサインです。上記のコードは以下のように抽象化(関数に)するだけで圧倒的にわかりやすくなります。また htmlescape
という関数になっているので単体テストも可能になりという、関数の中身を別の実装に置き換えるのも簡単です。
htmlescape() {
sed 's/&/\&/g; s/</\</g; s/>/\>/g; s/"/\"/g; s/'"'"'/\'/g'
}
echo "$str" | htmlescape
理解できない事に世の中には逆に関数を使うべきじゃないという考えが存在するらしいです(未経験者は知らないから?)。その理屈は処理があちこちに飛んでわからなくなるかららしいのですが、それは関数にする単位を間違えています。関数を同じ処理をまとめるためのものだと考えているとこういう間違いをやってしまいます。適切な名前をつけて正しく抽象化を行った場合、関数の中を見る必要はありません。名前から何をするの明確になるからです。例えば cut
コマンドの実装(C 言語のソースコード)をいちいち見ないのと同じことです。関数の中を見ないと処理が追えないということは適切な名前をつけられていないということを意味します。構造化プログラミングの基本が理解できていません。
再利用は二次的なメリットといいましたが、もちろん共通処理をまとめることも生産性の向上に繋がります。ある処理に関するコードが関数一つになっていれば、その関数を徹底的にテストするだけで関数を使用している箇所は最低限のテストで十分な信頼性を実現できます。もし関数を使わずにコピペしていたら重複している数だけテストをしなければいけません。一箇所を徹底的にテストしたとしてもコピペした先も同じであるという保証はありません。そのうちに一字一句間違いなくコピペされているか見比べたり、修正時にコピペされた部分を探し出してもれなく修正できたか?という不毛な仕事をしなければならなくなるでしょう。もし僅かに違いがあれば、それは意図的にそうしたのかそれとも単にミスなのか。コピペが増えれば増えるたびに大きな生産性の低下に繋がります。
ミニラッパー(関数・ライブラリ)で移植性とメンテナンス性を上げる
ミニラッパーというのは私がさっき作った名前ですが、なぜ単にラッパーと言わないのかというとラッパーには二種類あると思っているからです。一つは DirectX と OpenGL、Android と iPhone のように複数のプラットフォームに単一のコードで対応できるようにする巨大なラッパーです。このようなラッパーはラッパーの開発自体が一大プロジェクトになっていて、ラッパーを使うだけでアプリケーションが開発できるほどの多くの機能を備えています。
これはこれでありなのですが、私が言いたいミニラッパーは、この巨大なラッパーと目的が完全に違っていて必要最小限の目的を果たすためだけのラッパー関数やライブラリです。その目的は移植性とメンテナンス性をあげるためです。具体的な例として curl
コマンドのミニラッパーを作ることを考えます。シェルスクリプトの中でミニラッパーを作らずに curl
コマンドを直接使用すると curl
コマンドが入ってない環境のために wget
を使った実装を追加するときの修正が大変になります。curl
コマンドを使ってる場所ごとに wget
コマンドを使った場合のコードを追加しなければいけませんしテストも大変になります。ここで curl
コマンドを直接使わずにラッパー関数経由で使えば、修正箇所は少なくてすみます。
また実際の所 curl
コマンドを使うと言っても curl
コマンドのすべての機能を使うことはありません。使うのは限られた一部の機能だけです。ミニラッパーを使う目的は curl
コマンドを使う場所を減らして処理をシンプルにするだけでなく、使っている機能を明確にする事です。例えば HTTP GET と HTTP POST の2つが必要なだけなら、シンプルな get
関数 と post
関数を作るでしょう。それぞれの処理は簡単なことしかしないので curl
を使って実装するのも wget
を使って実装するのも簡単ですし、curl
も wget
もないが Perl はインストールされているという環境のために Perl で実装するのも簡単です。ミニラッパーを作ることで移植性とメンテナンス性を上げることが出来ます。
もちろん、全てのコマンドに対してラッパー関数を作るのは無駄な作業です。将来それが必要になりそうなところだけラッパー関数にしておけば十分です。また curl
と wget
の例からもわかるように、最初から両対応のコードを書く必要はありません。ラッパー関数にしておくだけで後から追加対応するのは簡単な作業になるからです。両対応にしないのに get
関数 と post
関数を作るのは無駄な作業ではないかと思うかもしれませんが、前項の「処理を抽象化する」という効果で生産性は上がります。つまり curl
の長くて多いオプションを見なくてすむようになり、何度も繰り返しコードに書かなくて済むのでコードの可読性と生産性が上がります。
どこでも動くと保証するための自動テスト
POSIX の標準規格が、複数のコマンドの動作を許容するということは、環境によって動作がバラバラであるということです。どの環境でも動作が完全に同じであればテストしなくても動く可能性が高いですが、シェルスクリプトではそういう事はありません。例えどこでも同じように動く書き方を心がけていたとしてもミスはありますし知らなかった動作に後で気づく可能性もあります。そのためテストなしに動作することを保証することは出来ません。シェルスクリプトで移植性があると主張するならばテストは必要不可欠です。
この場合のテストというのはもちろん自動テストです。なぜなら実装の数だけテストを実行する必要があるので、修正するたびに全てを手動でテストするとしたらいくら時間があっても足りないからです。メンテナンス(機能追加など)が多ければ多いほど、テストを自動化することは重要な意味を持ちます。また自動テストがなければアジャイルは実践できません(「計画」→「設計」→「実装」→「テスト」を 1 〜 2 週間ごと繰り返す開発サイクルであるため)。TDD も実践できません(「レッド」→「グリーン」→「リファクタリング」のサイクルであるため)。モダンな開発スタイルを採用するにも自動テストは不可欠です。
もちろん何が何でも自動テストしろと言っているわけではなく、使い捨てスクリプトで間違いようがない僅かな行数で移植性を考慮しないのであれば手動のテストでも十分だと思います。しかしながらそれなりの規模のソフトウェアで移植性があると主張するのであれば自動テストは絶対に必要になります。
ちなみに ShellSpec は Debian、Ubuntu、Alpine (busybox)、macOS、FreeBSD、NetBSD、OpenBSD、Solaris などの複数の環境とパッケージで提供されている複数のシェルの組み合わせでテストを行っています。だからこそこれらの環境であればどこでも動くと自信を持って言うことが出来ます。
複数のテスト環境を作り出すコンテナ・仮想マシン技術
複数の環境でテストを行うにはコンテナ技術、仮想マシン技術が欠かせません。もしこれがなかったらテスト環境の数だけマシンを用意しなければならなかったでしょう。特に Docker は強力で Debian 2.2 以降の全てのディストリ(+Alpine 等)でパッケージで提供されている全てのシェルの組み合わせ 100 パターン以上でテストを行うことを可能にしています。Docker ではできない BSD 系 や Solaris は仮想マシン上でテストを行っています。どこでも動くソフトウェアを作る場合、コンテナ・仮想マシン技術は欠かせません。
ちなみに ShellSpec をすべてのプラットフォームでテストしたら 2 時間ぐらいかかるのですが、ほとんど自動で行えるので待つだけです。もちろんコードの修正にたびに 2 時間待ってたりはできないのでリリース直前や重要な修正の時だけ行います。
テスト・リリース作業を自動化するCIサービス
テストコードを書いたとしてもテストの実行を自動化していなければミスが発生します。そのために CI サービスを利用します。さすがに Docker を使った古い Linux でのテストはサービス運営側に負担をかけすぎかなと思ってるのでローカルでやっており、OS のサポート期間が終了してないものだけをメインに GitHub にプッシュした時に自動的にテストが走るようにしています。またリリースも自動化しています。ShellSpec ではリリース時に配布用のパッケージを作成したりや 複数の Docker イメージをビルドしているのでそれら全てをミス無くやるのは大変です。それらも CI で自動化することによりリリースの負担を減らしています。これも生産性を上げる技術の一つです。
静的解析ツールを利用する(ShellCheck)
ソフトウェアの品質を上げるために静的解析ツールを利用するのもおすすめです。シェルスクリプトでは lint ツールの ShellCheck が有名です。これらのツールにも否定派がいます。否定派の主張としては問題ないコードも間違っていると指摘してくるということらしいのですが、それは lint ツールの役割を理解していません。lint ツールが指摘するようなコードは人間がコードレビューしても指摘します。そのときにコードは問題ないと説明するでしょう?それと同じです。lint ツールに指摘された部分のコードがそれで間違いないというなら、警告を無効にするだけです。理由をコメントで残しておけばなお良いでしょう。コードに記録が残るわけで口頭で説明するよりも何倍もマシです。もちろん lint ツールの指摘が正しいならコードを修正します。人間のレビューは時間がかかります。lint ツールはその一部をコンピュータで自動化しているにすぎません。これも生産性に関わる話の一つです。静的解析ツールも CI サービスに組み込むと良いでしょう。ミスを減らすことに繋がります。
その他の数多くの知識や考え方や技術やツールも必要
このようにソフトウェアというのは単に動くものを作ればいいというだけのものではありません。高い生産性はどうしても必要になります。高い生産性を実現するにはシェルスクリプト以外の知識も必要ですし、いろんなツールやサービスを使いこなす必要があります。ここにあげたものは一例でしかありません。ソフトウェア開発に関する技術、知識、ツールはまだまだたくさんあります。ネットだけでなく多くの本を読んで知識を得ましょう。とはいえネットだけに限らず書籍であっても古い情報や間違いやおかしな情報もあるので注意が必要です。最初のうちは特定の言語やフレームワークに偏らない幅広い考え方を扱った有名な本を読むのが良いでしょう。そういった考え方は古くなることは少ないですし、基礎的な知識や考え方は言語に依存しないので自然とおかしな情報を見抜けるようになります。ソフトウェア開発には多くの知識が必要となるので全てをやるのは大変かもしれませんが少しずつ取り入れて改善していくのが技術者としての成長です。最初は時間がかかると思いますが、開発の無駄やバグが減りいろんなことを自動化することが可能になるのですぐに時間は取り戻せます。
第三章 ShellSpecの目的と設計方針
この章では高い移植性と生産性を両立させた具体的なシェルスクリプト製のソフトウェアの例として ShellSpec をどのような考えで開発したのかを解説します。
注意書き
まず最初に明確にしておきたいのは ShellSpec の目的は極めて特殊であるということです。ShellSpec の設計方針を読むと第一弾やこの記事のここまでの内容と矛盾している所があると気づくでしょう。それは ShellSpec が極めて特殊な「どの環境でも動く信頼性が高い高機能なテストフレームワーク」だからです。
何を優先するかはその目的、何を作るかによって変わってきます。例えば Linux ディストリビューションの開発で CentOS のように システムシェルに bash を採用すると決めている場合、システムツールを Linux 専用かつ bash で開発したとしても移植性の問題は存在しません。なぜならそのシステムツールを他の環境で動かすこと自体がないからです。私は PoC(概念実証・実証実験)の一部で様々な形式のファイルからデータを抽出して加工するツールを作ったことがありますが、手元の環境でしか使うことはなく寿命も数ヶ月程度でした。このようなものに対して移植性やらバージョンアップ時の互換性を気にしても意味はありません。実験では素早く結果を出すことが重要ですからシェルスクリプトにはこだわらずいろんな形式のライブラリが揃っている Python や Ruby も使いました。
シェルスクリプトで高い移植性と生産性を両立したいなら GNU Coreutils やオープンソースのソフトウェアを使うべきであるという提案に変更はありませんが ShellSpec はそれを採用しません。「どの環境でも動く信頼性が高い高機能なテスティングフレームワーク」という目的が達成できないからです。この場合の「どの環境でも」というのは環境のデフォルト状態、つまり最小パッケージ構成の場合も含みます。だから ShellSpec では GNU Coreutils に依存することは出来ません。しかし殆どのソフトウェアは環境の最小パッケージ構成で動かなければいけないなどという制限はありません。むしろ依存パッケージが自動でインストールされるのが一般的です。業務用システムで使う環境で GNU Coreutils が使えないことなどまずありません(もちろん多くの Linux では予めインストールされています)。GNU Coreutils は Linux 以外にも移植されているので(パッケージのインストールが可能な場合に限れば)これに依存しても移植性は犠牲になりません。その上環境によるコマンドの動作の違いに悩まされることもなく機能も多いため高い生産性を実現することが出来ます。
世の中には POSIX に完全に準拠していない環境(組み込み機器等)ですら存在しています。あなたのプロジェクトはそういう環境でも動くシェルスクリプトを作るのが目的なのでしょうか?そうであれば対応するのは当然のことですが Unix 哲学「90 パーセントの解をめざす」の考えに従えば、完全な移植性を目指さない方が費用対効果は高くなります。**誰も必要としない移植性のために費用対効果が悪くなれば本末転倒です。**しかし ShellSpec はシェルスクリプトすべてをテスト可能にするために開発したテストフレームワークです。費用対効果が悪くなろうとも POSIX シェルが動く環境で動作しなければなりません。
とはいえ何も考えずに開発したら費用対効果(生産性)は極限まで悪くなります。そこで ShellSpec の開発ではシェルスクリプトにプログラミング言語としての技術を導入し、言語以外のさまざまな開発ツールを駆使し、自動化することで GNU Coreutils を使わない方法で高い移植性と生産性を両立させています。
最後に念を押して繰り返しますが、この章に書いてあることは「ShellSpec を開発するという目的のために選択したこと」です。目的が違うのであれば ShellSpec を安易に真似たりしないでください。真似る場合でも全てを真似るのではなく、十分検討したのち必要な部分のみを取り入れてください。
十分に念を押したと思うので、これより極めて特殊な ShellSpec の設計方針の話を始めます。
ShellSpecの目的
ShellSpecが重視している点です。基本的に優先順位が高い順で書いています。
- 信頼性・・・テストというのはソフトウェアの信頼性を高くするためにやるものです。そのテストを行うツールの信頼性が低ければ意味がありません。
- 移植性・・・私が一番テストしたかったシェルは dash です。これは POSIX シェルに近いシェルで Debian/Ubuntu のシステムシェルとして広く使われています。dash に対応するならば POSIX シェル全てをサポートすることが可能なはずです。そこで全ての POSIX シェルに対応することが目的となりました。
- 機能性・・・POSIX シェルが低機能だからといって機能性に妥協をしたくはありませんでした。ShellSpec が目標とするのはシェルスクリプト界の RSpec であり最終的に同等レベルのものを実現することを最初から意識していました。
- パフォーマンス・・・テストフレームワークにはパフォーマンスも重要です。実行が遅くテストに何分もかかるとしたらストレスがたまりますしリファクタリングも苦痛になります。
- メンテナンス性・・・高機能さを売りにする以上ソフトウェア全体が巨大になることは最初から想定済みです。高いメンテナンス性を実現するためにシェルスクリプトではあまり用いられない一般的なプログラミング言語の技術を導入しています。
基本方針
POSIX シェル専用(Bourne シェル切り捨て)
最初から Bourne シェルを切り捨てるという方針ではなかったのですが、開発の初期段階でいくつかのプロトタイプを作っているときに特定のコードでパフォーマンスが大きく下がることに気づきました。それが外部コマンドの呼び出しです。ShellSpec はテストフレームワークの特性上、同じ形式のデータを大量に処理するのではなくテストごとに異なる小さなデータを使って処理します。数値計算や文字列処理で expr
や sed
といった外部コマンドを呼び出すと大幅にパフォーマンスが低下するため、外部コマンドを使わずに算術式展開とパラメータ置換を使って処理することにしました。これらの機能は Bourne シェルでは使えないため必然的に Bourne シェルに対応しないことになりました。
パフォーマンスを犠牲にすれば Bourne シェルへの対応は不可能ではないと思いますが、私は Bourne シェルは一部の古い商用 Unix のシステムシェルであることを除けばすでに殆ど使われておらず、また Bourne シェルは POSIX シェルと互換性がなく(参考)、その違いを吸収するためにコードが複雑化することが想定されるので、それを考えると今更対応するメリットはないでしょう。Linux では POSIX シェルが使われていますし、今後のことを考えると ShellSpec でテストすると同時に POSIX シェルへ切り替える方をお勧めします。
POSIX で定義されているシェルとコマンドの移植性を一切信じない
ある程度、複数の環境で動作するシェルスクリプトを書こうとした人ならわかると思いますが、コマンドは環境依存がとても激しいです。POSIX で標準化されている機能だけ使えば問題ないはずだというのは POSIX を過信しており机上の空論に過ぎません。なぜなら POSIX では複数の異なる動きを認めているからです。POSIX 標準化の時点で環境依存が激しいのです。この問題点については第一弾で詳しく解説しています。また POSIX シェルもコマンドほど酷くはないにしろシェルによって動作が異なります。
POSIX で標準化されているからと言って、そのとおりに実装されているとは限らないし、古いシェルやコマンドにはバグもあるはずです。そこで ShellSpec では実際のシェルで動作検証して確認しない限り移植性があるとは一切信じないことにしました。重要なのは実際のシェルで動くかどうかです。POSIX に準拠していれば動くだろうという推測に意味はありません。
注意 信じていないのは「移植性」です。「後方互換性」ではありません。
可能な限り外部コマンドを使用しないでシェルで実装する
シェルとコマンドの移植性を信じないという前提で可能な限り多くの環境を対応するのに必要なのは依存するコマンドを最小限にすることです。極論を言えば ShellSpec がシェルの機能だけしか使っていなければ使っていない外部コマンドに移植性がなくても関係ありません。ShellSpec がシェルスクリプト用のテストフレームワークである以上シェルに依存することは避けられませんが、外部コマンドのいくつか(例 awk
、sed
、tr
等)はシェルで実装することが可能です。そこで可能な限り可能な限り外部コマンドを使わずに、シェルとシェルビルトインコマンドだけで実装することにしました。この決定は ShellSpec の移植性を高くしただけではなく(遅い)外部コマンド呼び出しによるボトルネックを回避できるという効果もあります。
ただし外部コマンドを全く使っていないわけではありません。日付の取得やディレクトリの作成やファイルの削除はシェルスクリプトだけでは実現できません。ソート処理は理論的にはシェルスクリプトだけで実装可能ですがシェルスクリプトでは苦手な処理を含むため遅くなる可能性があります。そういった理由がある場合に限り外部コマンドを使用しています。現在 ShellSpec (基本機能以外のインストーラー、拡張機能、補助ツールを除く)が使用している外部コマンドは cat
, date
, env
, ls
, mkdir
, od
, rm
, sleep
, sort
, time
, ps
, ln
, mv
のみです。複雑なコマンドやオプションであるほど実装されてない可能性がありバグが含まれてる可能性も高いだろうという想定の元、必要最小限の基本的なコマンドとオプションだけを使用しています。
POSIX に準拠していない場合でも可能な限り対応する
実際の所、ShellSpec が対応するかどうかは POSIX に準拠しているかどうかではなく、可能な限り対応しようという方針があるだけです。パフォーマンスの点から算術式展開とパラメータ置換を使うためわかりやすく POSIX シェルという要件にしていますが、実際には POSIX に完全に準拠してないシェルやコマンドにも対応しています。前項の理由により外部コマンドを可能な限り使用しない方針としたため、POSIX に準拠しないコマンドに対応することはさほど難しい話ではありません。POSIX に準拠していないコマンドを使う環境として Solaris 11 のデフォルト環境があります。Solaris では互換性を重視しデフォルトのパスから参照できるコマンドは POSIX 準拠ではなく歴史的な実装となっています。そういう場合でも ShellSpec が利用可能であるというメリットが生まれました。
シェル実行環境に副作用を与えない
シェルは set
コマンド、 shopt
コマンド (bash)、setopt
コマンド (zsh) などでシェル実行環境の状態を設定することができます。例えば shopt -s extglob
で extglob を有効にすることが出来ます。ShellSpec は移植性を高めるためにそれらの設定の状態に依存することなく動作することを目標にしています。また特定の設定が有効になっている前提にもしません。例えば shwordsplit
を有効にしてく必要はありません。なるべくシェルの設定に依存しない書き方をし、どうしてもそれが出来ない場合設定を一時的に変更しすぐに元に戻します。設定項目が多くおそらく完全に対応できていませんが、もし特定の設定によって動かない場合はバグとして修正する対象となります。
速度よりも移植性を優先する
すでにいくつか効果が大きい部分に特定のシェル専用の最適化は入れていますが、基本的には移植性を重視して同じコードでどこでも動く書き方を優先しています。
機能に妥協はしない
多くのシェルに対応したからといっても他のテストフレームワークに比べて機能が少なければ ShellSpec を使うメリットがありません。多くの人は bash だけで動けばいいと思う人も多いからです。他のフレームワーク(特に bats-core)や RSpec を参考にテストに便利な機能を実装します。
車輪の再発明をする
生産性を考えると車輪の再発明はするべきではありませんが、可能な限り外部コマンドを使わない設計としたため多くのもの再発明しています。例えば一時ディレクトリの作成(mktemp
相当)、UNIX 時間への変換、ランダム値の計算(xorshift32
の実装。/dev/random
には移植性がない)、ハッシュ値の計算(fnv1a
の実装)、並列実行などです。もしこのような機能をシェル関数で実装したものがあれば、それを採用していたと思いますが見つからなかったので仕方ありません。将来的には ShellSpec で実装したコードの中でライブラリに出来るものは独立したライブラリにしたいと思っています。
単一のテストコードで複数のシェルでテストできるようにする
ShellSpec の目的は複数のシェル(dash、bash、zsh 等)に対応したシェルスクリプトの開発を行えるようにすることです。シェルごとにテストコードの書き方が変わってしまうとメンテナンス性が下がるため、どのシェルでも同じ書き方でテストができるようにします。また必然的に開発したシェルスクリプトは複数のシェルで実行してテストすることになります。すなわちシェルスクリプトに記述しているシバンは基本的(外部コマンドとして呼び出す場合を除く)に無視して ShellSpec 実行時に指定します。デフォルトで使用するシェルは /bin/sh
です。
ShellSpec 自身のテストは可能な限り単体テストで行う
ShellSpec のテストは ShellSpec 自身で行っていますが可能な限り関数単位での単体テストでテストしています。それは shellspec
コマンドを実行するような機能テストを行うと時間がかかるからです。多くの環境で動作することを保証するために、環境ごとにテストを行っていますが時間がかかると動作確認も大変です。単体テストをメインとすることで素早くテストを行うことが出来ます。それでもおよそ 100 パターンの組み合わせを Docker でテストすると 2 時間ぐらいかかります。
テストコードの DSL について
ShellSpec はテストコードを独自の DSL で記述しますが、DSL を採用した理由は理解されづらい所だと思っています。単なる好みというわけではなく移植性を高めたりテストコードの生産性を向上させるために重要な役目を果たしており ShellSpec 全体の設計にも深く関わっています。DSL の設計はプロトタイプをいくつも作り、文法、実装、パフォーマンスの点から何度も再設計を行ったもっとも力を入れた部分です。
例
Describe 'bc command' # example group
add() { echo "$1 + $2" | bc; }
It 'performs addition' # example
When call add 2 3 # evaluation
The output should eq 5 # expectation
End
End
外部コマンドを使わないトランスレータ
DSL は内部のトランスレータによって純粋なシェルスクリプトに変換されてからシェル上で実行されます。一般的には文字列処理が得意な awk
などで変換した方が良いのですが、可能な限り外部コマンドを使わない方針としたため、シェルスクリプトだけで変換しています。シェルスクリプトは遅いですがそれでも十分なパフォーマンスがでるように単純な前方一致と置換だけで変換できるような文法にしました。ワンパス(テストファイルのパース処理を一回にすること)で変換できるように設計しているのも特徴です。
互換性問題やバグを回避するための DSL
テストコードを bats-core や shunit2 のような純粋なシェルスクリプトに近いコードで記述したいという要求があるのは最初から認識しており検討もしました。しかしながらシェルスクリプトそのままではシェルが本来持つ多くの罠(例 Bash Pitfalls)を回避することができません。シェルスクリプトは初心者がハマりやすい罠が多いと思っておりテストコードをシェルスクリプトで書くと、書いたテストコードに問題がないかを検証する必要が出てきてしまいます。テストコードのテストが必要になるような状況ではテストになりません。テストコードは誰が見ても(シェルスクリプトに詳しくない人でも)わかりやすく間違いようがない平坦さが必要です。
また移植性の話として、シェルには完全な互換性がありません。また古いシェルにはバグが含まれていたりします。DSL の多くはただのシェル関数への置換です。例えば When
は shellspec_when
へと変換されます。shellspec_when
関数の中でシェルの非互換性などを解決しているためテストコードではそれらを意識すること無くテストの記述に集中することができます。テストコードの生産性もソフトウェアの生産性にとって重要な話です。
DSL はシェルスクリプトと互換性がある拡張文法
DSL は一見特殊なコードに見えるかもしれませんが、Describe
や It
を大文字で始まっているシェル関数として見るとシェルスクリプトと互換性がある文法であると気づくと思います。このようにした理由はシェルスクリプト用のツール(ShellCheck 等)をテストコードにも使えるようにするためです。DSL の中にはシェルスクリプトコード(シェル関数等)を埋め込むことが出来ます。この設計はテストのための準備処理(setup
/ tearDown
相当の処理)やサポート関数やモック関数を DSL の中に定義することができるようにするためです。これらの関数はテストのために一時的に必要となる関数なので別のファイルに分離させた場合よりも DSL に含めるほうが適切です。そして DSL の中にシェルスクリプトコードを含める以上 ShellCheck を使って文法チェックをしたくなるのは当然だと思います。
DSL はシェルスクリプトと互換性を持たせた文法でありながらシェルスクリプトにはない機能を持っています。上の例からも気づくと思いますが、Describe
や It
はネスト可能なブロック命令になっています。これは構造化されたテストを書くことが出来るよう RSpec を参考に導入したものです。またそれぞれのブロックはそれぞれサブシェルに変換されるためテスト間の独立性が実現できます。他にもパラーメータ化テストや標準入力のデータを定義するなど本来のシェルスクリプトでは出来ないことも実現しており、テストコード記述の高い生産性を実現しています。
モックや外部コマンドに関するテストの強力なサポート
シェルスクリプトは外部コマンドを起動することに最適化されたスクリプト言語です。そのため ShellSpec には外部コマンドに関するテストの強力なサポートが含まれています。外部コマンドはシェルスクリプトからみた場合に外部依存のモジュールとみなすことが出来ます。モック機能を利用することでこれら外部依存のモジュールを切り離してテストすることが可能となっています。モック機能は ShellSpec のブロック命令と統合されており、ブロックの範囲内でのみモックに入れ替えることが可能です。他にも ShellSpec には環境の違いによるコマンドの挙動の違いに対応するための機能がいくつか含まれており、移植性の高いシェルスクリプトを容易にテストできるようにしています。
設計
互換性吸収レイヤーの導入
シェルや外部コマンドには移植性がなかったりバグがあったりしますが、それらの機能やコマンドを使用する場所ごとにワークアラウンドのコードを書いていたら生産性は大きく下がります。そこで ShellSpecでは、general.sh で基本的な処理を行う関数(例えば文字列置換置換関数等)を定義しシェルの互換性の問題の多く(全てではありません)を解決しています。general.sh
は少々読みづらいコードになっていますが、それによってその他のフレームワークの処理は互換性問題を気にすることなく実装することを可能にしています。
小さな関数を作る
ShellSpec は他のプログラミング言語製のソフトウェアと同じように小さな関数によって構成されています。小さな関数を作ることで関数単位でのテストが可能になりコードの可読性も上がります。もし関数を作らないと長い処理をまとめて実行することしかできなくなるため、実行の単位(テストの単位)もシェルスクリプトファイル単位となってしまいテストが大変な作業になってしまいます。例えばコマンドをパイプで繋いでいる場合に途中のコマンドの処理が正しいことをテストするのは難しくなりますが、その部分を関数として切り出しておけば簡単にテストすることができます。
小さな関数は役割ごとにライブラリファイルに分離し .
コマンドで読み込みこんでいます。ShellSpec では基本的にライブラリファイルごとにテストファイルを作ることを想定しています。ShellSpec の並列実行はテストファイルごとに並列で実行するため、テストコードが小さなファイルになっているほど並列処理の効果は高まります。
コードの重複を避ける
コードの重複は生産性を下げます。重複コードがあちこちに散らばり、修正のたびにあちこち同じような修正を行うのは大変ですし修正漏れが起きる可能性があります。コードが重複する=バグが発生する場所も増えるということですから、その部分に対するテストも重複してしまいます。コードの重複を下げると修正が簡単になり一箇所を重点的にテストするだけで高い信頼性を実現することが出来ます。
トランスレータをフィルタとして実装する
ShellSpec の DSL は内部で起動するトランスレータによって純粋なシェルスクリプトに変換されます。トランスレータはフィルタとして実装されており、パイプを使ってシェルコマンド(sh
コマンドなど)の標準入力に渡して実行します。中間ファイルを生成しないためトランスレート処理とテストコードは並行で実行されます。(すべてのファイルの変換が完了してから実行されるわけではありません)
注意 カバレッジ機能を使用した場合は kcov の制限によりシーク可能なシェルスクリプトファイルが必要となるためすべてのファイルを変換してから実行します。
レポーターもフィルタとして実装する
テストの実行結果は独自のレポーティングプロトコルによってレポーターの標準入力に入力されます。レポーターもフィルタとして実装されておりテスト実行結果が届くたびに画面上に実行結果を出力します。
2 つの並列処理技術(バックグラウンドプロセス、パイプ)を使う
シェルスクリプトはパイプを使うことで暗黙的に並列処理を行うことが出来ますが、それでは CPU コアをすべて使い切ることは出来ません。ShellSpec はバックグラウンドプロセスを使った並列処理を実装しておりテストファイルごとに並列で実行することが出来ます。またパイプを使うとサブシェルが生成されて遅くなる場合があります。そのためパイプを使うのはテストコード→(トランスレータによる変換)→シェルスクリプト→(シェルによるテストコード実行)→レポートプロトコル→(レポーターによるレポート出力)の大きなループだけで使用し処理の内部ではパイプを使っていません。
その他の機能
コードカバレッジ
カバレッジ計測に必要な実行した行のログ出力は bash、ksh、zsh でしかできません。そのためカバレッジ機能が使えるのはこれらのシェルのみです。POSIX 準拠の機能ではなく対応が一部のシェルだけであってもカバレッジ機能があることはソフトウェア開発に大きな信頼性と生産性をもたらします。
現状はカバレッジ計測に kcov を使用していますが、本来 kcov が対応しているのは bash のみです。ShellSpec は実行した行のログ出力を独自の方法に入れ替えおり ksh と zsh への対応を可能にしています。そのため kcov を使用している理由はカバレッジデータの取得ではなくソースコードとの突き合わせを行って CI に対応したレポートを作成するためだったりします。将来的には独自で実装することを考えていますが、カバレッジはテストとは違いどこか一つの環境で行えば十分であることが多いので、独自実装の優先順位は低いです。
kcov の README.md を見ればわかりますが、一般的には kcov /path/to/outdir executable [args for the executable]
の形でシェルスクリプトを実行することでカバレッジ計測を行います。bats-core や shunit2 でもこの方法でカバレッジ計測はできるようです。一方 ShellSpec ではフレームワーク自体に kcov 統合機能を実装しました。これは kcov を簡単に使えるようにする目的の他に、設計上の理由とパフォーマンス上の理由の2つがあります。ShelSpec はいくつかの内部コマンドの組み合わせで実行しているため、単一のシェルスクリプトの実行を想定している kcov ではうまく計測できません。これが設計上の理由です。また kcov 上で ShellSpec を実行してしまうとカバレッジを計測したいテストコードだけではなく ShellSpec 自身のカバレッジまで計測してしまいます。これによりパフォーマンスが大きく低下してしまうため必要な部分だけカバレッジを計測するようにしています。
デバッグ機能
シェルには set -x
を使った簡易的なデバッグ機能があるのですが、シンプルなシェルスクリプトでもないかぎりログが多すぎてあまり役に立ちません。ShellSpec のフィルタ機能を使うと単一のテストだけを実行するのも簡単です。これにより set -x
を効果的に使用することが出来ます。この機能は POSIX シェルの仕様として規定されているためすべてのシェルで使用することが出来ます。
また現在は対応していませんが 将来的なデバッガ機能である bashdb、kshdb、zshdbを統合したいと考えています。(統合が難しければ独自実装でデバッグ機能を搭載するかもしれません)
シンタックスチェック
シェル本来が持つシンタックスチェック機能(-n
オプション)を利用しテストコード(DSL)の文法が正しいかをチェックする機能が内蔵されています。ShellSpec の DSL をシェルスクリプトと互換性がある文法にした理由の一つです。
DSL をトランスレートした結果の出力
ShellSpec の DSL からシェルスクリプトに変換した結果を出力する機能です。主に ShellSpec のデバッグ用として作っています。理論的にはこの変換したシェルスクリプトを実行することでテスト可能にすることは可能ですが、内部の環境変数に依存するため今のところは簡単に実行することは出来ません。ShellSpec のデバッグとして便利であるため将来的にはトランスレートされたシェルスクリプトからの実行をサポートするのも良いかなと考えていますが、これを必要とするのは私ぐらいなので優先度は低いです。
その他のツール
ShellSpec リポジトリに含まれています(一部は別リポジトリです)が本体とは別という扱いのツールについてです。別のツールであるため外部コマンドを使用しないというようなルールはありません。
ウェブインストーラー
ウェブインストーラーを使うことで簡単にインストールが可能となります。基本的に開発者向けのツールとして提供しています。git
リポジトリからのインストールの場合、バージョンの変更を容易に行うことが出来ます。アーカイブからインストールを行う場合 curl
もしくは wget
コマンドを使用します。2つのコマンドに対応しているのは、どちらのコマンドしか入っていない環境が存在するためであり、そのような環境が実際に存在するからです(将来どちらかのコマンドが使えなくなった場合の保険ではありません)。ウェブインストーラーはインストール時の利便性を上げるのが目的であるため、ほとんどの環境で動けば問題ないという考えです。もし動かない環境があれば tar.gz
アーカイブからダウンロードすればよいです。
パッケージインストール
インストールを簡単にするため Homebrew などのパッケージマネージャーにも対応しています。
Docker 対応
テストを Docker コンテナで実行する機能があります。実験的機能です。
さいごに
ということで、高い移植性と生産性を両立するソフトウェアを書くのに必要な知識と考え方の紹介(とついでに ShellSpec の話)でした。シェルスクリプトの話ではありますが、他の言語にも当てはまる・・・はずなのですが、実際の所他の言語って移植性は言語やライブラリで解決されてる事が多いんですよね。そう考えるとやっぱりシェルスクリプト限定の話かもしれません。
まあ要点はシェルスクリプトでも他の言語でも生産性とテストは重要だということです。私にとってはシェルスクリプトでのテストはとてもしづらいものだったので ShellSpec はそれを解決するために私が必要だったから作ったものです。個人的には互換性の問題を設計で解決するというのは好きなテーマで実は JavaScript でも prototype.js が登場するよりも前に IE5.5 や Netscape 用で共通で使える簡易的なライブラリを個人的に作っていたりします。そういう私がシェルスクリプトの互換性問題を解決しようと思ったのは必然なのかもしれません。
さて次の第三弾では、いよいよシェルスクリプトで移植性と生産性を両立する具体的な実装方法を紹介したいと思います。
備考 この記事の特に第三章は書くのに疲れてきたので少しグダグダになってます。あとで書き直すかもしれません。誤字脱字も多そうだなぁ。