レガシーコード改善に役立つ5パターン〜インタフェースの抽出〜

はじめに

こんにちは。サーバサイドエンジニアの畑と申します。

弊社ではテスト駆動開発を推進しており、
私もその一環として、以下のような本を読んで勉強しています。

レガシーコード改善ガイド
レガシーコード改善ガイド

この本は、『依存関係だらけのコードをうまく改善し、
テストコードを書いてコードを保護する』ことをテーマにした本です。
実践で役立つ知識やパターンが満載で、おすすめの一冊です。

テストコードの記述に苦労しているエンジニアさん、
結構多いのではないでしょうか?

そんな方向けに、この『レガシーコード改善ガイド』から、
役に立ったパターンを5パターンほど紹介させていただきます!

 

対象読者

この記事は、以下のような方に向けて書いています。

  • テストが存在しない既存コードに対し、テストコードを追加しているエンジニア
  • テスタブルなコードを書くことに興味があるエンジニア

※サンプルコードはPHPでご紹介しています。
PHPに親しまれているエンジニアさんには、読みやすい記事かと思います。
(勿論、この記事で紹介しているテクニックは
特定の言語に依存したものではありませんので、
それ以外のエンジニアさんにも是非ご一読頂きたいです!)

 

各記事リンク

連載1回目は、「インタフェースの抽出」と呼ばれるパターンを紹介します。
2回目以降の記事は、以下リンクからご覧ください。

  • 1.インタフェースの抽出
  • 2.メソッドのパラメータ化(coming soon)
  • 3.サブクラス化とメソッドのオーバーライド(coming soon)
  • 4.インスタンス委譲の導入(coming soon)
  • 5.メソッドオブジェクトの取り出し(coming soon)
  • 関連サイト(coming soon)

 

どんなパターン?

「インタフェースの抽出」パターンは、テストコードを書いていて、
以下のようなケースに当てはまった時に役立ちます。

  • テストしたいメソッド内で、他クラスのメソッドを呼んでおり、かつ以下のケースに該当する。
  • 依存している他クラスが大規模すぎて、テスト時にクラスのインスタンスを生成できない。
  • 呼んでいる他クラスのメソッドが大きすぎるなどの理由で、ある引数を渡した時に、どのような戻り値を返すか予測できない。
  • テストしたいメソッド内で、テスト時に実行されては困る処理が行われる(外部APIとの通信、本番DBへの書き込み処理 etc)。

 

具体例(変更前)

上記の説明だけではなかなかイメージが浮かびませんよね ^^;
ですので、具体例を見ていきましょう!

複数ゲームタイトルの売り上げを、
メールで送信するような機能をイメージしてください。
以下に、クラス図・ソースコードを示します。

 

chap0101
図1.クラス図(変更前)

 

<?php 
/** 
 * メールによる通知を行うオブジェクト 
 */ 
class MailMessageNotifier 
{ 

  //... 

  /** 
   * 全ゲームの売り上げ情報を、メールで通知する 
   * 
   * @param SalesInfo[] $salesInfoArray 
   * @param JsonFileReader $fileReader 
   * @param string $configFilePath 
   * @return array 
   */ 
  public function notifyAllGameSales
    (array $salesInfoArray, JsonFileReader $fileReader, $configFilePath ){ 

    //ファイルから一覧を取る 
    $addresses = $fileReader->readFile($configFilePath);

    //売上げの降順でソート
    usort(
      $salesInfoArray,
      function($a,$b){
        return gmp_neg($a->getSales() - $b->getSales());
      });

    //SalesInfoオブジェクトを使って、メッセージを組み立てる
    $message = '';
    foreach($salesInfoArray as $salesInfo){
      $message .= $this->buildSalesMessage($salesInfo);
    }

    //メールを送信する
    $retArray = array();
    foreach($addresses as $address){
      $retArray[] = $this->sendMail($address, $message);
    }
    return $retArray;
  }

  //...

}

 

<? php
/**
 * JSON形式のファイルを読み込む
 * ユーティリティクラス
 */
class JsonFileReader
{

  //...

  /**
   * JSONファイル全体を読み込み、配列形式で結果を返す
   *
   * @param string $filePath 設定ファイルが存在するパス
   * @return array
   */
  public function readFile($filePath){

    //JSONデータを取得
    $jsonData = file_get_contents($filePath);

    //データをUTF8に変換
    $jsonData = mb_convert_encoding($jsonData, 'UTF8', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN');

    //連想配列形式でreturn
    return json_decode($jsonData,true);
  }

  //...

}

 

クラス図内のMailMessageNotifierクラスが、
メール送信を行うクラスに相当します。
また、JsonFileReaderという、ユーティリティクラスがあり、
JSON形式の設定ファイルから、
送信したいメールアドレスの一覧を取得できるようになっています。

さて、ここでMailMessageNotifierクラスの、
sendMailメソッドのテストを書くことを想定しましょう。

対象メソッド内では、JSONファイルの読み込み処理を行っています!

愚直にテストを書くと、テスト時に本物のファイルへの
アクセスが実行されてしまいます。
これでは、以下のような問題が発生してしまいますね。

  • テストの実行速度が遅くなる
  • テストの実行結果が設定ファイルに依存してしまう
    (万一設定ファイルが削除されると、テストが失敗してしまう)

 

具体例(変更後)

では、このコードを修正してみましょう。
テスト時に偽装オブジェクトを作る方法でも上手くいきますが、
今回は設計を改善にするためにも、
JsonFileReaderクラスのインタフェースを定義してみましょう。

以下のクラス図のように修正を行います。
まず、ファイルからの読み込みを表すFileReaderクラスを定義します。

 

chap0102
図2.クラス図(変更後)

 

JsonFileReaderクラスは、
このインタフェースを実装するように修正します。

また、MailMessageNotifierクラスの
notifyAllGameSalesメソッドは、
JsonFileReaderクラスの代わりに、
FileReaderインタフェースを引数に取るようにします。

修正版のソースコードは、以下のようになります。

 

<? php
//以下のインタフェースを追加
/**
 * ファイルの読み込みを行う
 */
interface FileReader
{

  //...

  /**
   * ファイルからデータを読み取って、配列で返す。
   *
   * @param string $filePath 設定ファイルが存在するパス
   * @return array
   */
  public function readFile($filePath);

  //...

}

 

<? php
//FileReaderクラスは、インタフェースを実装するように変更します
/**
 * JSON形式のファイルを読み込む
 * ユーティリティクラス
 */
class JsonFileReader implements FileReader
{

  //...

  /**
   * JSONファイル全体を読み込み、配列形式で結果を返す
   *
   * @param string $filePath 設定ファイルが存在するパス
   * @return array
   */
  public function readFile($filePath){

    //JSONデータを取得
    $jsonData = file_get_contents($filePath);

    //データをUTF8に変換
    $jsonData = mb_convert_encoding($jsonData, 'UTF8', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN');

    //連想配列形式でreturn
    return json_decode($jsonData,true);
  }

  //...

}

 

<? php 
/** 
 * メールによる通知を行うオブジェクト 
 */ 
class MailMessageNotifier { 
  //... 
  
  //notifyAllGameSalesメソッドは、
  //引数にFileReaderインタフェースを取るように変更
  /**
   * 全ゲームの売り上げ情報を、メールで通知する
   * 
   * @param SalesInfo[] $salesInfoArray 
   * @param FileReader $fileReader 
   * @param string $configFilePath 
   * @return array
   */
   public function notifyAllGameSales
    (array $salesInfoArray, FileReader $fileReader, $configFilePath ){
    //ファイルから一覧を取る
    $addresses = $fileReader->readFile($configFilePath);

    //売上げの降順でソート
    usort(
      $salesInfoArray,
      function($a,$b){
        return gmp_neg($a->getSales() - $b->getSales());
      });

    //SalesInfoオブジェクトを使って、メッセージを組み立てる
    $message = '';
    foreach($salesInfoArray as $salesInfo){
      $message .= $this->buildSalesMessage($salesInfo);
    }

    //メールを送信する
    $retArray = array();
    foreach($addresses as $address){
      $retArray[] = $this->sendMail($address, $message);
    }

    return $retArray;
  }

  //...

}

 

次に、テストコードを見ていきましょう。

テスト時は本物のファイルアクセスを行わないよう、
偽装オブジェクトを引数に渡します。

専用の偽装オブジェクトを定義しても良いですが、
今回は簡単のため、PHPUnit上で
モックオブジェクトを生成し、引数に渡すことにします。

 

<? php 
/**
 * MailMessageNotifierクラスのテスト 
 */ 

class MailMessageNotifierTest extends PHPUnit_Framework_TestCase
{
  //... 
  
  public function testNotifyAllGameSales(){
    //JsonFileReaderクラスのモックオブジェクトを生成 
    $fileReaderMock = $this->getMockBuilder('JsonFileReader')->getMock();
    $fileReaderMock->method('readFile')->willReturn(array(
      'hoge@gmail.com',
      'foo@gmail.com'
    ));

    //...(その他必要なオブジェクトの生成)

    //モックオブジェクトを渡す
    $notifier->notifyAllGameSales($salesInfoArray, $fileReaderMock, '');

    //...(結果のassert)
    
  }

  //...
}

 

まとめ

いかがでしょうか?
勿論、実際の開発現場では、
今回のサンプルコードとは比較にならない規模のクラスに対し、
テストコードを書くことがほとんどかと思います。

クラス内の全てのメソッドを理解して、
責務に応じたインタフェースを抽出することが、
本来ならば望ましいでしょう。

しかし、規模の大きい既存クラスの全ての機能・責務を把握し、
機能毎にインタフェースを抽出するのはかなり難しいかと思います。
(このことは、『レガシーコード改善ガイド』にも記載されています。)

ですので、まずはテストコードを書いたメソッドに
対応したインタフェースを抜き出し、
次回のテストコードの追加時に、
インタフェースにメソッドを追加する or 新規にインタフェースを追加する
という対処をするのが現実的かと思います。

このパターンのメリット

最後に、このパターンのメリットデメリットをまとめます。

まず、インタフェースに依存している状態
(抽象に依存している状態)を作り出せれば、
簡単に実装を変えることができますね。

今回の例ですと、「設定ファイルの形式を、JSON形式からYAML形式に変えたい」
と考えた場合、以下のようなYamlFileReaderクラスを定義して、
notifyAllGameSalesメソッドに渡す引数を変えてあげれば、
簡単に実装を差し替えることができます。

 

chap0103
図3.クラス図(実装をJSON形式からYAML形式に差し替える)

 

また、設計に問題があるようなクラスに対してこのパターンを適用する場合、
インタフェースを抽出することで、少しずつクラス設計を改善できるのも
このパターンの良いところですね。

このパターンのデメリット

次に、このパターンのデメリットについて見ていきます。

デメリットの一つとして、単純にモックオブジェクトを作るより
ずっと手間がかかってしまうことが大きなデメリットと言えるでしょう。

また、「インタフェースの抽出」作業を途中で放り投げてしまうと、
中途半端に切り出されたインタフェースが乱立してしまうことになり、
クラス設計が更に悪化してしまいます。
この点にも、注意を払う必要がありますね。

以上、「インタフェースの抽出」のご紹介でした!

作者 畑 俊樹

株式会社ORATTAのサーバサイドエンジニア。 PHPでコードを書いてます( ^ω^ )

コメントを残す