はじめに
長い間保守業務をしていると、何年も前に書いたコードの改修が必要になった時、
「自分が昔書いたコード、読みにくいな・・・」
と思う場面に遭遇することは良くあると思います。
これは、昔よりも今の方が経験を積んで良いコードを書けるようになった証でもあります。
一般的に言われている通り、読みにくいコードは積極的にリファクタリングすべきです。
一方で以下のような理由により、読みにくいコードを放置せざるを得ない状態にあることがあります。
- 納期が短い
- 開発者が少ない
- バグ対応なのでできるだけ早く復旧したい
- etc...
私は現在、開発がスタートしてからそろそろ7年が経過する社内プロダクトを抱えており、当時の未熟な書き方の積み重ねで少しずつ読みにくいコードになってきてしまいました。
ですが最近チームの人数が増え、リファクタリングの工数が取れるようになってきましたので、今流行りのクリーンアーキテクチャによるリファクタリングを当プロダクトで実践してみることにしました。
クリーンアーキテクチャとは?
クリーンアーキテクチャとは、ロバート・C・マーチンによって提唱され、ソフトウェア設計の原則とパターンを組み合わせたアーキテクチャスタイルです。
ソフトウェアの保守性、拡張性、テスト容易性を向上させることを目的としています。
インターネットでは以下の画像が有名かと思います。
クリーンアーキテクチャには以下のような特徴があります。
-
クリーンアーキテクチャは、ソフトウェアを複数のレイヤーに分割します。これにより、関心の分離が促進され、各レイヤーが特定の責任を持つようになります。
-
内側のレイヤーは外側のレイヤーに依存しません。依存関係は内側から外側に向かってのみ存在します。これにより、ビジネスロジックがインフラストラクチャやUIに依存しないように設計されます。(依存性の逆転)
また、クリーンアーキテクチャは以下の4つの層に分かれています。
Enterprise Business Rules
ビジネスルールやエンティティを表現するレイヤーで、アプリケーションの中心に位置します。このレイヤーは他のレイヤーに依存しません。
Application Business Rules
アプリケーション固有のビジネスルールを含むレイヤーで、エンティティを操作して特定のユースケースを実現します。
Interface Adapters
外部のインターフェース(例:UI、データベース、Web API)とアプリケーションの内部ロジックをつなぐ役割を持ちます。
Frameworks & Drivers
最も外側のレイヤーで、具体的なフレームワークやツール、データベース、UIなどが含まれます。このレイヤーは他のレイヤーに依存しますが、逆はありません。
プロダクトで使用しているフレームワークについて
CodeIgniter(バージョン3.1.13)になります。
(ただし3系は既に開発終了しており、現在は4系が開発中)
クリーンアーキテクチャを適用する上での制約
とはいえクリーンアーキテクチャをそのまま適用するのは難しいです。
特に 時間的制約 と フレームワークによる制約 の2点の制約が大きいです。
時間的制約
クリーンアーキテクチャを利用しようとすると、コードが肥大化します。
通常の実装に比べ、クラスやメソッド(特にgetter/setter)の数が増えるからです。
そのため、今回は以下2点の制約を入れることにしました。
実装クラスの削減
以下のクラスについては、作成すると若干冗長になるため、作成しないことにします。
- プレゼンター
- ユースケース(のインターフェース)
またテストコードについてもビジネスロジックを表すもの(値オブジェクトやエンティティ)に限定します。
少しずつクリーンアーキテクチャに置き換える
システム全体をクリーンアーキテクチャに一気に書き換えようとすると、新規開発と同じかそれ以上の開発工数がかかります。
ユーザー側としてはできるだけ早く機能が欲しいので、このやり方は現実的ではありませんし、会社としてもリファクタリングのためだけにコストをかけるべきか、判断が難しくなります。
そのため一気に適用するのではなく、改修が発生した機能だけをクリーンアーキテクチャの形に少しずつ置き換えることにします。
フレームワークによる制約
クリーンアーキテクチャは本来フレームワークに依存しないアーキテクチャですが、CodeIgniter3がやや時代遅れな(笑)フレームワークのため、以下の制約(依存関係)が生じます。
命名規則による制約
CodeIgniter3のクラス名やメソッド名、実はスネークケース(snake_case)が採用されています。(4系はキャメルケースに変更されている)
一般的なフレームワークはキャメルケース(CamelCase)を採用しています。
将来的にフレームワークを差し替える(3系→4系のバージョンアップ含む)際には、全ての層においてスネークケース→キャメルケースへの名前変換が発生します。
名前空間使用不可による制約
CodeIgniter3では名前空間を使用できず、全てのクラスはルート上に存在します。
既存のディレクトリの下にクラスを配置するのであれば問題ないですが、クリーンアーキテクチャ用にディレクトリを分ける場合、そのままでは load
を使用できません。(コアクラスを拡張すればできるかもしれないが、面倒なので採用しなかった)
どうすればいいかというと、ファイル単位で require_once
しなければならないです。
面倒ですし、IDEでファイルを読み込んだかどうかのチェックもしにくいので、開発効率が悪いです。(やっぱりコアクラス作るべきかも・・・)
名前空間が使えれば、use
で一発なんですけどね。
ローディングの制約
上記の通りCodeIgniter3ではuse
を使えないため、CodeIgniterのロードメソッドを明示的に呼び出す必要があります。
例えばリポジトリクラスで CI_Model
を使う場合、コンストラクタ内で $this->CI->load->model('user_model');
といった記述を入れなければいけません。
具体的な適用方法
以上の制約を踏まえ、以下のようにクリーンアーキテクチャを適用しました。
ディレクトリ構成
CodeIgniterの既存のディレクトリはそのままに、新たに作成するクラスを application/ 以下にディレクトリを作って配置しています。
application/
│
├── controllers/
│ └── (CI_Controllerファイル)
│
├── models/
│ └── (CI_Modelファイル)
│
├── views/
│ └── (ビュー関連ファイル)
│
├── domain/
│ ├── entities/
│ │ ├── User_entity.php
│ │ └── (その他のエンティティ)
│ ├── repositories/
│ │ ├── User_repository_interface.php
│ │ └── (その他のリポジトリインターフェース)
│ ├── services/
│ │ ├── User_service.php
│ │ └── (その他のドメインサービス)
│ └── value_objects/
│ ├── Id.php
│ ├── User_id.php
│ └── (その他の値オブジェクト)
│
├── dtos/
│ ├── User_dto.php
│ └── (その他のDTO)
│
├── infrastructure/
│ └── repositories/
│ ├── User_repository.php
│ └── (その他のリポジトリ実装)
│
├── requests/
│ ├── users/
│ │ └── User_request.php
│ │── Request_base.php
│ └── (その他のリクエスト)
│
├── use_cases/
│ └── users/
│ ├── User_show_use_case.php
│ ├── User_add_use_case.php
│ ├── User_edit_use_case.php
│ ├── user_delete_use_case.php
│ └── (その他のユースケース実装)
│
└── (その他のディレクトリ)
クリーンアーキテクチャに直接関わるディレクトリは下記の通りです。
application/domain
ここには Enterprise Business Rules 層 に含まれるクラスを配置します。
(application/domain/repositoriesだけ Application Business Rules 層)
- application/domain/entities: エンティティ
- application/domain/repositories: リポジトリのインターフェース
- application/domain/services: ドメインサービス
- application/domain/value_objects: 値オブジェクト
application/dtos
ここには DTO(Data Transfer Object) クラスを配置します。
リポジトリとエンティティの橋渡しの役割でのみ使用しています。
今後他の用途でのDTOを使う機会が出てきたら、さらにディレクトリを分けるかもしれません。
application/infractructure/repositories
ここには Interface Adapters 層 に含まれる、リポジトリの具象クラス(Gateways)を配置します。
application/requests
ここにはユースケースに渡すリクエストクラス(構造的にはDTOに近い)を配置します。
パラメータのバリデーション処理も入れているので、通常のDTOとは区別しています。
application/use_cases
ここには Application Business Rules 層 のユースケース(の具象クラス)を配置します。
機能単位でディレクトリを切っています。
各層のクラスの具体的な実装方法
以下に出てくるコードは説明用に書いたものであり、実際のプロダクトで動いているコードとは異なります。
Enterprise Business Rules (Entity)
値オブジェクト(Value Object)
値オブジェクトは、識別子を持たず、属性の値で等価性を判断する不変のデータ構造です。
エンティティの属性値やメソッドに渡す引数で使用しています。(DTOでは使用しない)
コンストラクタでプリミティブ値を引数に渡して生成しますが、コンストラクタ内でバリデーションをかけます。(例:IDは1以上でなければならない)
バリデーションに失敗した場合、DomainException
を返すようにしました。
インターネットでは InvalidArgumentException
を使用するケースがよく見られますが、型チェックはタイプヒンティングで実現できており、型チェック通過後のRange等に対するバリデーションエラーなので、DomainException
を採用しました。
また、データベースへの保存やビューへの表示など、内部値を取り出したい時のために「as_
+ 型名」というgetterを必要に応じて定義しています。
setterはイミュータブルなので定義しません。
例として、IDを値オブジェクトで表現すると以下のようになります。
(ユーザIDなど実際のIDはこのクラスを継承して作成しています)
<?php
class Id
{
/**
* @var int ID
*/
protected $id;
/**
* コンストラクタ
*
* @param int $id
*/
public function __construct(int $id)
{
if ($id <= 0) {
throw new DomainException("id cannot be negative or zero");
}
$this->id = $id;
}
/**
* int値を返す
*
* @return int
*/
public function as_int(): int
{
return $this->id;
}
/**
* IDが同じか
*
* @param static $other
* @return bool
*/
public function equals(Id $other): bool
{
return ($this->id === $other->id);
}
}
エンティティ
エンティティは値オブジェクトとは異なり、識別子を持つオブジェクトです。
リポジトリからエンティティを生成したり(正確にはリポジトリ→DTO→エンティティ)、生成したエンティティをリポジトリに保存したりしています。
DTOと異なり、エンティティの属性で積極的に値オブジェクトを使用することで、ビジネスロジックをより明確にしています。
DTO→エンティティへの変換、エンティティ→DTOへの変換メソッド(static)もエンティティクラスに定義しています。
エンティティはミュータブルなので、getter/setter両方定義しています。
<?php
// DTOのインポート
require_once APPPATH . 'dtos/User_dto.php';
/**
* ユーザーを表現するエンティティ
*/
class User_entity
{
/**
* @var User_id ユーザーID
*/
private $user_id;
/**
* @var string ユーザー名
*/
private $user_name;
/**
* コンストラクタ
*
* @param User_id $user_id
* @param string $user_name
*/
public function __construct(
User_id $user_id,
string $user_name
) {
$this->user_id = $user_id;
$this->user_name = $user_name;
}
/**
* DTOから生成
*
* @param User_dto $user_dto
* @return self
*/
public static function from_dto(User_dto $user_dto): self
{
return new self(
new User_id($user_dto->get_id()),
$user_dto->get_name()
);
}
/**
* @return User_id
*/
public function get_user_id(): User_id
{
return $this->user_id;
}
/**
* @return string
*/
public function get_user_name(): string
{
return $this->user_name;
}
/**
* @param User_id $new_user_id
* @return self
*/
public function set_user_id(User_id $new_user_id): self
{
$this->user_id = $new_user_id;
return $this;
}
/**
* @param string $new_user_name
* @return self
*/
public function set_user_name(string $new_user_name): self
{
$this->user_name = $new_user_name;
return $this;
}
}
DTO
リポジトリとエンティティとの橋渡しの役割を果たしています。
リポジトリから直接エンティティに値を代入するパターンが一般的ですが、CodeIgniterのモデルが返すレコードの受け皿にエンティティを指定することができないので、代わりにDTOを受け取るようにしています。
以下のコードは、「CI_Model
が結果を連想配列で返す」ことが前提のコードになっています。
後述の通り、CI_Model
から直接DTOにセットする方法もあるので、その場合は連想配列との相互変換メソッド(from_array()
, to_array()
)は不要になります。
<?php
/**
* ユーザーテーブルの1レコードを表すDTO
*/
class User_dto
{
/**
* @var int ユーザーID
*/
private $id;
/**
* @var string $name ユーザー名
*/
private $name;
/**
* コンストラクタ
*
* @param int $id
* @param string $name
*/
public function __construct(
int $id,
string $name
) {
$this->id = $id;
$this->name = $name;
}
/**
* 連想配列から生成
*
* @param array<string, int|string|null> $attributes
* @return self
*/
public static function from_array(array $attributes): self
{
return new self(
$attributes['id'],
$attributes['name']
);
}
/**
* 連想配列に変換
*
* @return array<string, int|string|null>
*/
public function to_array(): array
{
return [
'id' => $this->id,
'name' => $this->name,
];
}
/**
* @return int
*/
public function get_id(): int
{
return $this->id;
}
/**
* @return string
*/
public function get_name(): string
{
return $this->name;
}
/**
* @param int $id
* @return self
*/
public function set_id(int $id): self
{
$this->id = $id;
return $this;
}
/**
* @param string $name
* @return self
*/
public function set_name(string $name): self
{
$this->name = $name;
return $this;
}
}
ドメインサービス
開発中は複数エンティティを跨ぐ判定処理などで使用していましたが、ステータスを管理する値オブジェクトを作成すれば良いことに気づき、ほとんど実装しませんでした。
Application Business Rules (Use Cases)
ユースケース(インタラクタ)
ユースケースのインターフェースは定義せず、インタラクター(ユースケースの実装)のみを定義しました。
ユースケースは外側のコントローラーから呼び出される想定で、コンストラクタ生成時にリポジトリが渡され、ユースケース実行時にリクエスト(Input Data)を渡します(リクエストデータのバリデーションはコントローラ側で実施済み)。
下記例ではユーザー情報を表示するため、ユーザーIDの入ったリクエストデータが渡され、ユーザーエンティティを返しています(Output Data にあたるクラスは作っていません)。
<?php
// リポジトリインターフェースのインポート
require_once APPPATH . 'domain/repositories/User_repository_interface.php';
// リクエストのインポート
require_once APPPATH . 'requests/users/User_request.php';
// エンティティのインポート
require_once APPPATH . 'domain/entities/User_entity.php';
/**
* ユーザー情報を表示するユースケース
*/
class User_show_use_case
{
/**
* @var User_repository_interface
*/
private $User_repository;
/**
* コンストラクタ
*
* @param User_repository_interface $user_repository
*/
public function __construct(
User_repository_interface $user_repository
) {
$this->user_repository = $user_repository;
}
/**
* ユーザー情報の取得
*
* @param User_request $request
* @return User_entity
*/
public function execute(User_request $request): User_entity
{
$user_dto = $this->user_repository->find_by_id($request->get_user_id());
if ($user_dto === null) {
throw new UnexpectedValueException('failed to get user information')
}
return User_entity::from_dto($user_dto);
}
}
リクエスト
コントローラからユースケースへ渡す際に使用するデータ構造です。
コントローラに渡されたパラメータからリクエストを作成する際に必ずバリデーションを通すようにします。
(つまりユースケースに渡される際には有効なパラメータであることが保証されている)
バリデーションはリクエストと密に結びついているので同じクラス内に実装し、共通化できるところをベースクラスに切り出しました。
ただバリデーションの部分がうまくフレームワークと分離できなったので、ここは今後の課題になります。
<?php
/**
* リクエストのベースクラス
*/
abstract class Request_base
{
/**
* バリデーションルールを返すこと
*
* @return array
*/
protected static function rules(): array
{
return [];
}
/**
* バリデーション
*
* @param array $parameters
* @return void
* @throws Validation_exception
*/
protected static function validate(array $parameters): void
{
$CI =& get_instance();
if ($CI->load->is_loaded('form_validation') === false) {
$CI->load->library('form_validation');
}
$CI->form_validation->set_data($parameters);
$CI->form_validation->set_rules(static::rules());
if ($CI->form_validation->run() === false) {
throw new Validation_exception($CI->form_validation->error_array());
}
}
}
<?php
// ベースクラスのインポート
require_once APPPATH . 'requests/Request_base.php';
// 値オブジェクトのインポート
require_once APPPATH . 'domain/value_objects/User_id.php';
/**
* 特定のユーザーに対するリクエスト
*/
class User_request extends Request_base
{
private const VALIDATION_RULES = [
[
'field' => 'user_id',
'label' => 'ユーザーID',
'rules' => 'required|is_natural_no_zero|exists_in_db[users.id]'
],
];
/**
* @var User_id ユーザーID
*/
private $user_id;
/**
* コンストラクタ
*
* @param int $user_id
*/
public function __construct(int $user_id)
{
$this->user_id = new User_id($user_id);
}
/**
* バリデーションルールを返す
*
* @return array
*/
protected static function rules(): array
{
return self::VALIDATION_RULES;
}
/**
* リクエストオブジェクトの作成
*
* @param mixed $user_id
* @return self
* @throws Validation_exception
*/
public static function create_from_parameters($user_id): self
{
parent::validate([
'user_id' => $user_id,
]);
return new self($user_id);
}
/**
* @return User_id
*/
public function get_user_id(): User_id
{
return $this->user_id;
}
}
リポジトリ(インターフェース)
Application Business Rules ではリポジトリのインターフェースだけ定義します。
取得メソッドではDTOを返し、保存メソッドではDTOを渡します。
(直接エンティティでのやり取りでも良いかと思います)
<?php
// 値オブジェクトのインポート
require_once APPPATH . 'domain/value_objects/ids/User_id.php';
// DTOのインポート
require_once APPPATH . 'dtos/User_dto.php';
/**
* ユーザーリポジトリのインターフェース
*/
interface User_repository_interface
{
/**
* 指定ユーザーIDのユーザー情報を取得する
*
* @param User_id $user_id
* @return User_dto|null
*/
public function find_by_id(User_id $user_id): ?User_dto;
/**
* 保存する
*
* @param User_dto $user_dto
* @return bool
*/
public function save(User_dto $user_dto): bool;
}
Interface Adapters (Controllers, Presenters, Gateways)
コントローラ
CodeIgniterの CI_Controller
をそのまま使います。
コンストラクタでリポジトリの実体を作成し、プロパティにセットします。
URL・HTTPメソッドに対応するコントローラメソッドが呼ばれると、リクエストデータ及びユースケースを初期化し、実行します。
ユースケースの戻り値はビューに渡します(プレゼンターは実装していません)。
<?php
// リポジトリのインポート
require_once APPPATH . 'infrastructure/repositories/User_repository.php';
// リクエストのインポート
require_once APPPATH . 'requests/users/User_request.php';
// ユースケースのインポート
require_once APPPATH . 'use_cases/users/User_show_use_case.php';
/**
* ユーザー情報を表示・登録・更新するコントローラ
*/
class User extends MY_Controller
{
/**
* @var User_repository
*/
private $user_repository;
public function __construct()
{
parent::__construct();
// リポジトリの初期化
$this->user_repository = new User_repository();
}
/**
* ユーザー情報表示
*
* @param $user_id
*/
public function show($user_id = NULL)
{
$request = User_request::create_from_parameters(intval($user_id));
$use_case = new User_show_use_case($this->user_repository);
$user_entity = $use_case->execute($request);
$template = $this->twig->loadTemplate('pages/user/show.html.twig');
$this->output->set_output($template->render(['user' => $user_entity]));
}
}
リポジトリの実装
Application Business Rules で定義したインターフェースの具象クラスを作成します。
データベース操作を記述するのですが、$db
を直接使うのではなく、 CI_Model
を通して操作します。
また、リポジトリは集約単位でクラスを作りますが、CI_Model
はテーブル単位でクラスを作ります。
<?php
require_once APPPATH . 'domain/repositories/User_repository_interface.php';
/**
* ユーザー情報を集約するリポジトリ
*/
class User_repository implements User_repository_interface
{
/**
* @var User_model
*/
private $user_model;
/**
* コンストラクタ
*/
public function __construct()
{
$CI =& get_instance();
if ($CI->load->is_loaded('user_model') === false) {
$CI->load->model('user_model');
}
$this->user_model = $CI->user_model;
}
/**
* 指定ユーザーIDのユーザー情報を取得する
*
* @param User_id $user_id
* @return User_dto|null
*/
public function find_by_id(User_id $user_id): ?User_dto
{
// ユーザーテーブルから取得
$user_array = $this->user_model->get_by_id($user_id->as_int());
if ($user_array === null) {
return null;
}
// 連想配列→DTOに変換
return User_dto::from_array($user_array);
}
/**
* 1レコード保存する
*
* @param User_dto $user_dto
* @return bool
*/
public function save(User_dto $user_dto): bool
{
// 省略
}
}
上記は CI_Model
の各メソッドが連想配列を返すことが前提のコードになっています。
執筆時に調べていたところ、CI_Model
内でデータベースの取得データを直接DTOにセットする方法もあるようです。
こちらをモデル内に直接書いたほうがコード量を削減できそうです。
// first_row()の第一引数にDTOのクラス名を渡す
$dto = $this->db->from('users')->first_row('User_dto');
Frameworks & Drivers (Web, UI, External Interfaces, DB, Devices)
大きな変更点はないので割愛します。
クリーンアーキテクチャを実装してみて
クリーンアーキテクチャ適用前はビジネスロジックがコントローラ、モデル、ライブラリとあちこちに点在していて探すのに時間がかかっていましたが、エンティティや値オブジェクトの実装によりビジネスロジックが1箇所にまとめられ、コードの見通しが良くなりました。また、各種ステータスを管理する値オブジェクトを作成し、ビジネスロジックに関わる判定処理を持たせることにより、ドメインサービスの数を最小限に抑えられました。
また、ビジネスロジックがフレームワークに依存しにくくなり(依存を完全になくすことはできていないが)、今後CodeIgniterのバージョンアップを実施する際の影響範囲を小さくでき、バージョンアップのハードルを低くできそうです。
一方で、既存のコードをクリーンアーキテクチャに書き換えていくと、修正量が大変多くなり、レビュアーへの負荷が高まるようになりました。1件のプルリクに対し数千行もの修正が入ることもザラにあります。また、修正量が増える原因のほとんどはgetter/setterやオブジェクト間のデータの転送・変換処理など、ビジネスロジックとは直接関係ない箇所でした。書く側としては作業ゲーに近く、何も考えずコーディングに没頭できて心地良い反面、なんだか無駄な時間を過ごした気分にもなってきます(笑)。
このように一長一短あるものの、全体としてはクリーンアーキテクチャを実装する機会ができてよかったと思っています。最近流行りのアーキテクチャに触れたことで、自分がこれまで「良い」と思って書いていたコードも分かりにくいコードだったことを気づかせてくれ、技術者としても一歩成長できたと思います。