在软件领域,有一个古老的神话:即我能保证设计和代码实现完全一致。这的确是一个非常有价值的目标。试想下,如果我们的系统毫无设计,或者设计和代码实现毫无关联,在当今软件如此复杂的情况下,其实现和维护难度可想而知。
本文将结合我最近给ICT做软件设计培训的一些感悟,尝试介绍一些减少设计和实现之间鸿沟的方法,这些方法包括语言一致,设计一致和代码一致。除了介绍方法之外,我会以运营商计费系统为实践案例,辅助讲解如何落地一致性设计。诚然,完美的设计和代码一致的确是神话(和人月神话一样),因此,本文的立意并非是要指导你变成“神”,而是期望在减少设计和实现不一致的征途中,能迈出一小步。
“语言、语言、语言”,重要的事情说三遍,我一直在不遗余力的强调语言的重要性,是因为语言是一切的基础,正如维特根斯坦所说,“语言边界决定了我们的思维边界”。
要保证设计和实现的一致,首先要保证的,就是概念的完整性和语言的一致性。如果语言都做不到一致,其他的都是扯淡。
具体而言,我们需要保证“沟通语言,文档语言,设计语言,代码语言”的一致性。我们可以通过统一语言来做到这一点,统一语言(Ubiquitous Language)这个概念来自于Eric Evans的著作《Domain Driven Design》,其本人在谈到DDD核心的时候,也总是把“统一语言”放在第一位。
形成统一语言的关键就在于我们要制定一个领域词汇表,把领域中,涉及到的核心概念,用英文、中文、解释的形式无歧义的规定下来。这个词汇表作为团队的“共识”,就是true of source,后续的活动都应该严格遵循之。
沟通语言:就是我们日常交流、会议中使用的语言,大家都应该使用领域词汇表中的语言来沟通,否则就会出现很多的鸡同鸭讲,你就需要大把的时间去澄清概念。
文档语言:不管是需求文档,设计文档,测试文档等等,里面的语言都应该和领域词汇表保持一致。
设计语言:不管是用例图、模型图、流程图、类图、时序图等等设计中,其语言也应该和领域词汇表保持一致。
代码语言:最后,也是最重要的。在工程落地的时候,我们的代码当然也要和领域词汇表保持一致。除此之外,我们的代码还要和设计意图保持一致。
在我进入的任何一个项目的第一件事,无一例外,都是从领域概念开始的。首先我需要理解领域,然后挖掘概念,然后和团队一起形成领域词汇表(统一语言)。很多接受过我辅导的团队,事后回顾的时候,也都表示“统一语言”是对他们帮助最大的点之一。
接下来,我们以运营商计费系统为例,看看统一语言到底是什么,这个需求是这样的:
运营商向用户提供电话服务,支持用户拨打/接听电话,并对通话收取费用。如:主动拨打电话收取 0.5 元/分钟的通话费用;接听电话收取 0.4 元/分钟的通话费用。
运营商为了吸引客户,定义了若干电话套餐,总共有三种类型的套餐。这三个套餐分别是:
基础套餐:主叫收费 0.5 元/分钟;被叫收费 0.4 元/分钟
固定时长套餐:套餐月固定费 100 元,包含:200 分钟主叫通话时间+200 分钟被叫接听时间
家庭套餐:套餐月固定费 20 元,用户可以指定 N 个号码作为自己的亲情号,用户接听/拨打亲情号均不收费
我们要设计一个 计费系统 用于套餐计费规则的执行,保存计费记录,并通知账户系统扣减费用。注意:在一次通话过程中,通话控制系统可能会调用多次计费系统进行计费。
这个需求大家应该不陌生,就是我们日常在用的,通过分析需求,我们不难把里面的一些核心领域概念,诸如计费(Charge)、账户(Account)、计费套餐(ChargePlan)、计费规则(ChargeRule)等梳理出来,形成如下的领域词汇表。
英文 | 中文 | 解释 |
Charge | 计费 | 运营商对用户的通话进行计费。 |
Charge Record | 计费记录 | 每一次计费,都会生成一条计费记录 |
Session | 通话 | 一次通话,可能会产生多条Charge Record |
Account | 账户 | 用户在运营商开通,账户里有用户套餐信息和金额信息。 |
Charge plan | 计费套餐 | 是套餐这个概念,在计费系统中的映射。 |
BasicChargePlan | 基础套餐 | |
FixedTime ChargePlan | 固定时长套餐 | |
FamilyChargePlan | 家庭套餐 | |
Charge rule | 计费规则 | 不同的套餐对应的是不同的计费规则 |
Duration | 通话时长 | 计费主要是通过Duration计算出来的 |
Calling | 主叫 | 电话的拨打方 |
Called | 被叫 | 电话的接听方 |
Resource | 资源 | 套餐背后的权益(免费通话时间,亲情号码)被统一抽象为可以消耗的资源。 |
这里,大家需要注意的是除了显示概念(即需求里有明确说明的。比如账户这个概念),还有隐式概念。隐士概念有时候隐藏地很深,不能轻易的获得。比如上面标红的Resource这个概念,就是一个非常重要的隐式概念,是我们对套餐背后隐藏的客户权益进行的抽象,因为这个权益是可以被消耗的,所以管它叫资源(Resource)。
在建立领域词汇表的时候,有两个点需要注意一下。
一个是语言是符号,共识即正确。领域词汇表不是某个人的,而是团队的重要共识和资产。它首先需要团队达成共识,容易理解,至于翻译的正确性反而次之。我经常拿Kangaroo(袋鼠)这个单词举例子,库克船长初到澳大利亚的时候,指着袋鼠问土著这是什么,土著很害怕,回答说Kangaroo。其本意是说“我不知道”,虽然 Kangaroo不是袋鼠的本意,但作为一个符号,并不影响我们理解和使用。另外,为了方便理解,我曾经在CRM系统里,把私海这个概念用典型的Chinglish——PrivateSea来表示,而不是用正统的翻译Territory,因为PrivateSea更有利于大家理解。
所以,计费系统领域词汇表也是一样,比如套餐这个词,我在上课的时候,有学员把它翻译成Combo,说实话,我是觉得比Plan更好的。但假如团队已经习惯了用Plan来表示套餐,继续沿用也没问题。
第二个是语言会发展,也会迭代。鉴于统一语言的核心地位,领域词汇表就像是“圣旨”,大家都应该严格遵循。然而哪有一层不变的东西呢,有时候我们会发现新的概念,然后会补充到词汇表中,也可能发现直接的理解出现了问题,变更词汇表。当然,变更的成本是很高的,但如果非变不可的话,我们也要拥抱变化,该改就改。
理解了问题域,也有了领域词汇表,接下来便可以开展我们的设计工作了。设计工作主要包括应用架构设计,领域模型设计,详细设计等。这部分的要点是我们的设计要严格和领域词汇表保持一致。这里我们暂时忽略非功能设计需求。因为在当今基础实施相对比较完善的今天,DFX挑战没有太大,主要挑战还是在业务自身。当然有时候DFX也会变得很关键,只是这里我们先不考虑。
在进行设计之前,我们首先要确定一下应用架构。关于应用架构,这里就不过多着笔了,我其它文章有详细阐述。总体来说我们要遵循整洁架构(Clean Architecture)的思想,具体落地,你可以使用我开源的COLA(https://github.com/alibaba/COLA/)架构。
还是以计费系统为例,假设我们使用的是COLA架构,那么整个应用的形态。
为了对应用架构进行看护,我们可以考虑使用ArchUnit工具,来守护架构的完整性,主要是分层策略和层间以来关系。对于COLA架构,我们可以使用下面方式进行看护。
- public class CleanArchTest {
- @Test
- public void protect_clean_arch() {
- JavaClasses classes = new ClassFileImporter()
- .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
- .importPackages("com.huawei.charging");
-
- layeredArchitecture()
- .layer("adapter").definedBy("com.huawei.charging.adapter")
- .layer("application").definedBy("com.huawei.charging.application")
- .layer("domain").definedBy("com.huawei.charging.domain")
- .layer("infrastructure").definedBy("com.huawei.charging.infrastructure")
- .whereLayer("adapter").mayNotBeAccessedByAnyLayer()
- .whereLayer("domain").mayOnlyBeAccessedByLayers("application", "infrastructure")
- .as("The layer dependencies must be respected")
- .because("we must follow the Clean Architecture principle")
- .check(classes);
- }
- }
确定了应用架构,我们可以开始我们的建模工作了,这里的模型主要是领域模型(或者叫概念模型),包括领域建模和边界划分。补充说明一下,和计费系统关联的还有通话控制系统和账户系统。
这里我们主要关注的是计费系统,对于计费系统而言,它的核心实体无外乎就是Account、ChargePlan、ChargeRule、ChargeRecord等这些,其领域模型如下图所示。其中Account虽然是账户系统中的概念,但对于计费系统也同样重要,所以也应该表达出来。
再复杂的领域,其核心领域模型都不会太复杂,这种图可以用类UML类图来画,但是不要暴露太多的细节。另外,模型中的概念要严格遵循领域词汇表中的定义。如果在设计的时候发现词汇表需要调整,可以返回去修改词汇表。但千万不要分叉,这是保证我们设计一致性的第一关。
领域模型表述了系统的核心概念,但是要落到代码上,还是需要进一步进行详细设计。对于计费系统来说,计费是对账户(Account)的计费,一个账户可以开通多个套餐,每个套餐都拥有一个和多个计费规则等等。基于这样的思路,我们可以设计更加完整的类图,来指导我们写代码。
这是对领域模型的细化,添加了代码实现中需要的一些新元素,比如ChargeContext是用来做Charge的上下文,CompositeChargeRule是对计费规则进行了组合模式设计,对计费规则进行了封装,简化了Account的计费计算。
除了详细的类图设计,有时候对于复杂的业务逻辑,我们也会借助泳道、流程图、状态图、时序图等帮助我们理清业务逻辑。比如想下面这个泳道图实际上是在表达不同套餐之间的优先级关系,很显然家庭套餐优先级最高。
这里要再次强调一下,详细设计和模型设计一样,也要和领域词汇表保持一致。词汇表是圣旨,你可以调整,但不能偏离。
最后,也是最关键的部分。就是我们的代码实现要如何和我们的设计保持一致。首先,让我们先看一下计费系统(charging)的应用架构代码,这里最外层的4个package正是我们COLA提倡的4个层次。
如果去查看应用的DSM(Dependency Structure Matrix,依赖结构矩阵),会得到如下的分析结果,说明Domain的确是应用的核心,因为Domain被Application依赖了69次,被Infrastructure依赖了19次,而Domain自己没有对其他层次的依赖,这是符合我们架构约束要求的。
在保证应用架构一致性的前提下,我们还需要确保我们的设计和代码也是一致的。不妨,让我们整体上看一下我们Domain层的代码。
上图的代码结构说明,在domain里面最要的是account和charge,其中charge里面有chargeplan和chargerule,其中的每一个概念,每一个类都是和我们领域词汇表、设计中的命名保持一致的。这和我们的模型设计和详细设计是一致的。
语言的一致性是基础,对于代码来说,正确的反映模型关系,正确的反映设计意图也非常重要,否则,即使你做到了语言一致,也不算设计和实现一致。在设计部分,我说过计费是针对Account进行的,而且一个Account可以开通多个套餐,那么,我们不妨来看看Account的代码:
- public class Account {
- /**
- * 用户号码
- */
- private long phoneNo;
-
- /**
- * 账户余额
- */
- private Money remaining;
-
- /**
- * 账户所拥有的套餐
- */
- private List<ChargePlan> chargePlanList = new ArrayList<>();;
-
- @Resource
- private AccountGateway accountGateway;
-
- public Account(long phoneNo, Money amount, List<ChargePlan> chargePlanList){
- this.phoneNo = phoneNo;
- this.remaining = amount;
- this.chargePlanList = chargePlanList;
- }
-
- /**
- * 检查账户余额是否足够
- */
- public void checkRemaining() {
- if (remaining.isLessThan(Money.of(0))) {
- throw BizException.of(this.phoneNo + " has insufficient amount");
- }
- }
-
- /**
- * 对账户进行计费
- */
- public List<ChargeRecord> charge(ChargeContext ctx) {
- CompositeChargeRule compositeChargeRule = ChargeRuleFactory.get(chargePlanList);
- List<ChargeRecord> chargeRecords = compositeChargeRule.doCharge(ctx);
- log.debug("Charges: "+ chargeRecords);
-
- //跟新账户系统
- accountGateway.sync(phoneNo, chargeRecords);
- return chargeRecords;
- }
- }
从代码中不难看出,整个计费的入口是在Account的charge(ChargeContext ctx),Account的chargePlanList属性是其拥有的套餐。这个和我们设计中的语义是完全一致的。
再比如FamilyChargePlan(家庭套餐)这个类,前面我们说过套餐背后隐含的是资源(Resource),这个就是通过ChargePlan的getResource( )来体现的。
- public class FamilyChargePlan extends ChargePlan<FamilyChargePlan.FamilyMember> {
-
- public FamilyChargePlan() {
- this.priority = 2;
- }
-
- @Override
- public FamilyMember getResource() {
- return new FamilyMember();
- }
-
- public static class FamilyMember implements Resource{
- private Set<Long> familyMembers = new HashSet<>();
-
- /**
- * Mock here, 真实场景,情亲号码需要从外系统获取的
- */
- public FamilyMember() {
- familyMembers.add(13681874561L);
- familyMembers.add(15921582125L);
- }
-
- public boolean isMember(long phoneNo) {
- return familyMembers.contains(phoneNo);
- }
- }
- }
所以,在落地代码环节,这里的关键主要是两个层次的一致性:
第一层:代码中的语言要和统一语言保持一致,这个是基础,这一点做不到,设计和实现肯定不会一致。
第二层:代码的实现要体现设计意图,也就是你设计中的模型和代码要对应起来,设计中的关系(继承、组合、依赖等)在代码中要不折不扣的表征出来。
为了保证设计和实现的一致,统一语言是关键。只有在统一语言的牵引下,把我们从沟通、文档、设计、代码中的领域概念对齐,并在此基础上,最大程度的保证我们的代码反映出我们的设计意图,才有可能减少设计和实现之间的损耗。
完整计费系统代码案例:
https://github.com/alibaba/COLA/tree/master/samples/charge
如果文章对你有帮助,请支持我的新书: