前端技术探索

网络库的封装和实现

背景

背景: 虽然在前端领域有很多成熟的请求库, 但是在实际的开发过程中, 很难符合契合实际的开发需求

比如经典的库: axios 并不具备以下功能

  • 请求重试
  • 请求缓存
  • 请求幂等
  • 请求串行
  • 请求并发

SWR / VueRequest 虽然提供的三方功能很多, 仍然存在

  • 与上层框架过度绑定导致开发场景受限, 无法提供统一的 API
  • 成熟度不很完全(?) 不能自己随时开口子改设计
  • 没有聚合基础的请求库, 依然需要手动整合
  • 公共库并不包含内部制定的协议规范, 即便使用公共库,也需要进行二次封装

需要自行封装一套适配公司业务的前端请求库

方案和设计

库结构的宏观设计

整个库结构包含三层, 从下往上依次是:

  • 请求实现层: 提供请求基本功能
  • request-core: 提供网络上层控制, 比如请求串行, 请求并行, 请求重试, 请求防重等功能
  • request-bus: 为请求绑定业务功能, 该层是接入公司内部协议规范和接口文档, 向外提供业务接口 API

层是对代码逻辑的划分,具体实现有很多种

  • 每一层是一个包, 可以使用多包管理(momorepo)
  • 每个层是一个项目子文件夹
  • …..

优化设计

在请求实现层的实现有多种方式

  • 基于 XHR 原生 || 基于 fetch 原生

这种实现的多样性可能导致这一层的不稳定, 而 request-imp 是基础层, 这样会导致向上传递不稳定性,为了隔离这种情况
我们使用 DIP(Dependence Inversion Principle 依赖倒置原则) 彻底将 request-core 和请求的实现解耦

比如下面的示意代码

request-core

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 定义接口 并不实现
export interface Requestor {
get(url: string, options: RequestOptions): Promise<Response>
// ...
}

// 本模块的大部分功能都需要使用到Requestor
let req: Requestor
export function inject(requestor: Requestor) {
req = requestor
}
export function useRequestor() {
return req
}

// 创建一个可以重试的请求
export function createRetryRequestor(maxCount = 5) {
const req = useRequestor()
// 进一步配置
return req
}

// 创建一个并发请求
export function createParallelRequestor(maxCount = 5) {
const req = useRequestor()
// 进一步配置
return req
}

request-axios-imp 代码示意

1
2
3
4
5
6
7
8
9
10
11
import { Requestor } from 'request-core'
import axios from 'axios'

const ins = axios.create()

export requestor:Requestor = {
get(url,options) {
// 使用axios实现
}
// 其他方法...
}

request-bus 代码示意

1
2
3
import { requestor } from 'request-axios-imp'
import { inject } from 'request-core'
inject(requestor)

这样一来, 如果实现改变时, 无须对 reques-core 做改动, 仅需新增实现并改动依赖即可

比如, 将来如果改为使用 fetch api 来完成请求, 仅需要做一下改动

新增库 request-fetch-imp

1
2
3
4
5
6
7
8
9
10
import { Requestor } from 'request-core'

const ins = axios.create()

export requestor:Requestor = {
get(url,options) {
// 使用fetch实现
}
// 其他方法...
}

请求缓存

请求缓存是指创建一个带有缓存的请求, 当没有命中缓存时发送请求并缓存结果,当有缓存时直接返回缓存

1
2
3
4
5
const req = createCacheRequest()
req.get('/a') // 请求
req.get('/a') // 使用缓存
req.get('/b') // 请求
req.get('/b') // 使用缓存

要实现此功能需要考虑几个核心的问题

请求结果怎么存? 存在那些地方? 缓存键是什么?

我们希望用户能够指定缓存方案(内存/持久化), 同时也能够指定缓存键

1
2
3
4
5
6
7
const req = createCacheRequestor({
key: (config) {
// config 为某次请求的配置
return config.pathname // 使用pathname作为缓存键
},
persist: true // 是否开启持久化缓存
})

存储有多种方案, 不同的方案能够存储的格式不同, 支持的功能不同, 使用的 API 不同, 兼容性不同

为了去除这种差异, 避免将来存储方案变动时对其他代码造成影响, 需要设计一个稳定的借口来抹除不同方案的差异

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export interface CacheStore {
has(key: string): Promise<boolean>
set<T>(key: string, ...values: T[]): Promise<void>
get<T>(key: string): Promise<T>
// 其他字段
}

export function useCacheStore(isPersist): CacheStore {
if (!isPersist) {
return createMemoryStore()
} else {
return createStorage()
}
}

缓存何时失效? 基于时间还是其他条件?

我们希望用户能够指定缓存如何失效

1
2
3
4
5
6
7
8
const req = createCacheRequestor({
duration: 1000 * 60 * 60 //指定缓存时间
isValid (key, config){
// 自定义缓存是否有效,提供配置后,duration配置失效
// key表示缓存键, config表示此次请求配置
// 返回true表示缓存有效
}
})

如何实现?

核心逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function createCacheRequestor(cacheOptions) {
// 参数归一化
const options = normalizeOptions(cacheOptions)
// 使用缓存仓库
const store = useCacheSotre(options.persist)
// 获得请求实例
const req = useRequestor()
// 对请求进行配置
return req
}

// 对请求进行配置
// 注册请求发送前的事件
req.on('beforeRequest', async (config) => {
const key = option.key(config) // 获得缓存键
const hashKey = await store.has(key) // 是否存在缓存
if (hashKey && option.isValid(key, config)) {
// 返回缓存结果
}
})

req.on('responseBody', (config, resp) => {
const key = options.key(config) // 获得缓存键
store.set(key, resp.toPlain())
})

请求幂等

幂等性是一个数字概念, 常见于抽象代数 f(n) = 1 ^ n 无论 n 的值是多少, f(n)不变为 1
在网络请求中, 很多接口都要求幂等性, 比如支付, 同一订单多次支付和一次支付对用户余额的影响应该是一样的

要解决这个问题就必须保证: 要求幂等的请求不能重复提交

这里的关键问题就是定义什么是重复?

我们可以把重复定义为: 请求方法, 请求头, 请求体完全一致

因此我们可以使用hash将它们编码成一个字符串

1
2
3
4
5
6
7
8
9
10
function genKey(req) {
const spark = new SparkMD5()
spark.append(req.url)
for (const [key, value] of req.headers) {
spark.append(key)
spark.append(value)
}
spark.append(req.body)
return spark.end()
}

当请求幂等时, 直接返回缓存结果即可
在实现上, 可以直接利用缓存功能实现

1
2
3
4
5
6
function createIdempotentRequestor(genKey) {
return createCacheRequestor({
key: (config) => genKey > genKey(config) : hashRequest(config),
persist: false
})
}

样本代码

公司 API 接口数量庞大并且时常变化, 如果 request-bus 层是全部人工处理不仅耗时, 而且容易出错, 可以通过一些标准化的工具让整个过程自动化

样版代码的生成

使用 node 开发了命令行工具 request-template-cli 用于样本代码的生成
接口平台提供了生成标准格式的 API,通过请求 API 即可生成标准的 JSON 描述文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"endpoints": {
"article": {
"publishArticle": {
"path": "/api/article",
"description": "发布文章",
"method": "POST",
"auth": true,
"idempotent": true,
"cache": false,
"pager": false
// 其他字段
},
"getArticles": {
"path": "/api/article",
"description": "获取文章",
"method": "GET",
"auth": false,
"idempotent": false,
"cache": false,
"pager": true
// 其他字段
}
}
}
}

生成的样版代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// request-bus/templet/article.ts

import { createIdempotentRequest } from 'request-core'

/**
* 发布文章
*/
export const publishArticle = (() => {
const req = createIdempotentRequest()
return async (article: Article) => {
return req.post('/api/article', article).then((resp) => resp.json())
}
})()

/**
* 获取文章
*/
export const getArticles = (() => {
return async (page: number, size: number) => {
return req
.get('/api/article', {
params: {
page,
size
}
})
.then((resp) => resp.json())
}
})()

大文件上传

背景

Saas 平台中包含企业资料, 会议视频等大文件的上传, 如果不做特殊处理将遇到以下的问题

  1. 网络中断, 程序异常退出等问题导致文件上传失败, 而不得不重新上传
  2. 同一文件被不同用户反复上传, 白白占用网络和服务器储存资源

问题和方案

大文件上传的普遍方案是文件分片上传

如果把文件上传看做是一个不可分割的事务, 那么分片的目标就是把一个耗时的大事务划分为一个一个的小事务

由于使用 BFF 层来承接前端的文件请求, 因此需要开通前后端所有跟文件上传的阻碍

分片上传的主要障碍集中在

  • 如何减少页面阻塞
  • 前后端如何协调
  • 代码如何组织
  • 前端代码中的复杂逻辑
  • BFF 代码中的复杂逻辑

如何减少页面阻塞

分片上传的一个首要目标就是要尽量避免相同的分片重复上传. 服务器必须要能够识别来自各个客户端的各个上传请求中, 是否存在与过去分片相同的上传请求

服务器如何识别那些分片是相同的呢?

首先需要对相同下一个定义: 文件内容一样即为相同
可以对文件内容进行二进制的对比是一个非常耗时的操作, 于是可以可以选择基于内容的hash表示对比

hash 是一种算法, 可以将任何长度的数据转化成定长的数据

不仅针对分片如此, 针对整个文件也是如此

可见, 客户端需要承担两件重要的事情:

  1. 对文件进行分片. 并计算每个分片的 hash 值
  2. 根据所有的 hash 值, 计算整个文件的 hash 值

而计算 Hash 是一件 CPU 密集型的操作, 如果不加处理将会导致长时间的阻塞主线程

一般的做法

为了解决这个问题, 可以对大文件上传做一个假设: 绝大部分的文件上传都是新文件上传

有了这个假设, 我们就无须等待整体 hash 的计算结果, 直接上传分片即可, 同时可以把分片操作使用多线程+异步的方式进行上传处理

这样做的好处是: 页面完全无阻塞, 也无须等待整体 hash 即可启动上传, 相比于传统方案

  1. 对于新文件上传可以缩短整体上传时间, 消除页面的阻塞
  2. 对于旧文件上传可能会产生一些无效的请求, 但这些请求仅传递的是 hash, 并不真实上传文件数据, 所以对网络和服务器影响很小, 加上旧文件上传情况相对较少, 所以整体可以忽略不计

前后端如何协调

文件上传涉及到前后端的交互, 需要建立一个标准的通信协议, 通过协议要能完成下面几件核心交互:

  • 创建文件
  • hash 校验
  • 分片数据上传
  • 分片合并

创建文件协议

当客户端发送分片到服务器,需要告知服务器分片属于那一次文件上传,因此需要一个唯一标识来标识某一次文件上传

创建文件协议就是用于获取文件上传的唯一标识

  • uploadToken: 文件上传的唯一标识
  • chunkSize: 分片大小, 单位字节

hash 校验协议

客户端有时需要校验单个分片或整个文件的 hash, 服务器需要告知客户端它们目的的具体情况

  • Upload-Hash-Type: 取值 chunk 或 file, 分别代表分片 hash 和文件整体 hash
  • Upload-Hash: 分片或文件的具体 hash 值
  • hasFile: 指示服务器是否已经存储了对应的分片或文件
  • rest: 当校验文件 hash 时持有的响应字段, 指示该文件还剩余那些 hash 没有上传
  • url: 当校验文件 hash 时持有响应字段, 如果该文件已经完成上传出现字段, 表示文件而请求地址

分片数据上传协议

通过此协议. 上传具体的文件分片数据

分片合并协议

当所有的分片全部上传后, 通过此协议请求服务器完成分片合并

代码如何组织

大文件上传 SDK 的搭建分为三层

  • 上传协议: 约定前后端的通信格式
  • upload-core: 基于协议的 API, 提供协议字段的创建, 读取, 前后端通用工具函数等核心功能
  • upload-client: 应用于客户端的 SDK
  • upload-server: 用于 BFF 的 SDK

upload-core 中的通用函数

EventEmitter

统一前后端涉及到的基于各种事件的处理, 使用发布订阅模式提供统一的 EventEmitter 类

前端可能出现的各种事件: 上传进度改变事件, 上传暂停/恢复事件等等
后端可能出现的各种事件: 分片写入完成事件, 分布合并完成事件等等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
export class EventEmitter<T extends string> {
private events: Map<T, Set<Function>>
constructor() {
this.events = new Map()
}
on(event: T, listener: Function) {
if (!this.events.has(event)) {
this.events.set(event, new Set())
}
this.events.get(event)?.add(listener)
}
off(event: T, listener: Function) {
if (!this.events.has(event)) return
this.events.get(event)?.delete(listener)
}
once(event: T, listener: Function) {
const onceListener = (...args: any[]) => {
listener(...args)
this.off(event, listener)
}
this.on(event, listener)
}
emit(event: T, ...args: any[]) {
if (!this.events.has(event)) {
return
}
this.events.get(event)?.forEach((listener) => {
listener(...args)
})
}
}

TaskQueue

为了支撑前后端的多任务并发执行, 提供 TaskQueue 类

前端可能的并发执行: 并发请求
后端可能的并发执行: 并发的分片 Hash 校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// 可并发执行的任务队列
export class TaskQueue extends EventEmitter<'start' | 'pause' | 'drain'> {
// 待执行的任务
private tasks: Set<Task> = new Set()
// 当前正在执行的任务数
private currentCount = 0
// 任务状态
private status: 'paused' | 'running' = 'paused'
// 最大并发数
private concurency: number = 4
constructor() {
super()
this.concurency = this.concurency
}
// 添加任务
add(...tasks: Task[]) {
for (const t of tasks) {
this.tasks.add(t)
}
}
// 添加任务并执行启动
addAndStart(...tasks: Task[]) {
this.add(...tasks)
this.start()
}
// 启动任务
start() {
if (this.status === 'running') {
return
}
if (this.tasks.size === 0) {
// 当前无任务 触发drain事件
this.emit('drain')
return
}
// 设置任务状态为running
this.status = 'running'
// 触发start事件
this.emit('start')
// 开始执行下一个任务
this.runNext()
}
private takeHeadTask() {
const task = this.tasks.values().next().value
if (task) {
this.tasks.delete(task)
}
return task
}
// 执行下一个任务
private runNext() {
if (this.status !== 'running') {
// 如果整体的任务状态不是running 结束
return
}
if (this.currentCount >= this.concurency) {
// 并发已满 结束
return
}
// 取出第一个任务
const task = this.takeHeadTask()
if (!task) {
// 没有任务了
this.status = 'paused' // 暂停执行
this.emit('drain')
return
}
this.currentCount++
// 执行任务
Promise.resolve(task.run()).finally(() => {
// 任务执行完成后, 当前任务数执行下一个任务
this.currentCount--
this.runNext()
})
}
// 暂停
pause() {
this.status = 'paused'
this.emit('pause')
}
}

前端(upload-client)代码中的复杂问题

前端涉及到两个核心问题:

  • 如何对文件分片
  • 如何控制请求
如何对文件分片

首先要实现分片对象的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// chunk.ts
export interface Chunk {
blob: Blob // 分片的二进制数据
start: number // 分片的起始位置
end: number // 分片的结束位置
hash: string // 分片的hash值
index: number // 分片在文件的索引
}

// 创建一个不带hash的chunk
export function createChunk(
file: File,
index: number,
chunkSize: number
): Chunk {
const start = index * chunkSize
const end = Math.min((index + 1) * chunkSize, file.size)
const blob = file.slice(start, end)
return {
blob,
start,
end,
hash: '',
index
}
}

// 计算chunk的hash值
export function calcChunkHash(chunk: Chunk): Promise<string> {
return new Promise((resolve) => {
const spark = new SparkMD5.ArrayBuffer()
const fileReader = new FileReader()
fileReader.onload = (e) => {
spark.append(e.target?.result as ArrayBuffer)
resolve(spark.end())
}
fileReader.readAsArrayBuffer(chunk.blob)
})
}

接下来, 要对整个文件进行分片, 分片的方式有很多, 如:

  • 普通分片
  • 基于多线程的分片
  • 基于主线程时间切片的分片 (React Fiber)
  • 其他分片模式

考虑到通用性, 必须要向上层提供不同的分片模式, 同时还要允许上层自定义分片模式, 因此在设计上, 使用基于抽象类的模板模式来完成处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
export type ChunkSplitorEvents = 'chunks' | 'wholeHash' | 'drain'

export abstract class ChunkSplitor extends EventEmitter<ChunkSplitorEvents> {
protected chunkSize: number // 分片大小 (单位字节)
protected file: File // 待分片的文件
protected hash?: string // 整个文件的hash
protected chunks: Chunk[] // 分片列表
private handleChunkCount = 0 // 已计算hash的分片数据
private spark = new SparkMD5() // 计算Hash的工具
private hasSplited = false // 是否已经分片
constructor(file: File, chunkSize: number = 1024 * 1024 * 5) {
super()
this.file = file
this.chunkSize = chunkSize
// 获取分片数组
const chunkCount = Math.ceil(this.file.size / this.chunkSize)
this.chunks = new Array(chunkCount)
.fill(0)
.map((_, index) => createChunk(this.file, index, this.chunkSize))
}
split() {
if (this.hasSplited) {
return
}
this.hasSplited = true
const emitter = new EventEmitter<'chunks'>()
const chunkHanlder = (chunks: Chunk[]) => {
this.emit('chunks', chunks)
chunks.forEach((chunk) => {
this.spark.append(chunk.hash)
})
this.handleChunkCount += chunks.length
if (this.handleChunkCount === this.chunks.length) {
// 计算完成
emitter.off('chunks', chunkHanlder)
this.emit('wholeHash', this.spark.end())
this.spark.destroy()
this.emit('drain')
}
}
emitter.on('chunks', chunkHanlder)
this.calcHash(this.chunks, emitter)
}
abstract calcHash(chunks: Chunk[], emitter: EventEmitter<'chunks'>): void
// 分片完成后一些需要销毁的工作
abstract dispose(): void
}

基于此抽象类, 即可实现多种形式的分片模式, 每种模式只需要继承ChunkSplitor, 实现计算分片的 hash 即可

比如: 基于多线程的分片类可以非常简单的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// MutilThreadSplitor.ts
export class MutilThreadSplitor extends ChunkSplitor {
private workers: Worker[] = new Array(navigator.hardwareConcurrency || 4)
.fill(0)
.map(
() =>
new Worker(new URL('./SplitWorker.ts', import.meta.url), {
type: 'module'
})
)
calcHash(chunks: Chunk[], emitter: EventEmitter<'chunks'>): void {
const workerSize = Math.ceil(chunks.length / this.workers.length)
for (let i = 0; i < this.workers.length; i++) {
const worker = this.workers[i]
const start = i * workerSize
const end = Math.min((i + 1) * workerSize, chunks.length)
const workerChunks = chunks.slice(start, end)
worker.postMessage(workerChunks)
worker.onmessage = (e) => {
emitter.emit('chunks', e.data)
}
}
}
dispose(): void {
this.workers.forEach((worker) => worker.terminate())
}
}

// SplitWorker.ts
onmessage = function (e) {
const chunks = e.data as Chunk[]
for (const chunk of chunks) {
calcChunkHash(chunk).then((hash) => {
chunk.hash = hash
postMessage([chunk])
})
}
}
如何控制请求

对请求的控制涉及多个方面的问题:

  1. 如何充分利用带宽
    分片上传中涉及到大量的请求发送, 这些请求即不能一起发送造成网络阻塞, 也不能依次发送浪费带宽资源, 因此需要有请求并发控制的机制
    **方案: ** 利用基础库的 TaskQueue 实现并发控制
  2. 如何与上层请求库解耦
    考虑到通用性, 上层应用可能会使用各种请求库发送请求, 因此前端 SDK 不能绑定任何的请求库
    方案: 使用策略模式对请求库解耦

比如 请求策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 请求策略
export interface RequestStrategy {
// 文件创建请求, 返回token
createFile(file: File): Promise<string>
// 分片上传请求
uploadChunk(chunk: Chunk): Promise<void>
// 文件合并请求, 返回文件url
mergeFile(token: string): Promise<string>
// hash校验请求
patchHash<T extends 'file' | 'chunk'>(
token: string,
hash: string,
type: T
): Promise<
T extends 'file'
? { hasFile: boolean }
: { hasFile: boolean; rest: number[]; url: string }
>
}

请求控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
export class UploadController {
private requestStrategy: RequestStrategy // 请求策略,没有传递则使用默认策略
private splitStrategy: ChunkSplitor // 分片策略,没有传递则默认多线程分片
private taskQueue: TaskQueue // 任务队列
// 其他属性略

// 初始化
async init() {
// 获取文件token
this.token = await this.requestStrategy.createFile(this.file)
// 分片事件监听
this.splitStrategy.on('chunks', this.handleChunks.bind(this))
this.splitStrategy.on('wholeHash', this.handleWholeHash.bind(this))
}

// 分片事件处理
private handleChunks(chunks: Chunk[]) {
// 分片上传任务加入队列
chunks.forEach((chunk) => {
this.taskQueue.addAndStart(new Task(this.uploadChunk.bind(this), chunk))
})
}

async uploadChunk(chunk: Chunk) {
// hash校验
const resp = await this.requestStrategy.patchHash(
this.token,
chunk.hash,
'chunk'
)
if (resp.hasFile) {
// 文件已存在
return
}
// 分片上传
await this.requestStrategy.uploadChunk(chunk, this.uploadEmitter)
}

// 整体hash事件处理
private async handleWholeHash(hash: string) {
// hash校验
const resp = await this.requestStrategy.patchHash(this.token, hash, 'file')
if (resp.hasFile) {
// 文件已存在
this.emit('end', resp.url)
return
}
// 根据resp.rest重新编排后续任务
// ...
}
}

后端代码中的复杂问题

相对于客户端而言, 服务器面临着更大的挑战

如何隔离不同的文件上传?

在创建文件协议中, 服务器使用 uuid + jwt 生成一个不可纂改的唯一编码, 用于标识不同的文件上传

如何保证分片不重复?

重复的含义是指:

  1. 不保存重复分片
  2. 不上传重复分片

这就要求分片跨文件唯一, 并且永不删除

也就是: 服务器并不保存合并之后的文件, 仅记录文件中的分片顺序

合并分片到底做什么?

合并会造成很多问题, 比如:

  1. 极其耗时(需要找到文件并组装)
  2. 数据冗余

所以服务器并不发生真正的合并, 而是在数据库中记录文件中包含的分片

因此,合并操作时, 服务器仅做简单的处理:

  1. 校验文件大小
  2. 校验文件 hash
  3. 标记文件状态
  4. 生成文件访问地址

上面操作效率很高

实际访问文件

由于服务器并未发生真正的文件合并, 当后续请求该文件时, 服务器需要动态处理, 具体的做法是:

  1. 服务器收到对文件的请求, 并在数据库中找到了对应的文件
  2. 服务器读取文件的所有分片 ID, 依次找到对应的分片文件
  3. 服务器利用 TaskQueue 的并发控制能力, 逐步产生文件读取流, 并利用管道直接输出到网络 I/O

ABT 在前端基建中的实践

背景

产品有时无法确定哪种设计方案更好,因此希望前端能够同时上线多个产品方案,根据某套规则将用户导流到不同的方案。

在用户体验理论研究中,这种做法称之为 A/B Testing(后续简称 ABT)。

一次 ABT 实验会生成至少两套方案(对照组/实验组),并且可以允许多个实验共存。

ABT 实验会涉及多个岗位的协调,包含:前端、后端、测试、运维、产品,其中起主要作用的是产品和前端。

问题和方案

ABT 为前端带来诸多的挑战,其中包括:

如何协作?

在一个实验生命周期内涉及到哪些角色,角色之间是如何协作的?

前端如何开发?

实验具有以下几个特点:

  1. 多个实验共存
    产品可能会先后发起几十个甚至上百个实验,不同的实验有不同的分流规则,每个实验又有多个对照组
  2. 实验是精确到组件的,一个实验对应到多个前端组件
    一个组件不同的对照组之间的差异是灵活的
  3. 实验是频繁的
  4. 用户参与实验必须是无感的
  5. 实验推全后只保留一个对照组

流程和结构

ABT 运作流程

ABT SDK 的结构

image-20240306191832940

整个 ABT-SDK 包含了诸多 API 和工具,为应用开发提供支撑,其中

  • ABTCore: 提供 ABT 最底层的核心功能,比如实验信息、分流控制、代码剪枝、数据决策等等
  • ABT-Server: 针对服务器提供一些中间件
  • ABT-Vue/ABT-React: 针对前端两种框架提供一些组件、仓库、路由等
  • ABT-Webpack/ABT-Vite: 针对前端两种常见构建工具,提供一些插件集成,比如 ESLint 工具、PostCSS 插件、命令行工具等等

如何分流?

  1. 使用 Redis 存储当前每个实验不同对照组的参与人数
  2. 使用浏览器指纹+用户身份保证同一用户对同一实验仅参与一个组
    两种做法
    • 将指纹+用户身份+组打包成 JWT 发送给客户端(不精准,成本低)
    • 使用数据库保存映射关系(精准,成本高)
  3. 按照规则中的分流比例为新用户分配组别
  4. 将所有实验的 ID,以及每个组别的编号下发到客户端

如何改变运行代码?

实验和组别对运行时的影响主要是渲染组件的不同,但也有可能对其他代码造成影响。

由于每次实验所产生的差异是极其灵活的,因此难以使用一种标准化的静态格式来描述差异,这就不可避免的造成了对业务代码的侵入。

基建的一个重要目标就是要将这种侵入最小化、标准化。

提供高阶组件屏蔽组件差异

vue 示例

1
2
3
4
5
6
7
8
9
10
11
<ABTesting name="exp1">
<template #default>
<DefaultComp></DefaultComp>
</template>
<template #groupB>
<GroupBComp></GroupBComp>
</template>
<template #groupC>
<GroupCComp></GroupCComp>
</template>
</ABTesting>

react 示例

1
2
3
4
5
6
7
<ABTesting
name="exp1"
groupB={<GroupBComp></GroupBComp>}
groupC={<GroupCComp></GroupCComp>}
>
<DefaultComp></DefaultComp>
</ABTesting>

提供高阶函数屏蔽 API 差异

1
2
3
4
5
6
7
8
export const utilMethod = ABTCore.choose(
'exp1',
defaultMethod,
groupBMethod,
groupCMethod
)

const result = ABTCore.call('exp1', defaultMethod, groupBMethod, groupCMethod)

使用自定义指令屏蔽 CSS 差异

1
2
3
4
5
6
7
8
9
10
11
12
13
/* style.css */
@ab-testing exp1 {
default {
/* default styles */
.a {
}
}
groupB {
/* groupB styles */
.a {
}
}
}

利用自定义的 PostCSS 插件,会将上面的代码转换为

1
2
3
4
exp1-default-a {
}
exp1-groupb-a {
}

与此同时,我们也改变了 CSS Modules。

默认情况下,开启 CSS Modules 后,上面的代码会被转换为下面的 JS

1
2
3
4
export default {
'exp1-default-a': 'hash1',
'exp1-groupB-a': 'hash2'
}

我们对此作了改变,将代码变成了:

1
2
3
4
5
6
7
8
9
10
11
import { chooseValue } from 'ABTCore'
export default (function () {
return chooseValue('exp1', {
default: {
a: 'hash1'
},
groupB: {
a: 'hash2'
}
})
})()

实验推全后如何处理?

当产品完成实验后,会选定一种方案进行推全。

此时,会涉及到对应实验的代码如何剪枝的问题?

由于实验 SDK 并不向外界暴露当前用户所处的实验分组,因此,业务开发者要根据不同分组进行不同处理的代码逻辑必须使用实验 SDK 才能完成。

这就对自动化的实验推全提供了基础,由于所有的实验代码都是使用 SDK 完成的,因此可以通过一个简洁的逻辑即可完成自动化实验推全:

  1. 实验 SDK 为各种构建工具提供插件
  2. 打包时,插件会通过代码分析(AST),找出当前哪些文件对应到哪些实验
  3. 插件会对照最新的实验信息,找到已经被推全的实验
  4. 插件定位到所有与该实验有关的源码文件
  5. 插件提示开发者,是否对已推全的实验进行剪枝
  6. 开发者确认后,插件自动修改 AST 完成剪枝

通过 AST 完成剪枝逻辑是非常容易的

比如针对组件的剪枝

剪枝前

1
2
3
4
5
6
7
8
9
10
11
<ABTesting name="exp1">
<template #default>
<DefaultComp></DefaultComp>
</template>
<template #groupB>
<GroupBComp></GroupBComp>
</template>
<template #groupC>
<GroupCComp></GroupCComp>
</template>
</ABTesting>

剪枝后(假设将 groupB 推全)

1
<GroupBComp></GroupBComp>

细节问题?

白屏问题

对于一个 CSR 应用,它的组件渲染取决于所处的组别,而它所属哪个组别又必须通过网络通信才能确定。

这就导致了首屏渲染的白屏问题。

而我们观察到整个应用中实际上只有部分组件会参与到实验,对于没有参与到实验的组件是不需要等待分组信息的。

因此,我们将参与到实验的组件制作为异步组件,从而可以不影响其他组件的渲染。

image-20240306194033162

代码检查问题

由于实验推全时需要对代码进行剪枝,剪枝发生在编译时态,它通过 AST 检查代码中包含的 ABT-SDK 代码完成,而大部分 ABT-SDK 中的 API 都需要绑定实验名称,例如:

1
ABTCore.call('exp1', defaultMethod, groupBMethod, groupCMethod)

如果实验名称来自于一个变量或表达式或者其他需要在运行时才能确定的值,这就会导致剪枝失败。

因此我们制作了 ESLint 插件来约束开发者必须使用字面量或者其他在编译时态能确定的值。

开发规范

ABT-SDK 不会暴露用户的分组信息给开发者,这主要是考虑到开发者可能写出下面的代码:

1
2
3
4
5
if (用户的分组 === 'B') {
// 代码1
} else if (用户的分组 === 'C') {
// 代码2
}

这样的代码无法被代码剪枝工具察觉,容易在实验推全后仍然保留在代码中,虽然功能性不受影响,但会逐步降低代码的可维护性。

以上是不暴露的主要原因。

但开发者仍然有可能间接的获取到用户的分组,比如:

1
2
3
4
5
6
7
8
9
const data = ABTCore.data('exp1', {
groupB: 'B',
groupC: 'C'
})
if (data === 'B') {
// 代码1
} else if (data === 'C') {
// 代码2
}

这种代码很难通过自动化工具检查处理,因此需要通过开发规范来约束:

所有跟实验相关的处理,必须通过 ABT-SDK 完成