首页
学习
活动
专区
工具
TVP
发布

细数软件架构中的解耦

架构的定义

架构是软件方法学的范畴,它解决的是软件组织的问题,不解决软件算法的问题。两者的区别可用下图的积木做个类比:

算法就像一个个的积木块,比如绿色的圆柱,蓝色的三角,红色的方块等。而架构则是把各种积木块,组装成一个城堡,一辆小火车。为搭建这个城堡或小火车,架构师脑子里得有张图纸,图纸里既要定义需要哪些形形色色的积木块,又要考虑如何将它们组装起来。这工作很像建筑师,英文也的确叫architect。

这样类比,很容易让不太理解技术的企业家们陷入误区,会觉得架构师要比算法工程师更厉害?其实不然,这是两个细分领域的才能。不知道您注意到小火车车头上的烟囱没?它是一个像鸡腿菇一样的弧线造型,浇灌出这种造型的模子,要比三角形和方块形要难很多,它需要更深奥的几何学的支撑,这可以形象的看做是算法工程师解决的问题。

架构的意义

架构解决软件组织的问题,它能给企业创造什么价值?换句话说,好的软件组织,跟差的软件组织,从商业价值创造的角度,有什么不同?笔者以为架构的价值体现在可用性和敏捷性两个角度,但今天要讲的是敏捷性。敏捷性指的是快速、低成本、高质量地应对扩张市场的差异化需求。企业在初创期积累了不少软件资产,这些资产在当初的市场环境下,已被论证取得了市场业绩。但是伴随着企业扩张,市场会更加精细化、场景化,这些都会给我们的软件提出新的需求,企业需要借助前些年在这个领域积累的先发优势,一方面快速占领细分的市场;另一方面复用曾今积累的资产,发挥资产的规模经济效应。

比如京东电商,从高价值、标准化的3C数码起家,建立起自营电商模式;紧接着开始扩品,做低价值、但高频次、依然标准化的日用百货圈用户粘性;再做相对非标的服装发展女性用户和生态模式等,直指行业竞争的关键区;除了扩品还伴随着场景扩张,诸如2B企业业务、下沉市场拼购业务、泰国印尼国际业务等。供给角度的品类扩张,需求角度的场景扩张,构成了京东矩阵式垂直业务线。它们正是复用了零售中台的软件基础设施,才在一定程度上做到了快速扩张。

架构的灵魂

既然软件组织的价值如此重要,那么好的软件组织的标准是什么呢?又该如何做到呢?好坏的标准在解耦。解耦的对立面是耦合,耦合是指阻碍变化的依赖;解耦是要在依赖的基础上,做到应对可能的变化。依赖是必不可少的,依赖的本质是分工,正如亚当斯密的《国富论》论述的那样,分工有助于专业化、有助于提高效率。太抽象了!说了这么多,没讲清楚解耦是什么。的确,笔者也认为这样的解释只能让已经理解了的人再表示一次赞同,无法让原本不理解的人变得理解,这样毫无意义!我该如何诠释?事实上,很多真理是建立在归纳法基础上的。归纳法的好处是见得多了自然就会(归纳似乎是人脑的一种本能),比如诗词,只要熟读唐诗三百首,不会吟诗也会吟。不信你看,先来一篇叫“大漠孤烟直”的,没啥概念;再读一篇叫“空山新雨后”的,有点感觉了;最后“小桥流水人家”你自己就会了。如何写出点有意境的诗,你张口就来“床前明月光”,还不是自己写的?如果你去到草原晚上触景生情,即兴来上一句“明月篝火烤肥羊”,就能媲美“日照香炉生紫烟”了。所以笔者觉得,最好的方式就是细数那些软件架构中的解耦,让读者从铺陈式的实例中,自己找感觉。

笔者分3类6组(每类分进程内的应用层和进程间的架构层)给大家举例:

外加中间的Naming解析与Proxy代理融合的CNAME别名,总共7个案例。

中间层映射

中间层映射的设计理念是当A对B有依赖时,A不要直接依赖B,而是抽象一个中间层,让A依赖中间层,再由中间层映射到B,从而当B变成C时,不用修改A,只用调整中间层的映射关系。中间层映射,在应用层表现为面向接口动态绑定,在架构层表现为Naming解析动态绑定。

应用层-面向接口动态绑定

面向接口编程的核心思想是“先想清楚做什么,再想让谁来做”。什么叫想清楚了做什么?就是用接口的形式,描述输入什么,输出什么;但接口更多描述的是语法层面,语义层面的刻画还需配合单元测试及其断言(技术上叫Test Driven),还有文档。这跟企业家们常读的《高效能人士的7个习惯》里面讲的“以终为始”,思想上如出一辙。让谁来做?就涉及到运行时动态绑定。比如下图:

在Java面向对象的语言里,使用方通过Provider接口Response doService(Request r)来对外刻画它的招标文件。然后三个供应方,LocalProvider、RemoteProvider和AsyncProvider来应标。使用方只使用Provider接口,至于它跟哪个具体的Provider绑定,完全可以在“采购”时刻动态替换。

面向接口动态绑定的解耦,体现在使用方把依赖的服务抽象为一个接口,依赖这个抽象的接口,而不依赖于具体的服务提供者,以便应对服务提供者变化的可能性。

架构层-Naming解析动态绑定

上图是域名服务DNS的示意流程。客户端并不直接通过IP地址来访问Provider#A或B,而是先询问Naming服务,并依据返回的服务列表,再访问Provider#A或B。如果某个Provider故障了,可以替换转移到其他的Provider。出于性能考虑,也可以在客户端把Naming的结果缓存起来,并配个缓存更新机制。

基于ZooKeeper的应用层名字服务,思想上类似DNS。不同的是,它基于TCP长链接来实现Server Push,可及时刷新服务列表。

Naming解析动态绑定的解耦,体现在使用方把依赖的对象或网络进程,抽象为一个名字,名字代表的具体服务提供者则通过Lookup机制返回,进而做到如果提供者有变化,只要改变Lookup的结果,无需改变使用方代码。

前后节植入

前后节植入的设计理念是服务器是流程的集合,流程是环节的序列。改变一个流程的行为,可以通过在其前后植入一个新环节来实现。前后节植入,在应用层表现为Chain拦截模式,在架构层表现为Proxy代理模式。

应用层-Chain拦截模式

上图是Strtus2的架构,每个Action的执行,都会被包裹在一系列Interceptor里面,形成一条处理链Chain,每个Interceptor会进行PreHandler和PostHandler处理。这里的Interceptor可以增加、删除或替换,以此实现可拓展性。比如可以在Interceptor里做鉴权、日志、性能统计、限流等。

Chain插拔的动态绑定,通过增删替Interceptor,把过去URL与Action的1:1的处理关系,转变成了M:N的处理链。一类请求(某个URL),可以被多个Interceptor处理;一个Interceptor也可以处理多类请求。

顺便说一下,Strtus2这里说的“动态绑定”,是配置相对硬编码而言的。严格意义上,这里的绑定是编译期的,不是运行期的,是静态的绑定。类似的架构还有Spring AOP和Servlet Filters机制。

架构层-Proxy代理模式

上图是一个Proxy架构模式,这个应用极其广泛。比如HTTP的Nginx,SQL的Apache Calcite,memcached和redis的twitter/twemproxy。为什么?因为Proxy对于Backend而言就是流量入口,是中间人,能扮演架构层面的AOP机制,可拓展性非常强。

当一个请求过来后,刚开始Proxy转发给Backend#A。但是业务发展了,Proxy也可以转发给Backend#B以实现负载均衡,更重要的是A和B还可以不同的版本,以实现灰度发布。还可以植入PrePlugin和PostPlugin:

  • 在PrePlugin里可以做权限控制、流量控制、请求改写、缓存加速、恶意流量拦截、PV统计、性能Profile、ChaosMonkey混沌事件植入等等。
  • 在PostPlugin里,还可以做响应报文改写,安全加密(后端不用考虑数据安全,对外时统一加密处理)、压缩加速等等。

两者融合的实例-CNAME别名

上图是一种混合模式:既有Naming解析,又有Proxy代理。而且Naming服务,为了支持可拓展,还引入了父子层级,末端的Naming服务,完全可以委托给上一层级的Naming服务。

在DNS里面,我们经常会看到 www.example.org的域名解析,CNAME别名到 www.example.org.cdnprovider.com (它是 cdnprovider.com 的子域名),这样客户端不用修改,依然访问的是 www.example.org ,但是对应的后端服务,却不再是直接访问Provider#A或B,而是中间植入了CNAME Proxy,再由Proxy依据Plugin的决策,是否转发给问Provider#A或B。

这个设计太棒了!它使得商业公司 cdnprovider.com 给 www.example.org 提供CDN服务时,完全是零侵入,不需要修改任何一段代码,只需要在域名服务商那修改 www.example.org 的域名解析,这个操作代表 www.example.org 同意 cdnprovider.com 为他们提供CDN服务,代表授权。这一切,都源于基于Naming解析的动态绑定实现的解耦。同样的,除了CDN,我们的恶意流量清洗、灰度发布、性能分析等都可以采用这种方式,实现零侵入插拔。

事件流订阅

事件流订阅的设计理念是将瞬间的过程化调用转变成可回放的指令,对指令的响应可以不用再预定义。事件流订阅,在应用层表现为Mediator中介模式,在架构层表现为Broker消息模式。

应用层-Mediator中介模式

A直接调用B,意味着A对B产生了强依赖。当然我们可以通过面向接口编程,把这个依赖降低,降低到只依赖接口,不依赖实现。简单说,我们只依赖对事情的处理结果,不依赖于如何实现这个处理结果。

但是这还不够,因为我们还依赖了接口,接口意味着对处理语义的刻画。现实中有些情况,连语义的描述都要发生变化,也就是接口都要发生变化,如何进一步解耦呢?如下图:

A不直接调用B,而通过中介Mediator,解耦两步:

  • 先由A调用Mediator: A持有Mediator的引用,执行Mediator的方法,即mediator.publish(e)。
  • 再由Mediator调用B:  为了解耦Mediator对外界的依赖,我们用面向接口EventHandler来实现依赖反转。让B来实现EventHandler,当然如果B已经存在,或更有话语权,依然应该遵循依赖反转的原则,只不过Mediator模式的推进方可以再实现一个Adaptor,来帮助既有的B适配到EventHandler。

有了上述的设计模式后,具体的执行分三步:

  1. 订阅: 通过 mediator.subscribe(b)把未来的事件处理提前注册到Mediator。
  2. 发布: A向Mediator发布自己的事件。注意这个理念特别重要,A仅仅发布发生了什么事情,A并没有直接调用B声明对事情的处理。也就是A连对B的接口都不再依赖了!举个例子,比如新员工入职,刚开始要为员工办理磁条卡,只是办理磁条卡的供应商可能是甲,也可能是乙。这叫面向接口编程,但这还不够,因为随着公司的发展,现在新员工入职,有人脸识别了,不用再办磁条卡了,而是要登记人脸识别,另外员工福利更好了,对异地公干的新员工入职还会发放一笔安家费,这些都是之前的“接口”没有描述的。
  3. 执行: 当mediator收到A的事件后(A调用了mediator.publish(e)),mediator会通过EventHandler来回调预先通过mediator.subscribe(b)注册的处理类。

上述Mediator,有些局限性,对所有的Event,只能有一种EventHandler。如果我们把Mediator升级为一种通用的处理机制,一种平台,自然会有各种各样的Event,自然会我们会对Event做个分类或分组。我们把Event的分类或分组,叫做Topic;而把Event理解为Topic这个类里面的具体实例。并在Mediator里面维护,从Topic到EventHandler的一组处理器。如下图所示:

可以看到上述架构通过Map<Topic, List<EventHandler>> resolver 来维护从Topic到EventHandler的一组处理。为什么是List<EventHandler>,而不是EventHandler呢?为了更加灵活,比如上文提到的「现在新员工入职,有人脸识别了,不用再办磁条卡了,而是要登记人脸识别,另外员工福利更好了,对异地公干的新员工入职还会发放一笔安家费」。

架构层-Broker消息模式

上图的Broker模式,跟Mediator模式其实没有本质的不同,只不过Broker更加突出了借助消息中间件MQ实现异步。客户端提交一个委托,Broker持久化完成,并回复ACK,表示委托已收到。接着委托的消费处理,可以是离线的。通常需要支持Group机制:Group内部多个Instance是负载均衡的,它们共同瓜分委托消息的处理;而Group间是冗余复制的,它们各自消费各自的,相互之间隔离,有助于实现业务可拓展性。

比如一个新员工入职,它产生一个“新人入职”事件,然后行政部门会为其准备工卡、财务部门会为其准备工资卡、HR部门会为其缴纳社保。当然,随着公司业务发展,可能还会增加,比如业务部门的业务培训,风控部门的合规性培训等。

跟前面说的Proxy模式,相同点在于它们都是在架构层面实现可拓展性。不同点是,Proxy模式支持的是PreHandler和PostHandler;而Broker模式支持的是MidHandler。

关于作者

作者李伟,研究微服务与大数据方向,擅长中台架构敏捷性课题。就职于京东零售,担任资深架构师,加入京东前曾就职于搜狐和百度。

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/8hlh2qEWP1Y00qumdMQj
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券