Kang

一个针对优化架构的简单例子

· 9 min read

当前状况

有一个 StatusService,包含了名为的 save() 的方法,可以在 Repo 中存储新的 StatusModel

有两个模块会用到 save(),一个是 StatusService,另外一个是 ResetStatus。前者通过 StatusResource 接受请求,后者则通过 ResetStatusEventListener

ResetStatus 会直接调用 StatusService 中的 save() 从而控制 status 数据的存储。StatusService,这个命名看上去像是包揽了或代理了所有和 Status 有关的一切事务,查询,存储,删除等等,但却为之后的测试埋下了隐患。

当前逻辑的关系图:

以下是具体的代码片段:

// StatusService.java
public void save(final StatusModel statusModel) {
    final EntityID id = statusModel.id();

    // Business validation logic
    guardEntityExists(id);
    final List<ExternalInfo> externalInfos = lookupExternalInfo(id, getContext());
    guardInfoIsValid(statusModel, externalInfos);
    guardSomeBusinessRule(statusModel, externalInfos);

    // Data mapping logic
    final StatusModel mappedStatusModel = mapDataToMatchExternalInfo(statusModel, externalInfos);

    // Persistence and side-effects
    final boolean stateHasChanged = statusRepository.save(mappedStatusModel);
    if (stateHasChanged) {
        log.debug("Invalidating cache for ID={}", id);
        cacheService.invalidateCacheForIds(List.of(id.toString()));
    }
}

有两个模块使用它,第一个模块是:

// StatusResource.java
@PUT
@Path("entities/{id}/status")
public Response updateStatus(
        @PathParam("id") final EntityID id,
        @Valid final StatusUpdateRequest request) {

    statusService.save(mapToDomainModel(id, request.details()));

    return Response.ok().build();
}

第二个模块是:

// ResetStatus.java
public void reset(@NonNull final EntityID id) {
    log.info("Invoking factory reset of status for ID={}", id);
    final StatusModel currentStatus = statusRepository.findById(id);
    final StatusModel resetStatus = currentStatus.withStatusReset();
    statusService.save(resetStatus);
}

// ResetStatusEventListener.java
public void handleEvent(final SystemResetEvent event) {
    resetStatus.reset(event.getId());
}

该结构引发的问题

在做测试的时候,有个调用 reset 的 ITCase 返回了错误,显示在 inventory 中无法找到 Vin。经过排查,我发现由于 StatusService.save()guardEntityExists() 会检查 Vin,而这个测试没有注册 Vin,从而产生了错误。根据 StatusService 原本的设计,它只是为 StatusResource 提供了服务。换句话说,StatusService.save() 的实现并不适合 Reset。

从这个错误中,我们就找到两个清晰的 Use Case,一个是为了处理 Inbound 为 Resource 的请求,另外一个是为了处理 Inbound 为 Listener 的 Reset 请求。两个的共同之处是,它们都会使用到 Reposave() 方法,而区别在于,Reset 不需要 guardEntityExists()

思考

在经典的设计模式 Hexagonal Architecture 中,包含了 Inbound, Outbound, Use Case, Domain 等等模块。我们应该如何将现有的 java 类正确的归类,它们之间如何正确的交互,是非常重要的问题。

首先可以明确的归类是:

  • Inbound - ResetStatusEventListener
  • Inbound - StatusResource
  • Outbound - StatusRepository

剩下的就是确认 ResetStatusStatusService

根据名字 ResetStatus 中的 Rest 强烈的表明它是一个 Use Case,然后观察 StatusService 中的方法,发现它其实包含了非常多的逻辑,不仅有代表 StatusRepository 的「贫血方法」,例如:

// StatusService
public StatusModel findById(final EntityID id) {
    return statusRepository.findById(id);
}

同时还加入了属于 Status 的 Domain 方法,比如 lookupExternalInfo(),或之前提到过的 guardEntityExists()。现在为了简化,我们先不去考虑这些看似 Domain 的方法,只将它看做是另外一个 Use Case。我们现在拥有了两个:

  • Use Case - StatusService
  • Use Case - ResetStatus

Use Case 之间是本应该隔离的,但当前的结构并没有如此实现,所以看上去有些不舒服。经过这样的调整,结构就变得清晰许多。

重新梳理结构

新的结构中,ResetStatus 应该直接和 Outbound 连接,即直接将重置后的 status 保存进数据库,而不是再经过 StatusService

除此需要修改的部分是 StatusService 中的 save() 方法的名字。原因是这个方法并不纯粹,除了存储数据之外,还做了 guard,mapping,以及 invalidate 等额外的事务。很显然,它并没有只做「保存」这一件事,所以我更倾向于将它重新命名为 update(),或者其他类似的词,去表明它在编织一段流程,即彻底更新 status。

现在,我们的新结构是这个样子:

虽然整体结构清晰了,但 StatusService 的命名仍让人困惑。我查询了一些关于 Use Case 的定义和规范,结果是:一个 Use Case 类最好只有一个公共的方法。

原因如下:

  • Single Responsibility Principle - SRP: 一个类应该只有一个改变的理由。如果一个类只代表一个 Use Case,那么只有这个特定流程需要改变时,我们才需要改变它。
    • 对应的,如果一个类包含多个 Use Case,任何流程修改都会影响到其他的 Use Case,增加了错误的风险。
  • Clear Business Boundaries: 如果打开一个包,可以看到一系列清晰的 UseCase 列,就像系统功能的菜单,任何人都会立刻明白这个应用或服务提供了哪些业务。
  • Minimised Dependencies: 不同 Use Case 的依赖是不同的。如果将它们放入同一个类中,有些依赖可能完全没有必要,但仍需要被引入。这会导致依赖混乱和不必要的耦合。
  • Clear Transactional & Security Boundaries: Spring 框架的@Transactional 通常都是应用在公共方法上的。因此一个类只有一个公共方法时,事务和安全的范围明确:要么整个 Use Case 成功,要么全部失败,从而避免了一个类中多个方法之间管理复杂状态的麻烦。

根据该说明,StatusService 应该改名成 UpdateStatusUseCase,public 方法可以改成 apply()execute()

更名后的结构是:

之前我们都在讨论 UseCase,最后可以来说说 Domain。Domain 应该包含了真正的业务逻辑,而不是作为 Use Case 引入外部服务,编织并操作流程。在这个例子里,UpdateStatusUseCase (即原来的 StatusService) 的 guardEntityExists()lookupExternalInfo(),或 StatusModel 其实属于 Domain 这个范畴。Use Case 需要 Domain 中的业务逻辑作为支持,比如 ResetStatus 需要知道 StatusModel 才可以正确的重置 Status。

经过最后的整理,理想中的结构是:

后续的想法

我有两点发现。

第一点是,当大家去讨论 Architecture 主题的时候,永远会花许多时间,它仿佛就像是吞噬时间的黑洞。另外一点是,讨论 Architecture 有点像讨论政治或生活哲学,不论经验多少,似乎所有人都可以来发表一点评论和意见。有丰富经验的人所提出的观点自然会比没什么经验的人(比如我)更加深刻且重要,但它很容易被淹没在众多观点的海洋中。

这是个高熵的主题。

当然,不论 Architecture 怎么变化,最终都是要解决实际问题的。在这个例子中,问题就是如何让相关的 ITCase 测试通过。通过这个例子,我们重新回到已经出现问题的代码中,通过反思并重构它,最终解决问题。

如果让我从头开始,去构筑以充满清晰结构为目标的方案,我会感觉到无所适从,也找不到标准说,什么是所谓的更好。

在当下这一时刻,我们把一个模块 A 放在位置 1 和位置 2,其实没有任何区别,也不会引起任何问题,更多的仅仅是品味和感受的问题。我很难将它们称为「真正的问题」,而真正的问题往往是当系统开始投入使用后才会出现的,因此在这一阶段也容易出现过度工程化。

架构设计真正产生魅力的瞬间,或有经验的设计者的前瞻性,往往是通过时间才能检验出来的。

总而言之,通过这个简单的例子,我得以了解不同设计模块的大体定义,以及模块之间的关系和边界的设计是如何切实的影响到小系统的运作的。

感谢您的阅读!您的支持是我的动力。

如果您喜欢这篇文章,不妨请我喝杯咖啡。 ☕️