通识规律
在经典的设计原则之上,加上自己的一些理解,最终将设计原则归类成三个方面:「职责分解」、「层次抽象」和「变化扩展」。
1 职责分解
对职责分离有两点体会:一个是「你拥有什么信息就应该承担怎样的职责」;另一个是「一个类只做一件事」。其中第一点出自GRASP的「信息专家原则」,当我们在讨论是否是贫血模型时,你可以用这个原则去检验,如果一类中的成员属性操作放在另外一类中,大概率是不符合信息专家原则,举一个简单的例子,比如要计算订单的金额,那么这个计算方法应该是在订单类中,而不是放在另外一个类中,因为订单类中有订单的单价和数量。
另一点是出自于SOLID的单一职责,它的原意是一个类只有一个变化的原因,一个类专注于做一件事的好处是可提升复用性和减少依赖,反之一个类耦合了不同的操作,修改的频次就会变多,尽量少改动稳定的部分,在系统稳定性中有一个共性认知:故障的发生大概率与最近的发布有关。
职责分解最大的挑战是一个职责到底要划分到多细或多粗,很遗憾没有量化的标准,只能说只做一件事或者只有一个变化这样大的指导原则,更多地是我们在实践中总结出来的经验,比如「变与不变分离」、「读写分离」、「配置域与执行域分离」。
2 层次抽象
层次抽象是利用已发现的规律,让往后的开发变得简单,当我们在一线开发中,你会发现有一些规律,比如在日常开发中,发现开发主要涉及到与前端交互、业务逻辑处理和数据存储,这样就可以分成三层:「视图层」、「业务逻辑层」和「数据访问层」。
高层次依赖低层次,最高层次越具象,也会越简单,举一个例子,在传统Servlet开发中,一般的步骤是获取参数信息并转成业务层的对象,再进行业务处理,虽然不同的业务处理逻辑是不一样的,但参数获取是具有共性的操作,在SpringMVC中,我们可以直接定义POJO去映射参数,可以不用使用HttpServlet底层的操作去获取参数,这就是一种典型的层次抽象。
「层次特性」是复杂系统的固有属性,需要我们不断去探索,分层的确能极大地降低认知复杂度,相当是站在巨人的肩膀上看问题,利用已发现的规律办事效率会高很多,如上文提到的财务核算,做多了就会发现就那几种模式,当你没有摸清里面的规律时,觉会显得很零散。
3 变化扩展
软件如果没有变化,也就不需要所谓的设计原则,一次性工程怎么快就怎么来,而现实中遇到最多的现象是需求不断变化。变化扩展的挑战不在于技术,而是在于「怎么认知到哪里有变化」。常见变化扩展的技术有:配置项、接口、抽象类、拦截器、SPI、插件等,这些都是具体的解决手段,它们并不复杂,复杂在于哪里会有变化,这个是最难的。
软件设计的 6 条经验
在经典的设计原则之上,结合实践过程中的得与失,总结了以下 6 条设计经验,为了更容易理解,下面的案例选用常用的开源框架剖析设计思想,方便与大家产生共鸣。
1 在多变中找不变,模板方法治之
当一个业务有多个场景,并且不同的场景处理既有共性的地方,也有差异性的地方时,此时最容易想到的方法是用「模板方法」固定共性的逻辑,差异性的逻辑放到子类中实现。
在开源框架中,我们经常见到这样的设计思想,比如在 SpringMVC 中查找 Handler 的过程,不同的场景查找逻辑不一样,最常见的是 RequestMapping 方式查找,它是在 HandMapping 接口类中定义 getHandler 方法。
然后在抽象类 AbstractHandlerMapping 中定义模板方法,抽象方法又交由子类去实现。
在 MyBatis 框架中,Executor 定义了增删改查等方法,具体实现有如单条命令执行、批量命令执行等,模板方法定义在 BaseExecutor 类中,类结构继承关系如下所示,这也是一种最简单的三层设计结构:接口类、抽象类、子类。
2 涉及业务链路查询和复杂组装的,查询与命令职责分离治之
有一类业务,它涉及「查询」与「组装」两个操作,比如 Spring 中有 Bean 查询操作,与之对应的有 Bean 创建操作,这两个职责是不一样的,也有的称之为「读写分离」或者「查询与命令分离」,从本质上讲,它也遵循了接口单一职责。
再比如 SpringMVC 中,Handler 有查找的操作,对应也有 Handler 构建的操作,它也是分在两个不同的类中实现的,一般信息构建操作是在初始化过程中完成的,因此组装 Handler 的逻辑是实现了 InitializingBean 的 afterPropertiesSet()方法。
上面的两个案例,是命令复杂、查询简单的例子,还有一类场景是命令简单、查询复杂的例子,比如在 CQRS 模式中,命令执行之后,结果会通过某种机制从一个数据源同步到另外一个数据源做聚合分析,查询从分析结果中获取数据,典型的例子是数据从数据库同步到搜索引擎中,查询从搜索引擎中获取数据。
至此,在模板方法的基础之上,又增加了「查询与命令分离」的设计原则,类结构继承关系也随之发生了变化。
3 有面向用户配置的,配置域与执行域分离治之
有些业务前台用户能够直接配置操作的,比如在 SpringMVC 中,我们配置一个 Controller 的请求可以配置不同的属性,其中 RequestMapping 是直接面向用户视角的配置操作,在配置域的内容,是与现实操作一一映射的,RequestMapping 对应有一个类叫 RequestMappingInfo,然而在执行域,此时它就不需要配置域中的那么多信息,执行过程只要对象和方法的信息即可,对应有一个类中 HandlerMethod,由此可见,配置域和执行域两个抽象的视角是不一样的,一个是现实世界的直接映射,一个是偏底层执行。
RequestMappingHandlerMapping 类结构继承关系如下图所示。
再比如在 Spring 中,允许用户配置自定义的编辑器、BeanPostProcessor 处理器,也是由一个单独的接口类 ConfigurableBeanFactory 表达的。
这样的例子还有很多,比如 BeanDefinition 是面向配置域的,Bean 是执行域的,我们在定义 Bean 是有很多的属性,这些属性信息在 BeanDefinition 类中定义,而在执行过程中会生成一个对象,本质上是一个 Object。
4 业务有多样变化的,封装变化治之
应对变化的方法有很多,难的是要感知到变化并且封装好变化,比如 Spring Bean 实例化后进行初始化,在此期间就有很多操作,如常见的 Bean 依赖注入、AOP 代理等,Spring 抽象出 BeanPostProcessor 扩展类,在 Bean 初始化前后做一些额外的扩展工作。
设计扩展点时一定要把握好度,粒度过细则扩展点数量非常多,在 Spring 中设计就比较好,对于开发而言,有两个时机有明显的扩展诉求,一个是在 Bean 扫描时,可以允许用户自定义 Bean,此时有 BeanFactoryPostProcessor 扩展接口;另一个是在 Bean 初始化时的扩展,对应有 BeanPostProcessor 扩展接口。不管是 Spring 内部使用,还是外部开发,都是使用同样的扩展。
5 业务流程型操作,责任链治之
业务型操作,有明显的流程痕迹,比如前置检查、协议组装、接口调用等,节点与节点之间就构成了一条链条,只不过平时写代码时我们是放在一个大的流程中实现的。在 HttpClient 中,对于请求,我们有不同的操作流程,比如重试、缓存、重定向、调用 socket 等操作,HttpClient 使用责任链的模式。
链条上的每个节点都是独立操作的,方便扩展,责任链核心是链的构建和节点设计,这给平时写流程型业务代码提供了一种新的思路,大型系统中,有流程引擎,本质来讲它也是一条链,一个节点做完之后下一个节点继续做,思想上大同小异。
6 复杂系统场景,抽象治之
抽象是应对复杂场景的重要方法,这一点我们并不怀疑,最难的是要抽象什么去刻画业务,比如 AOP 切面编程,站在用户视角,就是告诉他哪些类、哪些方法需要被增强什么共性业务逻辑,比如日志切面类、权限切面类等,AOP 对它的抽象是「对指定的类和方法以某种方式织入特定的共性逻辑」。其中指定的类和方法抽象成切点,以某种方式抽象成通知,此时,你会发现它抽象出了一些概念出来,如切面、切点、通知。因此,对复杂业务场景,一定要有一套抽象的元数据去表征它,也即是领域模型,最高明的建模方法是下定义的方法,用一句简明的话讲清楚业务的结构和功能。
系统是元素和元素间以某种关联关系构成的一种结构,复杂系统是构成元素更多、关联关系更复杂,核心还是要找到「结构」,这种结构也即是领域模型,好的领域模型可遇而不可求,是要花大量的时间去探寻它,突然有一天在你脑海里灵光一现就出来了,这种感觉很奇妙,因此,领域建模是非常依赖经验而非方法。