服务端模块化架构设计|DDD 领域驱动设计与业务模块化(落地与实现)
本专栏 将通过以下几块内容来搭建一个 模块化:可以根据项目的功能需求和体量进行任意模块的组合或扩展 的后端服务
DDD领域驱动设计与业务模块化(落地与实现)(本文)
未完待续......
通过添加启动模块来任意组合和扩展功能模块
-
示例1:通过启动模块
juejin-appliaction-system
将juejin-user(用户)
和juejin-message(消息)
合并成一个服务减少服务器资源的消耗,通过启动模块juejin-appliaction-pin
来单独提供juejin-pin(沸点)
模块服务以支持大流量功能模块的精准扩容 -
示例2:通过启动模块
juejin-appliaction-single
将juejin-user(用户)
,juejin-message(消息)
,juejin-pin(沸点)
直接打包成一个单体应用来运行,适合项目前期体量较小的情况
PS:示例基于IDEA + Spring Cloud
为了能更好的理解本专栏中的模块化,建议读者先阅读 服务端模块化架构设计|项目结构与模块化构建思路
前情回顾
在上一篇 DDD领域驱动设计与业务模块化(概念与理解) 中,对DDD
的部分概念做了一些说明以及表达了我自己的一些理解,这篇文章就带大家实现一遍沸点功能(没有看过 概念与理解 的话建议先看 概念与理解 哦)
约定
首先对于DDD
中提到的CQRS
,我们约定增删改的视图模型命名用Command
作为后缀,如PinCreateCommand
,PinDeleteCommand
等
其次,我们假定可以在Controller
中直接传入当前登录用户的模型,如配合使用注解@Login
和HandlerMethodArgumentResolver
可以直接在入参中获得当前用户信息,这里就不具体展开如何实现了
包结构
这里我不得不吐槽一下,求求了,别再照着网上那些几年前傻了吧唧的教程,Controller
一个包,Service
一个包了,一共有多少类心里没点ABCD数么,点开几十个类一个屏幕都展示不下,分了包和没分包都没什么区别
一块功能一个包,找起来也方便,再说了你都领域驱动了,不这样分包也说不过去了吧
组件
我们先定义一些组件
Controller用于对外暴露接口
Service用于统筹领域模型和其他组件
Repository用于领域模型的持久化
Searcher用于支持复杂的条件查询
FacadeAdapter用于提供模型转换和外部数据获取
EventPublisher用于领域事件的发布
模型
直接贴代码
/**
* 沸点
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class PinImpl implements Pin {
/**
* 沸点ID
*/
protected String id;
/**
* 沸点内容
*/
protected String content;
/**
* 沸点圈子
*/
protected Club club;
/**
* 沸点用户
*/
protected User user;
/**
* 评论
*/
protected Map<String, Comment> comments;
/**
* 点赞
*/
protected Map<String, Like> likes;
/**
* 发布时间
*/
protected Long createTime;
/**
* 评论沸点
*/
@Override
public void addComment(Comment comment) {
if (comment == null) {
throw new IllegalArgumentException("Comment required");
}
comments.put(comment.getId(), comment);
}
/**
* 删除评论
*/
@Override
public void deleteComment(Comment comment) {
if (comment == null) {
throw new IllegalArgumentException("Comment required");
}
comments.remove(comment.getId());
}
/**
* 获得评论
*/
@Override
public Comment getComment(String commentId) {
if (!StringUtils.hasText(commentId)) {
throw new IllegalArgumentException("Comment id required");
}
return comments.get(commentId);
}
/**
* 点赞,用户 id 作为 key
*/
@Override
public void addLike(Like like) {
if (like == null) {
throw new IllegalArgumentException("Like required");
}
likes.put(like.getUser().getId(), like);
}
/**
* 取消点赞,用户 id 作为 key
*/
@Override
public void cancelLike(Like like) {
if (like == null) {
throw new IllegalArgumentException("Like required");
}
likes.remove(like.getUser().getId());
}
public static class Builder {
protected String id;
protected String content;
protected Club club;
protected User user;
protected Map<String, Comment> comments;
protected Map<String, Like> likes;
protected Long createTime;
public Builder id(String id) {
this.id = id;
return this;
}
public Builder content(String content) {
this.content = content;
return this;
}
public Builder club(Club club) {
this.club = club;
return this;
}
public Builder user(User user) {
this.user = user;
return this;
}
public Builder comments(Map<String, Comment> comments) {
this.comments = comments;
return this;
}
public Builder likes(Map<String, Like> likes) {
this.likes = likes;
return this;
}
public Builder createTime(Long createTime) {
this.createTime = createTime;
return this;
}
public PinImpl build() {
if (!StringUtils.hasText(id)) {
throw new IllegalArgumentException("Id required");
}
if (!StringUtils.hasText(content)) {
throw new IllegalArgumentException("Content required");
}
if (user == null) {
throw new IllegalArgumentException("User required");
}
if (comments == null) {
comments = new LinkedHashMap<>();
}
if (likes == null) {
likes = new LinkedHashMap<>();
}
if (createTime == null) {
createTime = System.currentTimeMillis();
}
return new PinImpl(
id,
content,
club,
user,
comments,
likes,
createTime);
}
}
}
这里使用Builder
来创建实例同时不对外提供Set
方法,这样就可以保证领域模型的数据对于业务来说必定正确(不通过反射等其他方式乱搞的话)
首先,在build
方法中校验数据,如果数据不符合业务要求就无法创建领域模型,其次不对外提供Set
方法,当领域模型生成之后就无法通过外部逻辑设置数据,防止被设置有问题的数据
所以当我们从领域模型中获得数据时,对应的数据必定是符合业务的,如在某个业务场景中某个字段不会为NULL
,那么从领域模型中获得的该字段的值就不会为NULL
,我们就不需要额外判断不必要的内容了
另外,领域模型提供的方法在内部校验入参与当前状态,也就是说我们在调用该方法时也不需要额外校验传入的参数
这样我们的领域模型就能完全把控自身的数据,按照设定的业务逻辑运行,达到一种自洽的状态
(这里定义了Pin
接口主要是为了体现领域模型基于行为而非属性)
沸点 Controller
@Tag(name = "沸点")
@RestController
@RequestMapping("/pin")
public class PinController {
@Autowired
private PinService pinService;
@Autowired
private PinSearcher pinSearcher;
@Operation(summary = "发布沸点")
@PostMapping
public void create(@RequestBody PinCreateCommand create, @Login User user) {
pinService.create(create, user);
}
@Operation(summary = "删除沸点")
@DeleteMapping
public void delete(@RequestBody PinDeleteCommand delete, @Login User user) {
pinService.delete(delete, user);
}
@Operation(summary = "沸点详情")
@GetMapping("/{id}")
public PinVO get(@PathVariable String id) {
return pinSearcher.get(id);
}
@Operation(summary = "分页查询沸点")
@GetMapping("/page")
public Pages<PinVO> page(PinQuery query, Pages.Args page) {
return pinSearcher.page(query, page);
}
}
HTTP
接口的作用就是将外部的参数按照一定的格式传入领域中并返回对应的数据,不包含逻辑层面的代码,这样接口层就非常简洁
当然大家可以把参数校验放在接口层,或者也可以将校验后置,因为我们的模型使用Builder
的方式自带了大部分校验逻辑
沸点 Service
/**
* 沸点服务
*/
@Service
public class PinService {
/**
* 视图和领域模型的转换适配器
*/
@Autowired
private PinFacadeAdapter pinFacadeAdapter;
/**
* 沸点存储
*/
@Autowired
private PinRepository pinRepository;
/**
* 评论存储
*/
@Autowired
private CommentRepository commentRepository;
/**
* 点赞存储
*/
@Autowired
private LikeRepository likeRepository;
/**
* 领域事件发布器
*/
@Autowired
private DomainEventPublisher eventPublisher;
/**
* 添加(发布)一条沸点
*/
public void create(PinCreateCommand create, User user) {
//获得领域模型
Pin pin = pinFacadeAdapter.from(create, user);
//添加(发布)沸点
pinRepository.create(pin);
//发布沸点添加(发布)事件
eventPublisher.publish(new PinCreatedEvent(pin, user));
}
/**
* 删除一条沸点
*/
@Transactional
public void delete(PinDeleteCommand delete, User user) {
//获得对应的沸点
Pin pin = pinRepository.get(delete.getId());
if (pin == null) {
throw new JuejinException("沸点不存在");
}
//删除沸点
pinRepository.delete(pin);
//删除沸点下面的评论
commentRepository.delete(pin.getComments());
//删除沸点下面的点赞
likeRepository.delete(pin.getLikes());
//发布沸点删除事件
eventPublisher.publish(new PinDeletedEvent(pin, user));
}
}
在发布沸点的时候,通过PinFacadeAdapter
将前端数据转成沸点的领域模型,然后通过PinRepository
进行存储,最后发布一个沸点发布的事件
在删除沸点的时候,通过id
获得领域模型,然后通过PinRepository
进行删除,同时通过CommentRepository
和LikeRepository
来删除相关的评论和点赞信息,最后发布一个沸点删除的事件
这里大家可能会疑惑,为什么评论和点赞要单独拿出来删除,不可以在PinRepository
的delete
方法中一起处理么?
在PinRepository
的delete
方法中一起处理当然是没有问题的,不过我这么做有另外的原因,会在下面的内容中讲到
沸点 FacadeAdapter
/**
* 沸点领域模型和视图的转换适配器
*/
@Component
public class PinFacadeAdapterImpl implements PinFacadeAdapter {
/**
* 圈子存储
*/
@Autowired
private ClubRepository clubRepository;
@Override
public Pin from(PinCreateCommand create, User user) {
return new PinImpl.Builder()
.id(generateId())
.content(create.getContent())
.club(getClub(create.getClubId()))
.user(user)
.build();
}
/**
* 获得圈子领域模型
*/
public Club getClub(String clubId) {
if (clubId == null) {
return null;
}
return clubRepository.get(clubId);
}
public String generateId() {
//雪花算法等方式生成ID
return UUID.randomUUID().toString();
}
}
PinFacadeAdapter
主要是模型间的转换,同时单独抽象一个FacadeAdapter
方便进行扩展
抽象 Repository
接下来我们要实现对应的存储,先搞个接口
DomainRepository
/**
* 领域存储
*/
public interface DomainRepository<T extends DomainObject> {
/**
* 单个新增
*/
void create(T object);
/**
* 多个新增
*/
void create(Collection<? extends T> objects);
/**
* 单个更新
*/
void update(T object);
/**
* 多个更新
*/
void update(Collection<? extends T> objects);
/**
* 单个删除
*/
void delete(T object);
/**
* 多个删除
*/
void delete(Collection<? extends T> objects);
/**
* 单个 id 查询
*/
T get(String id);
/**
* 多个 id 查询
*/
Collection<T> select(Collection<String> ids);
/**
* 单个条件查询
*/
T query(Conditions conditions);
/**
* 数量条件查询
*/
long count(Conditions conditions);
/**
* 列表条件查询
*/
List<T> list(Conditions conditions);
/**
* 分页条件查询
*/
Pages<T> page(Conditions conditions, Pages.Args page);
}
我们定义了一系列的增删改查方法作为通用的模版,这就是为什么在Service
中要调用多个Repository
对于每一个Repository
来说,我们只需要实现和业务无关的读写方法,这样方便直接替换,如当我们实现新的PinRepository
时,不需要根据业务来判断评论和点赞是否需要增删改,所以把这些逻辑都放在了Service
中,在扩展Repository
的时候就更简单了
条件 Conditions
针对大量的通用条件查询,我定义了Conditions
来处理
/**
* 查询条件
*/
@Getter
public class Conditions {
/**
* = 条件
*/
private final Collection<Equal> equals = new LinkedList<>();
/**
* in 条件
*/
private final Collection<In> ins = new LinkedList<>();
/**
* like 条件
*/
private final Collection<Like> likes = new LinkedList<>();
/**
* order by 条件
*/
private final Collection<OrderBy> orderBys = new LinkedList<>();
//有需要可以添加其他条件
/**
* 添加 =
*/
public Conditions equal(String key, Object value) {
equals.add(new Equal(key, value));
return this;
}
/**
* 添加 in
*/
public Conditions in(String key, Collection<?> values) {
ins.add(new In(key, values));
return this;
}
/**
* 添加 like
*/
public Conditions like(String key, String value) {
likes.add(new Like(key, value));
return this;
}
/**
* 添加 order by
*/
public Conditions orderBy(String key, boolean desc) {
orderBys.add(new OrderBy(key, desc));
return this;
}
/**
* = 条件
*/
@Getter
@AllArgsConstructor
public static class Equal {
/**
* = 的 key
*/
private final String key;
/**
* = 的 value
*/
private final Object value;
}
/**
* in 条件
*/
@Getter
@AllArgsConstructor
public static class In {
/**
* in 的 key
*/
private final String key;
/**
* in 的 values
*/
private final Collection<?> values;
}
/**
* like 条件
*/
@Getter
@AllArgsConstructor
public static class Like {
/**
* like 的 key
*/
private final String key;
/**
* like 的 value
*/
private final String value;
}
/**
* order by 条件
*/
@Getter
@AllArgsConstructor
public static class OrderBy {
/**
* order by 的 key
*/
private final String key;
/**
* order by 是否倒序
*/
private final boolean desc;
}
}
我们可以按需求生成一个个对应的Conditions
来解决大部分的通用查询,后面也会有相应的示例
AbstractDomainRepository
public abstract class AbstractDomainRepository<T extends DomainObject, P> implements DomainRepository<T> {
public abstract P do2po(T object);
public abstract T po2do(P object);
}
这里添加一对领域模型和数据模型相互转换的方法,之后会用到
因为我平时用MyBatis-Plus
比较多,所以就实现了一个MBPDomainRepository
/**
* 基于 MyBatis-Plus 的通用存储
*
* @param <T> 领域模型
* @param <P> 数据模型
*/
public abstract class MBPDomainRepository<T extends DomainObject, P> extends AbstractDomainRepository<T, P> {
/**
* 插入一条数据
*/
@Override
public void create(T object) {
getBaseMapper().insert(do2po(object));
}
/**
* 插入多条数据
*/
@Transactional(rollbackFor = Throwable.class)
@Override
public void create(Collection<? extends T> objects) {
objects.forEach(this::create);
}
/**
* 更新一条数据
*/
@Override
public void update(T object) {
getBaseMapper().updateById(do2po(object));
}
/**
* 更新多条数据
*/
@Transactional(rollbackFor = Throwable.class)
@Override
public void update(Collection<? extends T> objects) {
objects.forEach(this::update);
}
/**
* 删除一条数据
*/
@Override
public void delete(T object) {
getBaseMapper().deleteById(object.getId());
}
/**
* 删除多条数据
*/
@Override
public void delete(Collection<? extends T> objects) {
Set<String> ids = objects.stream()
.map(DomainObject::getId)
.collect(Collectors.toSet());
getBaseMapper().deleteBatchIds(ids);
}
/**
* 根据 id 获得一条数据
*/
@Override
public T get(String id) {
return po2do(getBaseMapper().selectById(id));
}
/**
* 根据 id 集合获得多条数据
*/
@Override
public Collection<T> select(Collection<String> ids) {
return getBaseMapper()
.selectBatchIds(ids)
.stream()
.map(this::po2do)
.collect(Collectors.toList());
}
/**
* 根据条件查询一条数据
*/
@Override
public T query(Conditions conditions) {
List<T> list = list(conditions);
if (list.isEmpty()) {
return null;
} else if (list.size() == 1) {
return list.get(0);
} else {
throw new JuejinException("存在多条数据");
}
}
/**
* 根据条件获得数量
*/
@Override
public long count(Conditions conditions) {
return getBaseMapper().selectCount(getWrapper(conditions));
}
/**
* 根据条件获得列表数据
*/
@Override
public List<T> list(Conditions conditions) {
return getBaseMapper()
.selectList(getWrapper(conditions))
.stream()
.map(this::po2do)
.collect(Collectors.toList());
}
/**
* 根据条件获得分页数据
*/
@Override
public Pages<T> page(Conditions conditions, Pages.Args page) {
IPage<P> p = new Page<>(page.getCurrent(), page.getSize());
return toPages(
getBaseMapper().selectPage(p, getWrapper(conditions)),
this::po2do);
}
/**
* 根据条件生成 Wrapper
*/
public Wrapper<P> getWrapper(Conditions conditions) {
QueryWrapper<P> wrapper = new QueryWrapper<>();
conditions.getEquals().forEach(it -> wrapper.eq(it.getKey(), it.getValue()));
conditions.getIns().forEach(it -> wrapper.in(it.getKey(), it.getValues()));
conditions.getLikes().forEach(it -> wrapper.like(it.getKey(), it.getValue()));
conditions.getOrderBys().forEach(it -> {
if (it.isDesc()) {
wrapper.orderByDesc(it.getKey());
} else {
wrapper.orderByAsc(it.getKey());
}
});
return wrapper;
}
/**
* 将 mbp 的 page 转为我们的领域 pages
*/
public Pages<T> toPages(IPage<P> p, Function<P, T> function) {
Pages<T> pages = new Pages<>();
pages.setCurrent(p.getCurrent());
pages.setSize(p.getSize());
pages.setTotal(p.getTotal());
pages.setPages(p.getPages());
pages.setRecords(p.getRecords()
.stream()
.map(function)
.collect(Collectors.toList()));
return pages;
}
/**
* 获得 Mapper
*/
public abstract BaseMapper<P> getBaseMapper();
}
我们把所有的逻辑都实现了,各个模块的Repository
只需要提供对应的Mapper
就可以了
沸点 Repository
/**
* 基于 MyBatis-Plus 的沸点存储实现
*/
@Repository
public class MBPPinRepository extends MBPDomainRepository<Pin, PinPO> implements PinRepository {
@Autowired
private PinMapper pinMapper;
@Override
public PinPO do2po(Pin pin) {
PinPO po = new PinPO();
po.setId(pin.getId());
po.setContent(pin.getContent());
po.setClubId(pin.getClub().getId());
po.setUserId(pin.getUser().getId());
po.setCreateTime(new Date(pin.getCreateTime()));
return po;
}
@Override
public Pin po2do(PinPO po) {
//评论和点赞的信息要到另外的表查询
//后续的文章会有另外的解决方案
return null;
}
@Override
public BaseMapper<PinPO> getBaseMapper() {
return pinMapper;
}
}
我们继承MBPDomainRepository
之后只需要额外编写领域模型和数据库的数据模型的转换逻辑就行了,实现一个模块对应的Repository
就会非常简单
沸点 Searcher
PinSearcher
主要是提供领域模型到视图的包装处理,以及提供无法用Conditions
来实现的复杂查询(如使用ES
来做查询等)
首先我们先在PinFacadeAdapter
中定义领域模型和视图的转换接口
/**
* 沸点模型与视图的转换适配器
*/
public interface PinFacadeAdapter {
/**
* 创建视图转沸点模型
*/
Pin from(PinCreateCommand create, User user);
/**
* 沸点模型转沸点视图
*/
PinVO do2vo(Pin pin);
/**
* 查询转条件
*/
Conditions toConditions(PinQuery query);
}
然后实现PinSearcherImpl
/**
* 沸点查询器
*/
@Component
public class PinSearcherImpl implements PinSearcher {
/**
* 沸点存储
*/
@Autowired
private PinRepository pinRepository;
/**
* 沸点模型与视图转换适配器
*/
@Autowired
private PinFacadeAdapter pinFacadeAdapter;
/**
* 根据 id 获得沸点视图
*/
@Override
public PinVO get(String id) {
return pinFacadeAdapter.do2vo(pinRepository.get(id));
}
/**
* 分页获得沸点
*/
@Override
public Pages<PinVO> page(PinQuery query, Pages.Args page) {
return pinRepository
.page(pinFacadeAdapter.toConditions(query), page)
.map(pinFacadeAdapter::do2vo);
}
}
主要就是通过Repository
查询出来后,用FacadeAdapter
转换成视图,没有什么复杂的逻辑
总结
到这里沸点的内容基本上就写完了,因为现在主要是功能的实现,所以目前整体的代码和设计还是比较粗糙的,我会在之后文章中对部分可复用和可简化的地方进行优化
我在落地DDD
的过程中偶然发现可以通过通用模版化的Repository
,让持久层尽量没有任何业务逻辑,方便以非常低的成本进行替换,这样就不用再担心数据模型的设计风险了
我还能想象到以后会不会出现这样的场景:
一脸不屑的你:你想用什么?MySQL,ES,MongoDB,HBase,InfluxDB,Neo4j,你想用哪个就用哪个呗,要不要我花个两天时间全部给你实现一遍?(狗头)
转载自:https://juejin.cn/post/7163851958338617352