一,传统架构下的实体模型
传统的应用架构,几乎就是根据需求设计数据库的表,根据表建立实体,对应着实体的就是DAO,Service,Controller,也就是传统的MVC三层架构。
回顾下我们平时写的代码,里面有着很多的xxxUtils工具类,很多的参数校验逻辑与业务逻辑混杂在一起,很多的实体类直接与数据库进行一对一映射。
好处很明显,在业务初期,开发起来很容易,相对比较简单,流水线式编码,但是,一旦后期需求变更,业务改造,数据库表发生变化,可能给我们带来毁灭性的负担。所谓牵一发而动全身,前面欠下了技术债,后面很难受,想补救工作量巨大,不补救,系统难以升级,难以扩展,灵活性急剧下降。
对于第三方(包括但不限于数据库)的强依赖,导致我们在做业务扩展的时候,顾虑重重,缺少了一往无前的动力。
但是截至目前,包括我所参与开发的项目,依然是采用这种模式,为什么?
- 数据库思维:从有了数据库的那一天起,开发人员的思考方式就逐渐从“写业务逻辑“转变为了”写数据库逻辑”,也就是我们经常说的在写CRUD代码。
- 所谓简单:贫血模型的优势在于“简单”,仅仅是对数据库表的字段映射,所以可以从前到后用统一格式串通。这里简单打了引号,是因为它只是表面上的简单,实际上当未来有模型变更时,你会发现其实并不简单,每次变更都是非常复杂的事情。
- 脚本思维:很多常见的代码都属于“脚本”或“胶水代码”,也就是流程式代码。脚本代码的好处就是比较容易理解,但长久来看缺乏健壮性,维护成本会越来越高。
两个概念,你是否明确?
- 数据模型:也就是和数据库一一映射的类
- 业务模型/领域模型:业务逻辑中,相关连的数据如何联动
真实代码结构中,Data Model和 Domain Model实际上会分别在不同的层里,Data Model只存在于数据层,而Domain Model在领域层,而链接了这两层的关键对象,就是Repository。
二,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的价值。
三,模型对象代码规范
1.什么是DO,DTO,Entity?
- Data Object:DO的字段类型和名称应该和数据库物理表格的字段类型和名称一一对应,这样我们不需要去跑到数据库上去查一个字段的类型和名称。
- Entity:实体对象是我们正常业务应该用的业务模型,它的字段和方法应该和业务语言保持一致,和持久化方式无关。也就是说,Entity和DO很可能有着完全不一样的字段命名和字段类型,甚至嵌套关系。Entity的生命周期应该仅存在于内存中,不需要可序列化和可持久化。
- DTO:主要作为Application层的入参和出参,比如CQRS里的Command、Query、Event,以及Request、Response等都属于DTO的范畴。DTO的价值在于适配不同的业务场景的入参和出参,避免让业务对象变成一个万能大对象。
2.对象之间的关系
在实际开发中DO、Entity和DTO不一定是1:1:1的关系。一些常见的非1:1关系如下:
复杂的实体拆分成多张数据库的表:常见的原因,字段多,查询性能差,需要将非检索、大字段等单独存为一张表,提升基础信息表的检索效率。
当然,除了一些数据查询频繁,聚合性非常强的表。
拆分的实体:我接触过的,订单,商品,购物车。
3.模型所在模块和转化器
由于现在从一个对象变为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); // 原来的很多复杂转化逻辑都收敛到一行代码了
}
}
4.模型规范总结
从使用复杂度角度来看,区分了DO、Entity、DTO带来了代码量的膨胀(从1个变成了3+2+N个)。但是在实际复杂业务场景下,通过功能来区分模型带来的价值是功能性的单一和可测试、可预期,最终反而是逻辑复杂性的降低。
四,Repository代码规范
1.接口规范
接口名称不应该使用底层实现的语法:我们常见的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层。
2.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是否需要更新还是插入。
3.Repository复杂实现
针对单一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模式是一个很简单,但是又能得到很多收益的事情。最大的收益就是可以彻底和底层实现解耦,让上层业务可以快速自发展。
我们假设现有的传统代码包含了以下几个类(还是用订单举例):
- OrderDO
- OrderDAO
可以通过以下几个步骤逐渐的实现Repository模式:
- 生成Order实体类,初期字段可以和OrderDO保持一致
- 生成OrderDataConverter,通过MapStruct基本上2行代码就能完成
- 写单元测试,确保Order和OrderDO之间的转化100%正确
- 生成OrderRepository接口和实现,通过单测确保OrderRepository的正确性
- 将原有代码里使用了OrderDO的地方改为Order
- 将原有代码里使用了OrderDAO的地方都改为用OrderRepository
- 通过单测确保业务逻辑的一致性。
转自:https://www.yuque.com/yinhuidong/uko54z/qztm26