传统的应用架构,几乎就是根据需求设计数据库的表,根据表建立实体,对应着实体的就是DAO,Service,Controller,也就是传统的MVC三层架构。
回顾下我们平时写的代码,里面有着很多的xxxUtils工具类,很多的参数校验逻辑与业务逻辑混杂在一起,很多的实体类直接与数据库进行一对一映射。
好处很明显,在业务初期,开发起来很容易,相对比较简单,流水线式编码,但是,一旦后期需求变更,业务改造,数据库表发生变化,可能给我们带来毁灭性的负担。所谓牵一发而动全身,前面欠下了技术债,后面很难受,想补救工作量巨大,不补救,系统难以升级,难以扩展,灵活性急剧下降。
对于第三方(包括但不限于数据库)的强依赖,导致我们在做业务扩展的时候,顾虑重重,缺少了一往无前的动力。
但是截至目前,包括我所参与开发的项目,依然是采用这种模式,为什么?
两个概念,你是否明确?
真实代码结构中,Data Model和 Domain Model实际上会分别在不同的层里,Data Model只存在于数据层,而Domain Model在领域层,而链接了这两层的关键对象,就是Repository。
在传统的MVC三层架构中,我们操作数据库的层,一般叫做DAO,或者Mapper层。
由于他与数据库直接耦合,导致了强依赖性。更可怕的是,由于我们都是在Service层直接注入Mapper层,导致了这种强依赖的传递,也就是整个应用体系开始变得越加依赖数据库DB。
举一个例子
public Interface UserDao{
public List<User> selectUserByIds(List<Integer> ids);
}
public class UserService{
@Resource
private UserDao userDao;
public List<User> getUserList(List<Integer> ids){
return userDao.selectUserByIds(ids);
}
}
这个代码,咋一看,简单明了,没有任何问题。但是假如现在由于数据量的增长和访问数量的增加,我需要引入缓存的逻辑,假如有十个地方调用了这个DAO中的方法,我需要在这十个地方都修改成:
public class UserService{
@Resource
private UserDao userDao;
@Resource
private RedisTemplate redisTemplate;
public List<User> getUserList(List<Integer> ids){
List<User> users=redisTemplate.opsForValue().get(key);
if(users!=null){
return users;
}else{
List<User> userList=userDao.selectUserByIds(ids);
redisTemplate.opsForValue().set(key,userList);
return userList;
}
}
}
所以,需要一个逻辑,能够隔离业务逻辑与DB之间的传递强耦合关系,让我们的应用更加灵活,健壮,这个就是Repository的价值。
在实际开发中DO、Entity和DTO不一定是1:1:1的关系。一些常见的非1:1关系如下:
复杂的实体拆分成多张数据库的表:常见的原因,字段多,查询性能差,需要将非检索、大字段等单独存为一张表,提升基础信息表的检索效率。
当然,除了一些数据查询频繁,聚合性非常强的表。
拆分的实体:我接触过的,订单,商品,购物车。
由于现在从一个对象变为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); // 原来的很多复杂转化逻辑都收敛到一行代码了
}
}
从使用复杂度角度来看,区分了DO、Entity、DTO带来了代码量的膨胀(从1个变成了3+2+N个)。但是在实际复杂业务场景下,通过功能来区分模型带来的价值是功能性的单一和可测试、可预期,最终反而是逻辑复杂性的降低。
接口名称不应该使用底层实现的语法:我们常见的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的最简单实现的例子。注意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是否需要更新还是插入。
针对单一Entity的Repository实现一般比较简单,但是当涉及到多Entity的Aggregate Root时,就会比较麻烦,最主要的原因是在一次操作中,并不是所有Aggregate里的Entity都需要变更,但是如果用简单的写法,会导致大量的无用DB操作。
举一个常见的例子,在主子订单的场景下,一个主订单Order会包含多个子订单LineItem,假设有个改某个子订单价格的操作,会同时改变主订单价格,但是对其他子订单无影响:
如果用一个非常naive的实现来完成,会导致多出来两个无用的更新操作,如下:
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());
}
}
}
在这个情况下,会导致4个UPDATE操作,但实际上只需要2个。在绝大部分情况下,这个成本不高,可以接受,但是在极端情况下(当非Aggregate Root的Entity非常多时),会导致大量的无用写操作。
在我们日常的代码中,使用Repository模式是一个很简单,但是又能得到很多收益的事情。最大的收益就是可以彻底和底层实现解耦,让上层业务可以快速自发展。
我们假设现有的传统代码包含了以下几个类(还是用订单举例):
可以通过以下几个步骤逐渐的实现Repository模式:
生成Order实体类,初期字段可以和OrderDO保持一致
生成OrderDataConverter,通过MapStruct基本上2行代码就能完成
写单元测试,确保Order和OrderDO之间的转化100%正确
生成OrderRepository接口和实现,通过单测确保OrderRepository的正确性
将原有代码里使用了OrderDO的地方改为Order
将原有代码里使用了OrderDAO的地方都改为用OrderRepository
通过单测确保业务逻辑的一致性。