1. 前言
笔者在过去的一年多中的大部分时间都在和webrtc直播打交道,这一年中,我们的前端直播代码经历了天翻地覆的改变,涉及到了很多关于直播架构的改变。我将简单阐述过去这一年的直播架构迭代,以及自己对软件架构的一些朴素思考
“ 我们过去在交付压力下笃信"够用就好"的朴素理念,却在需求迭代时陷入牵一发而动全身的架构泥潭,同时我也逐渐明白:过度设计对于组织来说也不一定是一件好事。架构设计追求的是局部最优解,而架构管理需要的是全局生存智慧 ”
2. 直播架构迭代的奋斗史
在聊架构的一些思考前,我会先阐述直播架构的迭代历史
2.1 直播架构 1.0 时代
2.1.1 业务需求
- 快速实现基于实时音视频供应商Agora 的 Web SDK的直播播放功能
- 满足基础直播画面渲染需求
- 完成简单的开始/停止直播控制
2.1.2 架构和实现
基本没有架构,对着demo一把搜哈即可,以下仅展示一些最基本的官网demo代码
import { ref, onMounted, onUnmounted } from 'vue'
import AgoraRTC from 'agora-rtc-sdk-ng'
const localPlayer = ref<HTMLElement | null>(null)
let client: IAgoraRTC['client']
// 初始化RTC客户端
const initRTC = async () => {
client = AgoraRTC.createClient({ mode: 'live', codec: 'h264' })
// 监听远程用户发布流
client.on('user-published', async (user, mediaType) => {
await client.subscribe(user, mediaType)
if (mediaType === 'video') {
user.videoTrack?.play(localPlayer.value!)
}
})
// 设置观众角色
await client.setClientRole('audience') // [[4]]
await client.join(APP_ID, CHANNEL, TOKEN)
}
onMounted(async () => {
try {
await initRTC()
// 如果是主播需要调用initLocalStream()
} catch (err) {
console.error('初始化失败:', err)
}
})
</script>
2.2 直播架构 2.0 时代
2.2.1 新的业务需求
- web端可以支持多SDK播放(Agora/腾讯云/开源SRS等)。
换句话说,web端可以根据后端返回的配置实现多sdk的直播播放。比如,A供应商服务器宕机了,web端可以快速切换成B供应商的直播。
- 基于WebRTC直播的衍生业务涌现
- 直播渲染模块需要使用WebRTC直播码流中携带的SEI数据,实现和直播画面同步的渲染效果
- 直播质量的埋点和直播异常监控监控
2.2.2 原先直播架构的局限性
- 业务代码直接引入供应商的SDK进行开发
如果按照原来的写法,实现这些需求可能会带来以下问题
- 业务组件的硬编码问题:实现这个需求就需要在一个业务组件中同时引入n种SDK,通过if else的逻辑来处理种种逻辑。
- 功能实现必然陷入混乱:各sdk的实现同一功能需要的流程、函数名都不同:比如实现一个最简单的直播播放功能,Agora是需要依赖于“直播房间”这个概念的,但是SRS没有这个概念。此外,对于相同作用的函数,各sdk的函数命名也不相同
- 测试噩梦:每修改一行代码,都无法准确知道其影响范围。此外,SDK的组合随让测试用例数呈指数级增长(n个SDK产生2^n-1种组合)
- 无法维护:未来每新增一个新功能,修改范围都是未知的,且修改范围必然极大。
没有重构,硬怼的话就可能产生如下代码:
// 案例1:开启直播函数大致实现
if (vendor === 'agora') {
const client = AgoraRTC.createClient({mode: 'live'});
await client.join(APP_ID, CHANNEL, TOKEN);
} else if (vendor === 'tencent') {
const player = new TcPlayer({autoplay: true});
player.src(PLAY_URL);
} else if (vendor === 'srs') {
const stream = await navigator.mediaDevices.getUserMedia(...);
const pc = new RTCPeerConnection();
pc.addTransceiver(stream.getVideoTracks()[0]);
}
// 案例2: 组件内部状态碎片化,容易造成时序问题、内容泄露等
const resolution = ref('');
const bitrate = ref(0);
agoraClient.on('video-size-changed', (w, h) => {
resolution.value = `${w}x${h}`;
});
tencentPlayer.on('resolutionUpdate', ({width, height}) => {
resolution.value = `${width}x${height}`;
});
srsConnection.on('track', (track) => {
track.on('metrics', ({bitrate: br}) => {
bitrate.value = br;
});
});
- 直播状态仅局限于业务组件,没有考虑过直播这一核心业务未来必然会衍生出各种各样的业务
如果按照原来的写法,实现这些需求可能会带来以下问题
- 直播数据收集难:每个sdk都可能有自己特有标明直播状态数据的事件或者函数,需要分别写对应的收集函数
- 直播数据处理难:每个sdk标识同一个含义的直播状态的状态名称也不尽相同
- 难以维护:衍生数据出现问题也不知道是哪个sdk出现了问题,哪个步骤出现问题。
没有重构,硬怼的话就可能产生如下代码:
const liveStore = useLiveStore()
// livestreams.vue 组件内部状态
const liveState = reactive({
isPlaying: false,
resolution: '1920x1080',
bitrate: 2500,
seiData: null
});
// AR组件中需要同步状态
const arState = ref({});
// 需要手动同步状态
watch(() => state.resolution, (val) => {
liveStore.SET_RESOLUTION(val)
});
watch(arState, (val) => {
liveStore.SET_AR_DATA(val)
});
// A_SDK收集分辨率
A_SDK.on('video-size-changed', (w, h) => {
liveState.resolution = `${w}x${h}`;
});
// B_SDK收集分辨率
B_SDK.on('video_status_receive', (state) => {
liveState.resolution = `${state.resolution.width}x${state.resolution.height};
});
.....
// A_SDK收集sei并处理成业务数据
A_SDK.on('on_sei_message_received', (event: onSEIMessageEvent) => {
arState.value = processSEI(eventS.sei);
});
// B_SDK收集sei并处理成业务数据
B_SDK.on('message_received', (sei: Uint8Array) => {
arState.value = processSEI(sei);
});
.....
2.2.3 新的架构和实现
- 如何从容地提供直播组件的数据和状态供其他衍生业务?
- 核心层抽象:定义直播能力标准接口、事件等。直播组件收集和处理同一类型的数据,不需要区分到底使用的是什么sdk。
- 集中式状态管理:使用类似于pinia的第三方库,衍生的其他业务可以在直播的store中取到自己需要的数据
- 如何让web端地优雅支持多Web SDK播放?
- 工厂模式:在所有的sdk都实现了核心层的抽象能力以后,所有sdk类都使用工厂模式进行创建, 直播组件无需关注使用的是哪个供应商的sdk,极大地简化了直播组件的业务实现。
直播组件实现直播功能的时候,无需关注使用的是哪个供应商的sdk,极大地简化了直播组件的业务实现。
核心层抽象
/** 直播服务抽象层(LiveStreamService.ts) */
interface ILiveStreamService {
// 初始化SDK(动态注入配置)
init(config: SDKConfig): Promise<void>;
// 加入频道(隔离不同SDK参数差异)
joinChannel(options: {
domId: string;
token: string;
channelId: string;
uid?: number;
}): Promise<void>;
// 事件统一抽象
on(event: 'resolution-changed', callback: (res: { width: number, height: number }) => void): void;
on(event: 'sei-received', callback: (seiData: string) => void): void;
// 状态统一获取
getStats(): Promise<StreamStats>;
// 销毁实例
destroy(): void;
// ......
}
// 声网SDK适配器(AgoraAdapter.ts)
export class AgoraAdapter implements ILiveStreamService {
private client: IAgoraRTCClient;
async joinChannel(options: JoinOptions) {
// 具体声网实现
this.client = AgoraRTC.createClient({ mode: 'live' });
await this.client.join(options.token, options.channelId, options.uid);
// 转换SDK特有事件为统一事件
this.client.on('video-resolution-changed', (res) => {
this.emit('resolution-changed', res); // 统一事件总线
});
}
// ......
}
工厂模式
// SDK工厂(LiveSDKFactory.ts)
export function createSDKAdapter(type: SDKType, config: SDKConfig): ILiveStreamService {
switch(type) {
case 'agora': return new AgoraAdapter(config);
case 'tencent': return new TencentAdapter(config);
case 'srs': return new SRSWebRTCAdapter(config);
default: throw new Error('Unsupported SDK type');
}
}
直播组件使用
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { createSDKAdapter } from '@/services/LiveSDKFactory'
// 直播服务实例
let liveService: ILiveStreamService | null = null
const liveStore = useLiveStore()
// 初始化直播
function initLiveStream () {
try {
// 1. 创建SDK适配器实例
liveService = createSDKAdapter(props.sdkType, props.sdkConfig)
// 2. 注册事件监听
liveService.on('resolution-changed', handleResolutionChange)
liveService.on('sei-received', handleSEIData)
// 3. 初始化SDK
await liveService.init(props.sdkConfig)
await liveService.joinChannel({
domId: videoContainer.value.id,
...props.channelParams
})
} catch (error) {}
}
// 事件处理
function handleResolutionChange ({ width, height }: { width: number; height: number }) {
liveStore.SET_RESOLUTION(...)
......
}
function handleSEIData (sei: string) {
liveStore.SET_SEI_DATA(...)
......
}
// 组件挂载时初始化
onMounted(() => {
initLiveStream()
})
// 组件卸载时清理
onUnmounted(() => {
if (liveService) {
// 销毁SDK实例
liveService.destroy()
liveService = null
......
}
})
// 监听SDK类型变化
watch(() => props.sdkType, (newVal) => {
......
})
</script>
2.3 直播架构 3.0 时代
2.3.1 新的业务需求
直播组件需要开放出去给其他业务组进行使用,每个业务对直播组件都可能出现定制化需求。从而衍生了以下需求
- 每个业务对现有直播组件的功能需求不一样,有一些仅需要播放直播即可,有一些需要更加高阶的需求
- 现有直播组件可能无法满足业务组的需求,但是基础直播组件业务又不能让其他业务随意修改,避免架构和代码出现腐化,软件熵增过快
2.3.2 原先直播架构的局限性
-
无法实现核心直播组件功能的按需使用 比如一些业务组件仅需要开关直播的功能,但是有一些业务需要更加复杂的直播功能
-
原先的直播模块与业务方的高耦合和低扩展性,违反了开闭原则(对扩展开放,对修改关闭)。
- 原先的直播模块如果不满足业务方的需求,如果业务方想对直播模块做一些修改,必须修改直播模块本身,违反了开闭原则
- 作为公共模块提供方的我,需要花费大量时间进行code review和业务上的沟通
2.3.3 新的架构和实现
- 如何实现核心直播组件功能的按需使用?
- 功能配置化:创建直播模块时可以使用SDKConfig来配置自己需要的功能
- 非核心功能插件化:如果不满足需求
- 业务模块应该如何修改直播模块?
- 装饰器模式、插件化:设计装饰器基类,实现
ILiveStreamService
接口,并透明转发方法调用。实现具体的装饰器类,如日志、SEI解析、监控等。 构建组合工厂类LiveServiceBuilder
,允许链式添加装饰器,并在构建时按顺序应用。
核心层抽象
直播类的设计
// LiveServiceBuilder.ts
export class LiveServiceBuilder {
private _service: ILiveStreamService;
private _decorators: Array<(service: ILiveStreamService) => ILiveStreamService> = [];
/**
* 初始化构建器
* @param type SDK类型
* @param config SDK基础配置
*/
constructor(type: SDKType, config: SDKConfig) {
this._service = createSDKAdapter(type, config);
}
/**
* 添加日志装饰层
* @param logger 可选自定义日志实现
*/
withLogging(logger?: LiveLogger): this {
this._decorators.push(service => new LoggingDecorator(service, logger));
return this;
}
/**
* 添加SEI解析装饰层
* @param parser 可选自定义SEI解析器
*/
withSEIParsing(parser?: SEIParser): this {
this._decorators.push(service => new SEIDecorator(service, parser));
return this;
}
/**
* 添加自定义装饰层
* @param decoratorFactory 装饰器工厂函数
*/
withCustomDecorator(
decoratorFactory: (service: ILiveStreamService) => ILiveStreamService
): this {
this._decorators.push(decoratorFactory);
return this;
/**
* 构建最终服务实例
*/
build(): ILiveStreamService {
return this._decorators.reduce(
(acc, decorate) => decorate(acc),
this._service
);
}
}
业务方对直播模块进行自定义功能组合或者修改
// 假设一个基于SEI数据渲染AR的组件需要使用直播模块
const createARLiveService = () => {
return new LiveServiceBuilder('agora', {
appId: 'YOUR_APP_ID',
token: 'TEMPORARY_TOKEN'
})
.withLogging(new SentryLogger()) // 接入Sentry日志
.withSEIParsing(new ARSEIParser()) // AR专用SEI解析
.build();
};
// 在Vue组件中使用
const arLiveService = createARLiveService();
// 使用增强功能
arLiveService.on('sei-parsed', (data) => {
ARRenderer.sync(data);
});
arLiveService.on('qos-alert', (alert) => {
NotificationCenter.show(`直播质量告警: ${alert.message}`);
});
3. 关于架构演进管理的一些思考
在对直播模块进行一步步的重构的时候,恰好听到了同事们讨论关于架构设计和架构管理的议题。我这一节会总结这一部分的讨论和思考
3.1 什么是架构设计?什么又是架构管理?
-
架构设计是技术方案的具体实现,涉及功能分解、技术选型等(例如选择什么前端框架或微服务架构),关注单点功能或者单模块交付。
-
架构管理则是全局性的决策过程,需在业务不确定性和技术可行性之间权衡,决定何时“过度设计”或“拉胯设计”。需在现有业务,未来扩展和维护、开发人力、团队水准、交付时间等约束下逼近最优解。架构管理的本质是"判断与取舍",而非单纯的技术实现。
3.2 架构决策的核心逻辑
我们先定义两个概念
- 过度设计: 在团队承受范围内的更高要求和扩展性的设计,不是脱离现实、天马行空的设计
- 拉跨设计:满足业务即可,以最快速度交付,别留下太大坑的设计
最理想的情况是每次交付某一模块或者功能的时候,希望有一个恰好的方案,既不会过度设计、又不会设计的太拉跨。 但是现实的情况是,我们没有办法预测真实的用户场景和未来业务发展情况,所以最终一定会选择过度设计或者拉胯设计,这是一个程度的问题。
所以,我们每次开发一个功能或者业务的时候,都需要对这个功能的架构设计和程度进行一个决策。决策的逻辑可能有如下几种
3.2.1 业务预期驱动决策
- 新 + 核心业务:选择“过度设计”以预留扩展性。此处的“过度”并非盲目复杂化,而是在团队能力范围内拔高要求。例如,指出,过度设计需避免“生搬硬套外部架构”,而应结合业务评估。
- 新 + 非核心业务:选择“拉胯设计”,即满足当前需求即可。避免过早通用性设计,但要避免过多技术债务。
- 旧 + 业务扩展:因预期体量增长而“过度设计”,需关注重构成本与未来收益的平衡。
3.2.2 成本和资源驱动决策
- 投入产出比:需量化架构落地的开发成本、运维成本与预期收益。举例说明
- 假设过度设计导致不能核心业务不能如期交付,核心业务也可能变成非核心。这时候就不能完全按业务预期来决策了。
- 如果过度设计和拉跨设计投入的成本差别不大,通常会决策为过度设计。
- 硬件资源、人力资源对决策的影响:
- 硬件资源(服务器配置)、人力资源(团队技术栈匹配度)直接影响技术选型
- 过度设计需控制在团队能力边界内,避免因技术复杂度导致交付失败
3.3 直播架构迭代复盘
直播版本 | 直播1.0 | 直播2.0 | 直播3.0 |
---|---|---|---|
业务价值 | 新 + 核心业务 | 旧 + 核心业务 | 旧 + 核心业务 |
业务确定性 | 高 | 中 | 低 |
实现成本 | 低 | 高(重构) | 中 |
开发者能力 | 初级 | 中级 | 中级 |
理论架构决策 | 拉跨设计 | 适度过度 | 过度设计 |
实际架构决策 | 拉跨设计 | 有扩展性,但后面来看是拉跨设计 | 未知 |
原因 | 开发者设计能力不足 + 业务压力 | 业务发展预估不足 | -- |