作者 | 無涯
软件的复杂性,是一个很泛的概念。但是一直都是开发过程中的一个难题,本文旨在探讨如何去从容应对复杂性。
软件的熵增、构造定律
1、熵增定律
熵的概念最早起源于物理学,热力学第二定律(又称“熵增定律”),表明了在自然过程中,一个孤立的系统总是从最初的集中、有序的排列状态,趋向于分散、混乱和无序;当熵达到最大时,系统就会处于一种静寂状态。
软件系统亦是如此, 在软件系统的维护过程中。软件的生命力会从最初的集中、有序的排列状态,逐步趋向复杂、无序状态,直到软件不可维护而被迫下线或重构。
2、构造定律
自然界是如何应对这复杂性?
这在物理中被称为构造定律 (Constructal Law), 是由Adrian Bejan于1995提出的:
For a finite-size system to persist in time (to live), it must evolve in such a way that it provides easier access to the imposed currents that flow through it.
对于一个有限大小的持续活动的系统,它必须以这种方式发展演进:它提供了一种在自身元素之间更容易访问的流动方式。
这个定理在自然界中比比皆是,最典型的比如水循环系统,海水蒸发到大气,下雨时降落在地面,一部分渗入地面流入江河,一部分继续蒸发,不断循环。这种自发性质的设计反映了这一趋势:他们允许实体或事物更容易地流动 - 以最少的能量消耗到达最远的地方,就连街道和道路这些人为地构建物体,往往也是有排序的模式,以提供最大的灵活性。
如何应对软件系统的复杂性?
软件系统的复杂性往往是被低估的。复杂越高,开发人员会感到不安。对其的理解认知负荷代价就越高,我们就更不快乐。真正的挑战是在构建我们的系统时要保持其有序以及工程师的生产方式。Ousterhout教授在《软件设计的哲学》书中提到:软件设计的最大目标,就是降低复杂度(complexity)。就是设计符合业务的构造定律的演进方式,一种可以以最小的开发维护成本, 使业务更快更好的流动发展的方式。
软件复杂性来自哪里, 如何解决?
1、不确定性的来源
1、业务的不确定性
2、技术的不确定性
3、人员流动的不确定性
2、如何面对不确定性
面对外部的确定性,转化为内核的确定性。面对外部的不确定性,找到稳定的内核基础。
1、专注问题域
当下互联网发展速度是迅猛的, 软件的形态也在不断的变化演进。面对未来的业务及变化,横向业务与纵向业务的发展都是不确定性的。Robert C. Martin提到的BDUF,永远不要想着在开始就设计好了全部的事情(big design up front),一定要避免过度设计。除非能够十分确认的可预见变化, 业务边界,否则专注解决当前1-2年内业务变化设计, 讲好当下的用户故事,专注解决眼前的问题域。 面向不确定设计,增量敏捷开发。
2、确认稳定的系统内核
随着业务的变化、系统设计也要持续演进升级。没有一开始就完美的架构, 好的架构设计一定演化来的,不是一开始就设计出来的。
一个健康公司的成长,业务横向、纵向会发展的会越来越复杂,支持业务的系统也一定会越来越复杂。
系统演进过程中的成本,会受到最开始的设计、系统最初的内核影响的。面对外部业务的不确定性, 技术的不确定性,外部依赖的不确定性。一个稳定的内核应该尽量把外部的不确定性隔离。
- 业务与技术的隔离。以业务为核心,分离业务复杂度和技术复杂度。
- 内部系统与外部依赖的隔离
- 系统中常变部分与不常变部分的隔离
- 隔离复杂性(把复杂性的部分隔离在一个模块,尽量不与其他模块互动)
3、无序性
系统和代码像多个线团一样散落一地一样,混乱不堪,毫无头绪。
4、如何面对无序性
1、统一认知(秩序化)
2、系统清晰明了的结构(结构化)
3、业务开发流程化(标准化)
注:这里说的流程化并非指必须使用类似BPM的流程编排系统,而是指对于一个需求,业务开发有一定的顺序, 有规划的先做一部分事情,开发哪一个模块再去做剩下的工作,是可以流程化的。
5、规模
业务规模的膨胀以及开发团队规模的膨胀,都会带来系统的复杂性提升。
6、如何面对规模膨胀带来的复杂性
1、业务隔离, 分而治之
2、专注产品核心竞争力的发展
3、场景分层
1、关键场景
投入更多的开发、测试资源、业务资源(比如单元测试覆盖率在90%以上)在关键场景。
2、普通场景
更快,更低成本、更少资源投入地完成普通场景的迭代
7、认知成本
是指开发人员需要多少知识才能完成一项任务。
在引入新的变化时,要考虑到带来的好处是否大于系统认知成本的提升,比如:之前提到的BPM流程编排引擎,如果对系统带来的好处不够多也是增加认知成本的一种。
不合适的设计模式也是增加认知成本的一种,前台同学吐槽的中台架构比较高的学习成本, 也是认知成本的一种。
8、如何降低认知成本
1、系统与现实业务更自然真实的映射,对业务抽象建模
软件工程师实际上只在做一件事情,即把现实中的问题搬到计算机上,通过信息化提升生产力。
2、代码的含义清晰,不模糊
3、代码的整洁度
4、系统的有序性, 架构清晰
5、避免过度设计
6、减少复杂、重复概念, 降低学习成本
7、谨慎引入会带来系统复杂性的变化
应对复杂性的利器
1、领域驱动设计——DDD
DDD是把业务模型翻译成系统架构设计的一种方式, 领域模型是对业务模型的抽象。
不是所有的业务服务都合适做DDD架构,DDD合适产品化,可持续迭代,业务逻辑足够复杂的业务系统,小规模的系统与简单业务不适合使用,毕竟相比较于MVC架构,认知成本和开发成本会大不少。但是DDD里面的一些战略思想我认为还是较为通用的。
1、对通用语言的提炼和推广
清晰语言认知, 比如之前在详情装修系统中:
ItemTemplate : 表示当前具体的装修页面
ItemDescTemplate、Template,两个都能表示模板概概念
刚开始接触这块的时候比较难理解这一块逻辑,之后在负责设计详情编辑器大融合这个项目时第一件事就是团队内先重新统一认知。
- 装修页面统一使用 —— Page概念
- 模板统一使用 —— Template概念
不将模板和页面的概念糅杂在一起,含糊不清,避免重复和混乱的概念定义。
2、贫血模型和充血模型
1)贫血模型
贫血模型的基本特征是:它第一眼看起来还真像这么回事儿。项目中有许多对象,它们的命名都是根据领域模型来的。然而当你真正检视这些对象的行为时,会发现它们基本上没有任何行为,仅仅是一堆getter/setter方法。
这些贫血对象在设计之初就被定义为只能包含数据,不能加入领域逻辑;所有的业务逻辑是放在所谓的业务层(xxxService, xxxManager对象中),需要使用这些模型来传递数据。
@Data public class Person { /** * 姓名 */ private String name; /** * 年龄 */ private Integer age; /** * 生日 */ private Date birthday; /** * 当前状态 */ private Stauts stauts; }
public class PersonServiceImpl implements PersonService { public void sleep(Person person) { person.setStauts(SleepStatus.get()); } public void setAgeByBirth(Person person) { Date birthday = person.getBirthday(); if (currentDate.before(birthday)) { throw new IllegalArgumentException("The birthday is before Now,It's unbelievable"); } int yearNow = cal.get(Calendar.YEAR); int dayBirth = bir.get(Calendar.DAY_OF_MONTH); /*大概计算, 忽略月份等,年龄是当前年减去出生年*/ int age = yearNow - yearBirth; person.setAge(age); } } }
public class WorkServiceImpl implements WorkService{ public void code(Person person) { person.setStauts(CodeStatus.get()); } }
这一段代码就是贫血对象的处理过程,Person类, 通过PersonService、WorkingService去控制Person的行为,第一眼看起来像是没什么问题,但是真正去思考整个流程。WorkingService, PersonService到底是什么样的存在?与真实世界逻辑相比, 过于抽象。基于贫血模型的传统开发模式,将数据与业务逻辑分离,违反了 OOP 的封装特性,实际上是一种面向过程的编程风格。但是,现在几乎所有的 Web 项目,都是基于这种贫血模型的开发模式,甚至连 Java Spring 框架的官方 demo,都是按照这种开发模式来编写的。
面向过程编程风格有种种弊端,比如,数据和操作分离之后,数据本身的操作就不受限制了。任何代码都可以随意修改数据。
2)充血模型
充血模型是一种有行为的模型,模型中状态的改变只能通过模型上的行为来触发,同时所有的约束及业务逻辑都收敛在模型上。
@Data public class Person extends Entity { /** * 姓名 */ private String name; /** * 年龄 */ private Integer age; /** * 生日 */ private Date birthday; /** * 当前状态 */ private Stauts stauts; public void code() { this.setStauts(CodeStatus.get()); } public void sleep() { this.setStauts(SleepStatus.get()); } public void setAgeByBirth() { Date birthday = this.getBirthday(); Calendar currentDate = Calendar.getInstance(); if (currentDate.before(birthday)) { throw new IllegalArgumentException("The birthday is before Now,It's unbelievable"); } int yearNow = currentDate.get(Calendar.YEAR); int yearBirth = birthday.getYear(); /*粗略计算, 忽略月份等,年龄是当前年减去出生年*/ int age = yearNow - yearBirth; this.setAge(age); } }
3)贫血模型和充血模型的区别
/** * 贫血模型 */ public class Client { @Resource private PersonService personService; @Resource private WorkService workService; public void test() { Person person = new Person(); personService.setAgeByBirth(person); workService.code(person); personService.sleep(person); } } /** * 充血模型 */ public class Client { public void test() { Person person = new Person(); person.setAgeByBirth(); person.code(); person.sleep(); } }
上面两段代码很明显第二段的认知成本更低, 这在满是Service,Manage 的系统下更为明显,Person的行为交由自己去管理, 而不是交给各种Service去管理。
贫血模型是事务脚本模式
贫血模型相对简单,模型上只有数据没有行为,业务逻辑由xxxService、xxxManger等类来承载,相对来说比较直接,针对简单的业务,贫血模型可以快速的完成交付,但后期的维护成本比较高,很容易变成我们所说的面条代码。
充血模型是领域模型模式
充血模型的实现相对比较复杂,但所有逻辑都由各自的类来负责,职责比较清晰,方便后期的迭代与维护。
面向对象设计主张将数据和行为绑定在一起也就是充血模型,而贫血领域模型则更像是一种面向过程设计,很多人认为这些贫血领域对象是真正的对象,从而彻底误解了面向对象设计的涵义。
Martin Fowler 曾经和 Eric Evans 聊天谈到它时,都觉得这个模型似乎越来越流行了。作为领域模型的推广者,他们觉得这不是一件好事,极力反对这种做法。
贫血领域模型的根本问题是,它引入了领域模型设计的所有成本,却没有带来任何好处。最主要的成本是将对象映射到数据库中,从而产生了一个O/R(对象关系)映射层。
只有当你充分使用了面向对象设计来组织复杂的业务逻辑后,这一成本才能够被抵消。如果将所有行为都写入到Service对象,那最终你会得到一组事务处理脚本,从而错过了领域模型带来的好处。而且当业务足够复杂时, 你将会得到一堆爆炸的事务处理脚本。
3、对业务的理解和抽象
限定业务边界,对业务进行与现实更自然的理解和抽象,数据模型与业务模型隔离,把业务映射成为领域模型沉淀在系统中。
4、结构与防腐层
User Interfaces负责对外交互, 提供对外远程接口
application应用程序执行其任务所需的代码。它协调域层对象以执行实际任务。该层适用于跨事务、安全检查和高级日志记录。
domain负责表达业务概念。对业务的分解,抽象,建模 。业务逻辑、程序的核心。防腐层接口放在这里。
infrastucture为其他层提供通用的技术能力。如repository的implementation(ibatis,hibernate, nosql),中间件服务等anti-corruption layer的implementation 防腐层实现放在这里。
防腐层的作用:封装三方服务。隔离内部系统对外部的依赖。
5、让隐性概念显性化
文档与注释可能会失去实时性(文档、注释没有人持续维护),但是线上生产代码是业务逻辑最真实的展现,减少代码中模糊的地方,让业务逻辑显性化体现出来,提升代码清晰度。
if (itemDO != null && MapUtils.isNotEmpty(itemDO.getFeatures()) && itemDO.getFeatures().containsKey(ITEM_PC_DESCRIPTION_PUSH)) { itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_PC_TEMPLATEID, "" + templateId); itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_SELL_PC_PUSH, "" + pcContent.hashCode()); } else { itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_PC_TEMPLATEID, "" + templateId); itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_WL_TEMPLATEID, "" + templateId); itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_SELL_PC_PUSH, "" + pcContent.hashCode()); itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_SELL_WL_PUSH, "" + content.hashCode()); }
比如这一段代码就把判断里的业务逻辑隐藏了起来,这段代码其实的业务逻辑是这样, 判断商品是否有PC装修内容。如果有做一些操作, 如果没有做一些操作,将hasPCContent 这个逻辑表现出来, 一眼就能看出来大概的业务逻辑,让业务逻辑显现化,能让代码更清晰。可以改写成这样:
boolean hasPCContent = itemDO != null && MapUtils.isNotEmpty(itemDO.getFeatures()) && itemDO.getFeatures().containsKey(ITEM_PC_DESCRIPTION_PUSH); if (hasPCContent) { itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_PC_TEMPLATEID, "" + templateId); itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_SELL_PC_PUSH, "" + pcContent.hashCode()); } else { itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_PC_TEMPLATEID, "" + templateId); itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_WL_TEMPLATEID, "" + templateId); itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_SELL_PC_PUSH, "" + pcContent.hashCode()); itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_SELL_WL_PUSH, "" + content.hashCode()); }
2、简单设计原则——《Clean Code》
1、保持系统最大可测试
只要系统可测试并且越丰富的单元测试越会导向保持类短小且目的单一的设计方案,遵循单一职责的类,测试起来比较简单。
遵循有关编写测试并持续运行测试的简单、明确规则,系统就会更贴近OO低耦合度,高内聚度的目标。编写测试越多,就越会遵循DIP之类的规则,编写最大可测试可改进并走向更好的系统设计。
2、避免重复
重复是拥有良好设计系统的大敌。它代表着额外的工作、额外的风险和额外且不必要的复杂度。除了雷同的代码,功能类似的方法也可以进行包装减少重复,“小规模复用”可大量降低系统复杂性。要想实现大规模复用,必须理解如何实现小规模复用。
共性的抽取也会使代码更好的符合单一职责原则。
3、更清晰的表达开发者的意图
软件项目的主要成本在于长期维护,当系统变得越来越复杂,开发者就需要越来越多的时间来理解他,而且也极有可能误解。
所以作者需要将代码写的更清晰:选用好名称、保持函数和类的短小、采用标准命名法、标准的设计模式名,编写良好的单元测试。用心是最珍贵的资源。
4、尽可能减少类和方法
如果过度使用以上原则,为了保持类的函数短小,我们可能会造出太多细小的类和方法。所以这条规则也主张函数和类的数量要少。
如应当为每个类创建接口、字段和行为必须切分到数据类和行为类中。应该抵制这类教条,采用更实用的手段。目标是在保持函数和类短小的同时,保持系统的短小精悍。不过这是优先级最低的一条。更重要的是测试,消除重复和清晰表达。
最后
总而言之,做业务开发其实一点也不简单,面对不确定性的问题域,复杂的业务变化,如何更好的理解和抽象业务,如何更优雅的应对复杂性,一直都是软件开发的一个难题。
在对抗软件熵增,寻找对抗软件复杂性,符合业务的构造定律的演进方式,我们一直都在路上。
参考
[1] 《Domain-Driven Design》 :https://book.douban.com/subject/1629512/
[2] 《Implementing Domain-Driven Design》 :https://book.douban.com/subject/25844633/
[3] 《Clean Code》:https://book.douban.com/subject/4199741/
[4] 《A Philosophy of Software Design》 :https://book.douban.com/subject/30218046/