阿里技术专家详解DDD系列 第三讲 - Repository模式
The following article is from 淘系技术 Author 殷浩
▐ 实体模型 vs. 贫血模型
有大量的XxxDO对象:这里DO虽然有时候代表了Domain Object,但实际上仅仅是数据库表结构的映射,里面没有包含(或包含了很少的)业务逻辑; 服务和Controller里有大量的业务逻辑:比如校验逻辑、计算逻辑、格式转化逻辑、对象关系逻辑、数据存储逻辑等; 大量的Utils工具类等。
无法保护模型对象的完整性和一致性:因为对象的所有属性都是公开的,只能由调用方来维护模型的一致性,而这个是没有保障的;之前曾经出现的案例就是调用方没有能维护模型数据的一致性,导致脏数据使用时出现bug,这一类的 bug还特别隐蔽,很难排查到。 对象操作的可发现性极差:单纯从对象的属性上很难看出来都有哪些业务逻辑,什么时候可以被调用,以及可以赋值的边界是什么;比如说,Long类型的值是否可以是0或者负数? 代码逻辑重复:比如校验逻辑、计算逻辑,都很容易出现在多个服务、多个代码块里,提升维护成本和bug出现的概率;一类常见的bug就是当贫血模型变更后,校验逻辑由于出现在多个地方,没有能跟着变,导致校验失败或失效。 代码的健壮性差:比如一个数据模型的变化可能导致从上到下的所有代码的变更。 强依赖底层实现:业务代码里强依赖了底层数据库、网络/中间件协议、第三方服务等,造成核心逻辑代码的僵化且维护成本高。
数据库思维:从有了数据库的那一天起,开发人员的思考方式就逐渐从“写业务逻辑“转变为了”写数据库逻辑”,也就是我们经常说的在写CRUD代码。 贫血模型“简单”:贫血模型的优势在于“简单”,仅仅是对数据库表的字段映射,所以可以从前到后用统一格式串通。这里简单打了引号,是因为它只是表面上的简单,实际上当未来有模型变更时,你会发现其实并不简单,每次变更都是非常复杂的事情 脚本思维:很多常见的代码都属于“脚本”或“胶水代码”,也就是流程式代码。脚本代码的好处就是比较容易理解,但长久来看缺乏健壮性,维护成本会越来越高。
数据模型(Data Model):指业务数据该如何持久化,以及数据之间的关系,也就是传统的ER模型; 业务模型/领域模型(Domain Model):指业务逻辑中,相关联的数据该如何联动。
▐ Repository的价值
硬件(Hardware):指创造了之后不可(或者很难)变更的东西。数据库对于开发来说,就属于”硬件“,数据库选型后基本上后面不会再变,比如:用了MySQL就很难再改为MongoDB,改造成本过高。 软件(Software):指创造了之后可以随时修改的东西。对于开发来说,业务代码应该追求做”软件“,因为业务流程、规则在不停的变化,我们的代码也应该能随时变化。 固件(Firmware):即那些强烈依赖了硬件的软件。我们常见的是路由器里的固件或安卓的固件等等。固件的特点是对硬件做了抽象,但仅能适配某款硬件,不能通用。所以今天不存在所谓的通用安卓固件,而是每个手机都需要有自己的固件。
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);
// 此处省略很多业务逻辑
}
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);
}
// 此处省略很多业务逻辑
}
模型对象代码规范
▐ 对象类型
Data Object (DO、数据对象):实际上是我们在日常工作中最常见的数据模型。但是在DDD的规范里,DO应该仅仅作为数据库物理表格的映射,不能参与到业务逻辑中。为了简单明了,DO的字段类型和名称应该和数据库物理表格的字段类型和名称一一对应,这样我们不需要去跑到数据库上去查一个字段的类型和名称。(当然,实际上也没必要一摸一样,只要你在Mapper那一层做到字段映射) Entity(实体对象):实体对象是我们正常业务应该用的业务模型,它的字段和方法应该和业务语言保持一致,和持久化方式无关。也就是说,Entity和DO很可能有着完全不一样的字段命名和字段类型,甚至嵌套关系。Entity的生命周期应该仅存在于内存中,不需要可序列化和可持久化。 DTO(传输对象):主要作为Application层的入参和出参,比如CQRS里的Command、Query、Event,以及Request、Response等都属于DTO的范畴。DTO的价值在于适配不同的业务场景的入参和出参,避免让业务对象变成一个万能大对象。
▐ 模型对象之间的关系
▐ 模型所在模块和转化器
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; }}
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); // 原来的很多复杂转化逻辑都收敛到一行代码了
}
}
@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);
}
▐ 模型规范总结
DO | Entity | DTO | |
目的 | 数据库表映射 | 业务逻辑 | 适配业务场景 |
代码层级 | Infrastructure | Domain | Application |
命名规范 | XxxDO | Xxx | XxxDTO XxxCommand XxxRequest等 |
字段名称标准 | 数据库表字段名 | 业务语言 | 和调用方商定 |
字段数据类型 | 数据库字段类型 | 尽量是有业务含义的类型,比如DP | 和调用方商定 |
是否需要序列化 | 不需要 | 不需要 | 需要 |
转化器 | Data Converter | Data Converter DTO Assembler | DTO Assembler |
从使用复杂度角度来看,区分了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基础实现
// 代码在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);
}
}
▐ Repository复杂实现
public class OrderRepositoryImpl extends implements OrderRepository {
private OrderDAO orderDAO;
private LineItemDAO lineItemDAO;
private OrderDataConverter orderConverter;
private LineItemDataConverter lineItemConverter;
// 其他逻辑省略
@Override
public void save(Order aggregate) {
if (aggregate.getId() != null && aggregate.getId().getValue() > 0) {
// 每次都将Order和所有LineItem全量更新
OrderDO orderDO = orderConverter.toData(aggregate);
orderDAO.update(orderDO);
for (LineItem lineItem: aggregate.getLineItems()) {
save(lineItem);
}
} else {
// 插入逻辑省略
}
}
private void save(LineItem lineItem) {
if (lineItem.getId() != null && lineItem.getId().getValue() > 0) {
LineItemDO lineItemDO = lineItemConverter.toData(lineItem);
lineItemDAO.update(lineItemDO);
} else {
LineItemDO lineItemDO = lineItemConverter.toData(lineItem);
lineItemDAO.insert(lineItemDO);
lineItem.setId(lineItemConverter.fromData(lineItemDO).getId());
}
}
}
▐ Change-Tracking 变更追踪
基于Snapshot的方案:当数据从DB里取出来后,在内存中保存一份snapshot,然后在数据写入时和snapshot比较。常见的实现如Hibernate 基于Proxy的方案:当数据从DB里取出来后,通过weaving的方式将所有setter都增加一个切面来判断setter是否被调用以及值是否变更,如果变更则标记为Dirty。在保存时根据Dirty判断是否需要更新。常见的实现如Entity Framework。
// 这个类是一个通用的支撑类,为了减少开发者的重复劳动。在用的时候需要继承这个类
public abstract class DbRepositorySupport<T extends Aggregate<ID>, ID extends Identifier> implements Repository<T, ID> {
@Getter
private final Class<T> targetClass;
// 让AggregateManager去维护Snapshot
@Getter(AccessLevel.PROTECTED)
private AggregateManager<T, ID> aggregateManager;
protected DbRepositorySupport(Class<T> targetClass) {
this.targetClass = targetClass;
this.aggregateManager = AggregateManager.newInstance(targetClass);
}
/**
* 这几个方法是继承的子类应该去实现的
*/
protected abstract void onInsert(T aggregate);
protected abstract T onSelect(ID id);
protected abstract void onUpdate(T aggregate, EntityDiff diff);
protected abstract void onDelete(T aggregate);
/**
* Attach的操作就是让Aggregate可以被追踪
*/
@Override
public void attach(@NotNull T aggregate) {
this.aggregateManager.attach(aggregate);
}
/**
* Detach的操作就是让Aggregate停止追踪
*/
@Override
public void detach(@NotNull T aggregate) {
this.aggregateManager.detach(aggregate);
}
@Override
public T find(@NotNull ID id) {
T aggregate = this.onSelect(id);
if (aggregate != null) {
// 这里的就是让查询出来的对象能够被追踪。
// 如果自己实现了一个定制查询接口,要记得单独调用attach。
this.attach(aggregate);
}
return aggregate;
}
@Override
public void remove(@NotNull T aggregate) {
this.onDelete(aggregate);
// 删除停止追踪
this.detach(aggregate);
}
@Override
public void save(@NotNull T aggregate) {
// 如果没有ID,直接插入
if (aggregate.getId() == null) {
this.onInsert(aggregate);
this.attach(aggregate);
return;
}
// 做Diff
EntityDiff diff = aggregateManager.detectChanges(aggregate);
if (diff.isEmpty()) {
return;
}
// 调用UPDATE
this.onUpdate(aggregate, diff);
// 最终将DB带来的变化更新回AggregateManager
aggregateManager.merge(aggregate);
}
}
public class OrderRepositoryImpl extends DbRepositorySupport<Order, OrderId> implements OrderRepository {
private OrderDAO orderDAO;
private LineItemDAO lineItemDAO;
private OrderDataConverter orderConverter;
private LineItemDataConverter lineItemConverter;
// 部分代码省略,见上文
@Override
protected void onUpdate(Order aggregate, EntityDiff diff) {
if (diff.isSelfModified()) {
OrderDO orderDO = converter.toData(aggregate);
orderDAO.update(orderDO);
}
Diff lineItemDiffs = diff.getDiff("lineItems");
if (lineItemDiffs instanceof ListDiff) {
ListDiff diffList = (ListDiff) lineItemDiffs;
for (Diff itemDiff : diffList) {
if (itemDiff.getType() == DiffType.Removed) {
LineItem line = (LineItem) itemDiff.getOldValue();
LineItemDO lineDO = lineItemConverter.toData(line);
lineItemDAO.delete(lineDO);
}
if (itemDiff.getType() == DiffType.Added) {
LineItem line = (LineItem) itemDiff.getNewValue();
LineItemDO lineDO = lineItemConverter.toData(line);
lineItemDAO.insert(lineDO);
}
if (itemDiff.getType() == DiffType.Modified) {
LineItem line = (LineItem) itemDiff.getNewValue();
LineItemDO lineDO = lineItemConverter.toData(line);
lineItemDAO.update(lineDO);
}
}
}
}
}
class ThreadLocalAggregateManager<T extends Aggregate<ID>, ID extends Identifier> implements AggregateManager<T, ID> {
private ThreadLocal<DbContext<T, ID>> context;
private Class<? extends T> targetClass;
public ThreadLocalAggregateManager(Class<? extends T> targetClass) {
this.targetClass = targetClass;
this.context = ThreadLocal.withInitial(() -> new DbContext<>(targetClass));
}
public void attach(T aggregate) {
context.get().attach(aggregate);
}
@Override
public void attach(T aggregate, ID id) {
context.get().setId(aggregate, id);
context.get().attach(aggregate);
}
@Override
public void detach(T aggregate) {
context.get().detach(aggregate);
}
@Override
public T find(ID id) {
return context.get().find(id);
}
@Override
public EntityDiff detectChanges(T aggregate) {
return context.get().detectChanges(aggregate);
}
public void merge(T aggregate) {
context.get().merge(aggregate);
}
}
class DbContext<T extends Aggregate<ID>, ID extends Identifier> {
@Getter
private Class<? extends T> aggregateClass;
private Map<ID, T> aggregateMap = new HashMap<>();
public DbContext(Class<? extends T> aggregateClass) {
this.aggregateClass = aggregateClass;
}
public void attach(T aggregate) {
if (aggregate.getId() != null) {
if (!aggregateMap.containsKey(aggregate.getId())) {
this.merge(aggregate);
}
}
}
public void detach(T aggregate) {
if (aggregate.getId() != null) {
aggregateMap.remove(aggregate.getId());
}
}
public EntityDiff detectChanges(T aggregate) {
if (aggregate.getId() == null) {
return EntityDiff.EMPTY;
}
T snapshot = aggregateMap.get(aggregate.getId());
if (snapshot == null) {
attach(aggregate);
}
return DiffUtils.diff(snapshot, aggregate);
}
public T find(ID id) {
return aggregateMap.get(id);
}
public void merge(T aggregate) {
if (aggregate.getId() != null) {
T snapshot = SnapshotUtils.snapshot(aggregate);
aggregateMap.put(aggregate.getId(), snapshot);
}
}
public void setId(T aggregate, ID id) {
ReflectionUtils.writeField("id", aggregate, id);
}
}
@Test
public void multiInsert() {
OrderDAO dao = new MockOrderDAO();
OrderRepository repo = new OrderRepositoryImpl(dao);
Order order = new Order();
order.setUserId(new UserId(11L));
order.setStatus(OrderState.ENABLED);
order.addLineItem(new ItemId(13L), new Quantity(5), new Money(4));
order.addLineItem(new ItemId(14L), new Quantity(2), new Money(3));
System.out.println("第一次保存前");
System.out.println(order);
repo.save(order);
System.out.println("第一次保存后");
System.out.println(order);
order.getLineItems().get(0).setQuantity(new Quantity(3));
order.pay();
repo.save(order);
System.out.println("第二次保存后");
System.out.println(order);
}
第一次保存前
Order(id=null, userId=11, lineItems=[LineItem(id=null, itemId=13, quantity=5, price=4), LineItem(id=null, itemId=14, quantity=2, price=3)], status=ENABLED)
INSERT OrderDO: OrderDO(id=null, parentId=null, itemId=0, userId=11, quantity=0, price=0, status=2)
UPDATE OrderDO: OrderDO(id=1001, parentId=1001, itemId=0, userId=11, quantity=0, price=0, status=2)
INSERT OrderDO: OrderDO(id=null, parentId=1001, itemId=13, userId=11, quantity=5, price=4, status=2)
INSERT OrderDO: OrderDO(id=null, parentId=1001, itemId=14, userId=11, quantity=2, price=3, status=2)
第一次保存后
Order(id=1001, userId=11, lineItems=[LineItem(id=1002, itemId=13, quantity=5, price=4), LineItem(id=1003, itemId=14, quantity=2, price=3)], status=ENABLED)
UPDATE OrderDO: OrderDO(id=1001, parentId=1001, itemId=0, userId=11, quantity=0, price=0, status=3)
UPDATE OrderDO: OrderDO(id=1002, parentId=1001, itemId=13, userId=11, quantity=3, price=4, status=3)
第二次保存后
Order(id=1001, userId=11, lineItems=[LineItem(id=1002, itemId=13, quantity=3, price=4), LineItem(id=1003, itemId=14, quantity=2, price=3)], status=PAID)
▐ 其他注意事项
Repository迁移路径
OrderDO OrderDAO
生成Order实体类,初期字段可以和OrderDO保持一致 生成OrderDataConverter,通过MapStruct基本上2行代码就能完成 写单元测试,确保Order和OrderDO之间的转化100%正确 生成OrderRepository接口和实现,通过单测确保OrderRepository的正确性 将原有代码里使用了OrderDO的地方改为Order 将原有代码里使用了OrderDAO的地方都改为用OrderRepository 通过单测确保业务逻辑的一致性。
写在后面
技术琐话
以分布式设计、架构、体系思想为基础,兼论研发相关的点点滴滴,不限于代码、质量体系和研发管理。本号由坐馆老司机技术团队维护。
长按扫码关注
【您的在看,我的莫大鼓励】