コードの可読性、ハッカビリティ、抽象化

Webアプリケーション向けにサーバを立ち上げるスクリプトを書いているとしましょう。

def deploy(ip):
    copy('code/', ip + ':~/code', recursive=True)
    write_template('conf/config.py', ip + ':~/config.py')
    write_template('conf/crontab', ip + ':~/.crontab')
    write_template('conf/crontab', ip + ':/etc/apache2/httpd.conf')
    run_as_root('service cron restart')
    run_as_root('service apache restart')
    post('https://pingdom.com/api/2.0/checks',
         { 'name':ip, 'host':ip, 'type':'ping' })

タスクを実行するものとリクエストに応答するものにマシンを分割するまでは、全てが順調です。デプロイのロジックのうち、どれを共有にし、どれを個別にしておくかが分からないので、まずは以下のようにロジックをコピー&ペーストすることにします。

def deploy_taskrunner(ip):
    # Warning: don't forget to edit deploy_webserver as well
    copy('code/', ip + ':~/code', recursive=True)
    write_template('conf/task_config.py', ip)
    write_template('conf/crontab', ip + ':~/.crontab')
    run_as_root('service cron restart')
    post('https://pingdom.com/api/2.0/checks',
         { 'name':ip, 'host':ip, 'type':'ping' })

def deploy_webserver(ip):
    # Warning: don't forget to edit deploy_taskrunner as well
    copy('code/', ip + ':~/code', recursive=True)
    write_template('conf/web_config.py', ip)
    write_template('conf/httpd.conf',
                   ip + ':/etc/apache2/httpd.conf')
    run_as_root('service apache restart')
    post('https://pingdom.com/api/2.0/checks',
         { 'name':ip, 'host':ip, 'type':'ping' })

アプリケーションはしばらくの間、何の問題もなく順調に動きます。手動で何かをデプロイする必要がある場合は、関連する関数からステップのリストを確認できます。パフォーマンス向上のために設定ファイルを微調整する必要がある場合は、単に両方のマシンにコードを追加します(同僚からあれこれ言われるかもしれませんが、我慢しましょう)。

ここで突如、問題発生です。APIをリリースしたことで、クラスタに3つ目の種類のマシンが必要になったのです。

デプロイスクリプトのコピー&ペーストしたコードについて同僚から不満が聞こえてくるので、共通のロジックを関数にまとめることにします。

def predeploy_common(ip):
    copy_code_to(ip)
    tweak_config_files(ip)

def postdeploy_common(ip):
    run_tests_on(ip)
    setup_pingdom(ip)

def deploy_taskrunner(ip):
    predeploy_common(ip)
    write_template('conf/task_config.py', ip)
    write_template('conf/crontab',
                   ip + ':~/.crontab')
    run_as_root('service cron restart')
    postdeploy_common(ip)

def deploy_webserver(ip):
    predeploy_common(ip)
    write_template('conf/web_config.py', ip)
    write_template('conf/httpd_web.conf',
                   ip + ':/etc/apache2/httpd.conf')
    run_as_root('service apache restart')
    postdeploy_common(ip)

def deploy_apiserver(ip):
    predeploy_common(ip)
    write_template('conf/api_config.py', ip)
    write_template('conf/httpd_api.conf',
                   ip + ':/etc/apache2/httpd.conf')
    run_as_root('service apache restart')
    postdeploy_common(ip)

これで大丈夫ですね。マシンの種類を効率的に増やすことができました。さらに新しい種類のマシンを追加する場合でも簡単です。アプリケーションの動作も、とりあえず問題ありません。

しかし、またもや問題発生です。LinuxだけでなくBSDのマシンのサポートを追加しようとしたら、tweak_config_files内で3段階深い関数を変更しなければならないことに気付きました。
あなたは同僚に対して、「コピー&ペーストしたコードを保持するのなら、単にif...thenのブロックを追加して、それで間に合わせてほしい」と指摘します。これに対し同僚は、馬鹿なことはやめろと言うでしょう。

コードレビューでたたかれる前に、単純に関数呼び出しの3つのレベルでパラメータbsd=Trueのスレッドを実行することを考えるでしょう。しかし、マシンをオブジェクトとして考えるべきであること、そのマシンが所有するのは動作であって、マシンのために動くパッシブIPアドレスの文字列ではないということに気付きます。ということで、コードをオブジェクト指向にリファクタリングすると、以下のようになります。

class Machine(object):
    # abstract class
    __metaclass__ = abc.ABCMeta
    def __init__(self, ip):
        self.ip = ip
    def copy_files(self, files):
        ...
    @abc.abstractmethod
    def tweak_ram_config(self):
        pass
    @abc.abstractmethod
    def tweak_fs_config(self):
        pass
    @abc.abstractmethod
    def tweak_network_config(self):
        pass
    def tweak_config_files(self):
        self.tweak_ram_config()
        self.tweak_fs_config()
        self.tweak_network_config()
    ...

class LinuxMachine(Machine):
    ...

class BSDMachine(Machine):
    ...

class DeploymentPlan(object):
    __metaclass__ = abc.ABCMeta
    @abc.abstractmethod
    def deploy(machine):
        pass
    ...

class APIDeploymentPlan(DeploymentPlan):
    ...

class WebDeploymentPlan(DeploymentPlan):
    ...

class TaskDeploymentPlan(DeploymentPlan):
    ...

これで大丈夫ですね。マシンの種類を効率的に増やすことができました。もし他の種類のハードウェアをさらに追加するなら、サブクラスを追加すればいいのです。そうすればアプリケーションはしばらく順調に動作するでしょう。

しかし、またもや問題発生です。お粗末なデプロイはマシンを暴走させてしまい、コンピュータをオフにしようと慌てているうちにアプリケーションで数十億ドルの損失を出してしまいます。というのは冗談です。コンピュータをシャットダウンするのは、そんなに難しいことではありません。しかし、これに近いことは起こり得ます。

このような事故の事後分析をして、あなたは動揺するでしょう。上司にも「何が起きたんだ?」と聞かれます。

そして、こう答えるでしょう。「クラスタ内の各マシンのサブクラスMachineや、デプロイしようと動くインスタンスDeploymentPlanを生成することでデプロイスクリプトが起動したんです」と。様々なファイルを見たり、頭の中でvtableを検索したりして数時間かけた末、結局はサブクラスDeploymentPlanのメソッドで、バグや甘かった仮定をトレースします。開発者は、どのMachineのサブタイプを扱っているのか混乱してしまったのです。

そして、上司はうなってこう言うでしょう。「前みたいに、1ファイルのスクリプトの関数にはできないのか?」と。

このような堂々巡りに私もよく出くわします。私だったらコードのコピー&ペーストから始めます(もしくは、if…thenステートメントの単一のスクリプト)。これは抽象化でもDRYでもありませんが、冒頭から最後まで読むことができ、変更したければどこでも修正可能なのです。

コードがより複雑になれば、扱いやすさを保つために、関数の階層に分割します。これをするといつも、リファクタリングしてコードの毛玉を取り除いているような爽快な気分になります。しかし、内部の修正を試みようとすると、多くの階層の関数において、引数のスレッドを実行しなくてはいけないことに気付くのです。

これに対しては、私は関数によってスレッドを実行するステートを使って、オブジェクト内でカプセル化を行うでしょう。しかし今は、各メソッドに膨大な量の隠れたステートへの(インスタンス変数の形での)アクセスがあり、それぞれのメソッド呼び出しは、呼び出されているオブジェクトのRuntimeクラスに依存する、異なるものの1つということになります。これがコードを見ることや実行される経路を知ることを難しくし、自分の頭でコードを覚えることがさらに困難になるのです。

コピー&ペーストのコードは、可読性もハッカビリティも備えていますが、抽象化が不十分です。関数の大きな木は、可読性と抽象化には優れていても、ハッキングがしにくいです。ステートフルオブジェクトや仮想ディスパッチは、どちらもハッキングと抽象化がしやすいのですが、理解するのが難しいです。いまだに私は、この3つを一度に達成できるパターンを見つけられていません。

実は、可読性、ハッカビリティ、抽象化の全てを手に入れることは本当に可能なのだろうかと疑問に思っています。3つのパターンを一周すると、まるで自分がコードの複雑性のもぐらたたきゲームで遊んでいるように感じられるのです。1つの問題を単純化すると、すぐに別のことが複雑になります。

これは、私が解決しようとしている問題が本質的に複雑だということなのでしょう。5つの異なるデプロイレシピを、4つの異なるハードウェアやOS構造で実行しようと試みれば、20の異なる潜在的な相互作用を処理することになります。この点に関しては、しっかりと調査して問題が本質的な複雑性を含んでいると気付かない限り、同じものを表現する様々な方法の間を何度も行ったり来たりすることになるでしょう。

同時に私は、まだ望みも抱いています。いつか、可読性、ハッカビリティ、そして抽象化の3つを兼ね備えた複雑なプログラムを書いてみせます。願わくは、私がお粗末なデプロイで誰かを爆発させてしまう前に。


2015年5月21日: 翻訳フィードバックをいただき一部翻訳を修正しました