使用 Laravel 进行清洁架构
Bob 大叔的“清洁架构”(Clean Architecture)在架构师圈里可谓是炙手可热。但说到实际的实现, Laravel却鲜有值得一提的方案。
这是可以理解的:Laravel 的MVC架构及其让您使用Facades跨层的倾向无助于设计干净、解耦的软件部件。
因此今天,我将向您介绍Laravel 应用程序内部清洁架构原则的有效实现,正如Robert C. Martin在《清洁架构》中解释的那样。
本文解释的概念的完整实现可以在我的GitHub 仓库中找到。我建议您在阅读本文时看一下实际的代码。
就这一次,让我们把手洗干净👍
一切始于一张图表
架构必须支持用例。[...] 这是架构师首先关注的问题,也是架构的首要任务。
(《清洁架构》第 16 章,第 148 页。)
如果您从未听说过用例,可以将其视为一种特性,即系统执行某些有意义任务的能力。UML允许您使用名称恰当的用例图来描述它们。
在CA中,用例是应用程序的核心。它们是控制应用程序机器的微芯片。
那么,我们应该如何实现这些用例呢?
很高兴你问了!这是第二张图:
让我简单解释一下,然后我们将深入研究实际的代码。
粉色线是控制流;它表示不同组件的执行顺序。首先,用户在视图上更改某些内容(例如,提交注册表单)。此交互成为一个Request
对象。控制器读取它并生成一个RequestModel
供 使用的UseCaseInteractor
。
然后UseCaseInteractor
执行其操作(例如,创建新用户),以 的形式准备响应ResponseModel
,并将其传递给Presenter
。进而通过 更新视图ViewModel
。
哇,好多啊😵 这可能是对CA 的主要批评;它很长!
调用层次如下:
Controller(Request)
⤷ Interactor(RequestModel)
⤷ Presenter(ResponseModel)
⤷ ViewModel
那么端口怎么样?
看得出来你观察力真棒!为了将底层(用例和实体,通常称为领域,在上面的架构中用红色和黄色圆圈表示)与高层(框架,用蓝色圆圈表示)解耦,我们需要适配器(绿色圆圈)。适配器的作用是使用各自的 API 和契约(或接口)在高层和低层之间传递消息。
在CA中,适配器至关重要。它们保证框架的变更不会导致领域层的变更,反之亦然。在CA中,我们希望用例与框架(实际实现)分离,以便两者都可以随意更改,而不会将更改传播到其他层。
因此,一个采用清晰架构设计的传统 PHP/HTML 应用程序,只需更改其控制器和呈现器,即可转换为 REST API——用例保持不变!或者,您可以使用相同的用例同时使用 HTML 和 REST。在我看来,这真是太棒了🤩
为此,我们需要“强制”适配器按照每一层所需的方式“运行”。我们将使用接口来定义输入和输出端口。本质上,它们的意思是:“如果你想跟我说话,你就必须这样做!”
等等等等。我想看代码!
既然UseCaseInteractor
意志是一切事物的核心,我们就从这个开始吧:
class CreateUserInteractor implements CreateUserInputPort
{
public function __construct(
private CreateUserOutputPort $output,
private UserRepository $repository,
private UserFactory $factory,
) {
}
public function createUser(CreateUserRequestModel $request): ViewModel
{
/* @var UserEntity */
$user = $this->factory->make([
'name' => $request->getName(),
'email' => $request->getEmail(),
]);
if ($this->repository->exists($user)) {
return $this->output->userAlreadyExists(
new CreateUserResponseModel($user)
);
}
try {
$user = $this->repository->create(
$user, new PasswordValueObject($request->getPassword())
);
} catch (\Exception $e) {
return $this->output->unableToCreateUser(
new CreateUserResponseModel($user), $e
);
}
return $this->output->userCreated(
new CreateUserResponseModel($user)
);
}
}
这里有3件事我们需要注意:
- 交互器实现接口
CreateUserInputPort
, - 交互器依赖于
CreateUserOutputPort
, - 交互者不会
ViewModel
自己做,而是告诉演示者去做,
由于Presenter
(此处抽象为CreateUserOutputPort
)位于适配器(绿色)层,因此从调用它确实是控制反转CreateUserInteractor
的一个很好的例子:框架不控制用例,而是用例控制框架。
如果你觉得它太复杂,那就忘掉这些,想想所有有意义的决策都是在用例层做出的——包括选择响应路径(userCreated
、userAlreadyExists
或unableToCreateUSer
)。控制器和演示器只是顺从的奴隶,没有业务逻辑。
我们永远无法排练足够多的内容,所以跟我一起唱:控制器👏应该👏不👏包含👏业务👏逻辑👏
那么从控制器的角度来看它是怎样的呢?
对于控制器来说,生活很简单:
class CreateUserController extends Controller
{
public function __construct(
private CreateUserInputPort $interactor,
) {
}
public function __invoke(CreateUserRequest $request)
{
$viewModel = $this->interactor->createUser(
new CreateUserRequestModel($request->validated())
);
return $viewModel->getResponse();
}
}
您可以看到它依赖于CreateUserInputPort
抽象而不是实际的CreateUserInteractor
实现。它使我们能够灵活地随意更改用例,并使控制器可测试。稍后会详细介绍。
好吧,这确实非常简单愚蠢。那主持人呢?
再次,非常简单:
class CreateUserHttpPresenter implements CreateUserOutputPort
{
public function userCreated(CreateUserResponseModel $model): ViewModel
{
return new HttpResponseViewModel(
app('view')
->make('user.show')
->with(['user' => $model->getUser()])
);
}
public function userAlreadyExists(CreateUserResponseModel $model): ViewModel
{
return new HttpResponseViewModel(
app('redirect')
->route('user.create')
->withErrors(['create-user' => "User {$model->getUser()->getEmail()} alreay exists."])
);
}
public function unableToCreateUser(CreateUserResponseModel $model, \Throwable $e): ViewModel
{
if (config('app.debug')) {
// rethrow and let Laravel display the error
throw $e;
}
return new HttpResponseViewModel(
app('redirect')
->route('user.create')
->withErrors(['create-user' => "Error occured while creating user {$model->getUser()->getName()}"])
);
}
}
传统上,所有这些代码都位于ifs
控制器端。这将迫使用例找到一种方法来“告诉”控制器发生了什么($user->wasRecentlyCreated
例如,使用或抛出异常)。
使用由用例控制的 Presenter,我们可以在不接触控制器的情况下选择和更改结果。这有多棒?
所以一切都依赖于抽象,我想容器在某个时候会参与其中?
你说得对,我的好朋友!今天能和你一起,我感到很开心。
以下是如何连接所有这些app/Providers/AppServiceProvider.php
:
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
// wire the CreateUser use case to HTTP
$this->app
->when(CreateUserController::class)
->needs(CreateUserInputPort::class)
->give(function ($app) {
return $app->make(CreateUserInteractor::class, [
'output' => $app->make(CreateUserHttpPresenter::class),
]);
});
// wire the CreateUser use case to CLI
$this->app
->when(CreateUserCommand::class)
->needs(CreateUserInputPort::class)
->give(function ($app) {
return $app->make(CreateUserInteractor::class, [
'output' => $app->make(CreateUserCliPresenter::class),
]);
});
}
}
我添加了 CLI 版本,以演示如何轻松地切换 Presenter,使用例返回不同的实例。更多细节,ViewModel
请查看实际实现👍
我可以测试一下吗?
天哪!它求你了!CA的另一个优点是它非常依赖抽象,这使得测试变得轻而易举。
class CreateUserUseCaseTest extends TestCase
{
use ProvidesUsers;
/**
* @dataProvider userDataProvider
*/
public function testInteractor(array $data)
{
(new CreateUserInteractor(
$this->mockCreateUserPresenter($responseModel),
$this->mockUserRepository(exists: false),
$this->mockUserFactory($this->mockUserEntity($data)),
))->createUser(
$this->mockRequestModel($data)
);
$this->assertUserMatches($data, $responseModel->getUser());
}
}
完整的测试类可以在这里找到。
我使用Mockery进行模拟,但它可以处理任何对象。代码可能看起来很长,但实际上编写起来非常简单,而且它可以轻松地覆盖 100% 的用例。
这个实现方式是不是和书上说的有点不一样?
是的。你看,CA是由 Java 开发人员设计的。而且,在大多数情况下,在 Java 程序中,如果你想更新视图,可以直接从 进行Presenter
。
但在 PHP 中并非如此。因为我们无法完全控制视图,而且框架是围绕控制器返回响应的概念构建的。
所以我不得不调整这些原则,让ViewModel
调用栈向上爬到控制器,以便返回正确的响应。如果你能想出更好的设计,请在评论区告诉我🙏
请在评论区告诉我你的想法好吗?你的意见对我很重要,因为我写这些文章是为了挑战我的视野,每天学习新的东西。
当然,欢迎您通过提交拉取请求 (pull-request)来对演示代码库提出修改建议。非常感谢您的贡献🙏
这篇文章花了我四天时间进行研究、实现、测试和撰写。非常感谢大家的点赞、关注,或者在社交媒体上分享🙏
谢谢大家,你们的贡献让我有动力为你们写更多文章👍
进一步阅读:
文章来源:https://dev.to/bdelespierre/how-to-implement-clean-architecture-with-laravel-2f2i