こないだSOLIDなるものの存在を知りました。
で、業務で携わっている地図アプリに新しく機能が追加されることになったので、せっかくなので出来るだけSOLID原則を守る努力をしつつ実装してみたいと思います。
SOLIDとは
以下の5つの設計原則の頭文字を取ったもの。
Single responsibility principle(単一責任の原則)
クラスを変更する理由は1つでなければならない。
class Aがあり、たとえばxという機能追加とyという機能追加があった場合、そのどちらの場合もclass Aを変更する理由になりえるみたいなことがないようにというもの。Open-closed principle(オープンクロースドの原則)
拡張に対して開いており修正に対して閉じていなければならない。
新しい機能を追加する場合などに、既存のコードを修正せず(修正に対して閉じている=closed)新しいコードを追加する(拡張に対して開いている=open)だけで目的が達成できる状態にしましょうというもの。Liskov substitution principle(リスコフの置換原則)
継承したクラスは継承元クラスと置換可能(同じ動作)をしなければならない。
class Aがありx()というメソッドを持っている。class Bというclass Aを継承したクラスがありx()というメソッドがある。たとえば以下のようなメソッドがあるとして
function(A $a)
{
...
$a->x();
...
}
class Aのインスタンスを入れた時もclass Bのインスタンスを入れた時も同じ動きになるようにしましょう、というもの。
Interface segregation principle(インターフェース分離の原則)
使用しないメソッドに依存させるべきではない。
メソッドx(), y(), z()を持つInterface Aをimplementしたclass Bとclass Cがあるとする。class Cではz()を使わないが、Interfaceにあるので実装しなければならない、みたいなことがないようにしましょうというもの。(細かくInterface分けましょうという意味)Dependency inversion principle(依存性逆転の原則)
具体的なものではなく抽象に依存させなければならない。
たとえばメソッド内で何かを直接newしている場合などは、そのnewするクラスに依存していると言える。Interfaceを間に挟んで
class A
{
public function(UserRepoInterface $userRepo, $data)
{
$user = $userRepo->create($data);
}
}
class UserRepository implements UserRepoInterface
{
public function create($userData)
{
$user = new User();
...(略)
return $user;
}
}
class A -> UserRepoInterface <- UserRepository
みたいに抽象的なものに依存させましょうというもの。
参考にしたもの
こちらにまとめました。
バージョンなど
- PHP 7.4.4
- Laravel 6.18.3
- 基本的にはバックエンドでマスターデータがあるテーブルから必要なものを検索->JSONを返すという処理
- 今ある機能は、顧客名や住所、業態などでの検索機能/個々のデータの詳細表示 などシンプルなもの
今回追加したい仕様
- すでにある通常の検索とは全く別の機能として、特定の属性の顧客のみ検索して地図上に出す機能を追加したい。
- 今ある顧客データとは全く違う顧客データから検索される
- 画面上の検索モーダルで所定の部分にチェックを入れて検索ボタンを押すと、チェックボックスの値に応じた属性のお客様の場所が地図上に表示される
__________________________
|□ xxのために訪問が必要な顧客|
|[検索] |
|________________________|
->チェック
->検索ボタン
->xxのために訪問が必要な顧客の一覧のJSONが返ってくる。フロント側で地図上に表示
- "xxの顧客" という部分は今後バリエーションが増えていくとのこと
__________________________
|□ xxのために訪問が必要な顧客|
|□ yyの顧客 |
|□ zzな顧客 |
|... |
|... |
| |
|[検索] |
|________________________|
みたいな感じで今後増えてゆく
- 今回はまず "購入していただいたキャッシュレス端末の初期設定のために訪問が必要な顧客" の場所を表示させるようにしたい
実装
実装してゆきます。
すでにある通常の検索機能(顧客名や住所、業態などでの検索)とは別の機能という位置付けなので、既存の検索処理を流用すべきではないと思います。
しかし処理自体は、渡されたパラメーターで条件分岐して、条件に合った顧客をDBから検索して返す というシンプルな流れでできそうです。
ただ考慮すべきは、 "表示したい顧客の属性は今後どんどんふえてゆく" ということでしょう。
大まかな流れとしては
1.値チェック
2.パラメーターの判別
3.判別したパラメーターに応じた検索処理(DBから取得)
4.結果JSONを返す
こんな感じでしょうか。
今回はルートパラメーターとして画面で選択された値を、クエリパラメーターとして画面に表示されている範囲の地図の座標をリクエストから受け取るようにしています。
//例
/xx/yyy/1?center=&sw=&ne=
JSONを返すメソッドは、継承元のクラスで以下のようにすでに実装されています。
/**
* return json response.
*
* @param $data
* @return \Illuminate\Http\JsonResponse
*/
protected function responseJson($data){
return response()->json(['status' => 200, 'data' => $data], 200);
}
/**
* return error json response.
*
* @param $data
* @param $status
* @return JsonResponse
*/
protected function responseErrorJson($data, $status){
return response()->json(['status' => $status, 'data' => $data], $status);
}
一旦何も考えずに書く
とりあえず一旦形を作ってみます。
public function specialFeatureSearch($condition, Request $request)
{
if (!in_array((int)$condition, array_values(config('values.special_feature_search.conditions')))) {
return $this->responseErrorJson([], 404);
}
$request->validate([
'center' => 'required',
'sw' => 'required',
'ne' => 'required',
]);
if ((int)$condition === config('values.special_feature_search.conditions.cashless')) {
$ret = $this->cashlessSearch($request->all());
}
return $this->responseJson($ret);
}
public function cashlessSearch()
{
return DB::table('cashless_data')
->(略)
->get()->all();
}
普通にやってもこんな書き方しないと思いますし、そもそもSOLID云々関係なくやばいコードですが、とりあえず一旦こういうふうに書いたとします。
で、これはSOLIDなんでしょうか?
たぶん違います。
specialFeatureSearchというメソッドに責務が偏りすぎです。そして$conditionの種類が増えた場合にもこのメソッドを修正しなければなりません。Controllerに全て書いてしまっているので、あらゆる変更がこのControllerの修正理由になりえます。
できるだけSOLID準拠にしてみる
役割を抽出しながらリファクタリングしていきます。
今は以下の役割が全ていっしょになってしまっている状態です。
値チェック
パラメーター判別
検索
JSONを返す
Controller
Controllerの役割はユーザーの入力やモデル、ビューなどを文字通り制御することです。値チェックの責務は別クラスに持たせた方がよさそうです。
なのでここですべきは、入力を受け取って適切な処理に渡し、戻ってきたものをJSONレスポンスとして返すことです。
一旦形だけのメソッドをControllerに作成しておきます。
public function specialFeatureSearch($condition, Request $request)
{
$params = $request->all();
//TODO: $ret = searchFunction($condition, $params);
return $this->responseJson($ret);
}
パラメーターの値チェック
値チェックはどこですべきかというと、LaravelではFormRequestが適当かなと思います。
ということでFormRequestを作成して、値チェックはそちらに任せることにします。
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'center' => ['required'],
'sw' => ['required'],
'ne' => ['required'],
'condition' => [
function ($attribute, $value, $fail){
if (!in_array($value, array_values(config('values.special_feature_search.conditions')))) {
return $fail("存在しない{$attribute}です");
}
}
],
];
}
/**
* ルートパラメータをバリデーション値に追加
*
* @return array
*/
public function validationData()
{
return array_merge($this->request->all(), ['condition' => $this->condition]);
}
/**
* 許可するリクエスト値
*
* @return array
*/
public function allowedRequest(): array
{
return [
'center' => $this->input('center'),
'sw' => $this->input('sw'),
'ne' => $this->input('ne'),
];
}
ルートパラメーターのチェックもFormRequest内で行います。validationData()をオーバーライドし、リクエスト値に 'condition'というキーでルートパラメーターを追加しています。
config以下に配置したファイルから値の配列を取得し、その中にない値が渡された場合はバリデーションをパスしないように実装しました。
バリデーション失敗時にJSONを返すメソッドは、FormRequestの継承元クラスで以下のように実装しています。
protected function responseErrorJson(int $status, $data){
throw new HttpResponseException(response()->json(['status' => $status, 'data' => $data], $status));
}
protected function failedValidation(Validator $validator)
{
$this->responseErrorJson(422, ['errors' => $validator->errors()->all()]);
}
allowedRequest()というメソッドを実装していますが、バリデーションをパスした後に続く処理で使うリクエスト値のみを抽出するためのものです。
リクエスト値に関する責務を持つのはControllerではなくFormRequestだろうという考えのもと、FormRequestにこのメソッドを追加しました。
値を受け渡す用のクラスを別途作成してそれに値を詰めて返すとより安全なのかもしれませんが、今回は配列で返すことにします。
このallowedRequestはController内で以下のように使用するようにしました。
public function specialFeatureSearch($condition, SpecialFeatureSearchRequest $request)
{
$params = $request->allowedRequest();
//TODO: $ret = searchFunction($condition, $params);
return $this->responseJson($ret);
}
SOLID準拠っぽい部分
- これで、リクエスト値の変更や以降の処理に渡す値の仕様変更が入ったときに、このFormRequestのみ修正すればいいという状態になりました。
- また、FormRequestクラスをDIで受け取ることによって、特定のFormRequestクラスに依存しない状態にできました。
検索処理
DBから顧客データを検索して結果を返す処理です。
前のコードでController内にcashlessSearch()として実装していたメソッドです。
ところで今回達成したいのは "画面で選択した属性の顧客を検索したい" というものでした。
今回はまず "購入していただいたキャッシュレス端末の初期設定のために訪問が必要な顧客" の場所を表示させるようにしたい
つまり、例えばここに "WiFi設備を契約していただいた顧客" の選択項目が追加された場合は "WiFi設備を契約していただいた顧客を検索する処理" が必要になり、その時キャッシュレス用の検索処理は必要ありません。
ということは、ひとつずつクラスを分けて実装したほうがいいということでしょう。
class CashlessSearch
{
public function search(array $params): array
{
return DB::table('cashless_data')
->(略)
->get()->all();
}
}
SOLID準拠っぽい部分
- これで、例えばキャッシュレス用検索の条件を変更したい場合はこの検索クラスのみ修正すればいいという状態にできました。
- また、別の検索条件が追加された場合は新しいクラスを作成すればいいので、このクラスには何のも影響ありません。
パラメーター判別
Controllerの$conditionという引数で受け取ったルートパラメーターを判別する処理です。
最初のコードで以下のように書いていた部分です。
if ((int)$condition === config('values.special_feature_search.conditions.cashless')) {
$ret = $this->cashlessSearch($request->all());
}
SOLIDに従うなら、パラメーターを判別する責務のみを持つクラスを作成したほうが良さそうです。
ただし、その中でifで分岐させまくるのはあまりよくない感じがします。
そしてその中で条件に応じて検索用メソッドを呼んで結果を返すということも、責務を超えた振る舞いになるのでやめたほうがよさそうです。
理想は "判別クラスは判別のみ、処理の制御はController" というふうになることです。
で、こちらの動画を参考にしました。
具体的には以下のように実装しました。
・検索クラス用のInterfaceを作成
・検索用メソッドsearch()と、判別条件に応じてbooleanを返すメソッドsupport()を持たせる
・判別クラスでは各検索クラスのsupport()を実行し、trueが返ってきた検索クラスのインスタンスを返す
Interface
まず検索クラス用のInterfaceを作成します。
このInterfaceに持たせるのは
- search()
- support()
の2つのメソッドです。
interface SpotsSpecialFeatureSearchInterface
{
/**
* 検索処理
*
* @param array $params
* @return array
*/
public function search(array $params): array;
/**
* 判別用
*
* @param int $condition
* @return bool
*/
public function support(int $condition): bool;
}
検索クラス
検索クラスはこのInterfaceをimplementsするように変更します。
class CashlessSearch implements SpotsSpecialFeatureSearchInterface
{
public function search(array $params): array
{
return $this->cashlessSearch($params);
}
/**
* @param int $condition
* @return bool
*/
public function support(int $condition): bool
{
return $condition === config('values.special_feature_search.conditions.cashless');
}
private function cashlessSearch(array $params): array
{
return DB::table('cashless_data')
->(略)
->get()->all();
}
}
判別クラス
判別クラスでは検索クラスをコンストラクターで受け取れるようにします。
各検索クラスのsupport()を実行し、trueが返ってきたクラスのインスタンスを返します。
戻り値にInterfaceを指定しているので、今後検索クラスが増えても、Interfaceをimplementsしたクラスにすればここは修正しなくて大丈夫です。
該当するものがない場合はnullを返すようにしました
class SpecialFeatureSearchPatternResolver
{
private array $searchClasses = [];
private CashlessSearch $cashlessSearch;
public function __construct(CashlessSearch $cashlessSearch)
{
$this->cashlessSearch = $cashlessSearch;
//SpotsSpecialFeatureSearchInterfaceをimplementしたクラスのインスタンス配列
//新しく検索パターンが追加されたら新しいクラスを作成し、この配列に加える
$this->searchClasses = [
$cashlessSearch
];
}
/**
* パターンに当てはまる検索クラスのインスタンスを返す
*
* @param int $condition
* @return SpotsSpecialFeatureSearchInterface
*/
public function resolve(int $condition): ?SpotsSpecialFeatureSearchInterface
{
foreach ($this->searchClasses as $searchClass) {
if ($searchClass->support($condition)) {
return $searchClass;
}
}
return null;
}
}
こうすることで、例えば "WiFI設備を契約した顧客" みたいなconditionが追加された場合に
class WifiSearch implements SpotsSpecialFeatureSearchInterface
{
public function search(array $params): array
{
return $this->wifiSearch($params);
}
/**
* @param int $condition
* @return bool
*/
public function support(int $condition): bool
{
return $condition === config('values.special_feature_search.conditions.wifi');
}
private function wifiSearch(array $params): array
{
return DB::table('wifi_data')
->(略)
->get()->all();
}
}
みたいに新しくクラスを作成して
class SpecialFeatureSearchPatternResolver
{
private array $searchClasses = [];
private CashlessSearch $cashlessSearch;
private WifiSearch $wifiSearch;
public function __construct(CashlessSearch $cashlessSearch, WifiSearch $wifiSearch)
{
$this->cashlessSearch = $cashlessSearch;
$this->wifiSearch = $wifiSearch; //<-追加
//SpotsSpecialFeatureSearchInterfaceをimplementしたクラスのインスタンス配列
//新しく検索パターンが追加されたら新しいクラスを作成し、この配列に加える
$this->searchClasses = [
$cashlessSearch
$wifiSearch //<-追加
];
}
というふうにResolverクラスに登録するだけでOKになります。
ServiceClass
Controllerで上記のresolve()を呼び、返ってきたインスタンスのsearch()を実行すればOK。
というふうにまず考えましたが、そうするとControllerが検索クラスに依存するような作りということになってしまうのでは? と思い、別クラスに呼び出し用メソッドを作成しました。
ビジネスロジック用にServiceクラスというものが存在するのですが、今回はそちらにsearch()の呼び出し用メソッドを追加して、それをControllerで呼ぶようにしました。
class SpotsService
{
(略)
/**
* spots特殊検索
*
* @param SpotsSpecialFeatureSearchInterface $searchClass
* @param array $params
* @return array
*/
public function spotsSpecialFeatureSearch(SpotsSpecialFeatureSearchInterface $searchClass, array $params): array
{
return $searchClass->search($params);
}
...(略)
}
おそらくServiceクラスもInterfaceをimplementsしたほうがいいのかもしれません。
で、ControllerのコンストラクタでInterfaceを受け取って、実行時は以下のようにするのがベストだと思います。
$this->interface->spotsSpecialFeatureSearch();
こうできれば
Controller -> Interface <- ServiceClass
というふうに抽象に依存する形で実装できます。
Serviceクラス自体が他の部分でも使われていますので、今回は一旦そのまま実装することにしました。
Controller
Controllerでの処理の制御は以下のようになります
public function specialFeatureSearch($condition, SpecialFeatureSearchRequest $request)
{
$params = $request->allowedRequest();
$condition = (int)$condition;
$searchClass = $this->specialFeatureSearchPatternResolver->resolve($condition);
if (is_null($searchClass)) {
return $this->responseErrorJson([], 404);
}
$ret = $this->spotService->spotsSpecialFeatureSearch($searchClass, $params);
return $this->responseJson($ret);
}
SOLID準拠っぽい部分
- $conditionの種類が増えた場合、検索クラスを新たに追加してResolverクラスに登録するだけでよくなりました。(関係ないクラスの修正が不要)
- 判別クラス、検索クラスでそれぞれ明確に責務が分かれた作りになりました。
- spotsSpecialFeatureSearch()に渡すインスタンスは、SpotsSpecialFeatureSearchInterfaceをimplementsしているクラスのものであれば、置き換えても同じ振る舞いをするような作りになりました。
- SpotsSpecialFeatureSearchInterfaceには、ある実装クラスでは使用しているけど他の実装クラスでは使用していないようなメソッドは存在しません。(今のところは...)
- 全体を通してできるだけ戻り値を指定することで、置換可能な状態を保てるようになりました。
考慮できていない部分
- conditionが複数渡されてくるような仕様に変更になった場合
- ServiceClassの扱い
- Interfaceがあったほうがいい
- 全体を通して配列を引き回している
- データ用のクラスを作成し、それに値を詰めるという方式にできればより振る舞いを安定させられそう
ほかにもたくさんあると思いますが、今思いついたのはこれぐらいです。
やってみて
そもそもSOLIDなるものを知ったのが結構最近で、つまり最近までそういうものを意識せずに実装してきたということになります。
まだ理解は浅いですが、実際に実装してみることで、いくらか知識の深まりを感じました。
準拠させるためにあまりにも多くの労力を使ってしまうのは本末転倒ですが、コードの良し悪しを根拠を持って判断する視点を持つという意味で、SOLIDは知っておいたほうがいいのではないかと思います。