PHP Mentors (Posts tagged intentionality)

1.5M ratings
277k ratings

See, that’s what the app is perfect for.

Sounds perfect Wahhhh, I don’t wanna

PHPカンファレンス関西2013 講演資料と感想

6月1日に大阪産業創造館で開催されたPHPカンファレンス関西2013にて、メンター久保と後藤がそれぞれ講演させて頂きました。足を運んで頂いた参加者の皆様、スタッフの皆様、ありがとうございました。

基調講演:意図を表現するプログラミング 久保 敦啓

意図を表現するプログラミング from Atsuhiro Kubo


セッション:関心を分離するってどういうこと? 後藤 秀宣


感想

まず今回の私たちメンター二人の講演内容についてですが、公式サイトに掲載されているセッションタイトルと概要以外、私たちの間で内容についての事前打ち合わせはありません。ですが、ある意味必然的とも言えますが、2つの講演内容は本質的に同じ事を主張しています。久保の講演における「意図」は、プログラマのメンタルモデルと言い換えることができます。この意図がもつれた状態をときほぐしていくために関心事を適切に分離していくわけで、「意図を表現するための道具」、「関心事を分離するための道具」をそれぞれ紹介しています。

この意図性・宣言的な記述という点では、カネハラアツシさんのGinqが素晴らしいと感じました。

PHPを使う上での不満点としては、やはり配列操作の記述しづらさ、貧弱さがあります。Ginqは、意図性の高いコードで配列操作を記述できるという点で、すぐにでも実戦投入したいライブラリです。Doctrineを使っているプロジェクトで、これまではDoctrine/CommonのArrayCollectionにあるmap()やfilter()を使っていましたが、Ginqでさらにスッキリと扱えそうです。

他の講演やLTなどどれも興味深く、充実した内容だったと思います。また来年も開催されれば、是非参加させていただきたいと思います。

conference osaka intentionality dsl sop

時計オブジェクト(ドメインクロック)を導入してテスト容易性と意図性を高める

現在の時刻を扱うロジックがアプリケーションコードに含まれるのは珍しいことではありませんが、これらのロジックのテストは簡単ではありません。以下のコードを見てみましょう。

<?php
...
class OrderService
{
    ...
    public function order(Order $order)
    {
        $currentHour = (integer) (new \DateTime())->format('H');
        if ($currentHour >= 10 && $currentHour < 21) {
            ...
        } else {
            throw new OrderException('ご注文は午前10時から午後9時まで!');
        }
    }
    ...

実際の現在の時刻に依存せずにif文の条件をテストする1つの方法は、DataTimeオブジェクトの生成部分をメソッドとして抽出し、そのメソッドの呼び出しをモックで置き換えることです。この場合プロダクションコードは以下のようになります。

<?php
...
class OrderService
{
    ...
    public function order(Order $order)
    {
        $currentHour = (integer) $this->createDateTime()->format('H');
        if ($currentHour >= 10 && $currentHour < 21) {
            ...
        } else {
            throw new OrderException('ご注文は午前10時から午後9時まで!');
        }
    }
    ...
    protected function createDateTime()
    {
        return new \DateTime();
    }
    ...

モッキングフレームワークPhakeを使った場合のテストコードは以下のようになります。

<?php
...
class OrderServiceTest extends \PHPUnit_Framework_TestCase
{
    /**
     * @test
     */
    public function 営業時間内は注文を受け付ける()
    {
        ...
        $orderService = \Phake::partialMock('...\OrderService');
        \Phake::when($orderService)->createDateTime()->thenReturn(new \DateTime('2013-04-01 10:00:00'));

        $orderService->order($order);

        ...
    }

    /**
     * @test
     */
    public function 営業時間外は例外を発生させる()
    {
        ...
        $orderService = \Phake::partialMock('...\OrderService');
        \Phake::when($orderService)->createDateTime()->thenReturn(new \DateTime('2013-04-01 21:00:00'));

        try {
            $orderService->order($order);

            $this->fail('期待通りの例外が発生しませんでした。');
        } catch (OrderException $e) {
        }
    }
}

以前の記事「関数・定数のラッパーオブジェクト(レガシープロキシー)を導入してテスト容易性を高める」ではこの方法の問題点について解説しました。

このように関数の呼び出しをメソッドの呼び出しに置き換えることで、部分的にモックが適用されたオブジェクト(パーシャルモック)を使ったテストが可能になります。

この方法の問題点はテストの都合によってプロダクションコードの変更が必要になることです。例え同じ関数であったとしても、クラスが異なれば再度同じことを行う必要があります。加えてパーシャルモックはDIコンテナとの統合が難しいため、テストコードにオブジェクトの構成の知識が流出することになります。

時計オブジェクト(ドメインクロック)を導入する

もう1つの方法はアプリケーション専用の時計オブジェクト(私はドメインクロックと呼んでいます。)を導入することです。最初に以下のようなクラスを用意します。

<?php
...
class Clock
{
    public function hour()
    {
        return (integer) (new DateTime()).format('H');
    }
}

ここでは現在の時間を返すhour()メソッドを定義しています。次にClockオブジェクトを使ってプロダクションコードを書き換えます。

<?php
...
class OrderService
{
    ...
    protected $clock;
    ...
    public function __construct(Clock $clock)
    {
        $this->clock = $clock;
    }
    ...
    public function order(Order $order)
    {
        $currentHour = $this->clock->hour();
        if ($currentHour >= 10 && $currentHour < 21) {
            ...
        } else {
            throw new OrderException('ご注文は午前10時から午後9時まで!');
        }
    }
    ...

最後にテストコードを書き換えます。先ほどのパーシャルモックの代わりにClockオブジェクトをモックオブジェクトで置き換えます。

<?php
...
class OrderServiceTest extends \PHPUnit_Framework_TestCase
{
    /**
     * @test
     */
    public function 営業時間内は注文を受け付ける()
    {
        ...
        $clock = \Phake::mock('...\Clock');
        \Phake::when($clock)->hour()->thenReturn(10);

        $orderService = new OrderService($clock);
        $orderService->order($order);

        ...
    }

    /**
     * @test
     */
    public function 営業時間外は例外を発生させる()
    {
        ...
        $clock = \Phake::mock('...\Clock');
        \Phake::when($clock)->hour()->thenReturn(21);
    
        $orderService = new OrderService($clock);
    
        try {
            $orderService->order($order);
        } catch (OrderException $e) {
            return;
        }
    
        $this->fail('期待通りの例外が発生しませんでした。');
    }
}

この方法では、プロダクションコードへの新たなメソッドの追加は不要になります。また、DIコンテナとの統合も問題ありません。

おわりに

ドメインクロックドメイン駆動設計(DDD: Domain-Driven Design)ビルディングブロック(エンテイティ、リポジトリ、ファクトリ等)と同様に定義済みのモデルと考えることができます。

ドメインクロックを導入することで現在の時刻を扱うコードのテスト容易性(testability)を高めることができます。加えて、こちらの方が重要なことですが、Clockクラスに依存するクラスが現在の時刻を扱うことは明白であるため、コードの意図性(intentionality)も同時に高めることができるのです。

testing mocking intentionality readablility ddd pattern

コードの抽象度を整える

コードの読みやすさ(readability)はソフトウェアの保守性を高め、より価値のあるものにします。コードの読みやすさを向上させるために抽象度を整えることは、プログラマーの日常的な活動です。今回はコードの抽象度を整えるための指針について考えてみます。

全体と詳細

他のメソッドを呼び出すコードでメソッドを構成する場合、メソッドは、それぞれがほぼ同じ抽象度になるようにしよう。

— Kent Beck 実装パターン p.95 8章 メソッド 複合メソッド

上記はKent Beck氏によるメソッドの実装パターンの一つ複合メソッドの冒頭にある文です。以前の私がどのような指針を持ってメソッドを分割していたのか、今となっては正確に思い出すことはできませんが、おそらく1メソッド40行程度を目安に分割可能なコード片を抽出したものにそれらしい名前を付けていたと思います。本パターンにより、そのようなものから脱却し、全体と詳細を分離するという指針をもってメソッドの分割を行うことができるようになります。

以下にStagehand_TestRunnerにおける具体例を示します。

変更前:

<?php
...
class PHPUnitRunner extends Runner
{
    ...
    public function run($suite)
    {
        $testResult = new \PHPUnit_Framework_TestResult();
        if ($this->printsDetailedProgressReport()) {
            $printer = new DetailedProgressPrinter(null, false, $this->terminal->colors());
        } else {
            $printer = new ProgressPrinter(null, false, $this->terminal->colors());
        }
        $printer->setRunner($this);

        $arguments = array();
        $arguments['printer'] = $printer;

        Stream::register();
        $arguments['listeners'] =
            array(
                new TestDoxPrinter(
                    fopen('testdox://' . spl_object_hash($testResult), 'w'),
                    $this->terminal->colors(),
                    $this->prettifier()
                )
            );

        if ($this->logsResultsInJUnitXML) {
            $arguments['listeners'][] = $this->junitXMLPrinterFactory->create(
                $this->createStreamWriter($this->junitXMLFile)
            );
        }

        if ($this->stopsOnFailure) {
            $arguments['stopOnFailure'] = true;
            $arguments['stopOnError'] = true;
        }

        if ($this->phpunitXMLConfiguration->isEnabled()) {
            $arguments['configuration'] = $this->phpunitXMLConfiguration->getFileName();
        }

        $testRunner = new TestRunner();
        $testRunner->setTestResult($testResult);
        $testRunner->doRun($suite, $arguments);

        $this->notification = $printer->getNotification();
    }
    ...

変更後:

<?php
...
class PHPUnitRunner extends Runner
{
    ...
    public function run($suite)
    {
        $printer = $this->createPrinter();
        $testResult = new \PHPUnit_Framework_TestResult();
        $testRunner = new TestRunner();
        $testRunner->setTestResult($testResult);
        $testRunner->doRun($suite, $this->createArguments($printer, $testResult));

        $this->notification = $printer->getNotification();
    }
    ...
    protected function createPrinter()
    {
        if ($this->printsDetailedProgressReport()) {
            $printer = new DetailedProgressPrinter(null, false, $this->terminal->colors());
        } else {
            $printer = new ProgressPrinter(null, false, $this->terminal->colors());
        }
        $printer->setRunner($this);

        return $printer;
    }
    ...
    protected function createArguments(ResultPrinter $printer, \PHPUnit_Framework_TestResult $testResult)
    {
        $arguments = array();
        $arguments['printer'] = $printer;

        Stream::register();
        $arguments['listeners'] =
            array(
                new TestDoxPrinter(
                    fopen('testdox://' . spl_object_hash($testResult), 'w'),
                    $this->terminal->colors(),
                    $this->prettifier()
                )
            );

        if ($this->logsResultsInJUnitXML) {
            $arguments['listeners'][] = $this->junitXMLPrinterFactory->create(
                $this->createStreamWriter($this->junitXMLFile)
            );
        }

        if ($this->stopsOnFailure) {
            $arguments['stopOnFailure'] = true;
            $arguments['stopOnError'] = true;
        }

        if ($this->phpunitXMLConfiguration->isEnabled()) {
            $arguments['configuration'] = $this->phpunitXMLConfiguration->getFileName();
        }

        return $arguments;
    }
    ...

変更前のrun()メソッドは、テストランナーが引数として受け取ったテストスイートを実行するという全体に、Printerオブジェクトの構成に関するコード、TestRunner::doRun()メソッドの第2引数に渡す配列の構成に関するコードといった詳細が混じっているため、コードの抽象度が揃っておらず読みにくいものになっています。

対して変更後のrun()メソッドは、それら詳細部分を別メソッドとして分離したことにより抽象度が揃い、変更前のものと比べると確実に読みやすくなっています。

問題と解決

概念的に凝集されたメカニズムを切り分けて別個の軽量なフレームワークにすること。特に、形式主義や十分にドキュメント化された種類のアルゴリズムに気を配ること。意図の明白なインタフェースを用いてフレームワークの機能を他から使えるようにすること。これで、ドメインの他の要素は問題を表現すること(「何が」)に集中でき、複雑な解決(「どのように」)はフレームワークに委譲できる。

— Eric Evans エリック・エヴァンスのドメイン駆動設計 (IT Architects’Archive ソフトウェア開発の実践) p.429 第4部 戦略的設計 第15章 蒸溜 凝集されたメカニズム(COHESIVE MECHANISMS)

上記はEric Evans氏によるドメインモデルの設計パターンの一つ凝集されたメカニズムにある文です(強調は筆者によるもの)。本パターンはドメインモデルを表現するクラスを対象に書かれたものですが、問題(「何が」)解決(「どのように」)を分離するという観点は一般的な指針としても活用することができます。

問題空間の言語と解決空間の言語

問題部分と解決部分に分離されたコードは同一のプログラミング言語で書かれてはいるものの本質的に別の言語空間に属すると考えることができます。すなわち、問題空間の言語解決空間の言語です。特にソフトウェアのコアドメインについては、このような観点の分離が重要となってくるのではないでしょうか。

コードを超えた表現へ

一口にコードの抽象度を整えるといっても掴みどころがなくなかなか難しいものですが、前述のような指針を活用することでより効果的にコードの読みやすさを高めることができます。特に問題と解決、問題空間の言語と解決空間の言語といった観点は、コードの読みやすさの向上だけでなく、ドメイン固有の意図(intention)の発見や意図性(intentionality)の向上にも繋がる可能性があります。そうして抽出された意図はテキストDSLやグラフィカルDSLとして表現され、ソースコードの形態ですらなくなるかもしれません。

intention intentionality language readablility