目录
- 一、灵魂拷问
- 二、CRUD Boy现状
- 三、贫血模型
- 四、事务脚本
- 五、从贫血模型演变到面向对象(富血模型)
- 六、借助DDD领域模型摆脱事务脚本
- 七、更多
一、灵魂拷问
Java作为面向对象的编程语言,使用Java编程的你面向对象了吗?
二、CRUD Boy现状
起初学习Java时,老师给我们介绍了Java语言各种面向对象的特性:封装(属性、方法)、继承、多态、关联关系(关联、依赖、组合、聚合)等等,当时觉得Java很牛B。工作了以后,写着写着代码连我们自己都没有觉察到我们写下Java代码已经变味了,好像丢失了面向对象分析与设计的精髓,似乎跟面不面向对象没啥关系了。作为一名 资深后端CRUD Boy,对于SpringMvc代码信手拈来,随便啥功能上来都这么整:
- 数据库设计(实体表、关系表)
- 设计接口、定义Controller
- 使用Mybatis定义DAO接口、映射DB实体关系为Java POJO(实体对象、关系对象、贫血模型)
- 编写Service写业务逻辑(事务脚本)、调用DAO操作POJO
细心点就会发现,这里哪有面向对象啊?
- POJO实体对象,仅有属性和getter/setter方法,对象的业务操作根本没有,严格来说不算是面向对象建模
- Service有面向对象吗?完全就是面向过程编程,用方法(事务脚本)怼操作。
- Controller为接口层,处理用户请求,纯技术维度的, 跟对象建模没关系。
三、贫血模型
假设存在实体:商品、商品标签,
且商品支持绑定多个商品标签。
数据库设计:
-- 商品表
CREATE TABLE goods (
id bigint(20) NOT NULL COMMENT '主键ID',
category_id bigint(20) NOT NULL COMMENT '所属分类ID',
goods_name varchar(120) NOT NULL COMMENT '商品名称',
manufacture_date date NOT NULL COMMENT '生成日期',
expiration_date date NOT NULL COMMENT '过期日期',
goods_weight decimal(10, 2) NOT NULL COMMENT '商品重量',
goods_weight_unit varchar(16) NOT NULL COMMENT '重量单位',
goods_desc varchar(1024) NOT NULL COMMENT '商品描述',
goods_price decimal(20, 2) NOT NULL COMMENT '商品价格',
goods_status int(11) NOT NULL COMMENT '商品状态(10已上架, 20已下架)',
create_time datetime NOT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间'
) ENGINE = InnoDB;
-- 商品标签表
CREATE TABLE goods_tag (
id bigint(20) NOT NULL COMMENT '主键ID',
tag_name varchar(64) NOT NULL COMMENT '标签名称',
tag_desc varchar(512) DEFAULT NULL COMMENT '标签描述',
create_time datetime NOT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间'
) ENGINE = InnoDB;
-- 商品标签绑定表
CREATE TABLE goods_tag_binding (
goods_id bigint(20) NOT NULL COMMENT '商品ID',
tag_id bigint(20) NOT NULL COMMENT '商品标签ID'
) ENGINE = InnoDB;
Java POJO:
/**
* 基础数据对象
*/
@Data
public class BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
/**
* Goods数据对象
*/
@Data
@TableName("goods")
public class Goods extends BaseEntity {
private Long categoryId;
private String goodsName;
private LocalDate manufactureDate;
private LocalDate expirationDate;
private BigDecimal goodsWeight;
private String goodsWeightUnit;
private String goodsDesc;
private BigDecimal goodsPrice;
private Integer goodsStatus;
}
/**
* 商品标签
*/
@Data
@TableName("goods_tag")
public class GoodsTag extends BaseEntity {
private String tagName;
private String tagDesc;
}
/**
* 商品与标签绑定关系 - 数据对象
*/
@Data
@TableName("goods_tag_binding")
public class GoodsTagBinding {
private Long goodsId;
private Long tagId;
}
通过以上代码,不难发现Goods、GoodsTag、GoodsTagBinding作为POJO,仅是对数据库表的映射,仅保留有getter/setter方法作为操作属性的手段,其中并没有任何实际的业务处理逻辑,实体对象中仅有属性没有操作,仅是作为数据的载体,如此即为贫血模型,而大量的业务操作都写到了Service实现中,导致Service过于臃肿。
四、事务脚本
由于DAO层采用了Mybatis,相较于完整的 ORM - 对象(Object)关系(Relation)映射,面向对象间的关联关系是无法直接通过Mybatis进行映射的,如以面向对象的方式建模商品和商品关联的标签,如下仅包含2个对象:Goods, GoodsTag,而商品与标签的关联关系可以通过Goods.goodsTags属性进行建模:
class Goods {
private Long id;
private String goodsName;
//...
//商品关联的标签集合
private Set<GoodsTag> goodsTags;
}
而在Mybatis中,除了Goods、GoodsTag对象,还需要格外引入数据库中的关系表GoodsTagBinding这第3个对象来表示商品和标签的关联关系,正是由于这种我们习以为常的映射,导致我们只能面向数据库编程,我们上层的业务逻辑只能针对Mybatis中的实体进行操作,除了需要操作业务中的对象实体Goods、GoodsTag,还不得不操作数据库中的关系对象GoodsTagBinding,使得本来的面向对象编程不得不变为面向数据库编程,如此即为事务脚本模式。
事务脚本(Transaction Script)是一种应用程序架构模式,主要用于处理简单的业务场景。它将业务逻辑和数据库访问紧密耦合在一起,以便实现对数据的操作。
简单描述就是将“脚本”(SQL)进行打包,然后放在一个“事务”中运行。这也就是“事务脚本”命名的由来。
例如Service中新建商品的代码如下:
/**
* 商品服务实现类
*/
@Service
public class GoodsServiceImpl implements GoodsService {
@Resource
private GoodsMapper goodsMapper;
@Resource
private CategoryMapper categoryMapper;
@Resource
private GoodsTagMapper goodsTagMapper;
@Resource
private GoodsTagBindingMapper goodsTagBindingMapper;
@Transactional(rollbackFor = Exception.class)
@Override
public SingleResponse<GoodsVo> createGoods(GoodsCreateDto goodsCreateDto) {
//验证新增商品分类、标签是否存在
this.validateGoods(goodsCreateDto.getCategoryId(), goodsCreateDto.getTagIds());
//创建商品实体
Goods goods = BaseAssembler.convert(goodsCreateDto, Goods.class);
//设置商品状态
goods.setGoodsStatus(GoodsStatus.UNSHELVED.getValue());
//保存商品
this.goodsMapper.insert(goods);
//批量保存新的标签绑定关系
if (!CollectionUtils.isEmpty(goodsCreateDto.getTagIds())) {
for (Long tagId : goodsCreateDto.getTagIds()) {
GoodsTagBinding goodsTagBinding = new GoodsTagBinding(goods.getId(), tagId);
this.goodsTagBindingMapper.insert(goodsTagBinding);
}
}
//转换GoodsDto
return SingleResponse.of(BaseAssembler.convert(goods, GoodsVo.class));
}
/**
* 验证商品分类、标签是否存在
*
* @param categoryId 分类ID
* @param goodsTagIds 标签ID集合
*/
private void validateGoods(Long categoryId, Collection<Long> goodsTagIds) {
//验证分类ID是否存在
Boolean existCategory = this.categoryMapper.existCategoryId(categoryId);
Validates.isTrue(existCategory, "category id does not exist");
//若标签ID集合为空,则直接验证通过
if (Objects.isNull(goodsTagIds) || goodsTagIds.isEmpty()) {
return;
}
//查询存在的标签数量
Integer existGoodsTagCount = this.goodsTagMapper.countByIdIn(goodsTagIds);
Validates.isTrue(existGoodsTagCount.equals(goodsTagIds.size()), "goods tags don't exist");
}
}
受限于使用Mybatis仅对数据表进行了直接映射:Goods、GoodsTag、GoodsTagBinding,
且使用了贫血模型,我们不得不在上层的Service中编写大量的业务逻辑,直接面向数据库进行编程,
在面向对象的维度,商品和其关联的标签是作为一个完整的对象模型的,但是在面向数据库层面就变成了3个对象,所以Service中新增商品的逻辑就要额外操作GoodsTagBinding这个对象,可参见如上代码中createGoods方法就是典型的事务脚本模式:
- 验证商品标签、分类是否存在
- 转换商品、通过setter设置商品状态、保存商品Goods
- 转换商品标签绑定关系列表、批量保存绑定关系GoodsTagBinding
至此,CURD Boy的三宗罪 就都清晰了,你占了哪几样:
- Mybatis
- 贫血模型
- 事务脚本
还有一种事务脚本思维,例如修改订单的状态,面向数据库的思维就会想着执行SQL,且仅去修改相关的属性,而面向对象思维则把订单看做一个整体,对订单状态的修改也是对订单的修改,不去关心底层是修改一个属性还是修改全部属性,如此也即引出了DDD中的仓库Repository模式。
事务脚本模式:
//OrderServcieImpl.updateOrderStatus中核心代码
this.orderMapper.updateStatus(orderId, newOrderStatus);
//OrderMapper.updateStatus方法
@Update("update orders set order_status = #{newOrderStatus} where id = #{orderId}")
Integer updateStatus(Long orderId, Integer newOrderStatus);
仓库模式:
//OrderServcieImpl.updateOrderStatus中核心代码
Order order = this.orderRepository.findById(orderId);
order.modifyStatus(newOrderStatus);
this.orderRepository.save(order);
五、从贫血模型演变到面向对象(富血模型)
贫血模型并没有对业务操作进行建模,仅有属性没有操作,
对象变成了对数据表的映射,进而导致想当然的面向对象编程变成了面向数据库编程,
Service承担了几乎全部的业务逻辑,最终呈现出的就是Servcie中的事务脚本模式。
模式没有好坏之分,只要适合就好。
但如果想要真正体验到面向对象建模的优势,将大量的业务逻辑都封装到对应的实体对象中,则可以借鉴DDD所倡导的富血模型、领域建模等思想。
所谓富血模型,就是对象中包含了大量的业务操作,不仅仅是数据属性的载体。
例如修改订单发货状态时,贫血模型中OrderServcieImpl.deliverOrder方法定义如下:
/**
* 订单服务实现类
*/
public class OrderServiceImpl implements OrderService {
//......
@Transactional(rollbackFor = Throwable.class)
@Override
public Response deliverOrder(OrderDeliverDto orderDeliverDto) {
Order order = this.orderRepository.selectById(orderDeliverDto.getOrderId());
Validates.notNull(order, "order not exist!");
//验证订单状态
Validates.isTrue(OrderStatus.PAID.getValue().equals(order.getOrderStatus()), "order status is not PAID");
//修改订单已发货状态
order.setOrderStatus(OrderStatus.DELIVERED.getValue());
order.setExpressCode(orderDeliverDto.getExpressCode());
order.setDeliverTime(orderDeliverDto.getDeliveryTime());
order.setUpdateTime(LocalDateTime.now());
//更新订单
this.orderRepository.updateById(order);
return Response.buildSuccess();
}
}
以上代码中,充斥着大量的order.set方法,代码可读性差,代码意图不明确。
使用富血模型进行建模,则可将大量的set方法都统一封装到Order对象中的deliverOrder方法中,由Order对象完成它本该完成的业务职责,提升了业务的内聚性以及代码的可读性、可维护性,调整后代码如下:
/**
* Order实体对象
*/
@Getter
@TableName("orders")
public class Order {
private Long id;
private BigDecimal orderPrice;
private String receiveAddress;
private OrderStatus orderStatus;
private LocalDateTime payTime;
private String expressCode;
private LocalDateTime deliverTime;
private LocalDateTime completeTime;
private LocalDateTime cancelTime;
/**
* 发货订单
*
* @param expressCode 快递单号
* @param deliverTime 发货时间
*/
public void deliverOrder(String expressCode, LocalDateTime deliverTime) {
//验证订单状态
Validates.isTrue(OrderStatus.PAID.equals(this.getOrderStatus()), "order status is not PAID");
//修改订单状态
this.orderStatus = OrderStatus.DELIVERED;
this.expressCode = expressCode;
this.deliverTime = deliverTime;
super.updateTime = LocalDateTime.now();
}
}
/**
* 订单服务实现类
*/
public class OrderServiceImpl implements OrderService {
//......
@Transactional(rollbackFor = Throwable.class)
@Override
public Response deliverOrder(OrderDeliverCommand orderDeliverCommand) {
Order order = this.orderRepository.findById(orderDeliverCommand.getOrderId());
Validates.notNull(order, "order not exist!");
//订单已发货
order.deliverOrder(orderDeliverCommand.getExpressCode(), orderDeliverCommand.getDeliveryTime());
//保存订单信息
this.orderRepository.save(order);
return Response.buildSuccess();
}
}
六、借助DDD领域模型摆脱事务脚本
在DDD中,应用架构主要分为4层:接口层、应用层、领域层、基础设施层,具体层次划分可见下图:
注:
D3S(DDD with SpringBoot)为本作者使用DDD过程中开发的框架及示例,
源码地址:https://gitee.com/luoex/d3s
在DDD中,应用层、领域层为其核心,领域层更是核心中的核心,领域层中的领域对象通常包括:
- 聚合Aggregate,可以理解为对象建模的边界,落到代码层面可对应一个包,包中包含高内聚的实体、值对象等,其中仅有一个实体作为聚合根AggregateRoot,只有聚合根才是访问聚合的唯一入口,聚合外部的对象不能引用除聚合根实体之外的任何内部对象。
- 实体Entity(可作为聚合根),即对应业务对象及操作的建模(富血模型),通常可通过ID唯一标识。
- 值对象ValueObject,通常作为实体的属性(不具备ID),亦可包含该值对象相关的业务操作。
- 仓库Repository(接口形式),负责聚合根(实体)的持久化,此层无需考虑底层数据库的实体、关系表,仅将聚合根作为一个整体进行持久化,具体底层的实现可在基础设施层Infrastructure实现Respository接口 又或者 直接通过ORM框架实现(如JPA、Hibernate)。
- 领域服务DomainService,不适合Entity、ValueObject中的逻辑均可放在DomainServcie,通常负责协调多个聚合根(实体)间的交互业务编排、大段复杂业务的编排等。
- 防腐层ACL(接口形式),负责与外部系统的交互,之所以叫做防腐层意为隔离外部的变化,不受外部变化的腐蚀,比如HttpClient接口、调用第三方服务等接口通过ACL进行定义,具体底层的实现可在基础设施层Infrastructure实现ACL接口 又或者 直接通过诸如OpenFeign等接口定义形式。
DDD中提倡的便是 富血模型,即由领域层的领域对象承担业务逻辑(面向对象建模),上层的应用层(应用服务Application Service)仅是对领域对象的流程编排,特别轻量的一层,将所有的业务逻辑都下沉到领域层。也就是说应用服务Service操作的都是领域对象,大量的业务逻辑都是由领域对象来完成的,Service仅是负责协调、调用领域对象来完成业务,并将领域对象(聚合根) 作为一个整体交由 仓库Respository 进行持久化,如此便规避了贫血模式、事务脚本的编程方式,直接面向领域对象进行编程。
以最初的新建商品为例,使用DDD的面向对象模型如下:
/**
* Goods数据对象(聚合根)
*/
@Data
@TableName("goods")
public class Goods extends BaseEntity {
private Long categoryId;
private String goodsName;
private LocalDate manufactureDate;
private LocalDate expirationDate;
private BigDecimal goodsWeight;
private String goodsWeightUnit;
private String goodsDesc;
private BigDecimal goodsPrice;
private Integer goodsStatus;
//商品绑定的标签ID集合
private Set<Long> tags;
/**
* 修改商品标签
*
* @param tags 商品标签ID集合
*/
public void modifyTags(Set<Long> tags) {
this.tags = tags;
}
/**
* 上架商品
*/
public void shelve() {
super.updateTime = LocalDateTime.now();
this.goodsStatus = GoodsStatus.SHELVED;
}
/**
* 下架商品
*/
public void unshelve() {
super.updateTime = LocalDateTime.now();
this.goodsStatus = GoodsStatus.UNSHELVED;
}
}
/**
* 商品标签(聚合根)
*/
@Data
@TableName("goods_tag")
public class GoodsTag extends BaseEntity {
private String tagName;
private String tagDesc;
}
注:
此处示例代码中的商品和标签的关联关系定义如下:
Set<Long> tags(实为标签ID集合)
而之前介绍面向对象建模时商品和标签的关联关系定义如下:
Set<GoodsTag> tags
此处的细微差别即揭示了DDD有别于面向对象建模的不同之处,
在DDD中强调聚合边界,聚合根作为持久化的整体,对聚合根的持久化便要对其包含的实体、值对象均进行持久化,加载查询时也要全部查询,所以聚合粒度的控制尤为重要,此处使用ID集合表示关联关系,也是为了更好的控制聚合的粒度,后续会再写一篇关于聚合的文章重点介绍此处。
如上代码中,原来的Goods、GoodsTag、GoodsTagBinding在使用DDD建模后仅保留了Goods、GoodsTag这2个实体,而Goods和GoodsTag间的关联关系则使用Goods.tags这个对象建模维度中的属性来表示(区别于数据库建模的关系表GoodsTagBinding),如此隔离了底层数据库思维(事务脚本模式),以面向对象的思维来对操作Goods,Goods作为一个整体(实为DDD中的聚合根)交由GoodsRespority(仓库)完成持久化,对于商品标签的操作即变成对商品中的标签ID集合属性Set<Long> tags
进行的操作,如此使用DDD建模后的Service代码调整如下:
/**
* 商品服务实现类
*/
public class GoodsServiceImpl implements GoodsService {
//......
@Transactional(rollbackFor = Exception.class)
@Override
public SingleResponse<GoodsVo> createGoods(GoodsCreateCommand goodsCreateCommand) {
//创建商品实体
Goods goods = GoodsAssembler.createGoods(goodsCreateCommand);
//注:此处使用的Specification模式,负责规则验证,后续会在系列文章中单独介绍此模式
//验证新增商品分类是否存在
this.categoryExistSpecification.isSatisfiedBy(goods.getCategoryId());
//验证商品标签是否存在
this.goodsTagExistSpecification.isSatisfiedBy(goods.getTags());
//保存商品
goods = this.goodsRepository.save(goods);
//转换GoodsDto
return SingleResponse.of(BaseAssembler.convert(goods, GoodsVo.class));
}
}
其中GoodsAssember.createGoods代码如下:
/**
* Goods转换器
*/
public interface GoodsAssembler {
static Goods createGoods(GoodsCreateCommand goodsCreateCommand) {
return new Goods(
goodsCreateCommand.getCategoryId(),
goodsCreateCommand.getGoodsName(),
goodsCreateCommand.getManufactureDate(), goodsCreateCommand.getExpirationDate(),
goodsCreateCommand.getGoodsWeight(), WeightUnit.of(goodsCreateCommand.getGoodsWeightUnit()),
goodsCreateCommand.getGoodsDesc(),
goodsCreateCommand.getGoodsPrice(),
GoodsStatus.UNSHELVED,
goodsCreateCommand.getTagIds()
);
}
}
此处GoodsAssembler充当了工厂的角色,负责创建领域对象,此处的创建区别于对象构造函数的创建,DDD中通过工厂创建领域对象指的是初始创建,创建成功后会通过仓库持久化,强调领域对象的生命周期从此开始。而面向对象中的构造函数,可以在初始创建时使用,也可以在数据库中查询出来后被重新构造时使用,又或者对象转换时使用,又或者通过无参构造函数创建然后一堆setter设置等等。相较于工厂,构造函数的使用并没有显式的生命周期阶段标识意义,故在DDD中创建领域对象还是首推工厂模式,工厂的实现可以通过:
- 领域层的工厂Factory
- 领域层的Entity自身的创建方法
- 应用层的转换器Assembler
- 应用层的Command参数创建方法
- …
本例中使用了应用层的转换器GoodsAssembler来实现工厂模式。
关于事务脚本、DDD建模的代码编排总结如下图:
七、更多
关于DDD的更多实战代码可参见:
D3S (DDD with SpringBoot) - https://gitee.com/luoex/d3s
D3S为本作者使用DDD过程中开发的框架及示例,旨在打造一款可落地的DDD(领域驱动设计)实现框架,提供了统一的DDD模型抽象,并扩展实现了DDD架构的一些关键特性,同时给出了截取自电商案例的基于D3S的示例实现(反模式、完整版、轻量版),力求打造一款即能发挥DDD的优势又能降低上手难度的DDD实现框架,也希望本示例能帮你迈出拥抱DDD的第一步。
后续会持续更新关于DDD的更多的知识、使用心得…敬请期待:
- 聚合
- 实体、值对象
- 仓库
- 工厂
- …
参考:
https://www.martinfowler.com/eaaCatalog/transactionScript.html
https://www.martinfowler.com/bliki/AnemicDomainModel.html
https://mp.weixin.qq.com/s/7kYGnp0KJGozQZDH1ohnNg