实战总结|复杂系统设计原则与案例

实战总结|复杂系统设计原则与案例
2023年07月07日 19:01 阿里云云栖号

作 者 | 不拔

导语:本文主要讲述了应对复杂性的一些原则和经验,通过实际案例解构设计思想,个人认为好的设计是体现在「职责分离」、「抽象分层」和「变化扩展」上,在类的结构设计上尤其要花心思去想,如「变与不变分离」、「配置域与执行域分离」、「查询与命令分离」。

一 复杂是软件的本质属性

1.1 复杂是软件的本质属性

正如Brooks所言,软件复杂性是软件固有的属性,这种固有的复杂性主要由4个方面的原因造成的:

  • 问题域的复杂性

  • 管理开发过程的复杂性

  • 随处可变的灵活性

  • 描绘离散系统行为的问题

上面每一个方面都有极大的挑战,以「问题域的复杂性」为例,现在我们的大型系统中,动不动就几十个应用,组合在一起就是一个复杂的系统,而每个人只负责其中一小部分,想要了解系统全部的运行状况是很难的,哪怕一个子系统,它包含的业务规则就巨多,因此说软件复杂是它的本质属性。

1.2 认知复杂度是影响软件复杂性的重要因素

影响软件复杂度的因素有很多,其中「认知复杂度」占据着很重要的困素。一提到复杂性,我们脑海里会浮出各种各样的印象:应用数多、代码行数超过百万级、业务规则复杂等,这些复杂度从本质上来看是认知复杂度超过了正常人的认知范围,比如看百万行级的代码与看100行代码相比,维护10个应用与维护1个应用相比,两个复杂度不是在同一个数量级上。

认知复杂度是软件的本质复杂度,从根本上规蔽不了,只能去理解、消化吸收,我们能做的是在理解的基础上去发现共性的「规律」,将这些「规律」抽象出来,让应用层开发变得简单。举一个印象最深的例子,当时做财务核算时,最开始面对的业务认知复杂度非常高,它关联电商交易、支付、营销、结算、资金等领域,依赖业务将近100张离线表,除了要理解电商业务链路外,还要站在财务视角把这些数据有序地组织起来,复杂度一下子就上升上来了,新人至少要花3个月的时间去消化这些业务知识。当进来做了一些需求开发后,慢慢发现了一些规律,利用发现的这些规律有助于提升需求沟通、开发的效率。

二 应对复杂性的设计方法

2.1 把握规律是应对复杂性的根本方法

「规律」是日常开发中发现有共性的地方,往后再遇到可以同样的问题可加速解决的效率。软件复杂度伴随着软件研发开始就产生的问题,「设计原则」就是应对复杂性过程中总结出来的规律。常见的设计原则有SOLID、GRASP、KISS、分层等,这些设计原则指导我们在面对复杂系统时应该如何去设计。原则的东西,我们总觉得有些虚无飘缈,感觉理解了,又好像落不了地,个人经验是建立自己的认知体系,在实践中修正认知。

2.2 通识规律

在经典的设计原则之上,加上自己的一些理解,最终将设计原则归类成三个方面:「职责分解」、「层次抽象」和「变化扩展」。

2.2.1 职责分解

对职责分离有两点体会:一个是「你拥有什么信息就应该承担怎样的职责」;另一个是「一个类只做一件事」。其中第一点出自GRASP的「信息专家原则」,当我们在讨论是否是贫血模型时,你可以用这个原则去检验,如果一类中的成员属性操作放在另外一类中,大概率是不符合信息专家原则,举一个简单的例子,比如要计算订单的金额,那么这个计算方法应该是在订单类中,而不是放在另外一个类中,因为订单类中有订单的单价和数量。

另一点是出自于SOLID的单一职责,它的原意是一个类只有一个变化的原因,一个类专注于做一件事的好处是可提升复用性和减少依赖,反之一个类耦合了不同的操作,修改的频次就会变多,尽量少改动稳定的部分,在系统稳定性中有一个共性认知:故障的发生大概率与最近的发布有关。

职责分解最大的挑战是一个职责到底要划分到多细或多粗,很遗憾没有量化的标准,只能说只做一件事或者只有一个变化这样大的指导原则,更多地是我们在实践中总结出来的经验,比如「变与不变分离」、「读写分离」、「配置域与执行域分离」。

2.2.2 层次抽象

层次抽象是利用已发现的规律,让往后的开发变得简单,当我们在一线开发中,你会发现有一些规律,比如在日常开发中,发现开发主要涉及到与前端交互、业务逻辑处理和数据存储,这样就可以分成三层:「视图层」、「业务逻辑层」和「数据访问层」。

高层次依赖低层次,最高层次越具象,也会越简单,举一个例子,在传统Servlet开发中,一般的步骤是获取参数信息并转成业务层的对象,再进行业务处理,虽然不同的业务处理逻辑是不一样的,但参数获取是具有共性的操作,在SpringMVC中,我们可以直接定义POJO去映射参数,可以不用使用HttpServlet底层的操作去获取参数,这就是一种典型的层次抽象。

「层次特性」是复杂系统的固有属性,需要我们不断去探索,分层的确能极大地降低认知复杂度,相当是站在巨人的肩膀上看问题,利用已发现的规律办事效率会高很多,如上文提到的财务核算,做多了就会发现就那几种模式,当你没有摸清里面的规律时,觉会显得很零散。

2.2.3 变化扩展

软件如果没有变化,也就不需要所谓的设计原则,一次性工程怎么快就怎么来,而现实中遇到最多的现象是需求不断变化。变化扩展的挑战不在于技术,而是在于「怎么认知到哪里有变化」。常见变化扩展的技术有:配置项、接口、抽象类、拦截器、SPI、插件等,这些都是具体的解决手段,它们并不复杂,复杂在于哪里会有变化,这个是最难的。

认识到多少变化,它取决于认识的宽度,看到多少内容会影响到系统设计,比如在SpringMVC中,我们最高常操作的是定义一个Controller,再在方法上写一个RequestMapping注解,但在实际中,它还有另外的写法,如实现Controller接口,正是有不同的场景和类型,处理上还有差别,此时就会有变化扩展的诉求。

2.3 软件设计的6条经验

在经典的设计原则之上,结合实践过程中的得与失,总结了以下6条设计经验,为了更容易理解,下面的案例选用常用的开源框架剖析设计思想,方便与大家产生共鸣。

2.3.1 在多变中找不变,模板方法治之

当一个业务有多个场景,并且不同的场景处理既有共性的地方,也有差异性的地方时,此时最容易想到的方法是用「模板方法」固定共性的逻辑,差异性的逻辑放到子类中实现。

在开源框架中,我们经常见到这样的设计思想,比如在SpringMVC中查找Handler的过程,不同的场景查找逻辑不一样,最常见的是RequestMapping方式查找,它是在HandMapping接口类中定义getHandler方法。

public interface HandlerMapping {   HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception;}

然后在抽象类AbstractHandlerMapping中定义模板方法,抽象方法又交由子类去实现。

public final HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {    // 抽象方法,交由具体的子类实现    Object handler = getHandlerInternal(request);    IF (handler == null) {        handler = getDefaultHandler();    }    if (handler == null) {        return null;    }    // 省略部分代码    HandlerExecutionChain executionChain = getHandlerExecutionChain(handler, request);    // 省略部分代码    return executionChain;}

在MyBatis框架中,Executor定义了增删改查等方法,具体实现有如单条命令执行、批量命令执行等,模板方法定义在BaseExecutor类中,类结构继承关系如下所示,这也是一种最简单的三层设计结构:接口类、抽象类、子类。

2.3.2 涉及业务链路查询和复杂组装的,查询与命令职责分离治之

有一类业务,它涉及「查询」与「组装」两个操作,比如Spring中有Bean查询操作,与之对应的有Bean创建操作,这两个职责是不一样的,也有的称之为「读写分离」或者「查询与命令分离」,从本质上讲,它也遵循了接口单一职责。

再比如SpringMVC中,Handler有查找的操作,对应也有Handler构建的操作,它也是分在两个不同的类中实现的,一般信息构建操作是在初始化过程中完成的,因此组装Handler的逻辑是实现了InitializingBean的afterPropertiesSet()方法。

上面的两个案例,是命令复杂、查询简单的例子,还有一类场景是命令简单、查询复杂的例子,比如在CQRS模式中,命令执行之后,结果会通过某种机制从一个数据源同步到另外一个数据源做聚合分析,查询从分析结果中获取数据,典型的例子是数据从数据库同步到搜索引擎中,查询从搜索引擎中获取数据。

至此,在模板方法的基础之上,又增加了「查询与命令分离」的设计原则,类结构继承关系也随之发生了变化。

2.3.3 有面向用户配置的,配置域与执行域分离治之

有些业务前台用户能够直接配置操作的,比如在SpringMVC中,我们配置一个Controller的请求可以配置不同的属性,其中RequestMapping是直接面向用户视角的配置操作,在配置域的内容,是与现实操作一一映射的,RequestMapping对应有一个类叫RequestMappingInfo,然而在执行域,此时它就不需要配置域中的那么多信息,执行过程只要对象和方法的信息即可,对应有一个类中HandlerMethod,由此可见,配置域和执行域两个抽象的视角是不一样的,一个是现实世界的直接映射,一个是偏底层执行。

@RestControllerpublic class UserController {    @RequestMapping(value = "/acquire", method = RequestMethod.GET)    public User getUser(@RequestParam("name") String name, @RequestParam("age") Integer age) {        return null;    }}

RequestMappingHandlerMapping类结构继承关系如下图所示。

再比如在Spring中,允许用户配置自定义的编辑器、BeanPostProcessor处理器,也是由一个单独的接口类ConfigurableBeanFactory表达的。

这样的例子还有很多,比如BeanDefinition是面向配置域的,Bean是执行域的,我们在定义Bean是有很多的属性,这些属性信息在BeanDefinition类中定义,而在执行过程中会生成一个对象,本质上是一个Object。

2.3.4 业务有多样变化的,封装变化治之

应对变化的方法有很多,难的是要感知到变化并且封装好变化,比如Spring Bean实例化后进行初始化,在此期间就有很多操作,如常见的Bean依赖注入、AOP代理等,Spring抽象出BeanPostProcessor扩展类,在Bean初始化前后做一些额外的扩展工作。

public interface BeanPostProcessor {    // 初始化前的操作    Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {        return bean;    }    // 初始化后的操作    Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {        return bean;    }}

设计扩展点时一定要把握好度,粒度过细则扩展点数量非常多,在Spring中设计就比较好,对于开发而言,有两个时机有明显的扩展诉求,一个是在Bean扫描时,可以允许用户自定义Bean,此时有BeanFactoryPostProcessor扩展接口;另一个是在Bean初始化时的扩展,对应有BeanPostProcessor扩展接口。不管是Spring内部使用,还是外部开发,都是使用同样的扩展。

2.3.5 业务流程型操作,责任链治之

业务型操作,有明显的流程痕迹,比如前置检查、协议组装、接口调用等,节点与节点之间就构成了一条链条,只不过平时写代码时我们是放在一个大的流程中实现的。在HttpClient中,对于请求,我们有不同的操作流程,比如重试、缓存、重定向、调用socket等操作,HttpClient使用责任链的模式。

链条上的每个节点都是独立操作的,方便扩展,责任链核心是链的构建和节点设计,这给平时写流程型业务代码提供了一种新的思路,大型系统中,有流程引擎,本质来讲它也是一条链,一个节点做完之后下一个节点继续做,思想上大同小异。

2.3.6 复杂系统场景,抽象治之

抽象是应对复杂场景的重要方法,这一点我们并不怀疑,最难的是要抽象什么去刻画业务,比如AOP切面编程,站在用户视角,就是告诉他哪些类、哪些方法需要被增强什么共性业务逻辑,比如日志切面类、权限切面类等,AOP对它的抽象是「对指定的类和方法以某种方式织入特定的共性逻辑」。其中指定的类和方法抽象成切点,以某种方式抽象成通知,此时,你会发现它抽象出了一些概念出来,如切面、切点、通知。因此,对复杂业务场景,一定要有一套抽象的元数据去表征它,也即是领域模型,最高明的建模方法是下定义的方法,用一句简明的话讲清楚业务的结构和功能。

系统是元素和元素间以某种关联关系构成的一种结构,复杂系统是构成元素更多、关联关系更复杂,核心还是要找到「结构」,这种结构也即是领域模型,好的领域模型可遇而不可求,是要花大量的时间去探寻它,突然有一天在你脑海里灵光一现就出来了,这种感觉很奇妙,因此,领域建模是非常依赖经验而非方法。

三 框架设计案例分析

有了上面的分析基础,再以SpringMVC DispatcherServlet为例,分析它的设计思想,它的类图结构如下图所示。

SpringMVC核心是对HttpServlet的封装,在HttpServlet中有两个重要的方法,一个是init()方法,一个是service()方法,init()方法是Servlet初始化时回调的方法,service()是处理请求时回调的方法。

在HttpServletBean类中,它重写了HttpServlet init()方法,主要完成SpringMVC子容器初始化的过程。FrameworkServlet类主要重写了service()方法,处理实际的如GET、POST请求,但它只是定义了一个抽象的doService()方法,实际处理过程是在DispatcherServlet类中,分发的Servlet是拦截所有的请求,然后匹配到目标Handler执行。

在DispatcherServlet类的设计中,体现出了「职责分离」和「变化扩展」的设计思想,init初始化与service执行分离,拦截器支持变化扩展。

上面列举的几个框架,它们都是解决了一些平常的问题,但不影响它们优秀的设计,如MyBatis、Spring、SpringMVC、HttpClient,它们并没有在一个大类中实现各种各样的功能,而是切分放在不同的类中,并且通过多层继承关系组合在一起,不管是可读性上,还是可扩展性上都非常不错。

四 认知是解决复杂性的基石

在认知面前,所有的方法和工具都是苍白的,就像一个人想不劳而获一样,总想找一种万能的方法解决所有的问题,而事实并没有,还得靠在实践中解决问题。复杂性也是同样的问题,没有万能的方法解决它,只有原则作为指导,而具体要怎么去做,还是得身体力行。当我们不理解框架为什么要设计得这么复杂时,大概率是我们对应用的场景了解还不够全面。

4.1 业务认知

当大家第一次去看Spring Bean扫描的逻辑时,它的逻辑是很复杂的,如果让我们自己去实现一个,你可能会很简单的设计出来,根据指定的路径扫描所有的类,如果有@Component的注解时就存放到BeanDefinnitionMap中,那为什么Spring要设计得这么复杂呢,原因是现实场景中Bean定义有多种方法,比如嵌套定义Bean,再比如先扫描出一部分Bean,此时这些Bean中有定义@CompentScan,又可以加载其它的Bean,所以你看这么多你不曾考虑的场景叠加在一起,实现起来的复杂度自然就高了。

还比如SpringMVC在查找Handler时,它的逻辑也挺复杂的,与我们日常通过一个URL映射到一个Handler不一样,在现实中完全有一种可能是相同的URL对应不同的请求方法,此时就不是一个简单映射的就能完成,还有一大堆的匹配逻辑,所以你会看到,当我们的业务认知了解得越来越多时,在设计中就会考虑更多的因素。

提升业务认知,除了沟通交流外,还得踏踏实实去工作一段时间,真正地了解里面的问题是什么,即使是踩坑,也是修正自己的认知,在做财务核算过程中,有些在今天看来是很低级的错误在当时就犯过,本质讲还是对业务的认知不够,不在自己的认知范围内就会犯错。

4.2 技术认知

除了业务认知外,技术也是在不断发展的,如果你不了解某个技术或技术点,此时你也不会想到好的设计方法。比如让你设计一个事件通知框架,本来这个功能倒不是那么复杂,它最难的点是在于如何找到事件对应的事件处理器,此时就有不同的解决方案,一种最简单的方法是在定义事件处理器时让用户指定事件类型,这似乎是一种解决方案,但站在用户使用的角度看,它并不是一种好的解决方案,把复杂留给用户而不是自己。为了提升用户使用体验,这里就要使用到泛型类型解析的方面的知识了,核心代码如下:

/** * 事件分发器 * * @author fulai.gfl */public class EventDispatcher {    /**     * 事件列表     */    private static List

 events = new ArrayList();    /**     * 事件处理器列表     */    private static List

 handlers = new ArrayList();    /**     * 添加事件     *     * @param event     */    public static void addEvent(Event event) {        events.add(event);    }    /**     * 添加事件处理器     *     * @param handler     */    public static void addHandler(Handler handler) {        handlers.add(handler);    }    /**     * 触发事件     *     * @param event     * @return     * @throws Exception     */    public Object fire(Event event) throws Exception {        Handler handler = getHandler(event);        if(Objects.isNull(handler)){            throw new Exception("没有找到指定的handler, event_name =" + event.getEventName());        }        return handler.handle(event);    }    /**     * 根据事件找到对应的Handler     *     * @param event     * @return     * @throws Exception     */    private Handler getHandler(Event event) throws Exception {        Handler handler = null;        for (Handler h : handlers) {            Type[] argumentsTypes = ((ParameterizedTypeImpl)h.getClass().getGenericInterfaces()[0])                .getActualTypeArguments();            if (Class.forName(((Class)argumentsTypes[0]).getName()).equals(event.getClass())) {                handler = h;            }        }        return handler;    }}

五 小结

本文主要讲述了应对复杂性的一些原则和经验,通过实际案例解构设计思想,个人认为好的设计是体现在「职责分离」、「抽象分层」和「变化扩展」上,在类的结构设计上尤其要花心思去想,如「变与不变分离」、「配置域与执行域分离」、「查询与命令分离」。归根到底,认知是解决复杂性的基石,需要我们去了解它,不管是业务还是技术上。

财经自媒体联盟更多自媒体作者

新浪首页 语音播报 相关新闻 返回顶部