Strategyパターンやってみました。
割と使い道があるんじゃないかと思います。
バージョン
Laravel 6.18.40
PHP 7.4.4
Strategyパターン
Strategyパターンはstrategy(戦略)という言葉の意味からわかるように、状況に応じて処理を動的に切り替えることを目的としたデザインパターンです。
それぞれの処理をクラスとして定義し、共通の呼び出し部分から呼び出して処理を代替できるようにします。(オブジェクト指向の文脈ではdelegationと言われるようです)
実装
仕様
例えば、ユーザーが単発の仕事に応募してその報酬をもらえるようなサイトがあるとします。仕事情報は管理画面からCSVでアップロードする仕様になっています。アップされたCSVの内容はDBに保存されます。
仕事にはいくつかの種類があり、扱うデータが異なります。なので管理者は様々なフォーマットのCSV(ヘッダー行が1行だったり2行だったり、列数が違っていたりなど)をアップロードします。
管理画面にあるCSVアップロードフォームは1つで、フォーム内にはファイル用inputとファイルのタイプを選択するselectが存在します。
<!--こんな感じ-->
<form action="{{route('route_name')}}" method="post">
@csrf
<input type="file" name="uploaded_file" />
<select name="file_type">
@foreach(config('values.file_type') as $label => $fileType)
<option value="{{$fileType}}">{{$label}}</option>
@endforeach
</select>
</form>
考えること
フォームが送信されたらコントローラー内のメソッドでリクエスト値を受け取ります。それからCSVファイル内のデータを読み取って、そのデータをデータベースに保存します。
仕様にもある通り、アップロードされるCSVのフォーマットは何種類もあります。1つのメソッドにまとめてifなどで条件分岐しながらファイルを処理することは可能ですが、読みにくくて変更に弱い処理になってしまいそうです。
Strategyパターンを使えば、今回の場合だと、アップロードされるファイルのタイプごとにクラスを分けてその中で処理を行うことができます。
Dto
先にデータをやりとりするためのDtoクラスを定義しておきます。
use Illuminate\Http\UploadedFile;
class FileImportDto
{
public UploadedFile $uploadedFile;
public int $fileType;
public string $fileName;
public string $filePath;
public function __construct(UploadedFile $uploadedFile, int $fileType, string $fileName, string $filePath)
{
$this->uploadedFile = $uploadedFile;
$this->$fileType = $fileType;
$this->$fileName = $fileName;
$this->$filePath = $filePath;
}
}
StrategyInterface
CSVファイルのタイプごとに処理が違うので、最終的にタイプごとにクラスを分ける前提です(上の図の各ConcreteStrategyに相当)。そのクラスの雛形となるInterfaceを作成します。
全てのクラスで必要な処理は
- CSVファイルの中身の読み取り
- データをDBに保存
この2つです。
use App\Models\FileImportDto;
interface StrategyInterface
{
/**
* @param FileImportDto $fileDto
* @return array
*/
public function readData(FileImportDto $fileDto): array;
/**
* @param array $fileData
* @return mixed
*/
public function storeData(array $fileData);
}
具体的な処理
StrategyInterfaceの実装クラスを作成します。何個でも作成できるのがStrategyパターンのメリットです。今回は一旦3種類にします。
上の仕様の例で仕事情報に種類があると書きましたが、"オフィスワーク"、"飲食"、"運送"の3種類あると仮定します。
具体的な処理内容は今回関係ないので省略します。
use App\Models\FileImportDto;
class OfficeWorkFile implements StrategyInterface
{
public function readData(FileImportDto $fileDto): array
{
//CSVファイルの中身を読み取って配列に詰めて返す処理
(略)
}
public function storeData(array $fileData)
{
//配列を受け取って、中身をDBに保存する処理
(略)
}
}
use App\Models\FileImportDto;
class RestaurantFile implements StrategyInterface
{
public function readData(FileImportDto $fileDto): array
{
//CSVファイルの中身を読み取って配列に詰めて返す処理
(略)
}
public function storeData(array $fileData)
{
//配列を受け取って、中身をDBに保存する処理
(略)
}
}
use App\Models\FileImportDto;
class DeliveryFile implements StrategyInterface
{
public function readData(FileImportDto $fileDto): array
{
//CSVファイルの中身を読み取って配列に詰めて返す処理
(略)
}
public function storeData(array $fileData)
{
//配列を受け取って、中身をDBに保存する処理
(略)
}
}
StrategyFactory
具体的な処理を行うクラスを実装できました。次に必要なのは、ファイルのタイプによってどのクラスをインスタンス化するか判別する処理です。
ここについては何通りか方法があると思いますが、今回はStrategyFactoryというクラスを作成してそこで行うことにします。
class StrategyFactory
{
/**
* @param int $fileType
* @return StrategyInterface
*/
public static function createStrategy(int $fileType): StrategyInterface
{
$strategy = null;
switch ($fileType) {
case config('values.file_type.office_work.value'):
$strategy = new OfficeWorkFile();
break;
case config('values.file_type.restaurant.value'):
$strategy = new RestaurantFile();
break;
case config('values.file_type.delivery.value'):
$strategy = new DeliveryFile();
break;
}
return $strategy;
}
}
Context
具体的な処理を、上の図でいう各ConcreteStrategyにdelegationするクラスです。今回の例では場合に応じてOfficeWorkFileクラス、RestaurantFileクラス、もしくはDeliveryFileに処理を行ってもらうクラスということになります。
StrategyFactory::createStrategy()でどのクラスをインスタンス化するか判別し、executeで処理を実行します。
class Context
{
private StrategyInterface $strategy;
public function __construct(FileImportDto $fileDto)
{
$this->setStrategy($fileDto->fileType);
}
/**
* @param int $fileType
*/
public function setStrategy(int $fileType)
{
$this->strategy = StrategyFactory::createStrategy($fileType);
}
/**
* @param FileImportDto $fileDto
*/
public function execute(FileImportDto $fileDto)
{
$fileData = $this->strategy->readData($fileDto);
$this->strategy->storeData($fileData);
}
}
Contextクラスを動かすメソッド
Contextクラスの処理を動かすメソッドが必要です。これはとてもシンプルで、以下のようなものになります。
public function processFile(FileImportDto $fileDto)
{
$context = new Context($fileDto);
$context->execute($fileDto);
}
こちらの処理をどこに置くかは好みが分かれると思います。これぐらいならControllerに置いてもよさそうな気がしますが、ビジネスロジック用にServiceクラスというものを作成してそちらに配置することにします。
use App\Models\FileImportDto;
use App\Services\Strategies\Context;
class FileImportService
{
public function processFile(FileImportDto $fileDto)
{
$context = new Context($fileDto);
$context->execute($fileDto);
}
}
FormRequest
あとはControllerからServiceクラスにあるprocessFileを呼び出せば完了です。
しかしその前に、リクエスト値をDtoに変換しなければなりません。これをそのままController内で行うのは少しfatな感じになりそうな気がします。
せっかくLaravelを使っているので、FormRequest内に変換処理を書くことにします。
class FileImportRequest extends 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 [
'uploaded_file' => /*バリデーションルール*/,
'file_type' => /*バリデーションルール*/
];
}
/**
* convert request values into FileImportDto
*
* @return FileImportDto
*/
public function convertIntoDto(): FileImportDto
{
$uploadedFile = $this->file('uploaded_file');
return new FileImportDto(
$uploadedFile,
(int)$this->input('file_type'),
$uploadedFile->getClientOriginalName(),
$uploadedFile->path()
);
}
}
Controller
Serviceクラスにある処理を呼び出すのみです。
Dtoへの変換処理はFormRequestに定義してあるので、かなりスッキリしたものになりました。
class FileImportController extends Controller
{
private $service;
public function __construct(FileImportService $service)
{
$this->service = $service;
}
public function importFile(FileImportRequest $request)
{
$this->service->processFile($request->convertIntoDto());
return redirect(route('route_name'));
}
}
これでStrategyパターンは完成です🎉