写在开头
系统建模是一个系统的灵魂所在。一个系统值不值钱,就看它的领域建模值不值钱。系统建模的健壮性和完整性脱离不了一些基础性原则的支撑。但我们在工作过程中构建系统领域模型时,经常会落入一些"陷阱"之中,导致系统模型的健壮性和完整性遭到破坏,给系统后期的运维和能力扩展带来一些不必要的麻烦。下面简单分享三个在工作中遇到过的"建模陷阱",希望能让自己在未来能够顺利"避坑"。
陷阱一:建模没有必要
领域建模的过程狭义上来说其实就是对于系统对象模型的构建过程,合理的系统建模不仅是对系统领域知识的填充与表达,更是一种约束与规范。 我们来看一个缺乏领域建模的典型例子:
java
public interface DataCreateService {
/**
* 创建新版本配置数据
*
* @param configData
* @return 租户->配置项->配置数据key->配置数据版本号
*/
Map<String, Map<String, Map<String, String>>> createData(List<ConfigData> configData);
}
以上接口方法的定义是我在工作中遇到过的一个真实案例的简化版。简单解释以下上述代码,上述代码的目的是批量创建多条配置数据,同时返回新创建数据对应的版本号。配置数据由其所属租户 、其所属配置项 、唯一键 、数据内容 、数据版本组成。
上述接口方法由于系统的领域关系建模的不完整,导致插入新配置数据后,对于数据新版本号的获取上使用了复杂的嵌套式Map结构。这种用法的问题在于:Map充其量只能算作是一种数据结构,不是领域模型。
我在工作中一直秉承一个观念:数据结构是领域模型的组成部分,但不能直接充当领域模型。但我在工作中经常遇到使用数据结构充当领域模型的情况,我认为这不是一种正确的做法,如同上述代码中所展现的,上述代码用一种极其复杂的套娃式Map结构定义配置数据插入方法的返回值数据版本,会造成以下两点问题:
-
无法表达领域知识 ,数据结构仅仅只是数值的组织方式,其缺少了如字段名称等数据模型的结构信息,结构信息的缺失导致的是领域知识的缺失,这会给方法的调用方和维护方都带来极大的困扰。有注释还好,在没有注释的情况下,除了方法定义者本人,有谁会知道这个
Map<String, Map<String, Map<String, String>>>
是什么意思。 -
缺少约束和规范 ,数据结构的存取约束仅限于类型限制,对于存取内容的限制几乎等同于零,而对象模型的结构化定义类似于一种数据的存取契约,直接限制了使用方不能存入模型中未定义的字段值。举个例子,在
Map<String, String>
中,使用方可以任意存取非预期内容例如"123->123",而在模型对象例如ConfigData
中,没有一个属性名会定义为"123",所以你无法这样做。
基本上任何一个领域建模不完整的系统在构建过程中一定会滥用数据结构作为系统数据的承载方式。所以有一个判断系统是否领域建模健壮且完整的小技巧:看看你的系统是否有各种复杂的数据结构在到处乱飞。
最后,我们来看看怎样通过领域建模对上述接口方法进行优化。使用Map<String, Map<String, Map<String, String>>>
定义数据版本无非是因为没有合适的对象模型承载数据版本,我们只需要针对数据版本进行建模即可,如下所示:
java
public class ValidVersion {
/**
* 租户id
*/
private String tntInstId;
/**
* 配置项
*/
private String dataItemName;
/**
* 配置数据key
*/
private String dataItemKey;
/**
* 配置数据版本号
*/
private String validVersion;
/**
* 唯一标识
*/
public String buildUniqueKey() {
return tntInstId + "_" + dataItemName + "_" + dataItemKey;
}
}
通过数据版本建模,配置数据创建的接口方法可以修改为如下所示:
java
public interface DataCreateService {
/**
* 创建新版本配置数据
*
* @param configData
* @return list of ValidVersion
*/
List<ValidVersion> createData(List<ConfigData> configData);
}
领域建模一是系统领域知识的填充与表达,二是约束与规范,缺少领域建模会使系统陷入使用数据结构传递数据的泥沼,因此我们的系统需要完整且健壮的领域建模。
陷阱二:建模不讲规范
意识到系统需要领域建模仅仅只是第一步,如果对于建模过程缺少严格且严谨的规范化把控,对象模型也会像系统逻辑代码一样逐步腐化,逐渐丢失其原本的价值和意义。
一个典型的腐化例子就是扩展参数的滥用和失控。什么是扩展参数,扩展参数就是对象模型中一个预留的Map数据结构extInfo
。如下所示:
java
public class ValidVersion {
/**
* 租户
*/
private String tntInstId;
/**
* 配置项
*/
private String dataItemName;
/**
* 配置数据key
*/
private String dataItemKey;
/**
* 配置数据版本号
*/
private String validVersion;
/**
* 扩展参数
*/
private Map<String, String> extInfo;
}
在对于对象模型的后续迭代过程中,当对象模型的字段新增存在阻碍时,有一种极为苟且的方式就是将需要添加的字段放在Map中,以绕过对象模型的结构变更,避免结构变更对上下游带来额外影响。
扩展参数带来的负面影响远大于使用扩展参数节省变更成本的正面收益。不少系统故障都是因为扩展参数的处理不当所引起,例如扩展参数传递缺漏,扩展参数反序列失败等问题。
更糟糕的是,随着系统维护的时间逐渐拉长,如果秉持着对象模型无脑使用扩展参数进行扩展的想法,随着扩展参数中存储的内容越来越多,对象模型会逐步腐化为扁平化的Map数据结构。当一个对象模型在扩展参数中存储的字段比其结构定义的字段还要多时,此时该对象模型和普通的Map数据结构有什么差别?这不就又回到了系统滥用数据结构存取系统数据的混乱状态。
当然任何问题都应该辩证看待,扩展参数也是如此。使用扩展参数确实是一种高效的模型结构变更手段,如果因为各种外部因素的影响比如研发资源紧张,研发时间紧张导致不得不使用扩展参数时,以一种正确的方式使用扩展参数也未尝不可。
什么是扩展参数的正确使用方式?即扩展参数的"上浮"与"下沉",以确保系统核心运行模型不存在扩展参数。简单来讲就是允许系统外部模型例如DO(存储模型)、DTO(传输模型)存在扩展参数,同时保证系统核心运行模型的完全结构化,不存在扩展参数,外部模型在转换为运行模型的过程中对扩展参数进行结构化转换,使扩展参数转换为运行模型的结构化参数。如下图所示:
举个代码示例:
java
/**
* 版本存储模型
*/
public class ValidVersionDO {
/**
* 租户id
*/
private String tntInstId;
/**
* 配置项
*/
private String dataItemName;
/**
* 配置数据key
*/
private String dataItemKey;
/**
* 配置数据版本号
*/
private String validVersion;
/**
* 扩展参数:
* 1. isLatest: Boolean -> 是否是最新版本
*/
private Map<String, String> extInfo;
}
java
/**
* 版本领域模型
*/
public class ValidVersionModel {
/**
* 租户id
*/
private String tntInstId;
/**
* 配置项
*/
private String dataItemName;
/**
* 配置数据key
*/
private String dataItemKey;
/**
* 配置数据版本号
*/
private String validVersion;
/**
* 是否是最新版本
*/
private Boolean isLatest;
}
java
/**
* 模型转换器
*/
public class ValidVersionConverter {
public static ValidVersionModel DO2Model(ValidVersionDO validVersionDO) {
ValidVersionModel validVersionModel = new ValidVersionModel();
validVersionModel.setTntInstId(validVersionDO.getTntInstId());
validVersionModel.setDataItemName(validVersionDO.getDataItemName());
validVersionModel.setDataItemKey(validVersionDO.getDataItemKey());
validVersionModel.setValidVersion(validVersionDO.getValidVersion());
// 扩展参数处理
validVersionModel.setLatest(Boolean.valueOf(validVersionDO.getExtInfo().get("isLatest")));
return validVersionModel;
}
public static ValidVersionDO Model2DO(ValidVersionModel validVersionModel) {
ValidVersionDO validVersionDO = new ValidVersionDO();
validVersionDO.setTntInstId(validVersionModel.getTntInstId());
validVersionDO.setDataItemName(validVersionModel.getDataItemName());
validVersionDO.setDataItemKey(validVersionModel.getDataItemKey());
validVersionDO.setValidVersion(validVersionModel.getValidVersion());
// 扩展参数处理
validVersionDO.addExtInfo("isLatest", Boolean.toString(validVersionModel.getLatest()));
return validVersionDO;
}
}
系统核心运行领域的运转依赖的是系统运行模型,只要系统核心运行模型保证完全结构化,就能最大程度避免扩展参数对系统运行态功能带来的影响,达到系统模型防腐的目的。
维持模型的结构化是保证建模规范的必要条件,当然还有很多其他原则需要遵守以维持建模规范,比如字段命名、注释等,这不在一一列举。
陷阱三:建模没有合理分层
在扩展参数的使用上,上文提到了我们需要保证系统核心运行模型的完全结构化,不受扩展参数的侵蚀。这其中蕴含了一个关键前置条件------系统的建模是存在合理分层的。如果系统的领域模型没有做到合理分层,那么扩展参数"上浮"至DTO,下沉至"DO",以保证系统核心运行模型的完全结构化就无从谈起。
很多缺乏建模分层的系统,会直接将DO当作运行模型参与系统核心功能活动,这种模型耦合方式带来的负面影响会随着系统的维护年限拉长而越发凸显,上文提到的扩展参数失控问题只是典型负面问题之一。
存储模型和运行模型的耦合只是模型分层缺陷一环,分层缺陷的另一环是很多人意识不到但是确实实实在在发生的,即传输模型和运行模型的耦合。
传输模型是系统提供给外部的API中所使用的模型,即DTO(Data Transfer Object)或者VO(View Object),供外部系统调用该系统的服务能力或者将系统数据提供给外界。
为什么直接将系统运行模型作为传输模型定义门面服务API不是合理的做法,其中的关键原因在于我们的系统往往是复杂的,系统的复杂带来的是运行模型的复杂,复杂的运行模型作为传输模型会将很多不必要的信息暴露于外界,给外部调用方带去无谓的复杂性。传输模型和运行模型的解耦分层,其实就是利用传输模型对运行模型的复杂性进行一定的消解,以降低系统功能变更与扩展过程中与外部的协调成本以及外部对于系统的理解成本。
这里举个传输模型和运行模型解耦的具体例子:
java
/**
* 运行模型
*/
public static class ValidVersionModel {
/**
* 租户
*/
private TntInstEnum tntInst;
/**
* 配置项
*/
private String dataItemName;
/**
* 配置数据key
*/
private String dataItemKey;
/**
* 配置数据版本号
*/
private String validVersion;
/**
* 是否是最新版本
*/
private Boolean isLatest;
}
/**
* 租户枚举值
*/
public enum TntInstEnum {
/**
* 支付宝
*/
ALIPAY,
/**
* 菜鸟
*/
CAI_NIAO,
/**
* 天猫
*/
T_MALL,
/**
* 集团
*/
GROUP,
/**
* 其他
*/
OTHER;
}
java
/**
* 传输模型
*/
public static class ValidVersionDTO {
/**
* 租户
*/
private String tntInst;
/**
* 配置项
*/
private String dataItemName;
/**
* 配置数据key
*/
private String dataItemKey;
/**
* 配置数据版本号
*/
private String validVersion;
/**
* 扩展参数
*/
private Map<String, String> extInfo;
}
上述代码用传输模型消解掉了运行模型中的复杂性,这其中的复杂性其实包含两方面。
一是内容的复杂性 ,即非必要系统数据不让外界感知,通过分层将一些运行模型中的非必要信息在传输模型中屏蔽掉,比如上述代码中运行模型的isLatest
字段被传输模型屏蔽掉了。
二是类型复杂性,即复杂的字段类型需要扁平化为String、int、long等基本类型。比如上述代码中运行模型将租户定义为枚举类型,而传输模型将枚举类型扁平化为了基本类型String。内容复杂性的消解很好理解,类型复杂性的扁平化是出于什么原因呢?这其实为了灵活扩展性考虑。
想象这样一个系统运维场景,假设系统需要对租户类型进行扩展,即往TntInstEnum中添加枚举类型。如果传输模型的租户字段也定义为了枚举类型,在调用方的客户端无法及时升级到最新版本的情况下,其集成的枚举类没有包含新添加的枚举类型,一旦外部调用方调用系统相关服务的时候,但凡涉及到了新的租户类型,客户端的传输模型ValidVersionDTO一定会因为枚举类型的缺失而反序列化报错。但如果传输模型中租户定义为基本类型String就不存在反序列化失败问题。不信可以试试。
复杂类型扁平化为基本类型,降低了外部用户集成系统服务的耦合性,以此实现系统服务灵活扩展,降低改造成本。
最后总结一下,系统建模是需要分层的,一种最简单分层框架就是分为传输模型DTO或者VO(服务层)、运行模型Model(核心领域层)、存储模型DO(存储层)。同时更重要的是相同数据的不同层级模型不一定要完全一致,否则就失去了分层的目的,不同层级模型完全可因为不同的动机和目的而出现一定的差异性,比如扩展参数的处理、字段类型的扁平化等,而差异性由模型转换逻辑收敛处理即可。
写在结尾
以上以小见大的三个点,建模必要性、规范性、分层性只是为了阐释系统建模过程中所需要遵循的一些原则和建模的大致框架,如果要具体到如何进行系统建模,该怎么设计系统领域模型那就是另一码事了,需要深入到各个不同的领域结合领域知识具体而谈。
但无论领域怎么变,建模原则永远是万变不离其宗的,我理解这也是一个工程师技术素养。扎实的技术素养让我们在面对任何系统建模问题时都能游刃有余。