再利用性と保守性のバランスが取れたコードの書き方
対象読者
- 綺麗なコードを書きたい人
- 仕様変更に強いコードを書きたい人
解説すること
単一責任の原則・開放閉鎖の原則の観点から再利用性と保守性のバランスの取れたコードの書き方を解説する。
クリーンアーキテクチャやDIなど、開発をする上で重要な知識は他にもあるが、今回の趣旨からは外れるため省略させていただく。
ちなみに、保守性と再利用性については下記のように定義している。
- 保守性とは必要なコードが1箇所に固まっていること。
- 再利用性とは複数箇所で共通のコードを利用できること。
サンプルに使用する言語など
サンプルとして注文明細の更新処理を題材としている。
コードはPHPとLaravelで書かれているが、他の言語であってもやり方はほとんど変わらないはずだ。
また、コード内ではstaticメソッドが多用されているが、メンバ変数と通常のメソッドを使って書いても問題ない。
それでは、再利用性と保守性のバランスの取れたコードの書き方について解説する。
ステップ1:スパゲティコードを書く
まずは一つのメソッド内にできる限りすべてを書くところから始めるべきだ。
ある程度経験を積んだプログラマーなら、直感的に共通化できる部分を見抜いて、別のメソッドにしたくなるかもしれない。
しかし、そこはグッとこらえて、長くて冗長なコードを作ることに注力してほしい。
この段階で簡単に動作を確認すると良い。
class Controller
{
function main(Request $request): JsonResponse
{
$validatedInput = $request->safe();
$id = $validatedInput->string('id');
$orderId = $validatedInput->string('orderId');
$name = $validatedInput->string('name');
$query = OrderDetail::where('id', $id)->where('order_id', $orderId);
$orderDetail = $query->first();
if (is_null($orderDetail)) {
throw new \Exception('OrderDetailを見つけることができませんでした。');
}
$orderDetail->name = $name;
$orderDetail->save();
return response()->json();
}
}
ステップ2:行ごとに責務分離を行う
行ごとに責務分離を行う。
1行内で変更する理由は一つでなければならない。
もうすこし簡単に言うと、定数、メソッドチェーン、入れ子になった関数などを、別の行に分解するということだ。
この段階では逆にコードが読みづらくなるかもしれないが、今後のステップにおいて必要な行為だ。
このステップをおろそかにしてしまうと、うまくメソッドの分離ができずに、保守性が低くなってしまう。
class Controller
{
function main(Request $request): JsonResponse
{
$validatedInput = $request->safe();
$inputKeyId = 'id';
$id = $validatedInput->string($inputKeyId);
$inputKeyOrderId = 'orderId';
$orderId = $validatedInput->string($inputKeyOrderId);
$inputKeyName = 'name';
$name = $validatedInput->string($inputKeyName);
$query = OrderDetail::query();
$tableColumnId = 'id';
$query = $query->where($tableColumnId, $id);
$tableColumnOrderId = 'order_id';
$query = $query->where($tableColumnOrderId, $orderId);
$orderDetail = $query->first();
$result = is_null($orderDetail);
if ($result) {
$message = 'OrderDetailを見つけることができませんでした。';
$exception = new \Exception($message);
throw $exception;
}
$orderDetail->name = $name;
$orderDetail->save();
return response()->json();
}
}
ステップ3:定数をメソッド外に移す
ここからはメソッドの外側に持っていくものを決めていく。
まず手始めに定数になっている箇所をconstにしてみよう。
このとき注意しなければいけない事がある。
定数の値が同じであるからと言って、軽率に共通化してはいけない。
例えば下記のコードであれば、 id
という値が2箇所存在するが、これは全く別の値である。
一方は入力値のキーであり、もう一方はテーブルのカラム名である。
共通化するのであれば、意味が限りなく同一でなければいけない。
class Controller
{
function main(Request $request): JsonResponse
{
$validatedInput = $request->safe();
$id = $validatedInput->string(self::REQUEST_KEY_ID);
$orderId = $validatedInput->string(self::REQUEST_KEY_ORDER_ID);
$name = $validatedInput->string(self::REQUEST_KEY_NAME);
$query = OrderDetail::query();
$query = $query->where(self::TABLE_COLUMN_ID, $id);
$query = $query->where(
self::TABLE_COLUMN_ORDER_ID,
$orderId
);
$orderDetail = $query->first();
$result = is_null($orderDetail);
if ($result) {
$exception = new \Exception(self::EXCEPTION_MESSAGE);
throw $exception;
}
$orderDetail->name = $name;
$orderDetail->save();
return response()->json();
}
const REQUEST_KEY_ID = 'id';
const REQUEST_KEY_ORDER_ID = 'orderId';
const REQUEST_KEY_NAME = 'name';
const TABLE_COLUMN_ID = 'id';
const TABLE_COLUMN_ORDER_ID = 'order_id';
const EXCEPTION_MESSAGE = 'OrderDetailを見つけることができませんでした。';
}
ステップ4:条件分岐・計算処理のメソッド化
条件分岐や計算処理をメソッド化していく。
SQLのwhere句も条件分岐と判断してメソッド化する。
今回の場合は、クエリのwhereメソッドと条件に一致した場合にエラーを投げる処理が対象だ。
class Controller
{
function main(Request $request): JsonResponse
{
$validatedInput = $request->safe();
$id = $validatedInput->string(self::REQUEST_KEY_ID);
$orderId = $validatedInput->string(self::REQUEST_KEY_ORDER_ID);
$name = $validatedInput->string(self::REQUEST_KEY_NAME);
$query = OrderDetail::query();
$query = self::whereId($query, $id);
$query = self::whereOrderId($query, $orderId);
$orderDetail = $query->first();
$exception = new \Exception(self::EXCEPTION_MESSAGE);
self::throwIf($exception, is_null($orderDetail));
$orderDetail->name = $name;
$orderDetail->save();
return response()->json();
}
static function whereId(Builder $query, string $id): Builder
{
return $query->where(self::TABLE_COLUMN_ID, $id);
}
static function whereOrderId(Builder $query, string $orderId): Builder
{
return $query->where(self::TABLE_COLUMN_ORDER_ID, $orderId);
}
static function throwIf(\Exception $exception, bool $determination): void
{
$determination && throw $exception;
}
const REQUEST_KEY_ID = 'id';
const REQUEST_KEY_ORDER_ID = 'orderId';
const REQUEST_KEY_NAME = 'name';
const TABLE_COLUMN_ID = 'id';
const TABLE_COLUMN_ORDER_ID = 'order_id';
const EXCEPTION_MESSAGE = 'OrderDetailを見つけることができませんでした。';
}
ステップ5:処理のまとまりをメソッド化する
一つの流れとして名前をつけることができる処理をメソッド化していく。
今回の場合は、「注文明細を探す」処理と「注文明細を更新する」処理をメソッド化できそうだ。
もし意味が同じであるメソッドあれば、この段階で共通化しておこう。
注意しなければいけないことは、constの説明時にも書いたが、処理内容が同じであるという理由で共通化してはいけない。
そのメソッドを使う理由(意味)が同じであることを必ずチェックしよう。
そうでなければ、プロジェクトが進み要件が変わったときに、メソッド内に複雑な条件分岐が大量に作られることになる。
class Controller
{
function main(Request $request): JsonResponse
{
$validatedInput = $request->safe();
$id = $validatedInput->string(self::REQUEST_KEY_ID);
$orderId = $validatedInput->string(self::REQUEST_KEY_ORDER_ID);
$name = $validatedInput->string(self::REQUEST_KEY_NAME);
$orderDetail = self::findOrderDetail($id, $orderId);
self::updateOrderDetail($orderDetail, $name);
return response()->json();
}
static function findOrderDetail(string $id, string $orderId): OrderDetail
{
$query = OrderDetail::query();
$query = self::whereId($query, $id);
$query = self::whereOrderId($query, $orderId);
$orderDetail = $query->first();
$exception = new \Exception(self::EXCEPTION_MESSAGE);
self::throwIf($exception, is_null($orderDetail));
return $orderDetail;
}
static function updateOrderDetail(
OrderDetail $orderDetail,
string $name,
): void {
$orderDetail->name = $name;
$orderDetail->save();
}
static function whereId(Builder $query, string $id): Builder
{
return $query->where(self::TABLE_COLUMN_ID, $id);
}
static function whereOrderId(Builder $query, string $orderId): Builder
{
return $query->where(self::TABLE_COLUMN_ORDER_ID, $orderId);
}
static function throwIf(\Exception $exception, bool $determination): void
{
$determination && throw $exception;
}
const REQUEST_KEY_ID = 'id';
const REQUEST_KEY_ORDER_ID = 'orderId';
const REQUEST_KEY_NAME = 'name';
const TABLE_COLUMN_ID = 'id';
const TABLE_COLUMN_ORDER_ID = 'order_id';
const EXCEPTION_MESSAGE = 'OrderDetailを見つけることができませんでした。';
}
ステップ6:クラスの分解
ここからは複数のクラスに分解していく。
ひとまずは同じファイル、または同じディレクトリ内で分解したほうが整理しやすいだろう。
クラスの分解は、おそらく今回の説明の中で最も判断が難しい部分だ。
なぜなら、絶対的な正解というのが存在せず、保守性と再利用性をトレードオフしながら考える必要があるからだ。
1クラスにメソッドをたくさんまとめれば、コードを追いやすくなる。
逆に1クラスのメソッドを小さくまとめれば、共通化をしやすくなる。
凝集度
保守性と再利用性の判断基準に凝集度というのがある。
凝集度が高ければ再利用性が高くなり、凝集度が低ければ保守しやすくなる。
凝集度をざっくりと説明するのであれば、クラス内の各staticメソッドの引数が同一であるほど凝集度が高い。
または、クラス内の各メソッドがメンバ変数を100パーセント利用していれば凝集度が高い、といえる。
今回の場合は、staticメソッドの第1引数が同一のものでクラスを分けを考えてみた。
クラス名は、なるべく第1引数の内容から考えるようにしている。
class Controller
{
function main(Request $request): JsonResponse
{
$validatedInput = $request->safe();
$id = $validatedInput->string(Request::ID);
$orderId = $validatedInput->string(Request::ORDER_ID);
$name = $validatedInput->string(Request::NAME);
$orderDetail = Finder::findOrderDetail($id, $orderId);
OrderDetailUpdater::update($orderDetail, $name);
return response()->json();
}
}
class Request
{
const ID = 'id';
const ORDER_ID = 'orderId';
const NAME = 'name';
}
class OrderDetail
{
const ID = 'id';
const ORDER_ID = 'order_id';
}
class Finder
{
static function findOrderDetail(string $id, string $orderId): OrderDetail
{
$query = OrderDetail::query();
$query = self::whereId($query, $id);
$query = self::whereOrderId($query, $orderId);
$orderDetail = $query->first();
$exception = new \Exception(self::EXCEPTION_MESSAGE);
self::throwIf($exception, is_null($orderDetail));
return $orderDetail;
}
const EXCEPTION_MESSAGE = 'OrderDetailを見つけることができませんでした。';
}
class OrderDetailUpdater
{
static function update(
OrderDetail $orderDetail,
string $name,
): void {
$orderDetail->name = $name;
$orderDetail->save();
}
}
class OrderDetailQuery
{
static function whereId(Builder $query, string $id): Builder
{
return $query->where(OrderDetail::ID, $id);
}
static function whereOrderId(Builder $query, string $orderId): Builder
{
return $query->where(OrderDetail::ORDER_ID, $orderId);
}
}
class Exception
{
static function throwIf(\Exception $exception, bool $determination): void
{
$determination && throw $exception;
}
}
ステップ7:ディレクトリの移動
最後にクラスを適切なディレクトリに配置する。
ここでも保守性と再利用性の判断が必要になってくる。
同じディレクトリ内にたくさんクラスを配置すれば保守性は高くなる。
一つ上の階層、または同階層の別のディレクトリにクラスを配置すれば複数ディレクトリで共通利用できる。
今回のコードの場合はどうなるだろうか?
Finder
, OrderDetailUpdater
, OrderDetailQuery
はAPIとブラウザのコントローラで使う可能性はあるが、他のユースケースで利用することはないのでApp\Sys\Order\Detail\Update\UseCase
に配置した。
Exception
は他のプロジェクトでも使えるくらい再利用性が高いのでApp\Util
に配置した。
Request
はAPIコントローラ内でのみ使用するのでApp\Sys\Order\Detail\Update\Api
に配置した。
OrderDetail
はおそらくDBアクセスする全ての機能で使用することになるので、ORMクラスに混ぜることにした。
共通利用するか判断できない場合は、いったんディレクトリを移動しないで、開発が進み共通化することが判明したときに別の階層に移動すればいい。
// App\Controller\Order\Detail\Update\Api
class Controller
{
...
}
// App\Sys\Order\Detail\Update\Api
class Request
{
...
}
// App\Orm
class OrderDetail
{
...
}
// App\Sys\Order\Detail\Update\UseCase
class Finder
{
...
}
// App\Sys\Order\Detail\Update\UseCase
class OrderDetailUpdater
{
...
}
// App\Sys\Order\Detail\Update\UseCase
class OrderDetailQuery
{
...
}
// App\Util
class Exception
{
...
}
さいごに
なるべく誰でも同じ手順を踏めば、再利用性と保守性のバランスの取れたコードを書けるように説明をしてみた。
しかし、これらの説明を読んで「やり方は分かったが大変そう」と感じた人もいるだろう。
確かにいくつもの手順を踏みコードを編集するので、簡単ではない。
工数も増えるだろう。
しかし、それ以上の価値があることは約束する。
Discussion