在本文中,我们将根据Robert C. Martin的clean Architecture创建一个用户注册API的示例。我们将使用他的原始层-实体,用例,接口适配器和框架/驱动程序。
Clean简洁架构概述
Clean的体系结构包含许多代码设计和原理,例如 SOLID,稳定的抽象等。但是,核心思想是 根据业务价值将系统划分为多个级别。因此,最高级别具有业务规则,每一个较低级别的业务规则都离I / O设备越来越近。
同样,我们可以将级别转换为层。在这种情况下,情况恰恰相反。内层等于最高级别,依此类推:
考虑到这一点,我们可以根据业务需要设置多个级别。但是,始终要考虑 依赖性规则–较高的级别绝不能依赖较低的级别。
规则
让我们开始为我们的用户注册API定义系统规则。一,业务规则:
- 用户密码必须超过五个字符
其次,我们有应用规则。它们可以是不同的格式,例如用例或故事。我们将使用讲故事的短语:
- 系统接收用户名和密码,验证用户是否不存在,并保存新用户以及创建时间
请注意,这里没有提到任何数据库,UI或类似内容。因为 我们的业务不关心这些细节,所以我们的代码也不关心。
实体层
正如干净的架构所建议的那样,让我们从业务规则开始:
interface User { boolean passwordIsValid(); String getName(); String getPassword(); } |
并且,一个UserFactory:
interface UserFactory { User create(String name, String password); } |
我们创建用户 工厂方法的原因有两个。保留稳定的抽象原理并隔离用户创建。
接下来,让我们同时实现:
class CommonUser implements User { String name; String password; @Override public boolean passwordIsValid() { return password != null && password.length() > 5; } // Constructor and getters } class CommonUserFactory implements UserFactory { @Override public User create(String name, String password) { return new CommonUser(name, password); } } |
如果我们的业务很复杂,那么我们应该尽可能清晰地构建领域代码。因此,此层是应用设计模式的好地方。特别是,应该考虑到领域驱动设计。
单元测试
测试我们的CommonUser:
@Test void given123Password_whenPasswordIsNotValid_thenIsFalse() { User user = new CommonUser("Baeldung", "123"); assertThat(user.passwordIsValid()).isFalse(); } |
如我们所见,单元测试非常清楚。毕竟,缺少mocks 是这一层的一个好信号。
通常,如果我们在这里开始考虑mocks ,也许我们正在将实体与用例混合在一起。
用例层
用例是 与系统自动化相关的规则。在“干净的体系结构”中,我们将其称为“交互器Interactors”。
首先,我们将构建我们的UserRegisterInteractor,以便我们可以看到前进的方向。然后,我们将创建并讨论所有使用的部分:
class UserRegisterInteractor implements UserInputBoundary { final UserRegisterDsGateway userDsGateway; final UserPresenter userPresenter; final UserFactory userFactory; // Constructor @Override public UserResponseModel create(UserRequestModel requestModel) { if (userDsGateway.existsByName(requestModel.getName())) { return userPresenter.prepareFailView("User already exists."); } User user = userFactory.create(requestModel.getName(), requestModel.getPassword()); if (!user.passwordIsValid()) { return userPresenter.prepareFailView("User password must have more than 5 characters."); } LocalDateTime now = LocalDateTime.now(); UserDsRequestModel userDsModel = new UserDsRequestModel(user.getName(), user.getPassword(), now); userDsGateway.save(userDsModel); UserResponseModel accountResponseModel = new UserResponseModel(user.getName(), now.toString()); return userPresenter.prepareSuccessView(accountResponseModel); } } |
我们正在执行所有用例步骤。同样,该层负责控制实体的动作。尽管如此,我们并未对UI或数据库的工作方式做任何假设。但是,我们正在使用UserDsGateway和UserPresenter,连同UserInputBoundary,这些都是我们的输入和输出边界。
输入和输出边界
边界是定义组件如何交互的契约。输入边界暴露出我们的用例到外层是:
interface UserInputBoundary { UserResponseModel create(UserRequestModel requestModel); } |
接下来,我们有了利用外层的输出边界。首先,让我们定义数据源网关:
interface UserRegisterDsGateway { boolean existsByName(String name); void save(UserDsRequestModel requestModel); } 视图展现: interface UserPresenter { UserResponseModel prepareSuccessView(UserResponseModel user); UserResponseModel prepareFailView(String error); } |
请注意,我们使用的是 依赖倒置原则,使我们的业务摆脱了数据库和UI等细节的困扰。
去耦模式
在继续之前,请注意边界是如何 定义系统的自然划分的契约。但是我们还必须决定如何交付我们的应用程序:
- 单体式/整体式-可能使用某些封装结构来组织
- 通过使用模块
- 通过使用服务/微服务
考虑到这一点,我们可以 使用任何去耦模式达到干净的架构目标。因此,我们应该准备根据当前和将来的业务需求在这些策略之间进行更改。选择了我们的解耦模式后,应根据我们的边界进行代码划分。
请求和响应模型
到目前为止,我们已经使用接口跨层创建了操作。接下来,让我们看看如何跨这些边界传输数据。
注意我们所有的边界如何仅处理String或Model对象:
class UserRequestModel { String login; String password; // Getters, setters, and constructors } |
基本上,只有简单的数据结构才能跨越边界。而且,所有模型都只有字段和getter/setter(banq注:实则是DTO或领域事件等VO)。另外,数据对象属于内部。因此,我们可以保留依赖性规则。
测试UserRegisterInteractor
现在,让我们创建单元测试:
@Test void givenBaeldungUserAnd12345Password_whenCreate_thenSaveItAndPrepareSuccessView() { given(userDsGateway.existsByIdentifier("identifier")) .willReturn(true); interactor.create(new UserRequestModel("baeldung", "123")); then(userDsGateway).should() .save(new UserDsRequestModel("baeldung", "12345", now())); then(userPresenter).should() .prepareSuccessView(new UserResponseModel("baeldung", now())); } |
我们可以看到,大多数用例测试都是关于控制实体和边界请求的。而且,我们的界面使我们可以轻松地模拟mock这些细节。
接口适配器
至此,我们完成了所有业务。现在,让我们开始插入我们的细节。
我们的业务应该只处理最方便的数据格式,我们的外部代理(如数据库或UI)也应该处理。但是,这种格式通常是不同的。因此,接口适配器层负责转换数据。
首先,让我们使用JPA映射用户表:
@Entity @Table(name = "user") class UserDataMapper { @Id String name; String password; LocalDateTime creationTime; //Getters, setters, and constructors } |
Mapper的目标是将对象映射到数据库格式。
@Repository interface JpaUserRepository extends JpaRepository<UserDataMapper, String> { } |
现在,是时候实现我们的UserRegisterDsGateway了:
class JpaUser implements UserRegisterDsGateway { final JpaUserRepository repository; // Constructor @Override public boolean existsByName(String name) { return repository.existsById(name); } @Override public void save(UserDsRequestModel requestModel) { UserDataMapper accountDataMapper = new UserDataMapper(requestModel.getName(), requestModel.getPassword(), requestModel.getCreationTime()); repository.save(accountDataMapper); } } |
在大多数情况下,代码可以说明一切。除了我们的方法外,请注意UserRegisterDsGateway的名称。如果我们改为选择UserDsGateway,那么除注册以外其他用户功能也只能放入UserDsGateway中。将很容易违反接口隔离原则。
现在,让我们创建我们的HTTP适配器:
@RestController class UserRegisterController { final UserInputBoundary userInput; // Constructor @PostMapping("/user") UserResponseModel create(@RequestBody UserRequestModel requestModel) { return userInput.create(requestModel); } } |
这里的唯一目标是接收请求并将响应发送给客户端。
在将响应发送客户端之前,我们应该格式化回应:
class UserResponseFormatter implements UserPresenter { @Override public UserResponseModel prepareSuccessView(UserResponseModel response) { LocalDateTime responseTime = LocalDateTime.parse(response.getCreationTime()); response.setCreationTime(responseTime.format(DateTimeFormatter.ofPattern("hh:mm:ss"))); return response; } @Override public UserResponseModel prepareFailView(String error) { throw new ResponseStatusException(HttpStatus.CONFLICT, error); } } |
界面展现的规则仅与适配器有关。
@Test void givenDateAnd3HourTime_whenPrepareSuccessView_thenReturnOnly3HourTime() { UserResponseModel modelResponse = new UserResponseModel("baeldung", "2020-12-20T03:00:00.000"); UserResponseModel formattedResponse = userResponseFormatter.prepareSuccessView(modelResponse); assertThat(formattedResponse.getCreationTime()).isEqualTo("03:00:00"); } |
我们在将所有逻辑发送到视图之前已经对其进行了测试。有些东西是很难测试,我们应该把它分成一个可测试和humble object。UserResponseFormatter是一种可测试的对象,能轻松地让我们来测试。
驱动和框架
实际上,我们通常不在此处编写代码。这是因为该层表示与外部代理的最低连接级别。例如,H2驱动程序连接到数据库或Web框架。在这种情况下,我们将使用spring-boot作为Web和依赖注入框架。因此,我们需要它的启动应用:
@SpringBootApplication public class CleanArchitectureApplication { public static void main(String[] args) { SpringApplication.run(CleanArchitectureApplication.class); } } |
到目前为止,我们在业务中没有使用任何 spring注释,包括UserRegisterController,我们应该 将spring-boot视为类似数据库、界面的其他任何细节。
可怕的主类
到目前为止,我们遵循稳定的抽象原理。同样,我们通过反转控制来保护我们的内层免受外部代理的攻击。最后,我们将所有对象创建与使用分开。在这一点上,我们需要创建剩余的依赖项并将它们注入到我们的项目中:
@Bean BeanFactoryPostProcessor beanFactoryPostProcessor(ApplicationContext beanRegistry) { return beanFactory -> { genericApplicationContext( (BeanDefinitionRegistry) ((AnnotationConfigServletWebServerApplicationContext) beanRegistry) .getBeanFactory()); }; } void genericApplicationContext(BeanDefinitionRegistry beanRegistry) { ClassPathBeanDefinitionScanner beanDefinitionScanner = new ClassPathBeanDefinitionScanner(beanRegistry); beanDefinitionScanner.addIncludeFilter(removeModelAndEntitiesFilter()); beanDefinitionScanner.scan("com.baeldung.pattern.cleanarchitecture"); } static TypeFilter removeModelAndEntitiesFilter() { return (MetadataReader mr, MetadataReaderFactory mrf) -> !mr.getClassMetadata() .getClassName() .endsWith("Model"); } |
在本例中,我们使用spring-boot 依赖项注入 来创建所有实例。但是我们没有使用 @Component,同时实现了根包的扫描,但只忽略Model对象。
尽管此策略可能看起来更复杂,但它使我们的业务与DI框架脱钩。另一方面,主类统管了我们整个系统。这就是为什么干净的体系结构认为它特殊层中可包含所有其他层的原因:
总结
在本文中,我们了解了Bob叔叔的干净架构是如何 在许多设计模式和原则之上构建的。另外,我们使用Spring Boot创建了一个用例。
完整的代码可以 在GitHub上找到。点击标题见原文