关于前端项目如何做开放&扩展的几点思路与实现方式。

关键词:开放、扩展、灵活性、定制、设计模式

背景

关于如何做好业务代码的开放,及其扩展的实现。随着业务的快速发展,不同业务技术团队对基础代码提出了更高的诉求,包括:如何做部分 UI 的定制、如何快速添加特定场景的功能、如何修改部分已有逻辑等等。

如果没有好的技术架构来支撑扩展,那么将会导致较多、较为频繁的技术咨询,且会重复开发。也可能因为没有好的隔离、抽象,导致整体的可维护性急剧下降,甚至导致线上稳定性的问题,致使业务不可用。

故本文对业务场景的项目代码,简单探讨对开放&扩展的几点思路,并尝试实践了一种笔者个人觉得较为适合当下场景的实现。

架构模式

业务场景的开发&扩展问题,可以很自然的扩展、联想到架构模式上,而且业界对此也有着非常多的研究,可以有较为借鉴。比如较为著名的 Gang of Four (GoF) 的设计模式

从软件的开放、扩展的角度看,能比较贴合场景对开放&扩展诉求的设计模式有:

观察者模式

观察者模式,又称发布-订阅模式。

image

观察者对目标的状态变化进行监听(attach),当目标的状态变化时(setState),触发自身的 通知函数(notify),并逐一通知观察者对象(update),观察者视情况获取最新的状态(getState)

将观察者模式应用到开发&扩展架构中,是相当常见的一种思路。比如 Eclipse 插件的开发:

Eclipse 的插件架构,提供了统一的「选中服务」(Selection Service),并对相关事件的发布者、订阅者做统一的管理,在观察者模式的基础上,有点 「消息总线」 的味道。

相关的选中事件,提供方可能有「代码选中」、「包路径选中」、「大纲树选中」等,观察者对象可以监听这些事件,并做相关处理,比如「属性面板」对上述发布者做相关监听,并分别显示 「代码」、「包」、「函数」等不同选中对象的相关属性。

模板方法+责任链模式

模板方法模式

image

即将代码执行的方式模板化(实际代码中,就是抽离为一个个对象、函数),并通过一个主函数,来依次调用相关函数(有点 hardcode 的意思)。

对需要扩展的对象或函数,做相关继承、派生,即完成对原有程序的自定义扩展。

如果对某些函数,有多个派生类的情况,需要做链式调用的,则可再引入 责任链模式。

image

某个派生类对其中一个 case 做处理,另外一个派生类对另外一种 case 做处理等等。

此种插件架构,在编译工具中尤为常见,比如 Maven、Webpack 等等。构建工具制定相关的生命周期(即模板方法),构建插件在此基础上,做相关派生并实现特定场景的特定功能(责任链)。

Maven Webpack
image

桥接模式

桥接模式:

image

将调用方和实现方,做较为彻底隔离,双方仅做「函数签名」的约定,在比较经典的 gRPC 中,其 Protocol Buffers (protobuf) 有类似作用。

从业务架构前后端语言中,也有类似的插件实现,比如:JVM 的 JNI、NodeJS 的 N-API

JVM NodeJS V8
image

小结

从现有的前后端工程体系中,无论是 IDE、编译构建、运行时容器 等等,都有不少开放&扩展的思路可以给我们提供借鉴思路。

从上述不同的架构设计模式中,可以尝试设计事件机制、制定业务生命周期、做好协议设计规范等等,来对原有的业务代码做好开放设计,相关的扩展方则可通过监听、继承、实现等方式,来对老的逻辑做相关干预。

开放&扩展诉求

虽有较多的思路来实现开放&扩展的技术架构,但从业务场景的核心诉求出发,什么样的架构设计更为符合现实场景的需求呢?

从开发角色和核心流程出发,主要有以下几点:

  • 【业务主体开放方】
    • 【前期】整体技术方案的接入成本低、少改造、少侵入。
    • 【中期】开放相关逻辑(函数、接口)简单、方便、灵活。
    • 【后期】整体开放范围可控、可靠、稳定。
  • 【业务客体扩展方】
    • 【接入期】扩展能力强(干预、拦截、重写),书写方便。

    可简单总结归纳为:前期接入成本、开放能力、扩展能力等三个核心维度。

各设计模式对比

从诉求出发,按三个核心维度,对前文所列的各种设计模式做一个简单的对比,相关表格如下:

方案 改造成本 开放性 扩展性
观察者模式
模板方法+责任链
桥接模式

观察者模式

  • 改造成本【高】:需对现有函数、对象做事件化改造,调用方式由直接执行变成事件触发;
  • 开放性【中】:对需要开放的定制点,需做事件触发,但对有依赖事件回调的地方,需要异步等待的改造;
  • 扩展性【中】:扩展方仅需监听相关事件,即可做相关扩展逻辑,但对原有逻辑的干预能力一般;

模板+责任链

  • 改造成本【中】:对需要开放的函数做相关抽离即可;
  • 开放性【高】:对已制定的生命周期,其开放能力较强,甚至是完全开放;
  • 扩展性【低】:因为模板方法的存在,仅可对固定生命周期的方法做扩展;

桥接模式

  • 改造成本【中】:对实现需做相关抽离,并能支持桥接模式;
  • 开放性【低】:需约定开放的函数签名,对逻辑代码本身不做干预;
  • 扩展性【高】:对扩展逻辑的实现有非常强的把控,且对实现不做约束;

方案设计

吸收上述设计模式的长处,并尽可能规避其劣势。选取:责任链 + 桥接模式。

规避模板的劣势,让原有的代码本身就是模板,能到任意点的扩展。保留责任链的相关设计思路。

在桥接基础上,放弃自定义的协议规范,由统一的出入参(即 I/O 逻辑)约定,并能支持责任链模式的多个派生调用(JNI,N-API 为 1 对 1 的模式)。

而观察者模式,虽被众多场景选用,但因为其事件异步的特性,从改造成本、干预能力来看,其优势较为一般。这和业务代码场景本身是不太适合的,不像 Eclipse 本身,其核心逻辑代码不会做大的开放,也不会让插件做修改,插件本身更像是能力增强,可有可无的角色定位。而业务场景下,对核心逻辑的改动,则是较为稀疏平常的诉求,随着业务的变化,其改动的频率和幅度均比较高。

所以,从开放&扩展的角度看,责任链和桥接是更为符合业务场景插件的技术架构设计。

从低改造成本的角度看,则可用装饰器模式,引入 Meta Programming(元编程)技术,对现有程序做相关声明,通过注入、反射等技术,来实现相关低成本开放能力。

方案实现

开放基础设计

对于一个已有的对象,如下:

class ClassA {

  async method(name) {
    console.log(`hello ${name}`);
  }
}

如果需要对 method 函数做相关开放,主体开放侧,仅需添加一个装饰器即可,如下:

需注意以下几点:

  • 原有的函数调用方,不需要修改原有的调用逻辑和出入参;
  • 被扩展的函数,获取函数的逻辑和方式也不需要做相关变更;
class ClassA {

  // 使用装饰器,对已有的逻辑代码做开放
  // 需控制、限定开放范围,一般仅限定函数级别
  @custom('ClassA.method')
  async method(name) {
    console.log(`hello ${name}`);
  }
}

扩展基础设计

扩展方的实现,需一并考虑运行时的逻辑。实现桥接、责任链的两种设计模式,此处参考了 Servlet Filter 的设计模式:

Filter 机制,是面向 http 协议的一套标准 request、response 的拦截机制:

  • 可以指定任意路径的 Filter,不同路径的 filter 的数量可以自由配置。
  • 可以对 http 协议做拦截、修改、重定向、响应等等操作,有非常强的干预能力,且在 Web 容器场景下,已成为事实上的标准规范。

    回归到 NodeJS 生态,也有一个非常优秀的 Koa-Compose 实现可以参考,其设计模式的叫法(洋葱模型)不太一样,但思路类似,示意如下:

image

有了上述的思路,整个责任链和桥接的模式就比较简单了。

  • 责任链:优化责任链为类 filter、类洋葱模型的形式;
  • 桥接:将桥接模式,统一为配合责任链模式的 context;

    所以,相关扩展模式如下:


// 通过扩展点声明,将此扩展桥接到主体逻辑
// 运行时,将通过责任链模式,链式调用此派生逻辑
custom('ClassA.method', async function(context, next) {

  // 获取函数调用方的入参
  const { args } = context;

  console.log(this, 'before', args[0]);

  // 链式调用下一个扩展,或是主体逻辑代码
  await next();

  console.log(this, 'after', args[0]);
})

此外,还需额外声明,之所以,没有使用标准的派生模式扩展相关函数(如下代码演示),有以下几点考虑(当然,后续也可以尝试此类方案):

  • 插件需独立部署,一般拿不到 ClassA 类,需通过字符串名称进行协议约定
  • 从控制、限定开放范围的视角看,一般不希望整个类被改写
// 开放扩展声明
@custom
class ClassAPlus extends ClassA {

  @override
  async method(name) {
    // 扩展代码
    console.log('before');
    super.method(name);
    console.log('after');
  }
}

其他设计

限定范围的、更为私有的内部扩展:

// 声明私有扩展点
const PRIVATE_EXTENSION = Symbol('private_extension');

class ClassA {

  @custom(PRIVATE_EXTENSION)
  async method(name) {
    console.log(`hello ${name}`);
  }
}

// 只有能拿到此私有变量的代码范围,才能做相关扩展逻辑
custom(PRIVATE_EXTENSION, async function(context, next) {
  // code gos here
})

支持同步、异步、返回值等,可参考 custom-decorators

落地效果

目前已在 1688 订单详情做相关落地,做了 30 多个的开放扩展点,扩展方目前有按终端(支付宝、鸿蒙、微信等)分的、有按业务分的。总体扩展方的代码量约在 600+ 以上。

另外通过 Dynamic Import 的方式,可以做到相关插件的按需加载,避免核心场景下,插件对核心主体逻辑的影响,限定了影响范围,保障了核心场景的稳定性。

总结

本文从业务场景的开发&扩展出发,简要参考现有前后端的工程体系的技术架构、设计模式,并抽取其中适合业务场景的模式,屏蔽相关设计模式中不适宜的。最终以洋葱模型 + 桥接的模式设计了整体的开放&扩展技术架构,相关的工具库参考 custom-decorators

随着前端技术的日趋成熟,从脚本语言到工程语言,从 jQuery 时代到 MVVM 的 Reactive Framework 时代,更为复杂、快速的业务迭代,对前端技术架构提出了更高的要求,对复杂项目的把控、抽象。好在相关设计模式的通用性,让我们能借鉴行业成熟的一些思路,并做相关落地。

后续,可在强类型校验、接口运行时判断、插件加载顺序控制,并结合 Service + DAO 层的抽象开展工作。

参考

  1. Architectural Pattern
  2. Eclipse Plug-in architecture
  3. 观察者模式
  4. 模板方法模式
  5. 责任链模式
  6. 桥接模式
  7. 拦截器模式
  8. 装饰器模式
  9. gRPC Protobuf
  10. Koa-Compose
  11. custom-decorators
  12. Maven: Lifecycle vs. Phase vs. Plugin vs. Goal
  13. 图解Webpack——实现Plugin
  14. 如何做扩展(1)
  15. 如何做扩展(2)

附录

Eclipse 插件的扩展形式

UI 扩展 函数扩展 事件扩展

设计模式

分类 模式
Creational 创建 Abstract factory 抽象的工厂 Builder 生成器 Factory method 工厂的方法 Prototype 原型 Singleton 单独
Structural 结构 Adapter 适配器 Bridge 桥 Composite 复合 Decorator 装饰 Facade 正面 Flyweight 轻量级 Proxy 代理
Behavioral 行为 Chain of responsibility 链的责任 Command 命令 Interpreter 口译员 Iterator 迭代 Mediator 调解员 Memento 纪念品 Observer 观察员 State 状态 Strategy 战略 Template method 模板的方法 Visitor 访客