关于前端项目如何做开放&扩展的几点思路与实现方式
关于前端项目如何做开放&扩展的几点思路与实现方式。
关键词:开放、扩展、灵活性、定制、设计模式
背景
关于如何做好业务代码的开放,及其扩展的实现。随着业务的快速发展,不同业务技术团队对基础代码提出了更高的诉求,包括:如何做部分 UI 的定制、如何快速添加特定场景的功能、如何修改部分已有逻辑等等。
如果没有好的技术架构来支撑扩展,那么将会导致较多、较为频繁的技术咨询,且会重复开发。也可能因为没有好的隔离、抽象,导致整体的可维护性急剧下降,甚至导致线上稳定性的问题,致使业务不可用。
故本文对业务场景的项目代码,简单探讨对开放&扩展的几点思路,并尝试实践了一种笔者个人觉得较为适合当下场景的实现。
架构模式
业务场景的开发&扩展问题,可以很自然的扩展、联想到架构模式上,而且业界对此也有着非常多的研究,可以有较为借鉴。比如较为著名的 Gang of Four (GoF) 的设计模式。
从软件的开放、扩展的角度看,能比较贴合场景对开放&扩展诉求的设计模式有:
观察者模式
观察者模式,又称发布-订阅模式。
观察者对目标的状态变化进行监听(attach),当目标的状态变化时(setState),触发自身的 通知函数(notify),并逐一通知观察者对象(update),观察者视情况获取最新的状态(getState)
将观察者模式应用到开发&扩展架构中,是相当常见的一种思路。比如 Eclipse 插件的开发:
Eclipse 的插件架构,提供了统一的「选中服务」(Selection Service),并对相关事件的发布者、订阅者做统一的管理,在观察者模式的基础上,有点 「消息总线」 的味道。
相关的选中事件,提供方可能有「代码选中」、「包路径选中」、「大纲树选中」等,观察者对象可以监听这些事件,并做相关处理,比如「属性面板」对上述发布者做相关监听,并分别显示 「代码」、「包」、「函数」等不同选中对象的相关属性。
模板方法+责任链模式
模板方法模式
即将代码执行的方式模板化(实际代码中,就是抽离为一个个对象、函数),并通过一个主函数,来依次调用相关函数(有点 hardcode 的意思)。
对需要扩展的对象或函数,做相关继承、派生,即完成对原有程序的自定义扩展。
如果对某些函数,有多个派生类的情况,需要做链式调用的,则可再引入 责任链模式。
某个派生类对其中一个 case 做处理,另外一个派生类对另外一种 case 做处理等等。
此种插件架构,在编译工具中尤为常见,比如 Maven、Webpack 等等。构建工具制定相关的生命周期(即模板方法),构建插件在此基础上,做相关派生并实现特定场景的特定功能(责任链)。
Maven | Webpack |
---|---|
桥接模式
桥接模式:
将调用方和实现方,做较为彻底隔离,双方仅做「函数签名」的约定,在比较经典的 gRPC 中,其 Protocol Buffers (protobuf) 有类似作用。
从业务架构前后端语言中,也有类似的插件实现,比如:JVM 的 JNI、NodeJS 的 N-API
JVM | NodeJS V8 |
---|---|
小结
从现有的前后端工程体系中,无论是 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 实现可以参考,其设计模式的叫法(洋葱模型)不太一样,但思路类似,示意如下:
有了上述的思路,整个责任链和桥接的模式就比较简单了。
- 责任链:优化责任链为类 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 层的抽象开展工作。
参考
- Architectural Pattern
- Eclipse Plug-in architecture
- 观察者模式
- 模板方法模式
- 责任链模式
- 桥接模式
- 拦截器模式
- 装饰器模式
- gRPC Protobuf
- Koa-Compose
- custom-decorators
- Maven: Lifecycle vs. Phase vs. Plugin vs. Goal
- 图解Webpack——实现Plugin
- 如何做扩展(1)
- 如何做扩展(2)
附录
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 访客 |