Skip to content

干净架构最佳实践

Cover

干净架构介绍

Bob 大叔在 2012 年的一篇博文 The Clean Architecture 中提出了一种适用于复杂业务系统的软件架构方式。在干净架构出现之前,已经有一些其它架构,包括 Hexagonal ArchitectureOnion ArchitectureScreaming ArchitectureDCIBCE。这些架构在本质上都是类似的,都采用分层的方式来达到一个共同的目标,那就是分离关注。干净架构将这些架构的核心理念提取了出来,形成了一种更加通用和灵活的架构。干净架构的设计理念如下图所示:

Clean architecture

采用干净架构的系统,可以达成以下目标:

  1. 框架无关性。干净架构不依赖于具体的框架和库,而仅把它们当作工具,因此不会受限于任何具体的框架和库。
  2. 可测试性。业务规则可以在没有 UI、数据库、Web 服务器等外部依赖的情况下进行测试。
  3. UI 无关性。UI 改变可以在不改动系统其它部分的情况下完成,比如把 Web UI 替换成控制台 UI。
  4. 数据库无关性。可以很容易地切换数据库类型,比如从关系型数据库 MySQL 切换到文档型数据库 MongoDB,因为业务规则并没有绑定到某种特定的数据库类型。
  5. 外部代理无关性。业务规则对外部世界一无所知,因此外部代理的变动不会影响到业务代码。

可以看到干净架构是围绕业务规则来设计的,核心就是要保证业务代码的稳定性。

各层描述

实体(Entities)

实体用于封装企业范围的业务规则。实体可以是拥有方法的对象,也可以是数据结构和函数的集合。如果没有企业,只是单个应用,那么实体就是应用里的业务对象。这些对象封装了最通用和高层的业务规则,极少会受到外部变化的影响。任何操作层面的改动都不会影响到这一层。

用例(Use Cases)

用例是特定于应用的业务逻辑,一般用来完成用户的某个操作。用例协调数据流向或者流出实体层,并且在此过程中通过执行实体的业务规则来达成用例的目标。用例层的改动不会影响到内部的实体层,同时也不会受外层的改动影响,比如数据库、UI 和框架的变动。只有而且应当应用的操作发生变化的时候,用例层的代码才随之修改。

接口适配器(Interface Adapters)

接口适配器层的主要作用是转换数据,数据从最适合内部用例层和实体层的结构转换成适合外层(比如数据持久化框架)的结构。反之,来自于外部服务的数据也会在这层转换为内层需要的结构。

框架和驱动(Frameworks and Drivers)

最外层由各种框架和工具组成,比如 Web 框架、数据库访问工具等。通常在这层不需要写太多代码,大多是一些用来跟内层通信的胶水代码。这一层包含了所有实现细节,把实现细节锁定在这一层能够减少它们的改动对整个系统造成的伤害。

干净架构并没有定死图中的四层,可以按需增加或减少层数。前提是保证向内依赖原则,并且抽象的层级越往内越高。

向内依赖原则

干净架构最核心的原则就是代码依赖关系只能从外向内,而不能反之。干净架构的每一圈层代表软件系统的不同部分,越往里抽象程度越高。外层为机制,内层为策略。这里说的依赖关系,具体指的是内层代码不能引用外层代码的命名软件实体,包括类、方法、函数和数据类型等。

依赖反转原则

向内依赖原则限定内层代码不能依赖外层代码,但如果内层代码确实需要调用外层代码代码怎么办?这个时候可以采用 依赖反转原则(Dependency Inversion Principle)。内层代码将其所依赖的外层服务定义为接口(Interface),外层代码实现该接口。这样依赖就反转了过来,变成了外层代码依赖内层代码。

传递数据

跨层传递的数据结构通常应比较简单。可以是语言提供的基本数据类型,简单的数据传输对象,函数参数,哈希表等。重要的是保证数据结构的隔离性和简单性,不要违反向内依赖原则。

干净架构简化

干净架构原图里涉及的概念比较多,容易造成大家理解上出现偏差。再加上干净架构并没有规定具体实施细节,因此在实施的时候不同的人会有不同的选择,有些选择甚至是错误的。为了简化和规范干净架构的实施,笔者对干净架构进行了一些简化,并增加了一些约束。下面是精简后的干净架构图:

Clean architecture simplified

Entities

实体层跟原设计基本一致。对于没有企业级别业务规则的单个产品应用来说,实体层就是一些业务对象。为了简化各层之间数据的传递,允许在各层之间传递实体对象,以避免创建额外的数据传输对象(DTO,Data Transfer Object)。

Use Cases

用例层是整个应用的核心,是产品业务逻辑所在之处。它通过执行一系列的业务规则来完成用户请求的操作,其中可能会调用各种外部服务。这里的服务不局限于网络服务,比如说数据库服务、API 服务,还可以是第三方软件库包所提供的函数和方法。为了避免直接依赖这些外部服务,需要将其抽象为接口并放在用例层,这样就转换成了内部依赖,外层适配层需要实现这些接口。

Interface Adapters

适配器层在内层用例层和外层界面层之间进行适配,无论将用例暴露为界面层的 API、CLI 还是 GUI,还是用例层调用界面层的外部服务,都需要在这里进行适配。

在这一层需要实现用例层定义的外部服务接口,一般会借助于界面层的第三方驱动、库、包等,这相当于是将第三方软件所提供的接口适配为用例层要求的接口。数据库访问中所使用的对象仓库(Object Repository)或数据访问对象(DAO,Data Access Object)属于这层。

为了将用例在界面层暴露给用户去使用,需要使用像控制器(Controller)或呈现器(Presenter)这样的组件来将用例层提供的接口适配为界面层用户交互接口。服务端的控制器会检查用户身份和权限,校验用户输入并转换为用例层所需类型,然后执行用例来完成用户请求的操作,最后将用例层返回的结果转换为界面层需要的类型后返回。客户端的呈现器负责响应用户操作(页面展示、表单提交等),它可能会委托一个或多个用例来操作服务端业务状态,或者直接操作本地业务状态。在操作完成后,呈现器还要负责更新 UI,不过呈现器并不直接操作 UI 元素,而是调用 UI 接口来完成。

Interfaces

这里 Interfaces 应理解为界面而不是接口,界面指的是软件系统暴露给外部的操作界面。界面层里的界面是双向的,用例暴露给外部的,以及外部提供给用例的。

用例暴露给外部的界面是为了让用户能跟用例交互,以便执行用例,一般会借助于某种(Web、UI、CLI 等)应用框架(Frameworks)来实现。对于服务端应用这个界面通常是 API,对于客户端应用可以是图形界面(GUI)或命令行界面(CLI)。在服务端应用里,界面层大部分的工作都交由框架来完成,因此开发人员需要编写的代码很少,比如加载配置、将控制器注册到某个路由、初始化和启动服务等。对于客户端应用来说,界面开发的工作量一般比较大,特别是对于那些界面比较复杂的应用。注意对于用户操作(交互)的处理应放到适配层的呈现器里,它负责控制界面变化。

外部提供给应用的界面包括驱动(Drivers)、库(Libraries)、包(Packages)等软件,它们会在适配器层转换为用例需要的接口,以供用例使用。

可以看到这一层包含了各种框架和驱动,因此在干净架构原图里称之为框架和驱动层(Frameworks and Drivers)。不过个人觉得界面层更为贴切,因为适配层(Interface Adapters)就是在外层 Interfaces 和内层 Use Cases 之间做适配(Adapt)。

在服务端应用中的实践

下面我们通过一个使用 Spring Boot 框架开发的 API 服务来讲解如何在服务端应用里实施干净架构,相关代码可从 GitHub 获取 Spring Boot in Practice

目录结构

软件架构最重要的表现形式就是代码的目录结构,下面是该 API 服务的目录结构:

txt
.
├── adapter # 适配层
│   ├── api # API 交互接口,将用例适配为 Rest API
│   ├── cli # 命令行交互接口,将用例适配为命令
│   ├── dao # 数据库访问,实现 usecase/port/dao 下的接口
│   ├── gui # 图形交互接口,将用例适配为图形界面操作
│   └── service # 第三方服务,实现 usecase/port/service 下的接口
├── entity # 实体层
│   ├── ...
│   └── UserBO.java # 用户实体
└── usecase # 用例层
    ├── ...
    ├── UserUsecases.java # 用户模块相关用例
    ├── exception # 用例层异常
    └── port # 用例层依赖的外部服务接口定义

上面的结构大体与干净架构一致,但更为细致,我们来一一看下。

实体层

我们的 API 服务没有什么企业级别的业务规则,因此实体简化成了 POJO 对象。实体是用例层要操作的对象,无论是从用例层流出还是流入用例层都传递的是实体对象。其它层需要按需转化成自己内部使用的类型,比如适配层里的对象仓库如果使用 Spring Data JPA 来实现,那么就需要在数据对象(DO,Data Object)和实体(BO,Business Object)之间相互转化。

下面是用户实体的定义:

java
package net.jaggerwang.sbip.entity;

import java.time.LocalDateTime;

import lombok.*;

/**
 * @author Jagger Wang
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserBO {
    private Long id;

    private String username;

    private String password;

    private String mobile;

    private String email;

    private Long avatarId;

    private String intro;

    private LocalDateTime createdAt;

    private LocalDateTime updatedAt;
}

用例层

为了防止用例太多导致类的数量很多,这里按照功能模块来组织用例,同一个模块的所有用例放在一个类里,每个用例对应一个方法。用例方法执行过程中如果出现业务异常,需要抛出 exception 目录下定义的某种异常来告知上层。在用例里面我们只需处理业务相关的异常,系统异常(比如程序 bug、数据库服务不可用、第三方服务响应超时等)一般不用处理,任其向上继续抛出即可,因为通常来说在用例层不知道如何处理这类异常。

用例层除了依赖内层实体层,不再有其它依赖(第三方依赖包也要尽量避免使用),因此不受外层所用工具和框架的影响,非常稳定也很容易进行单元测试。单元测试的时候可以根据需要给用例注入真实服务对象或者模拟对象。用例层依赖的外部服务接口定义统一放在 port 目录下,这里 port 表示门,意思是只有通过这道门用例才能访问到外部世界。

下面是用户模块的相关用例:

java
package net.jaggerwang.sbip.usecase;

import java.util.HashMap;
import java.util.List;
import java.util.Optional;

import net.jaggerwang.sbip.entity.RoleBO;
import net.jaggerwang.sbip.entity.UserBO;
import net.jaggerwang.sbip.usecase.exception.NotFoundException;
import net.jaggerwang.sbip.usecase.exception.UsecaseException;
import net.jaggerwang.sbip.usecase.port.dao.RoleDAO;
import net.jaggerwang.sbip.usecase.port.dao.UserDAO;
import net.jaggerwang.sbip.util.encoder.PasswordEncoder;
import net.jaggerwang.sbip.util.generator.RandomGenerator;
import org.springframework.stereotype.Component;

/**
 * @author Jagger Wang
 */
@Component
public class UserUsecase {
    private final static HashMap<String, String> MOBILE_VERIFY_CODES = new HashMap<>();
    private final static HashMap<String, String> EMAIL_VERIFY_CODES = new HashMap<>();

    private final UserDAO userDAO;
    private final RoleDAO roleDAO;

    public UserUsecase(UserDAO userDAO, RoleDAO roleDAO) {
        this.userDAO = userDAO;
        this.roleDAO = roleDAO;
    }

    public UserBO register(UserBO userBO) {
        if (userDAO.findByUsername(userBO.getUsername()).isPresent()) {
            throw new UsecaseException("用户名重复");
        }

        var user = UserBO.builder().username(userBO.getUsername())
                .password(encodePassword(userBO.getPassword())).build();
        return userDAO.save(user);
    }

    public String encodePassword(String password) {
        return new PasswordEncoder().encode(password);
    }

    // ...
}

可以看到除了内层实体,用例没有依赖其它层的类型和对象。

适配层

适配层首先要提供用例层里各个依赖服务的实现。以用户 DAO 为例,下面是其接口定义:

java
package net.jaggerwang.sbip.usecase.port.dao;

import java.util.List;
import java.util.Optional;

import net.jaggerwang.sbip.entity.UserBO;

/**
 * @author Jagger Wang
 */
public interface UserDAO {
    /**
     * 保存用户
     * @param userBO 要保存的用户
     * @return 已保存的用户
     */
    UserBO save(UserBO userBO);

    /**
     * 查找指定 ID 的用户
     * @param id 用户 ID
     * @return 用户
     */
    Optional<UserBO> findById(Long id);

    // ...
}

下面是用户 DAO 的实现:

java
package net.jaggerwang.sbip.adapter.dao;

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import net.jaggerwang.sbip.adapter.dao.jpa.entity.User;
import org.springframework.stereotype.Component;
import net.jaggerwang.sbip.adapter.dao.jpa.repository.UserRepository;
import net.jaggerwang.sbip.adapter.dao.jpa.entity.QUser;
import net.jaggerwang.sbip.adapter.dao.jpa.entity.QUserFollow;
import net.jaggerwang.sbip.adapter.dao.jpa.entity.UserFollow;
import net.jaggerwang.sbip.adapter.dao.jpa.repository.UserFollowRepository;
import net.jaggerwang.sbip.entity.UserBO;
import net.jaggerwang.sbip.usecase.port.dao.UserDAO;

/**
 * @author Jagger Wang
 */
@Component
public class UserDAOImpl implements UserDAO {
    private JPAQueryFactory jpaQueryFactory;
    private UserRepository userRepository;
    private UserFollowRepository userFollowRepository;

    public UserDAOImpl(JPAQueryFactory jpaQueryFactory, UserRepository userRepository,
                       UserFollowRepository userFollowRepository) {
        this.jpaQueryFactory = jpaQueryFactory;
        this.userRepository = userRepository;
        this.userFollowRepository = userFollowRepository;
    }

    @Override
    public UserBO save(UserBO userBO) {
        return userRepository.save(User.fromEntity(userBO)).toEntity();
    }

    @Override
    public Optional<UserBO> findById(Long id) {
        return userRepository.findById(id).map(user -> user.toEntity());
    }

    // ...
}

上面代码中自动注入的 UserRepository 是一个 Spring Data JPA 仓库。用户 DAO 的各个方法基本上都是委托 UserRepository 的对应方法来完成操作,不过它需要完成 UserBOUser DO 之间的类型转换,因为不能直接将底层的持久化对象暴露给用例层。除了使用 Spring Data JPA,还可以使用其它的数据库访问技术,比如 JdbcTemplateMyBatis,不过这些选择都不会影响到用例层。

适配层的另外一个责任就是把用例适配为界面层的 API,这是通过 Controller 来完成的。

下面是 Rest API 的 UserController

java
package net.jaggerwang.sbip.adapter.api.controller;

import java.util.Map;
import java.util.stream.Collectors;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import net.jaggerwang.sbip.usecase.exception.NotFoundException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import net.jaggerwang.sbip.adapter.api.controller.dto.RootDTO;
import net.jaggerwang.sbip.adapter.api.controller.dto.UserDTO;
import net.jaggerwang.sbip.usecase.exception.UsecaseException;

/**
 * @author Jagger Wang
 */
@RestController
@RequestMapping("/user")
@Api(tags = "User Apis")
public class UserController extends AbstractController {
    @PostMapping("/register")
    @ApiOperation("Register user")
    public RootDTO register(@RequestBody UserDTO userDto) {
        var userBO = userUsecase.register(userDto.toEntity());

        loginUser(userDto.getUsername(), userDto.getPassword());

        metricUsecase.increment("registerCount", 1L);

        return new RootDTO().addDataEntry("user", UserDTO.fromEntity(userBO));
    }

    // ...
}

为了避免返回给客户端的数据对象在结构上跟实体对象紧耦合(通常来说它们会有一些差异),使用了专门的 DTO (数据传输对象)来跟客户端交换数据。

在客户端应用中的实践

虽然干净架构更适合业务逻辑比较复杂、外部依赖服务较多的服务端应用,不过这也并不妨碍在客户端应用里使用它。使用干净架构并不会增加什么成本,但能使代码结构更清晰,还能在切换应用框架时复用之前的业务代码,这点对于前端和客户端这种框架变化比较快的场景尤其有用。

下面还是以一个实际的使用 Flutter 框架开发的移动应用来讲解,参考代码可从 GitHub 获取 Flutter in Practice

目录结构

下面是该 Flutter 应用的代码目录结构:

txt
.
├── adapter # 适配层
│   ├── presenter # 呈现器,负责处理用户操作,包括执行用例和更新 UI
│   └── service # 后端服务,实现 usecase/port/service 下的接口
├── config.dart # 应用配置
├── container.dart # IoC 容器
├── entity # 实体层
│   ├── ...
│   └── user.dart # 用户实体
├── main.dart # 应用入口
├── ui # 界面层
│   ├── app.dart # 应用根组件
│   ├── component # 各页面中复用的组件
│   ├── form # 表单
│   ├── page # 页面
│   ├── redux # Redux 状态管理
│   └── theme.dart # 主题配置
├── usecase # 用例层
│   ├── exception # 用例层异常
│   ├── port # 用例层依赖的外部服务接口定义
│   ├── ...
│   └── user.dart # 用户模块相关用例
└── util # 业务无关的小工具

上面的目录结构跟前面的 API 服务相差无几,除了使用 ui 目录替换了 api 目录。为了节省篇幅,下面只重点说一下在客户端应用里实施干净架构跟服务端应用的不同之处,具体实施细节可查阅参考代码。

实体层

本移动应用的实体对象是服务端 API 返回的数据传输对象,不是服务端的实体对象。对于没什么本地业务逻辑的应用,可以把它看作是视图对象(VO,View Object),其主要目的是用于界面渲染。

用例层

本移动应用没什么本地业务逻辑,因此用例层很薄,只是简单地调用服务端 API。为了避免代码重复,这里直接让呈现器调用服务端 API,而无需经过用例转发。对于服务端 API,因其属于外部服务,所以需要抽象为接口,这样也方便执行单元测试的时候使用模拟 API。

适配层

适配层除了需要实现用例层依赖的服务端 API 接口,更多的工作是呈现器的开发。呈现器负责响应用户操作,它控制着界面的变化,其作用相当于是服务端的控制器。当用户点击链接跳转到一个新页面时,它会从服务端请求相关数据,并传递给 UI 去渲染。当用户提交表单时,它会把用户输入提交到服务端,并把提交结果展示给用户。

界面层

本移动应用的界面层提供了运行于 Android 和 iOS 上的图形操作界面。借助于 Flutter 框架,我们可以采用响应式方式来实现 UI。传统的 MVP(Model-View-Presenter)模式里,View 需要提供大量接口给 Presenter,以便 Presenter 可以操控 UI 变化。在响应式 UI 里,组件通过更新其状态来自动触发 UI 更新,这就避免了呈现器去调用 UI 接口。对于把状态保存在内部的组件,呈现器只需返回数据给组件,组件会使用该数据去更新其内部状态,从而触发 UI 更新。如果组件状态保存在像 Redux 这样的状态管理器里,呈现器还需要负责发送 Action 去更新保存在 Redux Store 里的状态,以便监听了该部分状态的组件可以得到更新。

另外为了提升呈现器方法的可复用性,本应用里的呈现器方法并没有跟组件的交互事件回调函数一一对应。只是把各个组件中公共的交互处理逻辑提取到呈现器里,特定于每个组件的处理逻辑还是保留在组件中。不过有了呈现器提供的辅助,这些回调函数都非常简单,无非是执行一些页面跳转、错误显示、消息提示等操作。

参考资料

  1. The Clean Architecture
  2. Spring Boot in Practice
  3. Flutter in Practice

蜀ICP备2021032756号