深入浅出mediasoup—协议交互

news2024/11/24 14:21:33

本文主要分析 mediasoup 一对一 WebRTC 通信 demo 的协议交互,从协议层面了解 mediasoup 的设计与实现,这是深入阅读 mediasoup 源码的重要基础。

1. 时序图

下图是 mediasoup 客户端-服务器协议交互的总体架构,服务器是一个 Node.js 进程加一个 Worker 服务进程,客户端是一个 React 应用加一个支持 WebRTC 的浏览器,mediasoup 为客户端和服务器都提供了 SDK,服务器 SDK 封装了与 Worker 之间管道通信的协议细节,客户端 SDK 封装了 WebRTC API 接口,对外提供 ORTC 接口。

下图是 mediasoup 建立一对一 WebRTC 通信所涉及协议交互的时序图,展示了协议交互的几个重要阶段:初始化、准备、发送媒体和接受媒体,接下来的内容就来详细分析整个协议交互过程和实现细节。

2. 初始化阶段(Initialize)

Node.js 进程启动时会创建 Worker 子进程,并为每个 Worker 创建一个 WebRtcServer 对象用来承载媒体传输(在《通信框架》篇讲过,WebRtcServer 用来做端口汇聚)。同时,还会创建 WebSocket 服务,开始监听客户端连接。

客户端启动时会进行本地初始化,构造 URL 携带 roomId 和 peerId,开始连接 WebSocket 服务。服务器收到客户端连接会尝试创建房间(如果已经存在则不需创建),并在 Worker 上创建对应的 Router。

2.1. createWorker

可以在服务器的配置文件中配置启动的 Worker 数量,默认等于 cpu 核心数量。

module.exports =
{
	...

	mediasoup :
	{
		numWorkers     : Object.keys(os.cpus()).length,
		...
	}
	...
}

服务器启动后,根据配置文件调用 createWorker 创建指定数量的 Worker。

async function runMediasoupWorkers()
{
	// 从配置文件中获取启动的worker数量
	const { numWorkers } = config.mediasoup;

	for (let i = 0; i < numWorkers; ++i)
	{
		// 创建Worker
		const worker = await mediasoup.createWorker(
			{
				dtlsCertificateFile : config.mediasoup.workerSettings.dtlsCertificateFile,
				dtlsPrivateKeyFile  : config.mediasoup.workerSettings.dtlsPrivateKeyFile,
				logLevel            : config.mediasoup.workerSettings.logLevel,
				logTags             : config.mediasoup.workerSettings.logTags,
				rtcMinPort          : Number(config.mediasoup.workerSettings.rtcMinPort),
				rtcMaxPort          : Number(config.mediasoup.workerSettings.rtcMaxPort)
			});

		...

		// 保存到worker集合
		mediasoupWorkers.push(worker);
		
		...
	}
	...
}

createWorker 接口参数如下:

export async function createWorker<
	WorkerAppData extends types.AppData = types.AppData,
>({
	// 打印的日志级别
	logLevel = 'error',
	// 打印的日志标签
	logTags,
	// RTC 通信端口范围
	rtcMinPort = 10000,
	rtcMaxPort = 59999,
	// DTLS证书,所有DtlsTransport需要
	dtlsCertificateFile,
	// DTLS私钥,所有DtlsTransport需要
	dtlsPrivateKeyFile,
	// 可以配置 WebRTC 实验特性
	libwebrtcFieldTrials,
	// 自定义数据
	appData,
}: WorkerSettings<WorkerAppData> = {}): Promise<Worker<WorkerAppData>> {

createWorker 通过spawn 创建 Worker 子进程,并建立 pipe 通信。

constructor({
		logLevel,
		logTags,
		rtcMinPort,
		rtcMaxPort,
		dtlsCertificateFile,
		dtlsPrivateKeyFile,
		libwebrtcFieldTrials,
		appData,
	}: WorkerSettings<WorkerAppData>) {
	...

	// 创建Worker子进程
	this.#child = spawn(
		// worker可执行程序路径
		spawnBin,
		// worker启动参数
		spawnArgs,
		// options
		{
			// 子进程环境变量
			env: {
				MEDIASOUP_VERSION: version,
				...process.env, // 继承父进程环境变量
			},
			// 子进程跟随父进程一起退出
			detached: false,
			// 忽略子进程的标准输入(fd 0)
			// 创建管道关联子进程的标准输出(fd 1)和标准错误(fd 2),允许Node.js代码读取
			// 创建管道关联子进程的fd 3和fd 4,允许Node.js代码进行读写,用来传输自定义协议
			stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
			// 隐藏子进程控制台
			windowsHide: true,
		}

		// 保存子进程PID
		this.#pid = this.#child.pid!;

		// 基于子进程的fd 3和fd 4创建管道
		this.#channel = new Channel({
			// 主进程通过fd 3向子进程写入消息
			producerSocket: this.#child.stdio[3],
			// 主进程通过fd 4从子进程接收消息
			consumerSocket: this.#child.stdio[4],
			pid: this.#pid,
		});

		...

		// 监听子进程Worker的stdout,在主进程通过日志器输出
		this.#child.stdout!.on('data', buffer => {
			for (const line of buffer.toString('utf8').split('\n')) {
				if (line) {
					workerLogger.debug(`(stdout) ${line}`);
				}
			}
		});

		// 监听子进程Worker的stderr,在主进程通过日志器输出
		this.#child.stderr!.on('data', buffer => {
			for (const line of buffer.toString('utf8').split('\n')) {
				if (line) {
					workerLogger.error(`(stderr) ${line}`);
				}
			}
		});
	);
	...
}

2.2. createWebRtcServer

WebRtcServer 用来实现媒体通信端口聚合,可以配置其监听地址和端口,mediasoup 支持使用 TCP 或 UDP 传输媒体。

【注意】这里有两个地址,“ip”是进程监听的地址;“announceAddress”是公告给客户端来连接的地址,如果监听的是私网地址或 0.0.0.0,则一定要配置 announceAddress,否则客户端会连接失败。

module.exports =
{
	...
	mediasoup :
	{
		...
		webRtcServerOptions :
		{
			listenInfos :
			[
				{
					protocol         : 'udp',
					ip               : process.env.MEDIASOUP_LISTEN_IP || '0.0.0.0',
					announcedAddress : process.env.MEDIASOUP_ANNOUNCED_IP,
					port             : 44444
				},
				{
					protocol         : 'tcp',
					ip               : process.env.MEDIASOUP_LISTEN_IP || '0.0.0.0',
					announcedAddress : process.env.MEDIASOUP_ANNOUNCED_IP,
					port             : 44444
				}
			]
		},
		...
	}
	...
}

每个 Worker 对应创建一个 WebRtcServer,需要防止 WebRtcServer 监听端口冲突。

async function runMediasoupWorkers()
{
	...
	if (process.env.MEDIASOUP_USE_WEBRTC_SERVER !== 'false')
	{
		// 从配置文件获取选项信息
		const webRtcServerOptions = utils.clone(config.mediasoup.webRtcServerOptions);
		const portIncrement = mediasoupWorkers.length - 1;
	
		// 同一个主机上,不同Worker监听端口不能冲突
		for (const listenInfo of webRtcServerOptions.listenInfos)
		{
			listenInfo.port += portIncrement;
		}
	
		// 创建WebRtcServer
		const webRtcServer = await worker.createWebRtcServer(webRtcServerOptions);
	
		// 设置webrtcServer
		worker.appData.webRtcServer = webRtcServer;
	}
	...
}

createWebRtcServer 实现如下,在这里分配 WebRtcServer ID,向 Worker 进程发送 pipe 消息。

async createWebRtcServer<WebRtcServerAppData extends AppData = AppData>({
	listenInfos,
	appData,
}: WebRtcServerOptions<WebRtcServerAppData>): Promise<
	WebRtcServer<WebRtcServerAppData>
> {
	...
		
	// Build the request.
	const fbsListenInfos: FbsTransport.ListenInfoT[] = [];

	for (const listenInfo of listenInfos) {
		fbsListenInfos.push(
			new FbsTransport.ListenInfoT(
				listenInfo.protocol === 'udp'
					? FbsTransportProtocol.UDP
					: FbsTransportProtocol.TCP,
				listenInfo.ip,
				listenInfo.announcedAddress ?? listenInfo.announcedIp,
				listenInfo.port,
				portRangeToFbs(listenInfo.portRange),
				socketFlagsToFbs(listenInfo.flags),
				listenInfo.sendBufferSize,
				listenInfo.recvBufferSize
			)
		);
	}

	// 创建UUID作为WebRtcServer的ID
	const webRtcServerId = utils.generateUUIDv4();

	const createWebRtcServerRequestOffset =
		new FbsWorker.CreateWebRtcServerRequestT(
			webRtcServerId,
			fbsListenInfos
		).pack(this.#channel.bufferBuilder);

	// 向Worker进程发送pipe消息
	await this.#channel.request(
		FbsRequest.Method.WORKER_CREATE_WEBRTCSERVER,
		FbsRequest.Body.Worker_CreateWebRtcServerRequest,
		createWebRtcServerRequestOffset
	);

	const webRtcServer = new WebRtcServer<WebRtcServerAppData>({
		internal: { webRtcServerId },
		channel: this.#channel,
		appData,
	});

	this.#webRtcServers.add(webRtcServer);
	webRtcServer.on('@close', () => this.#webRtcServers.delete(webRtcServer));

	// Emit observer event.
	this.#observer.safeEmit('newwebrtcserver', webRtcServer);

	return webRtcServer;
}

2.3. connect

客户端启动后会立即连接 WebSocket 服务,建立双向信令传输信道。mediasoup 使用 protoo 库实现WebSocket 通信。连接 WebSocket 服务的逻辑流程描述如下:

1)app 打包时,指定 index.jsx 为入口

{
  ...
  "main": "lib/index.jsx",
	...
}
...
const PKG = require('./package');
...

function bundle(options)
{
	...
	let bundler = browserify(
		{
			entries      : PKG.main, // 入口定义
			...
		})
	...
}
...

2)加载 index.jsx 会执行 run 函数,并加载 Room 组件

// 生命周期钩子
domready(async () =>
{
	logger.debug('DOM ready');

	await utils.initialize();

	run();
});

...

// 渲染函数
render(
	<Provider store={store}>
		<RoomContext.Provider value={roomClient}>
			<Room />
		</RoomContext.Provider>
	</Provider>,
	document.getElementById('mediasoup-demo-app-container')
);

...

3)在 Room 组件加载过程中,会调用 RoomClient 的 join 方法

// 生命周期钩子
componentDidMount()
{
	const { roomClient }	= this.props;

	roomClient.join();
}

4)在 RoomClient::join 方法中创建 websocket 连接

async join()
{
	store.dispatch(
		stateActions.setMediasoupClientVersion(mediasoupClient.version));

	const protooTransport = new protooClient.WebSocketTransport(this._protooUrl);

	this._protoo = new protooClient.Peer(protooTransport);

	...
}

_protooUrl 根据页面 URL 进行构造,如果页面 URL 中没有指定,程序会自动生成 roomId 和 peerId 参数:

wss://192.168.28.164:4443/?roomId=cgxbcht8&peerId=5pwtlewx

2.4. mediasoup-version

Websocket 连接创建成功后,服务器会立即向客户端发送版本信息,版本号是从服务端 SDK 获取。

handleProtooConnection({ peerId, consume, protooWebSocketTransport })
{
	...
	
	// Notify mediasoup version to the peer.
	peer.notify('mediasoup-version', { version: mediasoup.version })
		.catch(() => {});

	...
}

服务端 SDK 中导出了 version,version 信息存储在 package.json 中。

...
export const version: string = require('../../package.json').version;
...

package.json 中 version 配置如下。

{
	"name": "mediasoup",
	"version": "3.14.6",
	...
}

2.5. createRouter

客户端 WebSocket 连接请求会携带 roomId,服务器会检查 roomId 是否已经存在,如果不存在则创建房间。

async function runProtooWebSocketServer()
{
	...
	protooWebSocketServer.on('connectionrequest', (info, accept, reject) =>
	{
		...
		queue.push(async () =>
		{
			const room = await getOrCreateRoom({ roomId, consumerReplicas });	
			...
		})
		...
	});
	...
}
async function getOrCreateRoom({ roomId, consumerReplicas })
{
	// 从map中寻找room
	let room = rooms.get(roomId);

	// 不存在则创建
	if (!room)
	{
		logger.info('creating a new Room [roomId:%s]', roomId);

		// 获取一个可用的worker(程序启动时已创建)
		const mediasoupWorker = getMediasoupWorker();

		// 创建Room
		room = await Room.create({ mediasoupWorker, roomId, consumerReplicas });

		// 保存Room
		rooms.set(roomId, room);

		// 监听room的close事件,从rooms移除关闭的房间
		room.on('close', () => rooms.delete(roomId));
	}

	return room;
}

Room::create 方法中,会调用接口 createRouter 创建 router。可以看出,一个 room 对应一个 router

	static async create({ mediasoupWorker, roomId, consumerReplicas })
	{
		logger.info('create() [roomId:%s]', roomId);

		// Create a protoo Room instance.
		const protooRoom = new protoo.Room();

		// Router media codecs.
		const { mediaCodecs } = config.mediasoup.routerOptions;

		// Create a mediasoup Router.
		const mediasoupRouter = await mediasoupWorker.createRouter({ mediaCodecs });

		...
	}

createRouter 的参数在 config.js 中配置,用来描述 router 的媒体能力。

module.exports =
{
	...
		
	routerOptions :
		{
			mediaCodecs :
			[
				{
					kind      : 'audio',
					mimeType  : 'audio/opus',
					clockRate : 48000,
					channels  : 2
				},
				{
					kind       : 'video',
					mimeType   : 'video/VP8',
					clockRate  : 90000,
					parameters :
					{
						'x-google-start-bitrate' : 1000
					}
				},
				{
					kind       : 'video',
					mimeType   : 'video/VP9',
					clockRate  : 90000,
					parameters :
					{
						'profile-id'             : 2,
						'x-google-start-bitrate' : 1000
					}
				},
				{
					kind       : 'video',
					mimeType   : 'video/h264',
					clockRate  : 90000,
					parameters :
					{
						'packetization-mode'      : 1,
						'profile-level-id'        : '4d0032',
						'level-asymmetry-allowed' : 1,
						'x-google-start-bitrate'  : 1000
					}
				},
				{
					kind       : 'video',
					mimeType   : 'video/h264',
					clockRate  : 90000,
					parameters :
					{
						'packetization-mode'      : 1,
						'profile-level-id'        : '42e01f',
						'level-asymmetry-allowed' : 1,
						'x-google-start-bitrate'  : 1000
					}
				}
			]
		},
  ...
}

createRouter 定义如下。生成 routeId,并生成创建 router 所需的 rtpCapabilities,然后向 Worker 发送管道消息创建 router。服务器支持的能力集在supportedRtpCapabilities.ts 文件中定义。

async createRouter<RouterAppData extends AppData = AppData>({
	mediaCodecs,
	appData,
}: RouterOptions<RouterAppData> = {}): Promise<Router<RouterAppData>> {
	...

	// Clone given media codecs to not modify input data.
	const clonedMediaCodecs = utils.clone<RtpCodecCapability[] | undefined>(
		mediaCodecs
	);

	// 生成RtpCapabilities,传入的codecs匹配服务器支持codecs
	const rtpCapabilities =
		ortc.generateRouterRtpCapabilities(clonedMediaCodecs);

	// 生成UUID作为Router ID
	const routerId = utils.generateUUIDv4();

	// 构建请求,请求参数只有routeId
	const createRouterRequestOffset = new FbsWorker.CreateRouterRequestT(
		routerId
	).pack(this.#channel.bufferBuilder);

	// 发送pipe消息并等待响应
	await this.#channel.request(
		FbsRequest.Method.WORKER_CREATE_ROUTER,
		FbsRequest.Body.Worker_CreateRouterRequest,
		createRouterRequestOffset
	);

	// rtpCapabilities保存在SDK创建的Route对象中
	const data = { rtpCapabilities };
	const router = new Router<RouterAppData>({
		internal: {
			routerId,
		},
		data,
		channel: this.#channel,
		appData,
	});

	this.#routers.add(router);
	router.on('@close', () => this.#routers.delete(router));

	// Emit observer event.
	this.#observer.safeEmit('newrouter', router);

	return router;
}

3. 准备阶段(Prepare)

准备阶段主要是获取服务器能力集,提前在服务器上创建一个发送transport和接收transport,为协商完成后的媒体传输做准备。

3.1. getRouterRtpCapabilities 

客户端在 WebSocket 连接创建成功后会调用 _joinRoom() 方法,获取服务器的能力集。

async join()
{
	...

	this._protoo.on('open', () => this._joinRoom());

	...
}

_joinRoom 方法中会发送 getRouterRtpCapabilities 消息并同步等待服务器响应。

async _joinRoom()
{
	try
	{
		...

		const routerRtpCapabilities =
			await this._protoo.request('getRouterRtpCapabilities');
		...
	}
	...
}

服务器收到请求,直接返回在 createRouter 阶段生成 rtpCapabilities。

async _handleProtooRequest(peer, request, accept, reject)
{
	switch (request.method)
	{
		case 'getRouterRtpCapabilities':
		{
			accept(this._mediasoupRouter.rtpCapabilities);

			break;
		}
		...
	}
	...
}

3.2. load

Device 是 mediasoup 客户端 SDK 的主要导出类,代表一个通信设备。在获取服务器的能力集后,客户端应立即使用服务器能力集作为参数初始化 Device。

async _joinRoom()
{
	...
	
	const routerRtpCapabilities =
		await this._protoo.request('getRouterRtpCapabilities');
	
	await this._mediasoupDevice.load({ routerRtpCapabilities });

	...
}

Device 会基于本地和服务器能力集,计算本端媒体接收能力集和 SCTP 能力集。在计算本端接收能力集时,首先匹配 primary codec,再匹配远端支持的 rtx codec。客户端媒体接收能力集,rtx codec由服务器决定。

async load({routerRtpCapabilities,}: {routerRtpCapabilities: RtpCapabilities;})
	: Promise<void> {
	...

	// 拷贝本地和服务器能力集
	const clonedRouterRtpCapabilities = utils.clone<RtpCapabilities>(
		routerRtpCapabilities
	);
	const nativeRtpCapabilities = await handler.getNativeRtpCapabilities();
	const clonedNativeRtpCapabilities = utils.clone<RtpCapabilities>(
		nativeRtpCapabilities
	);

	// 对两个能力集取交集
	this._extendedRtpCapabilities = ortc.getExtendedRtpCapabilities(
		clonedNativeRtpCapabilities,
		clonedRouterRtpCapabilities
	);

	// 生成媒体接收能力集
	this._recvRtpCapabilities = ortc.getRecvRtpCapabilities(
		this._extendedRtpCapabilities
	);

	// 生成SCTP能力集
	this._sctpCapabilities = await handler.getNativeSctpCapabilities();

	...
}

3.3. createWebRtcTransport

客户端可以通过在 URL 中添加“produce=false”来指示本端不发送媒体,如下所示:

https://192.168.28.164:3000/?roomId=6i7vwgur&produce=false

如果没有设置“produce=false”,则会请求服务器创建一个用来发送媒体的 WebRtcTransport。

还可以在 URL 中添加“forceTcp=true”来强制使用 TCP 来传输媒体,如下所示:

https://192.168.28.164:3000/?roomId=6i7vwgur&forceTcp=true

async _joinRoom()
{
	...
	if (this._produce)
	{
		// 请求创建WebRtcTransport
		const transportInfo = await this._protoo.request(
			'createWebRtcTransport',
			{
				forceTcp         : this._forceTcp, // 是否使用TCP传输媒体
				producing        : true,           // 支持发送媒体
				consuming        : false,          // 不支持接收媒体
				sctpCapabilities : this._useDataChannel
					? this._mediasoupDevice.sctpCapabilities
					: undefined
			});
		...
	}
	...
}

服务器收到 createWebRtcTransport 消息处理逻辑如下:

async _handleProtooRequest(peer, request, accept, reject)
{
	switch (request.method)
	{
		...
		case 'createWebRtcTransport':
		{
			// 构造createWebRtcTransport参数
			
			const {
				forceTcp,
				producing,
				consuming,
				sctpCapabilities
			} = request.data;
			
			const webRtcTransportOptions =
			{
				// WebRtcServer配置参数(监听地址和端口等信息)
				...utils.clone(config.mediasoup.webRtcTransportOptions),
				// 基于WebRtcServer创建Transport
				webRtcServer      : this._webRtcServer,
				// ICE应答超时时间???
				iceConsentTimeout : 20,
				enableSctp        : Boolean(sctpCapabilities),
				numSctpStreams    : (sctpCapabilities || {}).numStreams,
				appData           : { producing, consuming }
			};

			// 如果强制使用TCP传输媒体,则过滤非TCP地址
			if (forceTcp)
			{
				webRtcTransportOptions.listenInfos = webRtcTransportOptions.listenInfos
					.filter((listenInfo) => listenInfo.protocol === 'tcp');

				webRtcTransportOptions.enableUdp = false;
				webRtcTransportOptions.enableTcp = true;
			}

			// 调用Route接口创建Transport
			const transport =
				await this._mediasoupRouter.createWebRtcTransport(webRtcTransportOptions);

			...

			// Store the WebRtcTransport into the protoo Peer data Object.
			peer.data.transports.set(transport.id, transport);

			// 返回给客户端的信息
			accept(
				{
					id             : transport.id,
					iceParameters  : transport.iceParameters,
					iceCandidates  : transport.iceCandidates,
					dtlsParameters : transport.dtlsParameters,
					sctpParameters : transport.sctpParameters
				});

			...

			break;
		}

在 createWebRtcTransport 方法中会生成 transportId,然后向 Worker 进程发送管道消息,如果 transport 是建立在 webRtcServer 之上,则发送 ROUTER_CREATE_WEBRTCTRANSPORT_WITH_SERVER 消息,否则发送 ROUTER_CREATE_WEBRTCTRANSPORT 消息。

async createWebRtcTransport<
	WebRtcTransportAppData extends AppData = AppData,
>({
	webRtcServer,
	listenInfos,
	listenIps,
	port,
	enableUdp,
	enableTcp,
	preferUdp = false,
	preferTcp = false,
	initialAvailableOutgoingBitrate = 600000,
	enableSctp = false,
	numSctpStreams = { OS: 1024, MIS: 1024 },
	maxSctpMessageSize = 262144,
	sctpSendBufferSize = 262144,
	iceConsentTimeout = 30,
	appData,
}: WebRtcTransportOptions<WebRtcTransportAppData>): Promise<
	WebRtcTransport<WebRtcTransportAppData>
> {
	...

	// 生成UUID作为transportId
	const transportId = generateUUIDv4();

	...

	// 向Worker进程发送pipe消息
	const response = await this.#channel.request(
		webRtcServer
			? FbsRequest.Method.ROUTER_CREATE_WEBRTCTRANSPORT_WITH_SERVER
			: FbsRequest.Method.ROUTER_CREATE_WEBRTCTRANSPORT,
		FbsRequest.Body.Router_CreateWebRtcTransportRequest,
		requestOffset,
		this.#internal.routerId
	);

	...
}

3.4. createSendTransport

客户端收到 createWebRtcTransport 响应后,会调用 Device 接口创建 SendTransport,与服务器上创建的WebRtcTransport 遥相呼应。音视频复用同一个SendTransport。

async _joinRoom()
{
	...

	// 从服务器获取的一系列参数,用来创建本地Transport
	const {
		id,
		iceParameters,
		iceCandidates,
		dtlsParameters,
		sctpParameters
	} = transportInfo;

	// 本地创建SendTransport
	this._sendTransport = this._mediasoupDevice.createSendTransport(
		{
			id,
			iceParameters,
			iceCandidates,
			dtlsParameters :
			{
				...dtlsParameters,
				role : 'auto'
			},
			sctpParameters,
			iceServers             : [],
			proprietaryConstraints : PC_PROPRIETARY_CONSTRAINTS,
			additionalSettings 	   :
				{ encodedInsertableStreams: this._e2eKey && e2e.isSupported() }
		});
	...
}

createSendTransport 最终会调用 Chrome111::run(以 Chrome111 作为 HandlerInterface 实例进行说明,后同),内部会创建 PeerConnection 对象。

run({
	direction,
	iceParameters,
	iceCandidates,
	dtlsParameters,
	sctpParameters,
	iceServers,
	iceTransportPolicy,
	additionalSettings,
	proprietaryConstraints,
	extendedRtpCapabilities,
}: HandlerRunOptions): void {
	logger.debug('run()');

	// "send" or "recv"
	this._direction = direction;

	// 生成远端SDP,暂时没有媒体描述
	this._remoteSdp = new RemoteSdp({
		iceParameters,
		iceCandidates,
		dtlsParameters,
		sctpParameters,
	});

	// extendedRtpCapabilities是调用Device.load方法时生成的
	// 可以认为协商后的能力集

	// 本端能力集
	this._sendingRtpParametersByKind = {
		audio: ortc.getSendingRtpParameters('audio', extendedRtpCapabilities),
		video: ortc.getSendingRtpParameters('video', extendedRtpCapabilities),
	};

	// 远端能力集
	this._sendingRemoteRtpParametersByKind = {
		audio: ortc.getSendingRemoteRtpParameters(
			'audio',
			extendedRtpCapabilities
		),
		video: ortc.getSendingRemoteRtpParameters(
			'video',
			extendedRtpCapabilities
		),
	};

	if (dtlsParameters.role && dtlsParameters.role !== 'auto') {
		this._forcedLocalDtlsRole =
			dtlsParameters.role === 'server' ? 'client' : 'server';
	}

	// 创建PeerConnection
	this._pc = new (RTCPeerConnection as any)(
		{
			iceServers: iceServers || [],
			iceTransportPolicy: iceTransportPolicy || 'all',
			bundlePolicy: 'max-bundle',
			rtcpMuxPolicy: 'require',
			sdpSemantics: 'unified-plan',
			...additionalSettings,
		},
		proprietaryConstraints
	);

	...
}

3.5. createWebRtcTransport

客户端端可以通过在 URL 中添加“consume=false”来指示本端不接收媒体,如下所示:

https://192.168.28.164:3000/?roomId=6i7vwgur&consume=false

如果没有设置“consume=false”,则会请求服务器创建另一个用来接收媒体的 WebRtcTransport。

async _joinRoom()
{
	...
	
	if (this._consume)
	{
		// 请求创建WebRtcTransport
		const transportInfo = await this._protoo.request(
			'createWebRtcTransport',
			{
				forceTcp         : this._forceTcp, // 是否使用TCP传输媒体
				producing        : false,          // 不支持发送媒体
				consuming        : true,           // 支持接收媒体
				sctpCapabilities : this._useDataChannel
					? this._mediasoupDevice.sctpCapabilities
					: undefined
			});
		...
	}
	...
}

服务器收到createWebRtcTransport请求后,处理逻辑与1.2.3节一致,不再赘述。

3.6. createRecvTransport

客户端收到服务器响应后,会调用 Device 接口创建 RecvTransport。音视频复用同一个RecvTransport。

async _joinRoom()
{
	...

	// 从服务器获取的一系列参数,用来创建本地Transport
	const {
		id, // 服务器生成的transport ID
		iceParameters,
		iceCandidates,
		dtlsParameters,
		sctpParameters
	} = transportInfo;

	// 创建本地RecvTransport
	this._recvTransport = this._mediasoupDevice.createRecvTransport(
		{
			id,
			iceParameters,
			iceCandidates,
			dtlsParameters :
			{
				...dtlsParameters,
				role : 'auto'
			},
			sctpParameters,
			iceServers 	       : [],
			additionalSettings :
				{ encodedInsertableStreams: this._e2eKey && e2e.isSupported() }
		});

	...
}

createRecvTransport 最终会调用 Chrome111::run 方法,创建 PeerConnection 对象,相关处理逻辑与 createSendTransport 基本一致,不再赘述。

4. 加入房间(Join)

准备工作完成后,就可以加入房间了。加入房间的目的,一是与服务器交换能力集,这样客户端和服务器才能够完成能力协商;二是交换 peer 信息,这样才能够知道其他 peer 的存在并从其他 peer 接收媒体。加入房间时,如果本地要接收媒体,则需要携带本地能力集(房间中可能有其他 peer 正在发送媒体,节省一次协议交互)。

async _joinRoom()
{
	...
	const { peers } = await this._protoo.request(
		'join',
		{
			displayName     : this._displayName,
			device          : this._device,
			rtpCapabilities : this._consume
				? this._mediasoupDevice.rtpCapabilities
				: undefined,
			sctpCapabilities : this._useDataChannel && this._consume
				? this._mediasoupDevice.sctpCapabilities
				: undefined
		});
	...
}

服务器会返回房间中其他 peer,如果房间中其他用户正在发送媒体,要通知当前加入房间的 peer 接收媒体,接收媒体的逻辑放在后面再说,这里先按下不表。

async _handleProtooRequest(peer, request, accept, reject)
{
	...
	case 'join':
	{
		// Ensure the Peer is not already joined.
		if (peer.data.joined)
			throw new Error('Peer already joined');
	
		const {
			displayName,
			device,
			rtpCapabilities,
			sctpCapabilities
		} = request.data;
	
		// Store client data into the protoo Peer data object.
		peer.data.joined = true;
		peer.data.displayName = displayName;
		peer.data.device = device;
		peer.data.rtpCapabilities = rtpCapabilities;
		peer.data.sctpCapabilities = sctpCapabilities;
	
		// Tell the new Peer about already joined Peers.
		// And also create Consumers for existing Producers.
	
		const joinedPeers =
		[
			...this._getJoinedPeers(),
			...this._broadcasters.values()
		];
	
		// 返回除自己之外的Peer列表
		const peerInfos = joinedPeers
			.filter((joinedPeer) => joinedPeer.id !== peer.id)
			.map((joinedPeer) => ({
				id          : joinedPeer.id,
				displayName : joinedPeer.data.displayName,
				device      : joinedPeer.data.device
			}));
		accept({ peers: peerInfos });
		
		...
	}
	...
}

5. Send media

加入房间后,如果本地存在音视频设备,并且策略允许发送,则立即开始发送本地的音频和视频。但发送媒体的前提是完成 SDP 协商,并在服务器创建对应的 producer。

5.1. Client::produce

加入房间成功后,如果允许发送媒体,则打开麦克风和摄像头。由于已经获取到服务器的能力集,并且创建了SendTransport,已经有充足的信息完成发送媒体的协商。

async _joinRoom()
{
	...
	
	if (this._produce)
	{
		...

		// 打开麦克风
		this.enableMic();

		const devicesCookie = cookiesManager.getDevices();

		// 打开摄像头
		if (!devicesCookie || devicesCookie.webcamEnabled || this._externalVideo)
			this.enableWebcam();

		...
	}
	...
}

以 enableMic 为例,其内部会调用本地 SendTransport::produce 方法。

async enableMic()
{
	...
	
	this._micProducer = await this._sendTransport.produce(
		{
			track,
			codecOptions :
			{
				opusStereo : true,
				opusDtx    : true,
				opusFec    : true,
				opusNack   : true
			}
		});
	...
}

SentTransport::produce 方法内部先调用 Chrome111::send 方法。

async produce<ProducerAppData extends AppData = AppData>({
	track,
	encodings,
	codecOptions,
	codec,
	stopTracks = true,
	disableTrackOnPause = true,
	zeroRtpOnPause = false,
	onRtpSender,
	appData = {} as ProducerAppData,
}: ProducerOptions<ProducerAppData> = {}): Promise<
	Producer<ProducerAppData>
> {
	...

	const { localId, rtpParameters, rtpSender } =
		await this._handler.send({
			track,
			encodings: normalizedEncodings,
			codecOptions,
			codec,
			onRtpSender,
		});

	...
}

Chrome111::send 方法内部调用 setupTransport 准备 transport,然后完成 PeerConnection SDP 协商。

async send({track, encodings, codecOptions, codec,}
    : HandlerSendOptions): Promise<HandlerSendResult> {
	...

	// 调用PeerConnection接口添加transceiver,相当于添加SDP中的media section
	// 方向为sendonly
	const transceiver = this._pc.addTransceiver(track, {
			direction: 'sendonly',
			streams: [this._sendStream],
		});
	
	// 发送媒体需要客户端创建Offer
	let offer = await this._pc.createOffer();

	// 同步等待设置服务器WebRtcTransport参数
	if (!this._transportReady) {
		await this.setupTransport({
			localDtlsRole: this._forcedLocalDtlsRole ?? 'client',
			localSdpObject,
		});
	}

	...

	await this._pc.setLocalDescription(offer);

	...

	await this._pc.setRemoteDescription(answer);

	...
}

Chrome111::setupTransport 方法触发 @connect 事件,携带本端 DTLS 参数。

private async setupTransport({
	localDtlsRole,
	localSdpObject,
}: {
	localDtlsRole: DtlsRole;
	localSdpObject?: any;
}): Promise<void> {
	if (!localSdpObject) {
		localSdpObject = sdpTransform.parse(this._pc.localDescription.sdp);
	}

	// Get our local DTLS parameters.
	const dtlsParameters = sdpCommonUtils.extractDtlsParameters({
		sdpObject: localSdpObject,
	});

	// Set our DTLS role.
	dtlsParameters.role = localDtlsRole;

	// Update the remote DTLS role in the SDP.
	this._remoteSdp!.updateDtlsRole(
		localDtlsRole === 'client' ? 'server' : 'client'
	);

	// Need to tell the remote transport about our parameters.
	// 触发@connect事件,通知服务器
	await new Promise<void>((resolve, reject) => {
		this.safeEmit('@connect', { dtlsParameters }, resolve, reject);
	});

	this._transportReady = true;
}

Transport 监听 @connect 事件进一步触发 connect 事件。

handler.on(
	'@connect',
	(
		{ dtlsParameters }: { dtlsParameters: DtlsParameters },
		callback: () => void,
		errback: (error: Error) => void
	) => {
		...

		this.safeEmit('connect', { dtlsParameters }, callback, errback);
	}
);

5.2. connectWebRtcTransport

RoomClient 监听 connect 事件,向服务器发送 connectWebRtcTransport 请求,携带本端 DTLS 参数。

this._sendTransport.on(
	'connect', ({ iceParameters, dtlsParameters }, callback, errback) =>
	{
		this._protoo.request(
			'connectWebRtcTransport',
			{
				transportId : this._sendTransport.id,
				iceParameters,
				dtlsParameters
			})
			.then(callback)
			.catch(errback);
	});

服务器收到 connectWebRtcTransport 请求,调用 WebRtcTransport::connect 接口。

async _handleProtooRequest(peer, request, accept, reject)
{
	switch (request.method)
	{
		...
		case 'connectWebRtcTransport':
		{
			const { transportId, dtlsParameters } = request.data;
			const transport = peer.data.transports.get(transportId);

			if (!transport)
				throw new Error(`transport with id "${transportId}" not found`);

			await transport.connect({ dtlsParameters });

			accept();

			break;
		}
		...
	}
	...
}

WebRtcTransport::connect 方法会向 Worker 进程发送 WEBRTCTRANSPORT_CONNECT 消息,Worker 会设置 WebRTCTransport 的 DTLS 参数,并确定 DTLS 角色。

async connect({dtlsParameters,}: {dtlsParameters: DtlsParameters;}): Promise<void> {
	logger.debug('connect()');

	// 携带DTLS参数
	const requestOffset = createConnectRequest({
		builder: this.channel.bufferBuilder,
		dtlsParameters,
	});

	// 同步发送pipe消息
	const response = await this.channel.request(
		FbsRequest.Method.WEBRTCTRANSPORT_CONNECT,
		FbsRequest.Body.WebRtcTransport_ConnectRequest,
		requestOffset,
		this.internal.transportId
	);

	/* Decode Response. */
	const data = new FbsWebRtcTransport.ConnectResponse();

	response.body(data);

	// 设置DTLS角色:'auto' | 'client' | 'server'
	this.#data.dtlsParameters.role = dtlsRoleFromFbs(data.dtlsLocalRole());
}

5.3. Server::produce

WebRtcTransport 创建成功后,服务器回复 connectWebRtcTransport 响应,一路返回到调用 Chrome111::setupTransport 处,调用 setLocalDescription 和 setRemoteDescription 完成 PeerConnection SDP 协商,并触发“produce”事件。

async produce<ProducerAppData extends AppData = AppData>({
		track,
		encodings,
		codecOptions,
		codec,
		stopTracks = true,
		disableTrackOnPause = true,
		zeroRtpOnPause = false,
		onRtpSender,
		appData = {} as ProducerAppData,
	}: ProducerOptions<ProducerAppData> = {}): Promise<
		Producer<ProducerAppData>
	> {
		...

		const { localId, rtpParameters, rtpSender } =
			await this._handler.send({
				track,
				encodings: normalizedEncodings,
				codecOptions,
				codec,
				onRtpSender,
			});

		...

		const { id } = await new Promise<{ id: string }>(
			(resolve, reject) => {
				this.safeEmit(
					'produce',
					{
						kind: track.kind as MediaKind,
						rtpParameters,
						appData,
					},
					resolve,
					reject
				);
			}
		);

RoomClient 在创建 SendTransport 时,已经监听了“produce”事件,客户端 SDP 协商已经完成,需要向服务器发送 produce 请求,在服务器上创建 producer。

async _joinRoom()
{
	...
	this._sendTransport.on(
		'produce', async ({ kind, rtpParameters, appData }, callback, errback) =>
		{
			try
			{
				// eslint-disable-next-line no-shadow
				const { id } = await this._protoo.request(
					'produce',
					{
						transportId : this._sendTransport.id,
						kind,
						rtpParameters,
						appData
					});

				callback({ id });
			}
			catch (error)
			{
				errback(error);
			}
		});
	...
}

服务器收到 produce 请求,调用 Transport::produce 方法。

async _handleProtooRequest(peer, request, accept, reject)
{
	switch (request.method)
	{
		case 'produce':
		{
			...
			const producer = await transport.produce(
				{
					kind,
					rtpParameters,
					appData
					// keyFrameRequestDelay: 5000
				});
	
			// Store the Producer into the protoo Peer data Object.
			peer.data.producers.set(producer.id, producer);
	
			...
			
			accept({ id: producer.id });
	
			// Optimization: Create a server-side Consumer for each Peer.
			for (const otherPeer of this._getJoinedPeers({ excludePeer: peer }))
			{
				this._createConsumer(
					{
						consumerPeer : otherPeer,
						producerPeer : peer,
						producer
					});
			}
	
			...
	
			break;
		}

Transport::produce 方法向 Worker 发送 TRANSPORT_PRODUCE 消息,Worker 创建 producer 对象。至此,媒体发送的前期工作都已完成,浏览器开始连接 Worker 服务端口开始媒体传输。

async produce<ProducerAppData extends AppData = AppData>({
	id = undefined,
	kind,
	rtpParameters,
	paused = false,
	keyFrameRequestDelay,
	appData,
}: ProducerOptions<ProducerAppData>): Promise<Producer<ProducerAppData>> {
	logger.debug('produce()');

	if (id && this.#producers.has(id)) {
		throw new TypeError(`a Producer with same id "${id}" already exists`);
	} else if (!['audio', 'video'].includes(kind)) {
		throw new TypeError(`invalid kind "${kind}"`);
	} else if (appData && typeof appData !== 'object') {
		throw new TypeError('if given, appData must be an object');
	}

	// Clone given RTP parameters to not modify input data.
	const clonedRtpParameters = utils.clone<RtpParameters>(rtpParameters);

	// This may throw.
	ortc.validateRtpParameters(clonedRtpParameters);

	// If missing or empty encodings, add one.
	if (
		!clonedRtpParameters.encodings ||
		!Array.isArray(clonedRtpParameters.encodings) ||
		clonedRtpParameters.encodings.length === 0
	) {
		clonedRtpParameters.encodings = [{}];
	}

	// Don't do this in PipeTransports since there we must keep CNAME value in
	// each Producer.
	if (this.constructor.name !== 'PipeTransport') {
		// If CNAME is given and we don't have yet a CNAME for Producers in this
		// Transport, take it.
		if (
			!this.#cnameForProducers &&
			clonedRtpParameters.rtcp &&
			clonedRtpParameters.rtcp.cname
		) {
			this.#cnameForProducers = clonedRtpParameters.rtcp.cname;
		}
		// Otherwise if we don't have yet a CNAME for Producers and the RTP
		// parameters do not include CNAME, create a random one.
		else if (!this.#cnameForProducers) {
			this.#cnameForProducers = utils.generateUUIDv4().substr(0, 8);
		}

		// Override Producer's CNAME.
		clonedRtpParameters.rtcp = clonedRtpParameters.rtcp ?? {};
		clonedRtpParameters.rtcp.cname = this.#cnameForProducers;
	}

	const routerRtpCapabilities = this.#getRouterRtpCapabilities();

	// This may throw.
	const rtpMapping = ortc.getProducerRtpParametersMapping(
		clonedRtpParameters,
		routerRtpCapabilities
	);

	// This may throw.
	const consumableRtpParameters = ortc.getConsumableRtpParameters(
		kind,
		clonedRtpParameters,
		routerRtpCapabilities,
		rtpMapping
	);

	const producerId = id || utils.generateUUIDv4();
	const requestOffset = createProduceRequest({
		builder: this.channel.bufferBuilder,
		producerId,
		kind,
		rtpParameters: clonedRtpParameters,
		rtpMapping,
		keyFrameRequestDelay,
		paused,
	});

	// 发送pipe消息
	const response = await this.channel.request(
		FbsRequest.Method.TRANSPORT_PRODUCE,
		FbsRequest.Body.Transport_ProduceRequest,
		requestOffset,
		this.internal.transportId
	);

	/* Decode Response. */
	const produceResponse = new FbsTransport.ProduceResponse();

	response.body(produceResponse);

	const status = produceResponse.unpack();

	const data = {
		kind,
		rtpParameters: clonedRtpParameters,
		type: producerTypeFromFbs(status.type),
		consumableRtpParameters,
	};

	const producer = new Producer<ProducerAppData>({
		internal: {
			...this.internal,
			producerId,
		},
		data,
		channel: this.channel,
		appData,
		paused,
	});

	this.#producers.set(producer.id, producer);
	producer.on('@close', () => {
		this.#producers.delete(producer.id);
		this.emit('@producerclose', producer);
	});

	// 通知transport新增了一个producer
	this.emit('@newproducer', producer);

	// Emit observer event.
	this.#observer.safeEmit('newproducer', producer);

	return producer;
}

6. Receive Media

客户端从服务器接收媒体,有两个触发场景,处理逻辑是一样的。

1)Peer 加入房间时,如果房间中已经有其他 peeer 在发送媒体,则需要通知刚加入房间的 peer 接收其他 peer 发送的媒体。

2)Peer 加入房间后,开始发送媒体,服务器需要通知其他 peer 接收此 peer 发送的媒体。

6.1. newConsumer

当需要通知客户端接收媒体时,服务器会调用 _createConsumer 方法。_createConsumer 会先调用 consume 方法在 Worker 上创建 Consumer,然后向客户端发送 newConsumer 反向请求(带响应的通知)。

async _createConsumer({ consumerPeer, producerPeer, producer })
{
	...

	for (let i=0; i<consumerCount; i++)
	{
		promises.push(
			(async () =>
				{
					// Create the Consumer in paused mode.
					let consumer;

					try
					{
						consumer = await transport.consume(
							{
								producerId      : producer.id,
								rtpCapabilities : consumerPeer.data.rtpCapabilities,
								// Enable NACK for OPUS.
								enableRtx       : true,
								paused          : true
							});
					}

					...

					// Send a protoo request to the remote Peer with Consumer parameters.
					try
					{
						await consumerPeer.request(
							'newConsumer',
							{
								peerId         : producerPeer.id,
								producerId     : producer.id,
								id             : consumer.id,
								kind           : consumer.kind,
								rtpParameters  : consumer.rtpParameters,
								type           : consumer.type,
								appData        : producer.appData,
								producerPaused : consumer.producerPaused
							});

						...
				})()
			);
		}

		...
	}

Transport::consume 方法向 Worker 发送 TRANSPORT_CONSUME 消息在 Worker 上创建 Consumer。创建成功后,触发“newConsumer”事件。

async consume<ConsumerAppData extends AppData = AppData>({
	producerId,
	rtpCapabilities,
	paused = false,
	mid,
	preferredLayers,
	ignoreDtx = false,
	enableRtx,
	pipe = false,
	appData,
}: ConsumerOptions<ConsumerAppData>): Promise<Consumer<ConsumerAppData>> {
	...

  // 构造请求
	const consumerId = utils.generateUUIDv4();
	const requestOffset = createConsumeRequest({
		builder: this.channel.bufferBuilder,
		producer,
		consumerId,
		rtpParameters,
		paused,
		preferredLayers,
		ignoreDtx,
		pipe,
	});

	// 向 Worker 发送消息创建 Consumer 并同步等待
	const response = await this.channel.request(
		FbsRequest.Method.TRANSPORT_CONSUME,
		FbsRequest.Body.Transport_ConsumeRequest,
		requestOffset,
		this.internal.transportId
	);

	...

	// 创建并保存 consumer
	const consumer = new Consumer<ConsumerAppData>({});
	this.consumers.set(consumer.id, consumer);
	consumer.on('@close', () => this.consumers.delete(consumer.id));
	consumer.on('@producerclose', () => this.consumers.delete(consumer.id));

	// 触发 newConsumer 事件
	this.#observer.safeEmit('newconsumer', consumer);

	return consumer;
}

6.2. consume

RoomClient 监听 newConsumer 事件,调用 RecvTransport 的 consume 方法。

this._protoo.on('request', async (request, accept, reject) =>
{
	switch (request.method)
	{
		case 'newConsumer':
		{
			...
			const {
				peerId,
				producerId,
				id,
				kind,
				rtpParameters,
				type,
				appData,
				producerPaused
			} = request.data;

			const consumer = await this._recvTransport.consume(
				{
					id,
					producerId,
					kind,
					rtpParameters,
					streamId : `${peerId}-${appData.share ? 'share' : 'mic-webcam'}`,
					appData  : { ...appData, peerId } // Trick.
				});
			...
		}
		...
	}
	...
}

consume 方法最终会调用 chrome111::receive 方法。

async consume<ConsumerAppData extends AppData = AppData>({
	id,
	producerId,
	kind,
	rtpParameters,
	streamId,
	onRtpReceiver,
	appData = {} as ConsumerAppData,
}: ConsumerOptions<ConsumerAppData>): Promise<Consumer<ConsumerAppData>> {
	...

	const consumerCreationTask = new ConsumerCreationTask({
		id,
		producerId,
		kind,
		rtpParameters: clonedRtpParameters,
		streamId,
		onRtpReceiver,
		appData,
	});

	// Store the Consumer creation task.
	this._pendingConsumerTasks.push(consumerCreationTask);

	// 这里使用微任务进行调度
	queueMicrotask(() => {
		if (this._closed) {
			return;
		}

		if (this._consumerCreationInProgress === false) {
			this.createPendingConsumers<ConsumerAppData>();
		}
	});

	return consumerCreationTask.promise as Promise<Consumer<ConsumerAppData>>;
}
private async createPendingConsumers<ConsumerAppData extends AppData,>(): Promise<void> {
	...
	const results = await this._handler.receive(optionsList);
	...
}

由于客户端在连接服务器的时候已经拿到了服务器的能力集,可以依次执行以下动作:

1)receive 方法构造 offer 并调用 setRemoteDescription。

2)创建 answer 成功后,立即调用 connectWebRtcTransport 通知服务器准备好 transport(具体参考produce,不再赘述)。

3)同步等待服务器响应,使用创建的 answer 调用 setLocalDescription,完成 PeerConnection SDP 协商。
至此,接收通道已经准备好,接下来客户端可以连接 Worker 服务开始接受媒体。

async receive(optionsList: HandlerReceiveOptions[]): Promise<HandlerReceiveResult[]> {
	...

	await this._pc.setRemoteDescription(offer);

	let answer = await this._pc.createAnswer();

	...

	if (!this._transportReady) {
		await this.setupTransport({
			localDtlsRole: this._forcedLocalDtlsRole ?? 'client',
			localSdpObject,
		});
	}

	await this._pc.setLocalDescription(answer);

	...
}

7. 总结

如果只是完成基本的媒体通信,相比基于 SDP 的 WebRTC 协商,使用 mediasoup的 WebRTC 协商看起来更加复杂,这种感觉是正常的。这是因为 mediasoup 使用 ORTC 接口规范,ORTC 旨在简化 WebRTC 的 API,提供更加模块化和面向对象的接口,它把 WebRTC 的一整坨 API 进行了面向对象的细粒度模块划分,能够实现更灵活的控制。但这种模块化的拆分也带来了复杂性,以前的协商只需要处理 SDP 即可(包罗万象),现在要分别处理 Transport、DTLS、能力集等,再加上 mediasoup 服务器上 Worker/Producer/Consumer/Transport 等概念,如下图所示,使得 mediasoup 的交互流程理解起来更加困难。

如果你深入阅读 mediasoup client SDK 源码,会发现 SDP 与 ORTC 之间的转换逻辑更加复杂,客户端的实现其实也不简单,要真正吃透 mediaosoup client SDK 实现,需要对 WebRTC、SDP、ORTC 都有相当的理解才行。这超出了本文所讨论的范围。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1942048.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Django学习第一天(如何创建和运行app)

前置知识&#xff1a; URL组成部分详解&#xff1a; 一个url由以下几部分组成&#xff1a; scheme&#xff1a;//host:port/path/?query-stringxxx#anchor scheme:代表的是访问的协议&#xff0c;一般为http或者ftp等 host&#xff1a;主机名&#xff0c;域名&#xff0c;…

高翔【自动驾驶与机器人中的SLAM技术】学习笔记(三)基变换与坐标变换;微分方程;李群和李代数;雅可比矩阵

一、基变换与坐标变换 字小,事不小。 因为第一反应:坐标咋变,坐标轴就咋变呀。事实却与我们想象的相反。这俩互为逆矩阵。 第一次读没有读明白,后面到事上才明白。 起因是多传感器标定:多传感器,就代表了多个坐标系,多个基底。激光雷达和imu标定。这个标定程序,网上,…

秒杀优化: 记录一次bug排查

现象 做一人一单的时候&#xff0c;为了提升性能&#xff0c;需要将原来的业务改造成Lua脚本加Stream流的方式实现异步秒杀。 代码改造完成&#xff0c;使用Jmeter进行并发测试&#xff0c;发现redis中的数据和预期相同&#xff0c;库存减1&#xff0c;该用户也成功添加了进去…

HarmonyOS鸿蒙应用开发-ZRouter让系统路由表变得更简单

介绍 ZRouter是基于Navigation系统路由表和Hvigor插件实现的动态路由方案。 系统路由表是API 12起开始支持的&#xff0c;可以帮助我们实现动态路由的功能&#xff0c;其目的是为了解决多个业务模块&#xff08;HAR/HSP&#xff09;之间解耦问题&#xff0c;从而实现业务的复…

NoSQL之Redis非关系型数据库

目录 一、数据库类型 1&#xff09;关系型数据库 2&#xff09;非关系型数据库 二、Redis远程字典服务器 1&#xff09;redis介绍 2&#xff09;redis的优点 3&#xff09;Redis 为什么那么快&#xff1f; 4&#xff09;Redis使用场景 三、Redis安装部署 1&#xff0…

社交圈子小程序搭建-源码部署-服务公司

消息通知:当有新的消息、评论或回复时&#xff0c;用户需要收到系统的推送通知&#xff0c;以便及时查看和回复 活动发布与参加:用户可以在社交圈子中发布各种类型的活动&#xff0c;如聚餐、旅游、运动等。其他用户可以参加这些活动&#xff0c;并与组织者进行交流和沟通 社交…

ML.Net 学习之使用经过训练的模型进行预测

什么是ML.Net&#xff1a;&#xff08;学习文档上摘的一段&#xff1a;ML.NET 文档 - 教程和 API 参考 | Microsoft Learn 【学习入口】&#xff09; 它使你能够在联机或脱机场景中将机器学习添加到 .NET 应用程序中。 借助此功能&#xff0c;可以使用应用程序的可用数据进行自…

运行 npm install 报错-4048

我在已经开发中的项目&#xff0c;执行 npm install 命令时&#xff0c;出现报错&#xff1a; 并且之前在帖子中提到的报错类型还不一样&#xff08;帖子内容如下&#xff09;&#xff1a; 运行 npm run dev 总报错_运行npm run dev报错-CSDN博客 该报错内容主要为权限导致的&…

使用集成线性 LED 驱动器替代分立 LED 电路设计

在转向灯、刹车灯和尾灯等汽车照明中&#xff0c;LED 电路设计通常采用分立元件&#xff0c;如双极结晶体管 (BJT)。分立元件之所以突出有几个常见原因&#xff1a;它们简单、可靠且便宜。然而&#xff0c;随着 LED 数量和项目要求的增加&#xff0c;重新考虑离散设计可能是值得…

双边性:构建神经网络的新方法

正如承诺的那样&#xff0c;这是最近我遇到的最有趣的想法之一的第二部分。如果你错过了&#xff0c;请务必观看本系列的第一部分 - 神经科学家对改进神经网络的看法 - 我们讨论了双边性的生物学基础以及我们大脑的不对称性质如何带来更高的性能。 在这篇文章中&#xff0c;我…

<数据集>AffectNet表情识别数据集<目标检测>

数据集格式&#xff1a;VOCYOLO格式 图片数量&#xff1a;29752张 标注数量(xml文件个数)&#xff1a;29752 标注数量(txt文件个数)&#xff1a;29752 标注类别数&#xff1a;7 标注类别名称&#xff1a;[anger,contempt,disgust,fear,happy,neutral,sad,surprise] 序号类…

如何使用大语言模型绘制专业图表

过去的一年里&#xff0c;我相信大部分人都已经看到了大语言模型(后文简称LLM)所具备的自然语言理解和文本生成的能力&#xff0c;还有很多人将其应用于日常工作中&#xff0c;比如文案写作、资料查询、代码生成……今天我要向大家介绍LLM的一种新使用方式——绘图。这里说的绘…

HydraRPC: RPC in the CXL Era——论文阅读

ATC 2024 Paper CXL论文阅读笔记整理 问题 远程过程调用&#xff08;RPC&#xff09;是分布式系统中的一项基本技术&#xff0c;它允许函数在远程服务器上通过本地调用执行来促进网络通信&#xff0c;隐藏底层通信过程的复杂性简化了客户端/服务器交互[15]。RPC已成为数据中心…

Transformer-Bert---散装知识点---mlm,nsp

本文记录的是笔者在了解了transformer结构后嗑bert中记录的一些散装知识点&#xff0c;有时间就会整理收录&#xff0c;希望最后能把transformer一个系列都完整的更新进去。 1.自监督学习 bert与原始的transformer不同&#xff0c;bert是使用大量无标签的数据进行预训…

Spring 整合MongoDB xml解析

beans引用 xmlns:mongo"http://www.springframework.org/schema/data/mongo"xsi:schemaLocation"http://www.springframework.org/schema/data/mongo http://www.springframework.org/schema/data/mongo/spring-mongo.xsd " 具体…

学习使用Sklearn【LDA】线性判别分析,对iris数据分类!

数据集、代码均来自kaggle。地址&#xff1a;https://www.kaggle.com/datasets/himanshunakrani/iris-dataset?resourcedownload &#x1f680; 揭示线性分类器的力量:线性判别分析的探索 欢迎来到线性分类器的世界和线性判别分析(LDA)的迷人领域!&#x1f31f;在本笔记本中…

在服务器调用api操作rabbitmq

不同的rabbitmq版本可能api不同&#xff0c;仅做参考&#xff0c;RabbitMQ 3.7.18。同时&#xff0c;我基本没看官方api文档&#xff0c;根据rabbitmq客户端控制台调用接口参数来决定需要什么参数。例如&#xff1a; 1、添加用户 curl -u 用户名:密码 -H “Content-Type: a…

[亲测可用]俄罗斯方块H5-网页小游戏源码-HTML源码

本站的HTML模板资源&#xff1a;所见文章图片即所得&#xff0c;搭建和修改教程请看这篇文章&#xff1a;https://yizhi2024.top/8017.html

Maven 的模块化开发示例

Maven 的模块化开发是一种非常有效的软件开发方式&#xff0c;它允许你将一个大型的项目分割成多个更小、更易于管理的模块&#xff08;modules&#xff09;。每个模块都可以独立地构建、测试和运行&#xff0c;这不仅提高了开发效率&#xff0c;也便于团队协作和项目的维护。以…

华为云.云日志服务LTS及其基本使用

云计算 云日志服务LTS及其基本使用 - 文章信息 - Author: 李俊才 (jcLee95) Visit me at CSDN: https://jclee95.blog.csdn.netMy WebSite&#xff1a;http://thispage.tech/Email: 291148484163.com. Shenzhen ChinaAddress of this article:https://blog.csdn.net/qq_28550…