复杂系统设计原则与案例

proginn2031598168

共 9957字,需浏览 20分钟

 · 2023-08-23

一、复杂是软件的本质属性

1.1 复杂是软件的本质属性

      互联网经历了十多年的高速发展,各个领域方向的系统都已经历了多次升级迭代,大家在经手这些软件系统时,不免感叹现在软件系统的复杂度,其实件复杂性是软件固有的属性,这种固有的复杂性主要由4个方面的原因造成的:

  • 问题域的复杂性

  • 管理开发过程的复杂性

  • 随处可变的灵活性

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

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

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

        影响软件复杂度的因素有很多,其中「认知复杂度」占据着很重要的因素一提到复杂性,我们脑海里会浮出各种各样的印象:应用数多、代码行数超过百万级、业务规则复杂等,这些复杂度从本质上来看是认知复杂度超过了正常人的认知范围,比如看百万行级的代码与看100行代码相比,维护10个应用与维护1个应用相比,两个复杂度不是在同一个数量级上,有可能是指数级提升。认知复杂度是软件的本质复杂度,从根本上规避不了,只能去理解、消化吸收,我们能做的是在理解的基础上去发现共性的「规律」,将这些「规律」抽象出来,让应用层开发变得简单。

       举当前的例子,目前负责的是电商板块的物流资金结算业务系统,最开始面对的业务认知复杂度非常高,它关联电商交易、支付、营销、结算、资金等领域,依赖业务将近100张离线表,除了要理解电商业务链路外,还要站在物流,财务,风控等视角把这些数据有序地组织起来,复杂度一下子就上升上来了,新人至少要花3个月的时间去消化这些业务知识。 当进来做了一些需求开发后,慢慢发现了一些规律,利用发现的这些规律有助于提升需求沟通、开发的效率。

二、应对复杂性的设计方法

2.1 把握套路是应对复杂性的根本方法

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

6d5190a5a53d35afd69f2ced17bb2265.webp

2.2 通识规律

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

2.2.1 职责分离

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

        另一点是出自于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类中,类结构继承关系如下所示,这也是一种最简单的三层设计结构:接口类、抽象类、子类。

9f239655990802ab933a5cbe96d9081f.webp

2.3.2 命令职责-业务链路查询和复杂组装

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

4dfe8420cd8fd8100416c3acf162c671.webp

2.3.3 配置域与执行域分离-有面向用户配置

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

    @RestController
public class UserController {

@RequestMapping(value = "/acquire", method = RequestMethod.GET)
public User getUser(@RequestParam("name") String name, @RequestParam("age") Integer age) {

return null;
}
}

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

c2bfa793a496dea38fbadee375136e8e.webp

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

       这样的例子还有很多,比如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使用责任链的模式。

8cf25c1840c7a8c309344ac205cd459f.webp

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

2.3.6 合理抽象-复杂系统场景

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

67ac2fa97f22d9b3f6ae5f106b0f78b6.webp

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

三、框架设计案例分析

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

292939a6b520f943f2effa281f5bd63f.webp

        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<Event> events = new ArrayList<>();

/**
* 事件处理器列表
*/
private static List<Handler> handlers = new ArrayList<>();

/**
* 添加事件
*/
public static void addEvent(Event event) {
events.add(event);
}

/**
* 添加事件处理器
*/
public static void addHandler(Handler handler) {
handlers.add(handler);
}

/**
* 触发事件
*/
public Object fire(Event event) throws Exception {

Handler handler = getHandler(event);
if(Objects.isNull(handler)){
throw new Exception("event_name =" + event.getEventName());
}

return handler.handle(event);
}

/**
* 根据事件找到对应的Handler
*/
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;
}
}

五、小结

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






浏览 13
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报