To B 业务的生命周期和迭代通常持续多年。随着产品的迭代和演变,以接口调用为核心的前后端关系将变得非常复杂。迭代多年后,界面的任何修改都会给产品带来不可预测的问题。在这种情况下,在长期迭代下构建更稳定的前端应用,确保前端的稳定性和可扩展性变得非常重要。本文将重点介绍如何避免或减少界面变化对前端的影响。

作者 | 谢亚东 来源 | 阿里技术微信官方账号
To B 业务的生命周期和迭代通常持续多年。随着产品的迭代和演变,以接口调用为核心的前后端关系将变得非常复杂。迭代多年后,界面的任何修改都会给产品带来不可预测的问题。在这种情况下,在长期迭代下构建更稳定的前端应用,确保前端的稳定性和可扩展性变得非常重要。本文将重点介绍如何避免或减少界面变化对前端的影响。
一 困境与难题
为了更清晰解释前端面临的难题,我们以 To B 以业务中常见的仪表盘页面为例,该页面包含三部分信息显示:可用内存、已用内存和已用内存比例。
前端组件与接口之间的依赖如下图所示。
当接口返回结构调整时,MemoryFree 需要调整组件对接口的调用模式。MemoryUsage 与 MemoryUsagePercent 工作前也要修改。
真实的 To B 业务可能面临数百个接口,组件和接口的集成逻辑远比上述例子复杂。
经过几年甚至更长时间的迭代,接口将逐渐生成多个版本。由于界面稳定性和用户使用习惯,前端通常依靠多个版本来构建界面。当一些接口需要调整离线或更改时,前端需要重新理解业务逻辑,并进行大量的代码逻辑调整,以确保界面的稳定运行。
影响前端的常见接口变化包括但不限于:
- 返回字段调整
- 调用方用方式
- 多版本共存使用
当前端面临平台业务时,这些问题将变得更加困难。平台产品将包装一个或多个底层引擎,如基于机器学习平台的机器学习平台 TensorFlow、Pytorch 基于机器学习引擎的实时计算平台 Flink、Spark 构建计算机引擎。
虽然平台将上层包装发动机的大部分接口,但不可避免的是,一些底层接口将直接传输到前端。此时,前端不仅要处理平台的接口变化,还要面临开源发动机接口变化带来的挑战。
前端面临的困境是由独特的前后关系决定的。与其他领域不同,在 To B 业务中,前端通常以下游客户的身份接受后端供应商的供给,有些情况下会成为后端的跟随者。
在客户/供应商关系中,前端在下游,后端团队在上游。接口内容和上线时间通常由后端团队决定。
在跟随者关系中,上游后端团队不会根据前端团队的需要进行任何调整,前端只能适应上游后端模型。这通常发生在前端不能影响上游后端团队的时候,比如前端需要基于开源项目的界面设计界面,或者后端团队的模型非常成熟,难以修改。
作者描述了这样一个嵌入式架构设计的难题,与我们上面描述的难题非常相似。
软件应该是一种使用周期长的东西,随着硬件的发展,固件会过时,但事实上,虽然软件本身不会随着时间的推移而磨损,但硬件及其固件会随着时间的推移而过时,然后需要相应地改变软件。
无论是客户/供应商关系还是跟随者关系,就像软件不能决定硬件的开发和迭代一样,前端也很难或不能决定引擎和接口的设计。虽然前端本身不会随着时间的推移而变得不可用,但技术引擎和相关接口会随着时间的推移而过时,前端代码会随着技术引擎的迭代和更换而逐渐腐烂,最终难逃被迫重写的命运。
二 防腐层设计
早在 Windows 为了解决上述硬件、固件和软件的可维护性问题,工程师在诞生前介绍了它 HAL(Hardware Abstraction Layer)的概念, HAL 为软件提供服务,屏蔽硬件实现细节,使软件不必因硬件或固件的变化而频繁修改。
HAL 该领域的设计理念驱动设计(DDD) 又称防腐层(Anticorruption Layer)。在 DDD 在定义的各种上下文映射关系中,防腐层是最具防御性的。它经常用于下游团队,需要防止外部技术偏好或模型入侵,这有助于很好地隔离上下游模型。
在前端引入防腐层的概念,减少或避免当前后端上下文映射接口变更对前端代码的影响。
防腐层在行业内实现的方法有很多,无论是近年来的火灾 GraphQL 还是 BFF 可作为备选方案,但技术选择也受业务场景的限制。与 To C 业务完全不同,在 To B 在业务中,前后端关系通常是客户/供应商或追随者/追随者。在这种关系下,希望后端配合前端对接口 GraphQL 改造变得不现实, BFF 构建一般需要额外的部署资源和运维成本。
在上述情况下,在浏览器端构建防腐层更为可行,但在浏览器中构建防腐层也面临挑战。
无论是 React、Angular 还是 Vue 数据层解决方案数不胜数,从 Mobx、Redux、Vuex 等等,这些数据层解决方案实际上会侵入视图层。有没有防腐层解决方案可以完全解耦视图层? RxJS 为代表的 Observable 在这个时候,方案可能是最好的选择。
RxJS 是 ReactiveX 项目的 JavaScript 实现,而 ReactiveX 最早是 LINQ 微软架构师的扩展 Erik Meijer 领导团队发展。该项目的目标是提供一致的编程接口,帮助开发者更容易处理异步数据流。目前 RxJS 在开发过程中常被用作响应性编程开发工具,但在构建防腐层的场景中,RxJS 代表的 Observable 方案也能起到很大的作用。
我们选择 RxJS 主要基于以下几点考虑:
- 统一不同数据源的能力:RxJS 可以将 websocket、http 请求,甚至用户操作,页面点击等。 Observable 对象。
- 统一不同类型数据的能力:RxJS 将异步数据和同步数据统一为 Observable 对象。
- 数据处理能力丰富:RxJS 提供丰富 Operator 操作符,可对 Observable 在订阅前进行预处理。
- 不入侵前端架构:RxJS 的 Observable 可以与 Promise 这意味着相互转换 RxJS 所有概念都可以完全包装在数据层中,只能暴露视图层 Promise。
当在引入 RxJS 将所有类型的接口转换为 Observable 对象完成后,前端视图组件将仅依赖于 Observable,并与接口的细节解耦,Observable 可以与 Promise 相互转换,在视图层中得到的是简单的 Promise,可与任何数据层方案和框架一起使用。
除了转换为 Promise 此外,开发人员也可以与之相处 RxJS 例如,渲染层的解决方案 rxjs-hooks 混合,获得更好的开发体验。
三 防腐层实现
参照上述防腐层设计,我们实现了仪表板项目的开始 RxJS Observable 核心防腐层代码。
防腐层的核心代码如下
export function getMemoryFreeObservable(): Observable<number> { return fromFetch("/api/v1/memory/free").pipe(mergeMap((res) => res.json())); } export function getMemoryUsageObservable(): Observable<number> { return fromFetch("/api/v1/memory/usage").pipe(mergeMap((res) => res.json())); } export function getMemoryUsagePercent(): Promise<number> { return lastValueFrom(forkJoin([getMemoryFreeObservable(), getMemoryUsageObservable()]).pipe( map(([usage, free]) => ((usage / (usage free)) * 100).toFixed(2)) )); } export function getMemoryFree(): Promise<number> { return lastValueFrom(getMemoryFreeObservable()); } export function getMemoryUsage(): Promise<number> { return lastValueFrom(getMemoryUsageObservable()); }
MemoryUsagePercent 该组件将不再依赖于特定的接口,而是直接依赖于防腐层。
(0);" useeffect(()=">" (async="" ()=">" result="await" getmemoryusagepercent();="" setusage(result);="" })();="" },="" []);="" return="" <div>usage:="" {usage}="" %<="" div>;="" }="" export="" default="" memoryusagepercent;<="" code="">
1 返回字段调整
返回字段变更时,防腐层可以有效拦截接口对组件的影响,当 /api/v2/quota/free 与 /api/v2/quota/usage 的返回数据变更为以下结构时
{
requestId: string;
data: number;
}
我们只需要调整防腐层的两行代码,注意此时我们的上层封装的 getMemoryUsagePercent 基于 Observable 构建所以不需要进行任何改动。
export function getMemoryUsageObservable(): Observable<number> {
return fromFetch("/api/v2/memory/free").pipe(
mergeMap((res) => res.json()),
+ map((data) => data.data)
);
}
export function getMemoryUsageObservable(): Observable<number> {
return fromFetch("/api/v2/memory/usage").pipe(
mergeMap((res) => res.json()),
+ map((data) => data.data)
);
}
在 Observable 化的防腐层中,会存在高阶 Observable 与 低阶 Observable 两种设计,在上文的例子中,Free Observable 和 Usage Observable 为低阶封装,而 Percent Observable 利用 Free 和 Usage 的 Observable 进行了高阶封装,当低阶封装改动时,由于 Observable 本身的特性,高阶封装经常是不需要进行任何改动的,这也是防腐层给我们带来的额外好处。
2 调用方式改变
当调用方式发生改变时,防腐层同样可以发挥作用。/api/v3/memory 直接返回了 free 与 usage 的数据,接口格式如下。
{
requestId: string;
data: {
free: number;
usage: number;
}
}
防腐层代码只需要进行如下更新,就可以保障组件层代码无需修改。
export function getMemoryObservable(): Observable<{ free: number; usage: number }> {
return fromFetch("/api/v3/memory").pipe(
mergeMap((res) => res.json()),
map((data) => data.data)
);
}
export function getMemoryFreeObservable(): Observable<number> {
return getMemoryObservable().pipe(map((data) => data.free));
}
export function getMemoryUsageObservable(): Observable<number> {
return getMemoryObservable().pipe(map((data) => data.usage));
}
export function getMemoryUsagePercent(): Promise<number> {
return lastValue(getMemoryObservable().pipe(
map(({ usage, free }) => +((usage / (usage + free)) * 100).toFixed(2))
));
}
3 多版本共存使用
当前端代码需要在多套环境下部署时,部分环境下 v3 的接口可用,而部分环境下只有 v2 的接口部署,此时我们依然可以在防腐层屏蔽环境的差异。
export function getMemoryLegacyObservable(): Observable<{ free: number; usage: number }> {
const legacyUsage = fromFetch("/api/v2/memory/usage").pipe(
mergeMap((res) => res.json())
);
const legacyFree = fromFetch("/api/v2/memory/free").pipe(
mergeMap((res) => res.json())
);
return forkJoin([legacyUsage, legacyFree], (usage, free) => ({
free: free.data.free,
usage: usage.data.usage,
}));
}
export function getMemoryObservable(): Observable<{ free: number; usage: number }> {
const current = fromFetch("/api/v3/memory").pipe(
mergeMap((res) => res.json()),
map((data) => data.data)
);
return race(getMemoryLegacyObservable(), current);
}
export function getMemoryFreeObservable(): Observable<number> {
return getMemoryObservable().pipe(map((data) => data.free));
}
export function getMemoryUsageObservable(): Observable<number> {
return getMemoryObservable().pipe(map((data) => data.usage));
}
export function getMemoryUsagePercent(): Promise<number> {
return lastValue(getMemory().pipe(
map(({ usage, free }) => +((usage / (usage + free)) * 100).toFixed(2))
));
}
通过 race 操作符,当 v2 与 v3 任何一个版本的接口可用时,防腐层都可以正常工作,在组件层无需再关注接口受环境的影响。
四 额外应用
防腐层不仅仅是多了一层对接口的封装与隔离,它还能起到以下作用。
1 概念映射
接口语义与前端需要数据的语义有时并不能完全对应,当在组件层直接调用接口时,所有开发者都需要对接口与界面的语义映射足够了解。有了防腐层后,防腐层提供的调用方法包含了数据的真实语义,减少了开发者的二次理解成本。
2 格式适配
在很多情况下,接口返回的数据结构与格式与前端需要的数据格式并不符合,通过在防腐层增加数据转换逻辑,可以降低接口数据对业务代码的入侵。在以上的案例里,我们封装了 getMemoryUsagePercent 的数据返回,使得组件层可以直接使用百分比数据,而不需要再次进行转换。
3 接口缓存
对于多种业务依赖同一接口的情况,我们可以通过防腐层增加缓存逻辑,从而有效降低接口的调用压力。
与格式适配类似,将缓存逻辑封装在防腐层可以避免组件层对数据的二次缓存,并可以对缓存数据集中管理,降低代码的复杂度,一个简单的缓存示例如下。
class CacheService {
private cache: { [key: string]: any } = {};
getData() {
if (this.cache) {
return of(this.cache);
} else {
return fromFetch("/api/v3/memory").pipe(
mergeMap((res) => res.json()),
map((data) => data.data),
tap((data) => {
this.cache = data;
})
);
}
}
}
4 稳定性兜底
当接口稳定性较差时,通常的做法是在组件层对 response error 的情况进行处理,这种兜底逻辑通常比较复杂,组件层的维护成本会很高。我们可以通过防腐层对稳定性进行兜底,当接口出错时可以返回兜底业务数据,由于兜底数据统一维护在防腐层,后续的测试与修改也会更加方便。在上文中的多版本共存的防腐层中,增加以下代码,此时即使 v2 和 v3 接口都无法返回数据,前端仍然可以保持可用。
return race(getMemoryLegacy(), current).pipe(
+ catchError(() => of({ usage: '-', free: '-' }))
);
5 联调与测试
接口和前端可能会存在并行开发的状态,此时,前端的开发并没有真实的后端接口可用。与传统的搭建 mock api 的方式相比,在防腐层直接对数据进行 mock 是更方便的方案。
export function getMemoryFree(): Observable<number> {
return of(0.8);
}
export function getMemoryUsage(): Observable<number> {
return of(1.2);
}
export function getMemoryUsagePercent(): Observable<number> {
return forkJoin([getMemoryUsage(), getMemoryFree()]).pipe(
map(([usage, free]) => +((usage / (usage + free)) * 100).toFixed(2))
);
}
在防腐层对数据进行 mock 也可以用于对页面的测试,例如 mock 大量数据对页面性能影响。
export function getLargeList(): Observable<string[]> {
const options = [];
for (let i = 0; i < 100000; i++) {
const value = `${i.toString(36)}${i}`;
options.push(value);
}
return of(options);
}
五 总结
在本文中我们介绍了以下内容:
- 前端面对接口频繁变动时的困境及原因如何
- 防腐层的设计思想与技术选型
- 使用 Observable 实现防腐层的代码示例
- 防腐层的额外作用
请读者注意,只在特定的场景下引入前端防腐层才是合理的,即前端处于跟随者或供应商/客户关系中,且面临大量接口无法保障稳定和兼容。如果在防腐层可以在后端 Gateway 构建,或者接口数量较少时,引入防腐层带来的额外成本会大于其带来的好处。
RxJS 在防腐层构建场景下提供的更多的是 Observable 化的能力,如果读者不需要复杂的 operators 转换工具,也可以自行构建 Observable 构建方案,事实上只需要 100 行的代码就可以实现 mini-rxjs - StackBlitz。
改造后的前端架构将不再直接依赖接口实现,不会入侵现有前端数据层设计,还可以承担概念映射、格式适配、接口缓存、稳定性兜底以及协助联调测试等工作。文中所有的示例代码都可以在仓库 GitHub - vthinkxie/rxjs-acl: Anti Corruption Layer with RxJS 获得。
本文为阿里云原创内容,未经允许不得转载。