1. 架构的定义
架构是软件方法学的范畴,它解决的是软件组织的问题,不解决软件算法的问题。两者的区别可用下图的积木做个类比:
算法就像一个个的积木块,比如绿色的圆柱,蓝色的三角,红色的方块等。而架构则是把各种积木块,组装成一个城堡,一辆小火车。为搭建这个城堡或小火车,架构师脑子里得有张图纸,图纸里既要定义需要哪些形形色色的积木块,又要考虑如何将它们组装起来。这工作很像建筑师,英文也的确叫 architect。
这样类比,很容易让不太理解技术的企业家们陷入误区,会觉得架构师要比算法工程师更厉害?其实不然,这是两个细分领域的才能。不知道您注意到小火车车头上的烟囱没?它是一个像鸡腿菇一样的弧线造型,浇灌出这种造型的模子,要比三角形和方块形要难很多,它需要更深奥的几何学的支撑,这可以形象的看做是算法工程师解决的问题。
2. 架构的意义
架构解决软件组织的问题,它能给企业创造什么价值?换句话说,好的软件组织,跟差的软件组织,从商业价值创造的角度,有什么不同?笔者以为架构的价值体现在可用性和敏捷性两个角度,但今天要讲的是敏捷性。敏捷性指的是快速、低成本、高质量地应对扩张市场的差异化需求。企业在初创期积累了不少软件资产,这些资产在当初的市场环境下,已被论证取得了市场业绩。但是伴随着企业扩张,市场会更加精细化、场景化,这些都会给我们的软件提出新的需求,企业需要借助前些年在这个领域积累的先发优势,一方面快速占领细分的市场;另一方面复用曾经积累的资产,发挥资产的规模经济效应。
比如京东电商,从高价值、标准化的 3C 数码起家,建立起自营电商模式;紧接着开始扩品,做低价值、但高频次、依然标准化的日用百货圈用户粘性;再做相对非标的服装发展女性用户和生态模式等,直指行业竞争的关键区;除了扩品还伴随着场景扩张,诸如 2B 企业业务、下沉市场拼购业务、泰国印尼国际业务等。供给角度的品类扩张,需求角度的场景扩张,构成了京东矩阵式垂直业务线。它们正是复用了零售中台的软件基础设施,才在一定程度上做到了快速扩张。
3. 架构的灵魂
既然软件组织的价值如此重要,那么好的软件组织的标准是什么呢?又该如何做到呢?好坏的标准在解耦。解耦的对立面是耦合,耦合是指阻碍变化的依赖;解耦是要在依赖的基础上,做到应对可能的变化。依赖是必不可少的,依赖的本质是分工,正如亚当斯密的《国富论》论述的那样,分工有助于专业化、有助于提高效率。太抽象了!说了这么多,没讲清楚解耦是什么。的确,笔者也认为这样的解释只能让已经理解了的人再表示一次赞同,无法让原本不理解的人变得理解,这样毫无意义!我该如何诠释?事实上,很多真理是建立在归纳法基础上的。归纳法的好处是见得多了自然就会(归纳似乎是人脑的一种本能),比如诗词,只要熟读唐诗三百首,不会吟诗也会吟。不信你看,先来一篇叫“大漠孤烟直”的,没啥概念;再读一篇叫“空山新雨后”的,有点感觉了;最后“小桥流水人家”你自己就会了。如何写出点有意境的诗,你张口就来“床前明月光”,还不是自己写的?如果你去到草原晚上触景生情,即兴来上一句“明月篝火烤肥羊”,就能媲美“日照香炉生紫烟”了。所以笔者觉得,最好的方式就是细数那些软件架构中的解耦,让读者从铺陈式的实例中,自己找感觉。
笔者分 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,我们的恶意流量清洗、灰度发布、性能分析等都可以采用这种方式,实现零侵入插拔。
4. 事件流订阅
事件流订阅的设计理念是将瞬间的过程化调用转变成可回放的指令,对指令的响应可以不用再预定义。事件流订阅,在应用层表现为 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。
有了上述的设计模式后,具体的执行分三步:
- 订阅:通过 mediator.subscribe(b) 把未来的事件处理提前注册到 Mediator。
- 发布:A 向 Mediator 发布自己的事件。注意这个理念特别重要,A 仅仅发布发生了什么事情,A 并没有直接调用 B 声明对事情的处理。也就是 A 连对 B 的接口都不再依赖了!举个例子,比如新员工入职,刚开始要为员工办理磁条卡,只是办理磁条卡的供应商可能是甲,也可能是乙。这叫面向接口编程,但这还不够,因为随着公司的发展,现在新员工入职,有人脸识别了,不用再办磁条卡了,而是要登记人脸识别,另外员工福利更好了,对异地公干的新员工入职还会发放一笔安家费,这些都是之前的“接口”没有描述的。
- 执行:当 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> resolver 来维护从 Topic 到 EventHandler 的一组处理。为什么是 List,而不是 EventHandler 呢?为了更加灵活,比如上文提到的「现在新员工入职,有人脸识别了,不用再办磁条卡了,而是要登记人脸识别,另外员工福利更好了,对异地公干的新员工入职还会发放一笔安家费」。
架构层 -Broker 消息模式
上图的 Broker 模式,跟 Mediator 模式其实没有本质的不同,只不过 Broker 更加突出了借助消息中间件 MQ 实现异步。客户端提交一个委托,Broker 持久化完成,并回复 ACK,表示委托已收到。接着委托的消费处理,可以是离线的。通常需要支持 Group 机制:Group 内部多个 Instance 是负载均衡的,它们共同瓜分委托消息的处理;而 Group 间是冗余复制的,它们各自消费各自的,相互之间隔离,有助于实现业务可拓展性。
比如一个新员工入职,它产生一个“新人入职”事件,然后行政部门会为其准备工卡、财务部门会为其准备工资卡、HR 部门会为其缴纳社保。当然,随着公司业务发展,可能还会增加,比如业务部门的业务培训,风控部门的合规性培训等。
跟前面说的 Proxy 模式,相同点在于它们都是在架构层面实现可拓展性。不同点是,Proxy 模式支持的是 PreHandler 和 PostHandler;而 Broker 模式支持的是 MidHandler。