距离DDD实践反思写完已经过去一年,期间发生了很多事情,比如换了工作,细节按下不表;新团队的技术负责人对DDD在团队里的落地很关心,问最近有没有什么进展?这就很尴尬了:之前我接手并主要负责的XXX服务在现阶段是不太适合用DDD的,自身和外部其他几个服务的边界并不清楚(其中包含了一些历史技术债),而且当前处于一个变化比较快的阶段,也没有什么业务输入,不太适合贸然重构,所以并没有在XXX服务中搞DDD。

做技术总要有点追求嘛,虽然现阶段工作最高优先级还是保证业务快速发展,还是想继续实践下DDD的。
这时正巧一个老应用要做重构,在这个基础上一个新的类目管理功能,虽然是一个新的领域,但是产品文档确定的业务规则已经非常清晰,并且后续变化不会很大。美中不足的是需求对应的功能此时已经用传统的CRUD的方式写完了大半了,纠结了半天,还是决定:搞!

本文对于DDD的基础术语就不再单独讲解了,下面直接进入正题。
关于DDD,我一年前观点基本没有变化,这里再总结归纳一下。要先确定是否满足以下条件,再考虑是不是要用DDD,不要为了DDD而DDD。永远记住:没有银弹!

实践时,你就会发现DDD在项目落地时做了很多折中,不能教条化地照搬。
这里概括一下需求要点,已刨除掉需求具体的背景以及和本文无关的其他项目需求内容。
本次需要实现一个管理如下图的类目树结构的功能:

(图源:https://t.cj.sina.com.cn/articles/view/7321552158/1b466051e001010bfc)
具体的规则和支持的操作:
象征性地画一下限界上下文和ER图,因为隐藏了很多细节所以看上去很简单。ER图里并没有聚合根,要问为什么请继续往后看。


DDD只在脑中有概念是不够的,为了将概念转化为代码,第一步就是把这些概念变成代码,这样才能指导后续的编写。

实际上,这就可以看做是折中的开始了,因为DDD本身是不关心具体存储的,但是做模型设计,你必须考虑如何持久化。
本文中为了实现类目树本身并不会用到继承以下值对象的类,为了完整性考虑才写出来的。
/**
* 值对象抽象类
*
*/
public abstract class ValueObject {
}
/**
* 字段型值对象
*
* 表示这个值对象会使用表字段来存储。
*
* 并不总是表示一个单一的字段, 可能是多个字段组合而成。
*
* 你可以把枚举也看做值对象,但enum是没法继承这个类的。
*/
public abstract class FieldValueObject extends ValueObject {
}
/**
* 对象关系映射型值对象
*
* 表示这个值对象会使用关系数据库映射的方式来存储。
* 这里没有使用id,需要视情况而定
*/
public abstract class OrmValueObject extends ValueObject {
/**
* 创建时间
*/
protected Date created;
/**
* 更新时间
*/
protected Date lastModified;
}
/**
* 实体抽象类
* 封装了所有实体的通用属性
*/
public abstract class Entity {
/**
* id
*/
protected Long id;
/**
* 创建时间
*/
protected Date created;
/**
* 更新时间
*/
protected Date lastModified;
/**
* 是否逻辑删除
*/
protected Boolean deleted;
}
除了实体本身的属性,空的。
/**
* 聚合根
*/
public abstract class Aggregate extends Entity{
}
话说大了,其实这节列出来的都快成失血模型了。充血模型在哪里?等到下面一节就有了,先看看这些贫血模型提升下血压吧:

/**
* 内容类目节点
*
*/
public class Category extends Entity {
/**
* 名称
*/
private String name;
/**
* 层级, 0表示根
*/
private Integer level;
/**
* 父节点id, 如果不存在则为0
*/
private Long parentId;
/**
* 根节点id, 如果是根节点则是它本身
*/
private Long rootId;
/**
* 内容类型
*/
private ContentTypeEnum contentType;
/**
* 节点状态
* 0-下架,1上架
*/
private CategoryStatusEnum status;
/**
* 节点顺序, 有小到大递增
*/
private Integer index;
/**
* 节点路径, 不含它自己
* 用于冗余
*/
private List<Long> path;
public boolean isOff() {
return status!=null && StringUtils.equals(status.getCode(), CategoryStatusEnum.OFF.getCode());
}
public boolean isOn() {
return status!=null && StringUtils.equals(status.getCode(), CategoryStatusEnum.ON.getCode());
}
}
/**
* 类目节点上的内容
*
*/
public class Content extends Entity {
/**
* 所属的类目id
*/
private Long categoryId;
/**
* 所属类目节点的根id, 用于冗余查询
*/
private Long rootId;
/**
* 内容id
*/
private String contentId;
/**
* 内容类型
*/
private ContentTypeEnum contentType;
/**
* 内容在类目树上的路径(id),,用于冗余查询
*/
private List<Long> path;
}
CRUD也可以用Repository,你也可以把Repository用Tunnel代替,这里还是使用Repository来表示将持久化的对象加载到内存中、将内存对象持久化的服务。
Repository与直接调用mybatis提供的mapper/DAO不同点:
在本次需求里,Repository具体提供了哪些方法就不列举了,可以看下面一个方法,它通过事务绑定了两个动作,保证新建的根节点的rootId字段是它自己创建时生成的主键。
@Repository
public class CategoryRepository {
@Resource
private CategoryDAO categoryDAO;
// 其他方法略
/**
* 创建根节点
*/
@Transactional(rollbackFor = Exception.class)
public long addRoot(Category category) {
CategoryDO categoryDO = CategoryConverter.toDO(category);
categoryDAO.insert(categoryDO);
categoryDAO.updateRootId(categoryDO.getId());
category.setId(categoryDO.getId());
return categoryDO.getId();
}
}
直到这里,除了看似玄虚的建模抽象类,几乎和CRUD没什么区别对不对?

重点来了:聚合根!
先抽象出聚合根,再将领域方法合理地抽象到聚合根,DDD才算是开始落地。再回顾一下【需求分析】这一节,所有的操作都是和节点有关的,但是单个节点不能支持所有的操作,比如子节点排序,是包含了一个节点下所有的子节点的操作。那么,将一棵类目树作为聚合根,所有对节点的操作都抽象为 对一棵树上某个节点及关联节点的操作,是不是就把操作本身和聚合根联系到了一起呢?
/**
* 类目树 - 聚合根
* 领域对象(树)的领域方法, 本身包含了操作节点的持久化管理, 即所有操作需要满足:
* 对树及树的节点的操作, 内存中的对象必须和持久化的保持一致, 如果进行持久化, 内存中存在的也需要进行更新, 反之亦然
*
* 使用树进行操作, 需要注意不要在同一个流程中对同一个对象混用树和repository进行操作, 否则会发生数据不一致
*
*/
public class CategoryTree extends Aggregate {
/**
* 类目树的根节点id
*/
final private long rootId;
/**
* 类目节点缓存
* 可能不是全部的节点都会缓存
* key: 节点id
* value: 节点
*/
final private Map<Long, Category> nodeMap = Maps.newHashMap();
/**
* 节点仓储
*/
final private CategoryRepository categoryRepository;
/**
* 节点内容仓储
*/
final private ContentRepository contentRepository;
/**
* 节点并发锁
*/
final private RedisLock redisLock;
/**
* 初始化, 数据懒加载
*
* @param rootId
* @param categoryRepository
* @param classifiedContentRepository
* @param redisLock
*/
public categoryTree(Long rootId, categoryRepository categoryRepository,
ContentRepository contentRepository, RedisLock redisLock) {
this.rootId = rootId;
this.deleted = false;
this.categoryRepository = categoryRepository;
this.contentRepository = contentRepository;
this.redisLock = redisLock;
}
// 领域方法, 见下文
... ...
}
你会发现,如果想要在聚合根实现领域方法,因为会涉及持久化,聚合根一定是和Repository绑定在一起的。那么,聚合根很自然的变成了充血模型。
虽然聚合根是类目树的根节点,我不推荐将所有这课类目树的所有节点都加在到内存中,而是在每次操作时按需加载,操作完直接持久化,否则你会面对着无休止的数据一致性的纠结。
聚合根里包含了Repository、Redis并发锁,总不能每次new的时候都手动注入一次吧?

如果不用new来创建对象,很自然的可以想到用工厂类来做这些脏活累活。
@Service
public class CategoryTreeFactory {
@Resource
private CategoryRepository categoryRepository;
@Resource
private ContentRepository contentRepository;
@Resource
private RedisLock redisLock;
/**
* 通过根构造(加载)树
* @param rootId
* @return
*/
public ContentCategoryTree build(long rootId) {
ContentCategory root = categoryRepository.loadOne(rootId);
if(root == null) {
throw new RuntimeException("根节点不存在");
}
if(root.getRootId() != rootId) {
throw new RuntimeException("rootId对应的节点不是根节点");
}
return new CategoryTree(rootId, categoryRepository, contentRepository, redisLock);
}
/**
* 通过节点构造(加载)类目树
*
* @param categoryId
* @return
*/
public CategoryTree buildByNode(long categoryId) {
Category category = categoryRepository.loadOne(categoryId);
if(category == null) {
throw new RuntimeException("类目节点不存在");
}
return build(category.getRootId());
}
/**
* 创建一个只有根的新树
* @param name
* @param contentType
* @return
*/
public CategoryTree buildNewTree(String name, ContentTypeEnum contentType) {
int index = 1;
Set<String> rootNameSet = Sets.newHashSet();
List<ContentCategory> roots = contentCategoryRepository.loadRoots();
if(!CollectionUtils.isEmpty(roots)) {
// 获得新的根节点的顺序
index = roots.get(roots.size()-1).getIndex() + 1;
roots.forEach(p->rootNameSet.add(p.getName()));
}
// 以后改成按类型名称排序
if(rootNameSet.contains(name)) {
throw new RuntimeException("根节点名称重复");
}
Category root = new Category();
root.setName(name);
root.setStatus(CategoryStatusEnum.OFF);
root.setContentType(contentType);
root.setIndex(index);
// 临时设置一个id,规避持久化问题
root.setRootId(CategoryConstant.ROOT_PARENT_ID);
root.setParentId(CategoryConstant.ROOT_PARENT_ID);
root.setLevel(CategoryConstant.ROOT_LEVEL);
root.setDeleted(false);
long rootId = categoryRepository.addRoot(root);
return build(rootId);
}
}
接下来,就要在聚合根充实领域服务了,这一步是和抽象聚合根是紧密结合在一起的。
这里先铺垫一下,为了提高代码的复用性,需要因地制宜的抽一下模板方法。在本例中,有两种:
先看下适用于不同场景的两个方法接口。
/**
* 类目操作方法接口
* 只适用于单个节点
*
*/
public interface CategorySingleOperation<R> {
/**
* 方法接口
* @return
*/
R process();
}
/**
/
public interface CategoryTraverseOperation {
/*
* 方法接口
* @return
*/
void process(Long categoryNodeId);
}
再看下两种场景对应的模板方法,它们把一些通用操作封装了一下。自下而上的操作时,使用了堆栈和对列。
/**
* 对一个节点进行操作模板方法
* @param func 具体的操作
* @param nodeId 节点id
* @param withLock 是否加互斥锁
* @param <R>
* @return
*/
private <R> R executeForOneNode(Long nodeId, boolean withLock, CategorySingleOperation<R> func) {
Category node = nodeMap.get(nodeId);
if(node == null) {
node = categoryRepository.loadOne(nodeId);
nodeMap.put(nodeId, node);
}
if(node == null) {
throw new RuntimeException("待处理的节点不存在");
}
if(withLock) {
if(!redisLock.acquire(buildLockKey(nodeId), SystemConstants.CATEGORY_LOCK_TIME)) {
throw new RuntimeException("并发锁获取失败");
}
R r = func.process();
redisLock.release(buildLockKey(nodeId));
return r;
} else {
return func.process();
}
}
/**
* 从一个节点开始, 自上而下逐层进行操作模板方法
* @param func 具体的操作
* @param nodeId 节点id
* @param withLock 是否加互斥锁
* @return
*/
private void executeForDownUpByLevel(Long nodeId, boolean withLock, CategoryTraverseOperation func) {
Category node = loadOne(nodeId);
if(node == null) {
throw new MeiJianException(PbdErrorCodeEnum.NO_DATA.getCode(), "节点不存在!");
}
// 按层组装节点
LinkedList<Category> queueForTraverse = Lists.newLinkedList();
LinkedList<Long> stackForHandle = Lists.newLinkedList();
queueForTraverse.offer(node);
while(!queueForTraverse.isEmpty()) {
Category currentNode = queueForTraverse.poll();
stackForHandle.push(currentNode.getId());
List<Category> children = categoryRepository.loadByParentId(currentNode.getId());
if(!CollectionUtils.isEmpty(children)) {
children.forEach(queueForTraverse::offer);
}
}
// 自底向上处理
while(!stackForHandle.isEmpty()) {
Long currentCategoryId = stackForHandle.pop();
if(withLock) {
if(!redisLock.acquire(buildLockKey(nodeId), SystemConstants.CATEGORY_LOCK_TIME)) {
throw new RuntimeException("并发锁获取失败");
}
func.process(currentCategoryId);
redisLock.release(buildLockKey(nodeId));
} else {
func.process(currentCategoryId);
}
}
}
终于到这里了。前面经过噼里啪啦一顿抽象,领域方法写起来已经很简单了,下面举几个例子,分别展示单个节点操作和自底向上操作一个节点下的所有节点的写法。
实际上不止这几个方法,通过模板方法省掉了大量重复代码,看上去也干净整洁很多,这里就不一一列举了。
/**
* 增加类目节点, 序号为父节点下最大值
* @param parentId
* @param name
* @param contentType
* @return
*/
public Long addContentCategory(Long parentId, String name, ContentTypeEnum contentType) {
return executeForOneNode(parentId, true, () -> {
int index = 0;
Category parent = loadOne(parentId);
List<Category> children = categoryRepository.loadByParentId(parent.getId());
if(!CollectionUtils.isEmpty(children)) {
for(Category child: children) {
if(child.getIndex() > index) {
index = child.getIndex();
}
}
}
index++;
return categoryRepository.add(buildCategory(name, contentType, parent, index));
});
}
/**
* 删除节点及节点上的内容
* 为了防止脏数据, 从底向上删
*
* @param categoryId
*/
public void deleteNodes(Long categoryId) {
executeForDownUpByLevel(categoryId, false, currentCategoryId-> {
contentRepository.deleteByCategoryId(currentCategoryId);
categoryRepository.delete(currentCategoryId);
});
}
面对一部分需求里的内容,你会发现CQRS有时并不是要故意搞什么高大上的概念,而是不得已而为之......只靠领域服务臣妾做不到啊?

比如,为了通过UI展示一颗类目树,你需要提供一个接口一次性把所有类目节点查出来,并且保持树的结构;
再比如,你要展示一个类目节点及其下面所有子级类目节点关联的内容,对于子级还要像子级的子级这样递归下去。
对于第一个场景,总不能把模型转VO这件事在聚合根里做吧?我选择另写一个CategoryReadService包裹着一些Repository来承载这种层级查询,顺便把其他所有的纯查询请求都用它来对接;
对于第二个场景,直接上ES走搜索了。
再补一个场景,一些***钻的查询需求会破坏你原先自洽的mysql索引设计。
可以这样归纳:不要让查询破坏你的建模和设计。
整个实践下来发现,居然在无意间把聚合根、实体工厂、领域方法、读写分离都串起来了。代码很有条理,复用性也比较高,收获颇丰,对DDD也有了新的认识。

不过话说回来,这次也算是占了建模难度低的便宜,类目树它本身是一颗树,可以用数据结构里树的相关知识做抽象,其他的场景用DDD抽象未必有这么简单。
很好奇,就使用rubyonrails自动化单元测试而言,你们正在做什么?您是否创建了一个脚本来在cron中运行rake作业并将结果邮寄给您?git中的预提交Hook?只是手动调用?我完全理解测试,但想知道在错误发生之前捕获错误的最佳实践是什么。让我们理所当然地认为测试本身是完美无缺的,并且可以正常工作。下一步是什么以确保他们在正确的时间将可能有害的结果传达给您? 最佳答案 不确定您到底想听什么,但是有几个级别的自动代码库控制:在处理某项功能时,您可以使用类似autotest的内容获得关于哪些有效,哪些无效的即时反馈。要确保您的提
我正在使用i18n从头开始构建一个多语言网络应用程序,虽然我自己可以处理一大堆yml文件,但我说的语言(非常)有限,最终我想寻求外部帮助帮助。我想知道这里是否有人在使用UI插件/gem(与django上的django-rosetta不同)来处理多个翻译器,其中一些翻译器不愿意或无法处理存储库中的100多个文件,处理语言数据。谢谢&问候,安德拉斯(如果您已经在rubyonrails-talk上遇到了这个问题,我们深表歉意) 最佳答案 有一个rails3branchofthetolkgem在github上。您可以通过在Gemfi
我安装了ruby版本管理器,并将RVM安装的ruby实现设置为默认值,这样'哪个ruby'显示'~/.rvm/ruby-1.8.6-p383/bin/ruby'但是当我在emacs中打开inf-ruby缓冲区时,它使用安装在/usr/bin中的ruby。有没有办法让emacs像shell一样尊重ruby的路径?谢谢! 最佳答案 我创建了一个emacs扩展来将rvm集成到emacs中。如果您有兴趣,可以在这里获取:http://github.com/senny/rvm.el
是否有简单的方法来更改默认ISO格式(yyyy-mm-dd)的ActiveAdmin日期过滤器显示格式? 最佳答案 您可以像这样为日期选择器提供额外的选项,而不是覆盖js:=f.input:my_date,as::datepicker,datepicker_options:{dateFormat:"mm/dd/yy"} 关于ruby-on-rails-事件管理员日期过滤器日期格式自定义,我们在StackOverflow上找到一个类似的问题: https://s
导读:随着叮咚买菜业务的发展,不同的业务场景对数据分析提出了不同的需求,他们希望引入一款实时OLAP数据库,构建一个灵活的多维实时查询和分析的平台,统一数据的接入和查询方案,解决各业务线对数据高效实时查询和精细化运营的需求。经过调研选型,最终引入ApacheDoris作为最终的OLAP分析引擎,Doris作为核心的OLAP引擎支持复杂地分析操作、提供多维的数据视图,在叮咚买菜数十个业务场景中广泛应用。作者|叮咚买菜资深数据工程师韩青叮咚买菜创立于2017年5月,是一家专注美好食物的创业公司。叮咚买菜专注吃的事业,为满足更多人“想吃什么”而努力,通过美好食材的供应、美好滋味的开发以及美食品牌的孵
我认为我的问题最好用一个例子来描述。假设我有一个名为“Thing”的简单模型,它有一些简单数据类型的属性。像...Thing-foo:string-goo:string-bar:int这并不难。数据库表将包含具有这三个属性的三列,我可以使用@thing.foo或@thing.bar之类的东西访问它们。但我要解决的问题是当“foo”或“goo”不再包含在简单数据类型中时会发生什么?假设foo和goo代表相同类型的对象。也就是说,它们都是“Whazit”的实例,只是数据不同。所以现在事情可能看起来像这样......Thing-bar:int但是现在有一个新的模型叫做“Whazit”,看起来
我想用这两种语言中的任何一种(最好是ruby)制作一个窗口管理器。老实说,除了我需要加载某种X模块外,我不知道从哪里开始。因此,如果有人有线索,如果您能指出正确的方向,那就太好了。谢谢 最佳答案 XCB,X的下一代API使用XML格式定义X协议(protocol),并使用脚本生成特定语言绑定(bind)。它在概念上与SWIG类似,只是它描述的不是CAPI,而是X协议(protocol)。目前,C和Python存在绑定(bind)。理论上,Ruby端口只是编写一个从XML协议(protocol)定义语言到Ruby的翻译器的问题。生
我有一个要在我的Rails3项目中使用的数组扩展方法。它应该住在哪里?我有一个应用程序/类,我最初把它放在(array_extensions.rb)中,在我的config/application.rb中我加载路径:config.autoload_paths+=%W(#{Rails.root}/应用程序/类)。但是,当我转到railsconsole时,未加载扩展。是否有一个预定义的位置可以放置我的Rails3扩展方法?或者,一种预先定义的方式来添加它们?我知道Rails有自己的数组扩展方法。我应该将我的添加到active_support/core_ext/array/conversion
这是我在ActiveAdmin中的自定义页面ActiveAdmin.register_page"Settings"doaction_itemdolink_to('Importprojects','settings/importprojects')endcontentdopara"Text"endcontrollerdodefimportprojectssystem"rakedataspider:import_projects_ninja"para"OK"endendend我想做的是,当我单击“导入项目”按钮时,我想在Controller中执行rake任务。但是我无法访问该方法。可能是什
我正在寻找用于Rails的优质管理插件。似乎大多数现有的插件/gem(例如“restful_authentication”、“acts_as_authenticated”)都围绕着self注册等展开。但是,我正在寻找一种功能齐全的基于管理/管理角色的解决方案——但不是简单地附加到另一个非基于角色的解决方案。如果我找不到,我想我会自己动手......只是不想重新发明轮子。 最佳答案 RyanBates最近做了两个关于授权的railscast(注意身份验证和授权之间的区别;身份验证检查用户是否如她所说的那样,授权检查用户是否有权访问资源