直播模块架构迭代之路 | 架构设计和管理的一些粗浅思考

74 阅读12分钟

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 新的业务需求

  1. web端可以支持多SDK播放(Agora/腾讯云/开源SRS等)。

换句话说,web端可以根据后端返回的配置实现多sdk的直播播放。比如,A供应商服务器宕机了,web端可以快速切换成B供应商的直播。

  1. 基于WebRTC直播的衍生业务涌现
  • 直播渲染模块需要使用WebRTC直播码流中携带的SEI数据,实现和直播画面同步的渲染效果
  • 直播质量的埋点和直播异常监控监控

2.2.2 原先直播架构的局限性

  1. 业务代码直接引入供应商的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;
  });
});
  1. 直播状态仅局限于业务组件,没有考虑过直播这一核心业务未来必然会衍生出各种各样的业务

如果按照原来的写法,实现这些需求可能会带来以下问题

  • 直播数据收集难:每个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 新的架构和实现

  1. 如何从容地提供直播组件的数据和状态供其他衍生业务?
  • 核心层抽象:定义直播能力标准接口、事件等。直播组件收集和处理同一类型的数据,不需要区分到底使用的是什么sdk。
  • 集中式状态管理:使用类似于pinia的第三方库,衍生的其他业务可以在直播的store中取到自己需要的数据
  1. 如何让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 原先直播架构的局限性

  1. 无法实现核心直播组件功能的按需使用 比如一些业务组件仅需要开关直播的功能,但是有一些业务需要更加复杂的直播功能

  2. 原先的直播模块与业务方的高耦合和低扩展性,违反了开闭原则(对扩展开放,对修改关闭)。

  • 原先的直播模块如果不满足业务方的需求,如果业务方想对直播模块做一些修改,必须修改直播模块本身,违反了开闭原则
  • 作为公共模块提供方的我,需要花费大量时间进行code review和业务上的沟通

2.3.3 新的架构和实现

  1. 如何实现核心直播组件功能的按需使用?
  • 功能配置化:创建直播模块时可以使用SDKConfig来配置自己需要的功能
  • 非核心功能插件化:如果不满足需求
  1. 业务模块应该如何修改直播模块?
  • 装饰器模式、插件化:设计装饰器基类,实现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 什么是架构设计?什么又是架构管理?

  1. 架构设计是技术方案的具体实现,涉及功能分解、技术选型等(例如选择什么前端框架或微服务架构),关注单点功能或者单模块交付。

  2. 架构管理则是全局性的决策过程,需在业务不确定性技术可行性之间权衡,决定何时“过度设计”或“拉胯设计”。需在现有业务,未来扩展和维护、开发人力、团队水准、交付时间等约束下逼近最优解。架构管理的本质是"判断与取舍",而非单纯的技术实现。



3.2 架构决策的核心逻辑

我们先定义两个概念

  1. 过度设计: 在团队承受范围内的更高要求和扩展性的设计,不是脱离现实、天马行空的设计
  2. 拉跨设计:满足业务即可,以最快速度交付,别留下太大坑的设计

最理想的情况是每次交付某一模块或者功能的时候,希望有一个恰好的方案,既不会过度设计、又不会设计的太拉跨。 但是现实的情况是,我们没有办法预测真实的用户场景和未来业务发展情况,所以最终一定会选择过度设计或者拉胯设计,这是一个程度的问题。

所以,我们每次开发一个功能或者业务的时候,都需要对这个功能的架构设计和程度进行一个决策。决策的逻辑可能有如下几种

3.2.1 业务预期驱动决策

  1. 新 + 核心业务:选择“过度设计”以预留扩展性。此处的“过度”并非盲目复杂化,而是在团队能力范围内拔高要求。例如,指出,过度设计需避免“生搬硬套外部架构”,而应结合业务评估。
  2. 新 + 非核心业务:选择“拉胯设计”,即满足当前需求即可。避免过早通用性设计,但要避免过多技术债务。
  3. 旧 + 业务扩展:因预期体量增长而“过度设计”,需关注重构成本与未来收益的平衡。

3.2.2 成本和资源驱动决策

  1. 投入产出比:需量化架构落地的开发成本、运维成本与预期收益。举例说明
  • 假设过度设计导致不能核心业务不能如期交付,核心业务也可能变成非核心。这时候就不能完全按业务预期来决策了。
  • 如果过度设计和拉跨设计投入的成本差别不大,通常会决策为过度设计。
  1. 硬件资源、人力资源对决策的影响:
  • 硬件资源(服务器配置)、人力资源(团队技术栈匹配度)直接影响技术选型
  • 过度设计需控制在团队能力边界内,避免因技术复杂度导致交付失败

3.3 直播架构迭代复盘

直播版本直播1.0直播2.0直播3.0
业务价值新 + 核心业务旧 + 核心业务旧 + 核心业务
业务确定性
实现成本高(重构)
开发者能力初级中级中级
理论架构决策拉跨设计适度过度过度设计
实际架构决策拉跨设计有扩展性,但后面来看是拉跨设计未知
原因开发者设计能力不足 + 业务压力业务发展预估不足--