使用 Laravel 进行清洁架构

2025-05-25

使用 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


Enter fullscreen mode Exit fullscreen mode

那么端口怎么样?

看得出来你观察力真棒!为了将底层(用例和实体,通常称为领域,在上面的架构中用红色和黄色圆圈表示)高层(框架,用蓝色圆圈表示)解耦,我们需要适配器(绿色圆圈)。适配器的作用是使用各自的 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)
        );
    }
}


Enter fullscreen mode Exit fullscreen mode

这里有3件事我们需要注意:

  • 交互器实现接口CreateUserInputPort
  • 交互器依赖CreateUserOutputPort
  • 交互者不会ViewModel自己做,而是告诉演示者去做,

由于Presenter(此处抽象为CreateUserOutputPort)位于适配器(绿色)层,因此从调用它确实是控制反转CreateUserInteractor的一个很好的例子:框架不控制用例,而是用例控制框架。

如果你觉得它太复杂,那就忘掉这些,想想所有有意义的决策都是在用例层做出的——包括选择响应路径userCreateduserAlreadyExistsunableToCreateUSer)。控制器和演示器只是顺从的奴隶,没有业务逻辑。

我们永远无法排练足够多的内容,所以跟我一起唱:控制器👏应该👏​​不👏包含👏业务👏逻辑👏

那么从控制器的角度来看它是怎样的呢?

对于控制器来说,生活很简单:



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();
    }
}


Enter fullscreen mode Exit fullscreen mode

您可以看到它依赖于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()}"])
        );
    }
}


Enter fullscreen mode Exit fullscreen mode

传统上,所有这些代码都位于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),
                ]);
            });
    }
}


Enter fullscreen mode Exit fullscreen mode

我添加了 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());
    }
}


Enter fullscreen mode Exit fullscreen mode

完整的测试类可以在这里找到。

我使用Mockery进行模拟,但它可以处理任何对象。代码可能看起来很长,但实际上编写起来非常简单,而且它可以轻松地覆盖 100% 的用例。

这个实现方式是不是和书上说的有点不一样?

是的。你看,CA是由 Java 开发人员设计的。而且,在大多数情况下,在 Java 程序中,如果你想更新视图,可以直接从 进行Presenter

但在 PHP 中并非如此。因为我们无法完全控制视图,而且框架是围绕控制器返回响应的概念构建的。

所以我不得不调整这些原则,让ViewModel 调用栈向上爬到控制器,以便返回正确的响应。如果你能想出更好的设计,请在评论区告诉我🙏


请在评论区告诉我你的想法好吗?你的意见对我很重要,因为我写这些文章是为了挑战我的视野,每天学习新的东西。

当然,欢迎您通过提交拉取请求 (pull-request)来对演示代码库提出修改建议。非常感谢您的贡献🙏

这篇文章花了我四天时间进行研究、实现、测试和撰写。非常感谢大家的点赞、关注,或者在社交媒体上分享🙏

谢谢大家,你们的贡献让我有动力为你们写更多文章👍


进一步阅读:

文章来源:https://dev.to/bd​​elespierre/how-to-implement-clean-architecture-with-laravel-2f2i
PREV
RUST 编程完整课程 2024 我使用的是 Rust 版本 1.77.1
NEXT
你可能不需要前端框架