解决浏览器 WebSocket 认证难题:豆包语音识别的代理方案实践
其实在做 HagiCode 项目的语音识别功能时,我们也是满怀信心地选择了字节跳动的豆包语音识别服务。刚开始的设计很简单嘛——前端直接连豆包的 WebSocket 服务。这有什么难的?不就是建个连接,传点数据的事儿吗? 可是吧,万万没想到——豆包的 API 要求通过 HTTP header 传递认证信息,什么 accessToken、secretKey 之类的。这下就有点尴尬了,因为浏览器的 WebSocket API 根本不支持设置自定义 header。 你说不支持怎么办嘛? 那时候也是纠结了一阵子的。毕竟摆在面前的两个选择: 第一种方案吧,凭证就直接暴露在前端代码和本地存储里了。这安全吗?反正我是不太敢苟同的。而且有些 API 必须用 header 验证,根本走不通。 最终想了想,还是选了第二种方案——在后端实现一个 WebSocket 代理。说起来也是巧合,这个方案最初是在我们的 playground 试验场里验证的,后来确认稳定了才应用到生产环境。毕竟谁也不想在生产环境当小白鼠嘛,这点儿道理我还是懂的。 本文分享的方案来自我们在 HagiCode 项目中的实践经验。 HagiCode 是一个 AI 代码助手项目,支持语音交互功能。怎么说呢,也就是因为需要在前端调用语音识别服务,我们才遇到了这个 WebSocket 认证问题,也才有了后面的解决方案。有时候想想吧,困难这东西也不是完全没有好处,至少让我们学会了用代理,不是吗? 标准 WebSocket API 看起来真的很简单: 但问题就出在"简单"这两个字上——它只在 URL 里传递参数,没法像 HTTP 请求那样设置 headers: 你看看,这找谁说理去?对于豆包语音识别这类需要 header 认证的服务,这个限制简直就是一道迈不过去的坎儿。 罢了罢了,又能怎样呢? 在设计方案的时候,我们也是左思右想,权衡了又权衡。 决策一:代理模式选择 我们比较了两种方案: 最后选了原生 WebSocket。说实话,也就是因为它最轻量,适合简单的双向二进制流转发。加个 SignalR 吧,确实有点杀鸡用牛刀的感觉,而且会增加延迟——这又何苦呢? 决策二:连接管理策略 我们采用了"每连接单会话"模式——每个前端 WebSocket 连接对应一个独立的豆包后端连接。 这样做的好处也是显而易见的: 其实说白了也就是——简单粗暴有时候反而是最好的选择。复杂的方案不一定好,简单的不一定差。 决策三:认证信息存储 凭证存在后端配置文件(appsettings.yml 或环境变量)里,通过依赖注入加载: 这安全感嘛,总归是要有的。毕竟谁也不想自己的凭证满天飞,不是吗? 整体数据流是这样的: 流程倒也不复杂,也就是这么几步: 一切看起来都是那么自然,不是吗? 用 ConcurrentDictionary 管理会话,线程安全也就不用操心了。每个连接进来就创建一个 Session,断开时自动清理——这大概就是所谓的"来也匆匆,去也匆匆"罢。 配置验证嘛,也就是为了在启动时就发现问题,避免运行时出什么幺蛾子。这点儿保障还是要的。 前端和后端之间用 JSON 格式的文本消息做控制,用二进制消息传音频数据。 控制消息示例: 识别结果示例: 这种设计把控制信号和音频数据分开,处理起来也是更清晰一些。有时候分而治之确实是个不错的办法。 用 AudioWorkletNode 做音频处理,性能也会更好一些: AudioWorklet 比 ScriptProcessorNode 性能好很多,不会有音频卡顿的问题。这年代,谁还愿意听那种刺刺拉拉的噪音呢? 日志配置很重要,方便排查问题。Serilog 的 File sink 可以按天滚动,日志文件也不会太大。毕竟有些问题嘛,事后诸葛亮总是要容易一点的。 这些也就是一些基本的操作罢了。 总而言之,稳字当头。 格式不对会导致识别失败或者效果很差。这点儿规矩还是要守的。 安全无小事,且行且珍惜罢。 这些优化手段,能用上的就用上罢。 部署这事儿吧,说简单也简单,说复杂也复杂。也就是因人而异,因地制宜罢。 WebSocket 代理方案解决了浏览器 WebSocket API 不支持自定义 header 的根本问题。在 HagiCode 项目中,这个方案从 playground 验证到生产环境部署,证明了它的可行性和稳定性。 关键点总结: 如果你也在做需要 WebSocket 认证的功能,希望这个方案能给你一些启发。 有什么问题的话,欢迎来讨论。毕竟技术这东西嘛,都是在交流中进步的。 感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。 本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。解决浏览器 WebSocket 认证难题:豆包语音识别的代理方案实践
浏览器 WebSocket API 不支持自定义 HTTP header,这给需要通过 header 传递认证信息的语音识别服务带来了挑战。本文分享 HagiCode 项目中如何通过后端代理方案解决这个问题,以及从 playground 到生产环境的实践过程。
背景
关于 HagiCode
技术挑战分析
浏览器 WebSocket 的限制
const ws = new WebSocket('wss://example.com/ws');// 这在 WebSocket API 里是不支持的
const ws = new WebSocket('wss://example.com/ws', {
headers: {
'Authorization': 'Bearer token'
}
});架构设计决策
方案 优点 缺点 决策 原生 WebSocket 轻量、简单、直接转发 需手动处理连接管理 选择 SignalR 自动重连、强类型 过度复杂、额外依赖 不选 数据流设计
前端 (浏览器)
│
│ ws://backend/api/voice/ws
│ WebSocket (二进制)
▼
后端 (代理)
│
│ wss://openspeech.bytedance.com/
│ (带认证 header)
▼
豆包 API核心组件实现
1. WebSocket 端点配置
app.Map("/ws", async context =>
{
if (context.WebSockets.IsWebSocketRequest)
{
// 从查询参数读取配置
var appId = context.Request.Query["appId"];
var accessToken = context.Request.Query["accessToken"];
// 验证必需参数
if (string.IsNullOrEmpty(appId) || string.IsNullOrEmpty(accessToken))
{
context.Response.StatusCode = 400;
return;
}
// 接受 WebSocket 连接
using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
// 消息处理循环
var buffer = new byte[4096];
while (!webSocket.CloseStatus.HasValue)
{
var result = await webSocket.ReceiveAsync(buffer, CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
{
await webSocket.CloseAsync(
result.CloseStatus.Value,
result.CloseStatusDescription,
CancellationToken.None);
break;
}
// 处理音频数据
await HandleAudioDataAsync(buffer, result.Count);
}
}
});2. 会话管理
public class DoubaoSessionManager : IDoubaoSessionManager
{
private readonly ConcurrentDictionary<string, DoubaoSession> _sessions = new();
public DoubaoSession CreateSession(string connectionId)
{
var session = new DoubaoSession(connectionId);
_sessions[connectionId] = session;
return session;
}
public async Task SendAudioAsync(string connectionId, byte[] audioData)
{
if (_sessions.TryGetValue(connectionId, out var session))
{
await session.SendAudioAsync(audioData);
}
}
public void RemoveSession(string connectionId)
{
if (_sessions.TryRemove(connectionId, out var session))
{
session.Dispose();
}
}
}3. 配置验证
public class ClientConfigDto
{
public string AppId { get; set; } = null!;
public string Access set; } =Token { get; null!;
public string? ServiceUrl { get; set; }
public string? ResourceId { get; set; }
public int? SampleRate { get; set; }
public int? BitsPerSample { get; set; }
public int? Channels { get; set; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(AppId))
throw new ArgumentException("AppId is required");
if (string.IsNullOrWhiteSpace(AccessToken))
throw new ArgumentException("AccessToken is required");
}
}消息协议设计
{
"type": "control",
"messageId": "msg_123",
"timestamp": "2026-03-03T10:00:00Z",
"payload": {
"command": "StartRecognition",
"parameters": {
"hotwordId": "hotword1",
"boosting_table_id": "table123"
}
}
}{
"type": "result",
"timestamp": "2026-03-03T10:00:03Z",
"payload": {
"text": "你好世界",
"confidence": 0.95,
"duration": 1500,
"isFinal": true,
"utterances": [
{
"text": "你好",
"startTime": 0,
"endTime": 800,
"definite": true
}
]
}
}前端接入实践
WebSocket 连接
class DoubaoVoiceClient {
constructor(config) {
this.config = config;
this.ws = null;
}
async connect() {
const url = new URL(this.config.wsUrl);
// 添加查询参数
Object.entries(this.config.params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
this.ws = new WebSocket(url);
return new Promise((resolve, reject) => {
this.ws.onopen = () => {
console.log('[DoubaoVoice] Connected');
resolve();
};
this.ws.onmessage = (event) => {
this._handleMessage(JSON.parse(event.data));
};
this.ws.onerror = reject;
});
}
_handleMessage(message) {
switch (message.type) {
case 'status':
this._handleStatus(message.payload);
break;
case 'result':
this.onResult?.(message.payload);
break;
case 'error':
console.error('[DoubaoVoice] Error:', message.payload);
break;
}
}
}
// 使用示例
const client = new DoubaoVoiceClient({
wsUrl: 'ws://localhost:5000/ws',
params: {
appId: 'your-app-id',
accessToken: 'your-access-token',
sampleRate: 16000,
bitsPerSample: 16,
channels: 1
}
});音频采集与发送
// audio-worklet.js
class AudioProcessorWorklet extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
const input = inputs[0]?.[0];
if (!input) return true;
// 转换为 16-bit PCM
const pcm = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) {
pcm[i] = Math.max(-32768, Math.min(32767, input[i] * 32767));
}
this.port.postMessage({
type: 'audioData',
data: pcm.buffer
}, [pcm.buffer]);
return true;
}
}
registerProcessor('audio-processor', AudioProcessorWorklet);
// 主线程代码
async function startAudioRecording() {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
sampleRate: 48000
}
});
const audioContext = new AudioContext();
const audioSource = audioContext.createMediaStreamSource(stream);
await audioContext.audioWorklet.addModule('/audio-worklet.js');
const audioWorkletNode = new AudioWorkletNode(audioContext, 'audio-processor');
audioWorkletNode.port.onmessage = (event) => {
if (event.data.type === 'audioData' && ws?.readyState === WebSocket.OPEN) {
ws.send(event.data.data); // 直接发送二进制数据
}
};
audioSource.connect(audioWorkletNode);
}后端配置
appsettings.json 示例
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{ "Name": "Console" },
{
"Name": "File",
"Args": { "path": "logs/log-.txt", "rollingInterval": "Day" }
}
]
},
"Kestrel": {
"Urls": "http://0.0.0.0:5000"
}
}注意事项和最佳实践
1. 连接监控
2. 错误处理
3. 音频格式要求
4. 安全考虑
5. 性能优化
部署建议
总结
参考资料