6.动手实践整洁架构 - 实现Web适配器

187 阅读9分钟

如今,大多数应用程序都有某种 Web 界面 - 要么是我们可以通过 Web 浏览器与之交互的 UI,要么是其他系统可以调用以与我们的应用程序交互的 HTTP API。

在我们的目标架构中,与外界的所有通信都通过适配器进行。那么,让我们讨论一下如何实现提供此类 Web 界面的适配器。

依赖倒置

图 13 给出了与我们讨论 Web 适配器相关的架构元素的视图 - 适配器本身以及与应用层交互的端口:

Web 适配器是“驱动”或“输入”适配器。它接受来自外部的请求,并将其转换为对我们的应用层的调用,告诉它要做什么。控制流从 Web 适配器中的控制器到应用层中的服务。

应用层提供 Web 适配器可以通过其进行通信的特定端口。服务实现这些端口,并且 Web 适配器可以调用这些端口。 如果我们仔细观察,我们会发现这就是依赖倒置原则的实际应用。

由于控制流从左到右,我们也可以让 Web 适配器直接调用用例,如图 14 所示。

那么为什么我们要在适配器和用例之间添加另一层间接层呢?原因是端口是外界可以与我们的应用层交互的规范。有了端口,我们就可以准确地知道与外界进行的通信,这对于任何处理遗留代码库的维护工程师来说都是有价值的信息。

话虽如此,我们将在第 11 章“有意识地走捷径”中讨论的捷径之一就是忽略输入端口并直接调用应用程序服务。

不过,仍然存在一个问题,这与高度交互的应用相关。想象一个应用通过 WebSocket 将实时数据发送到用户的浏览器。应用如何将这些实时数据发送到 Web 适配器,而 Web 适配器又将其发送到用户的浏览器?

对于这种情况,我们肯定需要一个端口。该端口必须由 Web 适配器实现并由应用程序核心调用,如图 15 所示。

从技术上讲,这将是一个输出端口,并使 Web 适配器成为输入和输出端口适配器。但同一个适配器没有理由不能同时使用。 在本章的其余部分中,我们将假设 Web 适配器只是输入适配器,因为这是最常见的情况。

Web 适配器的职责

Web 适配器实际上有什么作用?假设我们想为 BuckPal 应用程序提供 REST API。 Web 适配器的职责从哪里开始到哪里结束?

Web 适配器通常执行以下操作:

  1. 将 HTTP 请求映射到 Java 对象

  2. 执行授权检查

  3. 验证输入

  4. 将输入映射到用例的输入模型

  5. 调用用例

  6. 将用例的输出映射回 HTTP

  7. 返回 HTTP 响应

首先,Web 适配器必须侦听符合特定条件(例如特定 URL 路径、HTTP 方法和内容类型)的 HTTP 请求。然后,必须将匹配的 HTTP 请求的参数和内容反序列化为我们可以使用的对象。

通常,Web 适配器会执行身份验证和授权检查,如果失败则返回错误。

然后可以验证输入对象的状态。但是我们不是已经讨论过输入验证是输入模型对用例的责任吗?是的,用例的输入模型应该只允许在用例上下文中有效的输入。但在这里,我们讨论的是 Web 适配器的输入模型。从输入模型到用例,它可能具有完全不同的结构和语义,因此我们可能必须执行不同的验证。

我不提倡在 Web 适配器中实现与我们在用例的输入模型中所做的相同的验证。相反,我们应该验证是否可以将 Web 适配器的输入模型转换为用例的输入模型。任何阻止我们进行此转换的事情都是验证错误。

这给我们带来了 Web 适配器的下一个职责:使用转换后的输入模型调用某个用例。然后,适配器获取用例的输出并将其序列化为 HTTP 响应,然后发送回调用者。

如果途中出现任何问题并引发异常,Web 适配器必须将错误转换为消息发送回调用者。

我们的 Web 配器肩负着很多责任,但这也有很多应用层不应该关心的职责。任何必须做的事 HTTP 一定不能泄漏到应用层。如果应用层知道我们正在外部处理 HTTP,那么我们基本上就失去了从其他不使用 HTTP 的输入适配器执行相同域逻辑的选项。在一个好的架构中,我们希望保持选择的开放性。

请注意,如果我们从域和应用层而不是 Web 层开始开发,那么 Web 适配器和应用层之间的边界就会自然出现。如果我们首先实现用例,而不考虑任何特定的输入适配器,我们就不会试图模糊边界。

切片控制器

在大多数 Web 框架中(例如 Java 世界中的 Spring MVC),我们创建控制器类来执行我们上面讨论的职责。那么,我们是否构建一个单一控制器来回答针对我们应用的所有请求?我们不必这样做。一个 Web 适配器当然可能包含多个类。

然而,我们应该小心,将这些类放入相同的包层次结构中,以将它们标记为属于一起,如第 3 章“组织代码”中所述。

那么,我们要构建多少个控制器?我说我们宁可创建太多,也不要创建太少。我们应该确保每个控制器实现一个尽可能窄的 Web 适配器切片,并尽可能少地与其他控制器共享。

让我们对 BuckPal 应用程序中的账户实体进行操作。一种流行的方法是创建一个 AccountController 来接受与账户相关的所有操作的请求。提供 REST API 的 Spring 控制器可能类似于以下代码片段。

package buckpal.adapter.web;

@RestController
@RequiredArgsConstructor
class AccountController {
    private final GetAccountBalanceQuery getAccountBalanceQuery;
    private final ListAccountsQuery listAccountsQuery;
    private final LoadAccountQuery loadAccountQuery;
    private final SendMoneyUseCase sendMoneyUseCase;
    private final CreateAccountUseCase createAccountUseCase;

    @GetMapping("/accounts")
    List<AccountResource> listAccounts(){
        ...
    }
    
    @GetMapping("/accounts/id")
    AccountResource getAccount(@PathVariable("accountId") Long accountId){
        ...
    }
    
    @GetMapping("/accounts/{id}/balance")
    long getAccountBalance(@PathVariable("accountId") Long accountId){
        ...
    }
    
    @PostMapping("/accounts")
    AccountResource createAccount(@RequestBody AccountResource account){
        ...
    }
    
    @PostMapping("/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}")
    void sendMoney(
    @PathVariable("sourceAccountId") Long sourceAccountId,
    @PathVariable("targetAccountId") Long targetAccountId,
    @PathVariable("amount") Long amount) {
        ...
    }
}

有关账户资源的所有内容都在一个类中,这感觉很好,但让我们讨论一下这种方法的缺点。 首先,每个类的代码较少是一件好事。我曾参与过一个遗留项目,其中最大的类有

30,000 行代码。这一点也不好玩。即使多年来控制器只积累了 200 行代码,但它仍然比 50 行代码更难掌握,即使它被干净地分成了方法。

同样的论点对于测试代码也有效。如果控制器本身的代码很多,那么测试代码也会很多。

通常,测试代码比生产代码更难掌握,因为它往往更加抽象。我们还希望某段生产代码的测试很容易找到,这在小的类中更容易些,不是吗?

然而,同样重要的是,将所有操作放入单个控制器类中可以鼓励数据结构的重用。在上面的代码示例中,许多操作共享 AccountResource 模型类。它充当任何操作中所需的一切的存储桶。 AccountResource 可能有一个 id 字段,这在创建操作中是不需要的,并且可能会造成混淆。另外,账户与用户对象具有一对多的关系,我们在创建或更新书籍时是否包含这些 User 对象?列表操作会返回用户吗?这是一个简单的例子,但是在任何规模以上的项目中,我们都会在某个时候提出这些问题。

因此,我提倡为每个操作创建一个单独的控制器(可能在一个单独的包中)的方法。此外,我们应该尽可能接近我们的用例来命名方法和类:

package buckpal.adapter.web;

@RestController
@RequiredArgsConstructor
public class SendMoneyController {
    private final SendMoneyUseCase sendMoneyUseCase;

    @PostMapping(path = "/accounts/sendMoney/{sourceAccountId}/{targetAccountId}/{amount}")
    void sendMoney(
            @PathVariable("sourceAccountId") Long sourceAccountId,
            @PathVariable("targetAccountId") Long targetAccountId,
            @PathVariable("amount") Long amount) {
        SendMoneyCommand command = new SendMoneyCommand(
                new AccountId(sourceAccountId),
                new AccountId(targetAccountId),
                Money.of(amount));

        sendMoneyUseCase.sendMoney(command);
    }
}

此外,每个控制器都可以有自己的模型,例如 CreateAccountResource 或 UpdateAccountResource,或者使用原语作为输入,例如上面的示例。这些专门的模型类甚至可能是控制器的包私有的,因此它们可能不会意外地在其他地方重用。控制器可能仍然共享模型,但是使用另一个包中的共享类会让我们更多地思考它,也许我们会发现我们不需要一半的字段并创建我们自己的字段。

此外,我们应该认真考虑控制器和服务的名称。例如,使用 RegisterAccount 代替 CreateAccount 不是更好的名称吗?在我们的 BuckPal 应用程序中,创建账户的唯一方法是用户注册。因此我们在类名中使用“register”这个词来更好地表达它们的含义。当然,也有一些情况, 通常会以创建...,更新...,以及删除...充分描述了一个用例,但在实际使用它们之前我们可能需要三思而后行。 这种切片风格的另一个好处是它使不同操作的并行工作变得轻而易举。如果两个开发人员处理不同的操作,我们就不会出现合并冲突。

这如何帮助我构建可维护的软件?

在为应用程序构建 Web 适配器时,我们应该记住,我们正在构建一个适配器,它将 HTTP 转换为对应用层用例的方法调用,并将结果转换回 HTTP,并且不执行任何域逻辑。

另一方面,应用层不应该做HTTP,所以我们应该确保不要泄露HTTP细节。这使得 Web 适配器可以在需要时被另一个适配器替换。

在讨论 Web 控制器进行切片时,我们不应该害怕创建许多不共享模型的小类。它们更容易掌握、测试和支持并行工作。最初设置这种细粒度的控制器需要做更多的工作,但在维护过程中会得到回报。