阿里技术专家详解DDD系列 第三讲 - Repository模式
Posted nogos
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了阿里技术专家详解DDD系列 第三讲 - Repository模式相关的知识,希望对你有一定的参考价值。
为什么要用 Repository
实体模型 vs. 贫血模型
Entity(实体)这个词在计算机领域的最初应用可能是来自于Peter Chen在1976年的“The Entity-Relationship Model - Toward a Unified View of Data"(ER模型),用来描述实体之间的关系,而ER模型后来逐渐的演变成为一个数据模型,在关系型数据库中代表了数据的储存方式。
而2006年的JPA标准,通过@Entity等注解,以及Hibernate等ORM框架的实现,让很多Java开发对Entity的理解停留在了数据映射层面,忽略了Entity实体的本身行为,造成今天很多的模型仅包含了实体的数据和属性,而所有的业务逻辑都被分散在多个服务、Controller、Utils工具类中,这个就是Martin Fowler所说的的Anemic Domain Model(贫血领域模型)。
如何知道你的模型是贫血的呢?可以看一下你代码中是否有以下的几个特征:
- 有大量的XxxDO对象:这里DO虽然有时候代表了Domain Object,但实际上仅仅是数据库表结构的映射,里面没有包含(或包含了很少的)业务逻辑;
- 服务和Controller里有大量的业务逻辑:比如校验逻辑、计算逻辑、格式转化逻辑、对象关系逻辑、数据存储逻辑等;
- 大量的Utils工具类等。
而贫血模型的缺陷是非常明显的:
- 无法保护模型对象的完整性和一致性:因为对象的所有属性都是公开的,只能由调用方来维护模型的一致性,而这个是没有保障的;之前曾经出现的案例就是调用方没有能维护模型数据的一致性,导致脏数据使用时出现bug,这一类的 bug还特别隐蔽,很难排查到。
- 对象操作的可发现性极差:单纯从对象的属性上很难看出来都有哪些业务逻辑,什么时候可以被调用,以及可以赋值的边界是什么;比如说,Long类型的值是否可以是0或者负数?
- 代码逻辑重复:比如校验逻辑、计算逻辑,都很容易出现在多个服务、多个代码块里,提升维护成本和bug出现的概率;一类常见的bug就是当贫血模型变更后,校验逻辑由于出现在多个地方,没有能跟着变,导致校验失败或失效。
- 代码的健壮性差:比如一个数据模型的变化可能导致从上到下的所有代码的变更。
- 强依赖底层实现:业务代码里强依赖了底层数据库、网络/中间件协议、第三方服务等,造成核心逻辑代码的僵化且维护成本高。
虽然贫血模型有很大的缺陷,但是在我们日常的代码中,我见过的99%的代码都是基于贫血模型,为什么呢?我总结了以下几点:
- 数据库思维:从有了数据库的那一天起,开发人员的思考方式就逐渐从“写业务逻辑“转变为了”写数据库逻辑”,也就是我们经常说的在写CRUD代码。
- 贫血模型“简单”:贫血模型的优势在于“简单”,仅仅是对数据库表的字段映射,所以可以从前到后用统一格式串通。这里简单打了引号,是因为它只是表面上的简单,实际上当未来有模型变更时,你会发现其实并不简单,每次变更都是非常复杂的事情
- 脚本思维:很多常见的代码都属于“脚本”或“胶水代码”,也就是流程式代码。脚本代码的好处就是比较容易理解,但长久来看缺乏健壮性,维护成本会越来越高。
但是可能最核心的原因在于,实际上我们在日常开发中,混淆了两个概念:
- 数据模型(Data Model):指业务数据该如何持久化,以及数据之间的关系,也就是传统的ER模型;
- 业务模型/领域模型(Domain Model):指业务逻辑中,相关联的数据该如何联动。
所以,解决这个问题的根本方案,就是要在代码里严格区分Data Model和Domain Model,具体的规范会在后文详细描述。在真实代码结构中,Data Model和 Domain Model实际上会分别在不同的层里,Data Model只存在于数据层,而Domain Model在领域层,而链接了这两层的关键对象,就是Repository。
Repository的价值
在传统的数据库驱动开发中,我们会对数据库操作做一个封装,一般叫做Data Access Object(DAO)。DAO的核心价值是封装了拼接SQL、维护数据库连接、事务等琐碎的底层逻辑,让业务开发可以专注于写代码。但是在本质上,DAO的操作还是数据库操作,DAO的某个方法还是在直接操作数据库和数据模型,只是少写了部分代码。在Uncle Bob的《代码整洁之道》一书里,作者用了一个非常形象的描述:
- 硬件(Hardware):指创造了之后不可(或者很难)变更的东西。数据库对于开发来说,就属于”硬件“,数据库选型后基本上后面不会再变,比如:用了mysql就很难再改为MongoDB,改造成本过高。
- 软件(Software):指创造了之后可以随时修改的东西。对于开发来说,业务代码应该追求做”软件“,因为业务流程、规则在不停的变化,我们的代码也应该能随时变化。
- 固件(Firmware):即那些强烈依赖了硬件的软件。我们常见的是路由器里的固件或安卓的固件等等。固件的特点是对硬件做了抽象,但仅能适配某款硬件,不能通用。所以今天不存在所谓的通用安卓固件,而是每个手机都需要有自己的固件。
从上面的描述我们能看出来,数据库在本质上属于”硬件“,DAO 在本质上属于”固件“,而我们自己的代码希望是属于”软件“。但是,固件有个非常不好的特性,那就是会传播,也就是说当一个软件强依赖了固件时,由于固件的限制,会导致软件也变得难以变更,最终让软件变得跟固件一样难以变更。
举个软件很容易被“固化”的例子:
private OrderDAO orderDAO;
public Long addOrder(RequestDTO request)
// 此处省略很多拼装逻辑
OrderDO orderDO = new OrderDO();
orderDAO.insertOrder(orderDO);
return orderDO.getId();
public void updateOrder(OrderDO orderDO, RequestDTO updateRequest)
orderDO.setXXX(XXX); // 省略很多
orderDAO.updateOrder(orderDO);
public void doSomeBusiness(Long id)
OrderDO orderDO = orderDAO.getOrderById(id);
// 此处省略很多业务逻辑
在上面的这段简单代码里,该对象依赖了DAO,也就是依赖了DB。虽然乍一看感觉并没什么毛病,但是假设未来要加一个缓存逻辑,代码则需要改为如下:
private OrderDAO orderDAO;
private Cache cache;
public Long addOrder(RequestDTO request)
// 此处省略很多拼装逻辑
OrderDO orderDO = new OrderDO();
orderDAO.insertOrder(orderDO);
cache.put(orderDO.getId(), orderDO);
return orderDO.getId();
public void updateOrder(OrderDO orderDO, RequestDTO updateRequest)
orderDO.setXXX(XXX); // 省略很多
orderDAO.updateOrder(orderDO);
cache.put(orderDO.getId(), orderDO);
public void doSomeBusiness(Long id)
OrderDO orderDO = cache.get(id);
if (orderDO == null)
orderDO = orderDAO.getOrderById(id);
// 此处省略很多业务逻辑
这时,你会发现因为插入的逻辑变化了,导致在所有的使用数据的地方,都需要从1行代码改为至少3行。而当你的代码量变得比较大,然后如果在某个地方你忘记了查缓存,或者在某个地方忘记了更新缓存,轻则需要查数据库,重则是缓存和数据库不一致,导致bug。当你的代码量变得越来越多,直接调用DAO、缓存的地方越来越多时,每次底层变更都会变得越来越难,越来越容易导致bug。这就是软件被“固化”的后果。
所以,我们需要一个模式,能够隔离我们的软件(业务逻辑)和固件/硬件(DAO、DB),让我们的软件变得更加健壮,而这个就是Repository的核心价值。
模型对象代码规范
对象类型
在讲Repository规范之前,我们需要先讲清楚3种模型的区别,Entity、Data Object (DO)和Data Transfer Object (DTO):
- Data Object (DO、数据对象):实际上是我们在日常工作中最常见的数据模型。但是在DDD的规范里,DO应该仅仅作为数据库物理表格的映射,不能参与到业务逻辑中。为了简单明了,DO的字段类型和名称应该和数据库物理表格的字段类型和名称一一对应,这样我们不需要去跑到数据库上去查一个字段的类型和名称。(当然,实际上也没必要一摸一样,只要你在Mapper那一层做到字段映射)
- Entity(实体对象):实体对象是我们正常业务应该用的业务模型,它的字段和方法应该和业务语言保持一致,和持久化方式无关。也就是说,Entity和DO很可能有着完全不一样的字段命名和字段类型,甚至嵌套关系。Entity的生命周期应该仅存在于内存中,不需要可序列化和可持久化。
- DTO(传输对象):主要作为Application层的入参和出参,比如CQRS里的Command、Query、Event,以及Request、Response等都属于DTO的范畴。DTO的价值在于适配不同的业务场景的入参和出参,避免让业务对象变成一个万能大对象。
模型对象之间的关系
在实际开发中DO、Entity和DTO不一定是1:1:1的关系。一些常见的非1:1关系如下:
复杂的Entity拆分多张数据库表:常见的原因在于字段过多,导致查询性能降低,需要将非检索、大字段等单独存为一张表,提升基础信息表的检索效率。常见的案例如商品模型,将商品详细描述等大字段单独保存,提升查询性能:
多个关联的Entity合并一张数据库表:这种情况通常出现在拥有复杂的Aggregate Root - Entity关系的情况下,且需要分库分表,为了避免多次查询和分库分表带来的不一致性,牺牲了单表的简洁性,提升查询和插入性能。常见的案例如主子订单模型:
从复杂Entity里抽取部分信息形成多个DTO:这种情况通常在Entity复杂,但是调用方只需要部分核心信息的情况下,通过一个小的DTO降低信息传输成本。同样拿商品模型举例,基础DTO可能出现在商品列表里,这个时候不需要复杂详情:
合并多个Entity为一个DTO:这种情况通常为了降低网络传输成本,降低服务端请求次数,将多个Entity、DP等对象合并序列化,并且让DTO可以嵌套其他DTO。同样常见的案例是在订单详情里需要展示商品信息:
模型所在模块和转化器
由于现在从一个对象变为3+个对象,对象间需要通过转化器(Converter/Mapper)来互相转化。而这三种对象在代码中所在的位置也不一样,简单总结如下:
- DTO Assembler:在Application层,Entity到DTO的转化器有一个标准的名称叫DTO Assembler。Martin Fowler在P of EAA一书里对于DTO 和 Assembler的描述:Data Transfer Object。DTO Assembler的核心作用就是将1个或多个相关联的Entity转化为1个或多个DTO。
- Data Converter:在Infrastructure层,Entity到DO的转化器没有一个标准名称,但是为了区分Data Mapper,我们叫这种转化器Data Converter。这里要注意Data Mapper通常情况下指的是DAO,比如Mybatis的Mapper。Data Mapper的出处也在P of EAA一书里:Data Mapper
如果是手写一个Assembler,通常我们会去实现2种类型的方法,如下;Data Converter的逻辑和此类似,略过。
public class DtoAssembler
// 通过各种实体,生成DTO
public OrderDTO toDTO(Order order, Item item)
OrderDTO dto = new OrderDTO();
dto.setId(order.getId());
dto.setItemTitle(item.getTitle()); // 从多个对象里取值,且字段名称不一样
dto.setDetailAddress(order.getAddress.getDetail()); // 可以读取复杂嵌套字段
// 省略N行
return dto;
// 通过DTO,生成实体
public Item toEntity(ItemDTO itemDTO)
Item entity = new Item();
entity.setId(itemDTO.getId());
// 省略N行
return entity;
我们能看出来通过抽象出一个Assembler/Converter对象,我们能把复杂的转化逻辑都收敛到一个对象中,并且可以很好的单元测试。这个也很好的收敛了常见代码里的转化逻辑。
在调用方使用时是非常方便的(请忽略各种异常逻辑):
public class Application
private DtoAssembler assembler;
private OrderRepository orderRepository;
private ItemRepository itemRepository;
public OrderDTO getOrderDetail(Long orderId)
Order order = orderRepository.find(orderId);
Item item = itemRepository.find(order.getItemId());
return assembler.toDTO(order, item); // 原来的很多复杂转化逻辑都收敛到一行代码了
虽然Assembler/Converter是非常好用的对象,但是当业务复杂时,手写Assembler/Converter是一件耗时且容易出bug的事情,所以业界会有多种Bean Mapping的解决方案,从本质上分为动态和静态映射。
动态映射方案包括比较原始的 BeanUtils.copyProperties、能通过xml配置的Dozer等,其核心是在运行时根据反射动态赋值。动态方案的缺陷在于大量的反射调用,性能比较差,内存占用多,不适合特别高并发的应用场景。
所以在这里我给用Java的同学推荐一个库叫MapStruct(MapStruct官网)。MapStruct通过注解,在编译时静态生成映射代码,其最终编译出来的代码和手写的代码在性能上完全一致,且有强大的注解等能力。如果你的IDE支持,甚至可以在编译后看到编译出来的映射代码,用来做check。在这里我就不细讲MapStruct的用法了,具体细节请见官网。
用了MapStruct之后,会节省大量的成本,让代码变得简洁如下:
@org.mapstruct.Mapper
public interface DtoAssembler // 注意这里变成了一个接口,MapStruct会生成实现类
DtoAssembler INSTANCE = Mappers.getMapper(DtoAssembler.class);
// 在这里只需要指出字段不一致的情况,支持复杂嵌套
@Mapping(target = "itemTitle", source = "item.title")
@Mapping(target = "detailAddress", source = "order.address.detail")
OrderDTO toDTO(Order order, Item item);
// 如果字段没有不一致,一行注解都不需要
Item toEntity(ItemDTO itemDTO);
在使用了MapStruct后,你只需要标注出字段不一致的情况,其他的情况都通过Convention over Configuration帮你解决了。还有很多复杂的用法我就不一一指出了。
模型规范总结
从使用复杂度角度来看,区分了DO、Entity、DTO带来了代码量的膨胀(从1个变成了3+2+N个)。但是在实际复杂业务场景下,通过功能来区分模型带来的价值是功能性的单一和可测试、可预期,最终反而是逻辑复杂性的降低。
Repository代码规范
接口规范
上文曾经讲过,传统Data Mapper(DAO)属于“固件”,和底层实现(DB、Cache、文件系统等)强绑定,如果直接使用会导致代码“固化”。所以为了在Repository的设计上体现出“软件”的特性,主要需要注意以下三点:
- 接口名称不应该使用底层实现的语法:我们常见的insert、select、update、delete都属于SQL语法,使用这几个词相当于和DB底层实现做了绑定。相反,我们应该把 Repository 当成一个中性的类 似Collection 的接口,使用语法如 find、save、remove。在这里特别需要指出的是区分 insert/add 和 update 本身也是一种和底层强绑定的逻辑,一些储存如缓存实际上不存在insert和update的差异,在这个 case 里,使用中性的 save 接口,然后在具体实现上根据情况调用 DAO 的 insert 或 update 接口。
- 出参入参不应该使用底层数据格式:需要记得的是 Repository 操作的是 Entity 对象(实际上应该是Aggregate Root),而不应该直接操作底层的 DO 。更近一步,Repository 接口实际上应该存在于Domain层,根本看不到 DO 的实现。这个也是为了避免底层实现逻辑渗透到业务代码中的强保障。
- 应该避免所谓的“通用”Repository模式:很多 ORM 框架都提供一个“通用”的Repository接口,然后框架通过注解自动实现接口,比较典型的例子是Spring Data、Entity Framework等,这种框架的好处是在简单场景下很容易通过配置实现,但是坏处是基本上无扩展的可能性(比如加定制缓存逻辑),在未来有可能还是会被推翻重做。当然,这里避免通用不代表不能有基础接口和通用的帮助类,具体如下。
我们先定义一个基础的 Repository 基础接口类,以及一些Marker接口类:
public interface Repository<T extends Aggregate<ID>, ID extends Identifier>
/**
* 将一个Aggregate附属到一个Repository,让它变为可追踪。
* Change-Tracking在下文会讲,非必须
*/
void attach(@NotNull T aggregate);
/**
* 解除一个Aggregate的追踪
* Change-Tracking在下文会讲,非必须
*/
void detach(@NotNull T aggregate);
/**
* 通过ID寻找Aggregate。
* 找到的Aggregate自动是可追踪的
*/
T find(@NotNull ID id);
/**
* 将一个Aggregate从Repository移除
* 操作后的aggregate对象自动取消追踪
*/
void remove(@NotNull T aggregate);
/**
* 保存一个Aggregate
* 保存后自动重置追踪条件
*/
void save(@NotNull T aggregate);
// 聚合根的Marker接口
public interface Aggregate<ID extends Identifier> extends Entity<ID>
// 实体类的Marker接口
public interface Entity<ID extends Identifier> extends Identifiable<ID>
public interface Identifiable<ID extends Identifier>
ID getId();
// ID类型DP的Marker接口
public interface Identifier extends Serializable
业务自己的接口只需要在基础接口上进行扩展,举个订单的例子:
// 代码在Domain层
public interface OrderRepository extends Repository<Order, OrderId>
// 自定义Count接口,在这里OrderQuery是一个自定义的DTO
Long count(OrderQuery query);
// 自定义分页查询接口
Page<Order> query(OrderQuery query);
// 自定义有多个条件的查询接口
Order findInStore(OrderId id, StoreId storeId);
每个业务需要根据自己的业务场景来定义各种查询逻辑。
这里需要再次强调的是Repository的接口是在Domain层,但是实现类是在Infrastructure层。
Repository基础实现
先举个Repository的最简单实现的例子。注意OrderRepositoryImpl在Infrastructure层:
// 代码在Infrastructure层
@Repository // Spring的注解
public class OrderRepositoryImpl implements OrderRepository
private final OrderDAO dao; // 具体的DAO接口
private final OrderDataConverter converter; // 转化器
public OrderRepositoryImpl(OrderDAO dao)
this.dao = dao;
this.converter = OrderDataConverter.INSTANCE;
@Override
public Order find(OrderId orderId)
OrderDO orderDO = dao.findById(orderId.getValue());
return converter.fromData(orderDO);
@Override
public void remove(Order aggregate)
OrderDO orderDO = converter.toData(aggregate);
dao.delete(orderDO);
@Override
public void save(Order aggregate)
if (aggregate.getId() != null && aggregate.getId().getValue() > 0)
// update
OrderDO orderDO = converter.toData(aggregate);
dao.update(orderDO);
else
// insert
OrderDO orderDO = converter.toData(aggregate);
dao.insert(orderDO);
aggregate.setId(converter.fromData(orderDO).getId());
@Override
public Page<Order> query(OrderQuery query)
List<OrderDO> orderDOS = dao.queryPaged(query);
long count = dao.count(query);
List<Order> result = orderDOS.stream().map(converter::fromData).collect(Collectors.toList());
return Page.with(result, query, count);
@Override
public Order findInStore(OrderId id, StoreId storeId)
OrderDO orderDO = dao.findInStore(id.getValue(), storeId.getValue());
return converter.fromData(orderDO);
从上面的实现能看出来一些套路:所有的Entity/Aggregate会被转化为DO,然后根据业务场景,调用相应的DAO方法进行操作,事后如果需要则把DO转换回Entity。代码基本很简单,唯一需要注意的是save方法,需要根据Aggregate的ID是否存在且大于0来判断一个Aggregate是否需要更新还是插入。
这里通过OrderDataConverter,实现DO和Entity之间的转换,向下使用DO进行持久化交互,向上使用Entity和业务域交互。
以上是关于阿里技术专家详解DDD系列 第三讲 - Repository模式的主要内容,如果未能解决你的问题,请参考以下文章
阿里技术专家详解 DDD 系列- Domain Primitive
阿里技术专家详解 DDD 系列- Domain Primitive