找回密码
 立即注册
首页 业界区 业界 解决浏览器 WebSocket 认证难题:豆包语音识别的代理方 ...

解决浏览器 WebSocket 认证难题:豆包语音识别的代理方案实践

骂治并 昨天 09:15
解决浏览器 WebSocket 认证难题:豆包语音识别的代理方案实践

浏览器 WebSocket API 不支持自定义 HTTP header,这给需要通过 header 传递认证信息的语音识别服务带来了挑战。本文分享 HagiCode 项目中如何通过后端代理方案解决这个问题,以及从 playground 到生产环境的实践过程。
背景

其实在做 HagiCode 项目的语音识别功能时,我们也是满怀信心地选择了字节跳动的豆包语音识别服务。刚开始的设计很简单嘛——前端直接连豆包的 WebSocket 服务。这有什么难的?不就是建个连接,传点数据的事儿吗?
可是吧,万万没想到——豆包的 API 要求通过 HTTP header 传递认证信息,什么 accessToken、secretKey 之类的。这下就有点尴尬了,因为浏览器的 WebSocket API 根本不支持设置自定义 header。
你说不支持怎么办嘛?
那时候也是纠结了一阵子的。毕竟摆在面前的两个选择:

  • 把认证信息塞到 URL 查询参数里——简单粗暴
  • 在后端做一层代理——看起来麻烦一点
第一种方案吧,凭证就直接暴露在前端代码和本地存储里了。这安全吗?反正我是不太敢苟同的。而且有些 API 必须用 header 验证,根本走不通。
最终想了想,还是选了第二种方案——在后端实现一个 WebSocket 代理。说起来也是巧合,这个方案最初是在我们的 playground 试验场里验证的,后来确认稳定了才应用到生产环境。毕竟谁也不想在生产环境当小白鼠嘛,这点儿道理我还是懂的。
关于 HagiCode

本文分享的方案来自我们在 HagiCode 项目中的实践经验。
HagiCode 是一个 AI 代码助手项目,支持语音交互功能。怎么说呢,也就是因为需要在前端调用语音识别服务,我们才遇到了这个 WebSocket 认证问题,也才有了后面的解决方案。有时候想想吧,困难这东西也不是完全没有好处,至少让我们学会了用代理,不是吗?
技术挑战分析

浏览器 WebSocket 的限制

标准 WebSocket API 看起来真的很简单:
  1. const ws = new WebSocket('wss://example.com/ws');
复制代码
但问题就出在"简单"这两个字上——它只在 URL 里传递参数,没法像 HTTP 请求那样设置 headers:
  1. // 这在 WebSocket API 里是不支持的
  2. const ws = new WebSocket('wss://example.com/ws', {
  3.   headers: {
  4.     'Authorization': 'Bearer token'
  5.   }
  6. });
复制代码
你看看,这找谁说理去?对于豆包语音识别这类需要 header 认证的服务,这个限制简直就是一道迈不过去的坎儿。
罢了罢了,又能怎样呢?
架构设计决策

在设计方案的时候,我们也是左思右想,权衡了又权衡。
决策一:代理模式选择
我们比较了两种方案:
方案优点缺点决策原生 WebSocket轻量、简单、直接转发需手动处理连接管理选择SignalR自动重连、强类型过度复杂、额外依赖不选最后选了原生 WebSocket。说实话,也就是因为它最轻量,适合简单的双向二进制流转发。加个 SignalR 吧,确实有点杀鸡用牛刀的感觉,而且会增加延迟——这又何苦呢?
决策二:连接管理策略
我们采用了"每连接单会话"模式——每个前端 WebSocket 连接对应一个独立的豆包后端连接。
这样做的好处也是显而易见的:

  • 实现简单,符合典型使用场景
  • 易于调试和故障排查
  • 资源隔离,避免会话间互相干扰
其实说白了也就是——简单粗暴有时候反而是最好的选择。复杂的方案不一定好,简单的不一定差。
决策三:认证信息存储
凭证存在后端配置文件(appsettings.yml 或环境变量)里,通过依赖注入加载:

  • 配置方式简单,符合现有后端配置模式
  • 敏感信息不暴露给前端
  • 支持多环境配置(开发、测试、生产)
这安全感嘛,总归是要有的。毕竟谁也不想自己的凭证满天飞,不是吗?
数据流设计

整体数据流是这样的:
  1. 前端 (浏览器)
  2.   │
  3.   │ ws://backend/api/voice/ws
  4.   │ WebSocket (二进制)
  5.   ▼
  6. 后端 (代理)
  7.   │
  8.   │ wss://openspeech.bytedance.com/
  9.   │ (带认证 header)
  10.   ▼
  11. 豆包 API
复制代码
流程倒也不复杂,也就是这么几步:

  • 前端通过 WebSocket 连接后端代理
  • 后端代理接收音频数据,用带 header 的方式连接豆包 API
  • 豆包 API 返回识别结果,代理转发给前端
  • 全程异步双向流式传输
一切看起来都是那么自然,不是吗?
核心组件实现

1. WebSocket 端点配置
  1. app.Map("/ws", async context =>
  2. {
  3.     if (context.WebSockets.IsWebSocketRequest)
  4.     {
  5.         // 从查询参数读取配置
  6.         var appId = context.Request.Query["appId"];
  7.         var accessToken = context.Request.Query["accessToken"];
  8.         // 验证必需参数
  9.         if (string.IsNullOrEmpty(appId) || string.IsNullOrEmpty(accessToken))
  10.         {
  11.             context.Response.StatusCode = 400;
  12.             return;
  13.         }
  14.         // 接受 WebSocket 连接
  15.         using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
  16.         // 消息处理循环
  17.         var buffer = new byte[4096];
  18.         while (!webSocket.CloseStatus.HasValue)
  19.         {
  20.             var result = await webSocket.ReceiveAsync(buffer, CancellationToken.None);
  21.             if (result.MessageType == WebSocketMessageType.Close)
  22.             {
  23.                 await webSocket.CloseAsync(
  24.                     result.CloseStatus.Value,
  25.                     result.CloseStatusDescription,
  26.                     CancellationToken.None);
  27.                 break;
  28.             }
  29.             // 处理音频数据
  30.             await HandleAudioDataAsync(buffer, result.Count);
  31.         }
  32.     }
  33. });
复制代码
2. 会话管理
  1. public class DoubaoSessionManager : IDoubaoSessionManager
  2. {
  3.     private readonly ConcurrentDictionary<string, DoubaoSession> _sessions = new();
  4.     public DoubaoSession CreateSession(string connectionId)
  5.     {
  6.         var session = new DoubaoSession(connectionId);
  7.         _sessions[connectionId] = session;
  8.         return session;
  9.     }
  10.     public async Task SendAudioAsync(string connectionId, byte[] audioData)
  11.     {
  12.         if (_sessions.TryGetValue(connectionId, out var session))
  13.         {
  14.             await session.SendAudioAsync(audioData);
  15.         }
  16.     }
  17.     public void RemoveSession(string connectionId)
  18.     {
  19.         if (_sessions.TryRemove(connectionId, out var session))
  20.         {
  21.             session.Dispose();
  22.         }
  23.     }
  24. }
复制代码
用 ConcurrentDictionary 管理会话,线程安全也就不用操心了。每个连接进来就创建一个 Session,断开时自动清理——这大概就是所谓的"来也匆匆,去也匆匆"罢。
3. 配置验证
  1. public class ClientConfigDto
  2. {
  3.     public string AppId { get; set; } = null!;
  4.     public string Access set; } =Token { get; null!;
  5.     public string? ServiceUrl { get; set; }
  6.     public string? ResourceId { get; set; }
  7.     public int? SampleRate { get; set; }
  8.     public int? BitsPerSample { get; set; }
  9.     public int? Channels { get; set; }
  10.     public void Validate()
  11.     {
  12.         if (string.IsNullOrWhiteSpace(AppId))
  13.             throw new ArgumentException("AppId is required");
  14.         if (string.IsNullOrWhiteSpace(AccessToken))
  15.             throw new ArgumentException("AccessToken is required");
  16.     }
  17. }
复制代码
配置验证嘛,也就是为了在启动时就发现问题,避免运行时出什么幺蛾子。这点儿保障还是要的。
消息协议设计

前端和后端之间用 JSON 格式的文本消息做控制,用二进制消息传音频数据。
控制消息示例:
  1. {
  2.     "type": "control",
  3.     "messageId": "msg_123",
  4.     "timestamp": "2026-03-03T10:00:00Z",
  5.     "payload": {
  6.         "command": "StartRecognition",
  7.         "parameters": {
  8.             "hotwordId": "hotword1",
  9.             "boosting_table_id": "table123"
  10.         }
  11.     }
  12. }
复制代码
识别结果示例:
  1. {
  2.     "type": "result",
  3.     "timestamp": "2026-03-03T10:00:03Z",
  4.     "payload": {
  5.         "text": "你好世界",
  6.         "confidence": 0.95,
  7.         "duration": 1500,
  8.         "isFinal": true,
  9.         "utterances": [
  10.             {
  11.                 "text": "你好",
  12.                 "startTime": 0,
  13.                 "endTime": 800,
  14.                 "definite": true
  15.             }
  16.         ]
  17.     }
  18. }
复制代码
这种设计把控制信号和音频数据分开,处理起来也是更清晰一些。有时候分而治之确实是个不错的办法。
前端接入实践

WebSocket 连接
  1. class DoubaoVoiceClient {
  2.     constructor(config) {
  3.         this.config = config;
  4.         this.ws = null;
  5.     }
  6.     async connect() {
  7.         const url = new URL(this.config.wsUrl);
  8.         // 添加查询参数
  9.         Object.entries(this.config.params).forEach(([key, value]) => {
  10.             url.searchParams.set(key, value);
  11.         });
  12.         this.ws = new WebSocket(url);
  13.         return new Promise((resolve, reject) => {
  14.             this.ws.onopen = () => {
  15.                 console.log('[DoubaoVoice] Connected');
  16.                 resolve();
  17.             };
  18.             this.ws.onmessage = (event) => {
  19.                 this._handleMessage(JSON.parse(event.data));
  20.             };
  21.             this.ws.onerror = reject;
  22.         });
  23.     }
  24.     _handleMessage(message) {
  25.         switch (message.type) {
  26.             case 'status':
  27.                 this._handleStatus(message.payload);
  28.                 break;
  29.             case 'result':
  30.                 this.onResult?.(message.payload);
  31.                 break;
  32.             case 'error':
  33.                 console.error('[DoubaoVoice] Error:', message.payload);
  34.                 break;
  35.         }
  36.     }
  37. }
  38. // 使用示例
  39. const client = new DoubaoVoiceClient({
  40.     wsUrl: 'ws://localhost:5000/ws',
  41.     params: {
  42.         appId: 'your-app-id',
  43.         accessToken: 'your-access-token',
  44.         sampleRate: 16000,
  45.         bitsPerSample: 16,
  46.         channels: 1
  47.     }
  48. });
复制代码
音频采集与发送

用 AudioWorkletNode 做音频处理,性能也会更好一些:
  1. // audio-worklet.js
  2. class AudioProcessorWorklet extends AudioWorkletProcessor {
  3.     process(inputs, outputs, parameters) {
  4.         const input = inputs[0]?.[0];
  5.         if (!input) return true;
  6.         // 转换为 16-bit PCM
  7.         const pcm = new Int16Array(input.length);
  8.         for (let i = 0; i < input.length; i++) {
  9.             pcm[i] = Math.max(-32768, Math.min(32767, input[i] * 32767));
  10.         }
  11.         this.port.postMessage({
  12.             type: 'audioData',
  13.             data: pcm.buffer
  14.         }, [pcm.buffer]);
  15.         return true;
  16.     }
  17. }
  18. registerProcessor('audio-processor', AudioProcessorWorklet);
  19. // 主线程代码
  20. async function startAudioRecording() {
  21.     const stream = await navigator.mediaDevices.getUserMedia({
  22.         audio: {
  23.             echoCancellation: true,
  24.             noiseSuppression: true,
  25.             autoGainControl: true,
  26.             sampleRate: 48000
  27.         }
  28.     });
  29.     const audioContext = new AudioContext();
  30.     const audioSource = audioContext.createMediaStreamSource(stream);
  31.     await audioContext.audioWorklet.addModule('/audio-worklet.js');
  32.     const audioWorkletNode = new AudioWorkletNode(audioContext, 'audio-processor');
  33.     audioWorkletNode.port.onmessage = (event) => {
  34.         if (event.data.type === 'audioData' && ws?.readyState === WebSocket.OPEN) {
  35.             ws.send(event.data.data); // 直接发送二进制数据
  36.         }
  37.     };
  38.     audioSource.connect(audioWorkletNode);
  39. }
复制代码
AudioWorklet 比 ScriptProcessorNode 性能好很多,不会有音频卡顿的问题。这年代,谁还愿意听那种刺刺拉拉的噪音呢?
后端配置

appsettings.json 示例
  1. {
  2.   "Serilog": {
  3.     "MinimumLevel": {
  4.       "Default": "Information",
  5.       "Override": {
  6.         "Microsoft": "Warning",
  7.         "System": "Warning"
  8.       }
  9.     },
  10.     "WriteTo": [
  11.       { "Name": "Console" },
  12.       {
  13.         "Name": "File",
  14.         "Args": { "path": "logs/log-.txt", "rollingInterval": "Day" }
  15.       }
  16.     ]
  17.   },
  18.   "Kestrel": {
  19.     "Urls": "http://0.0.0.0:5000"
  20.   }
  21. }
复制代码
日志配置很重要,方便排查问题。Serilog 的 File sink 可以按天滚动,日志文件也不会太大。毕竟有些问题嘛,事后诸葛亮总是要容易一点的。
注意事项和最佳实践

1. 连接监控


  • 定期输出会话状态日志,方便追踪连接生命周期
  • 监控音频段数量和持续时间,识别异常连接
  • 记录与豆包服务的连接状态和重连情况
这些也就是一些基本的操作罢了。
2. 错误处理


  • 捕获并记录所有 WebSocket 异常
  • 使用 IAsyncDisposable 确保资源清理
  • 实现优雅的连接关闭和超时处理
总而言之,稳字当头。
3. 音频格式要求


  • 采样率:16000 Hz(推荐)或 8000 Hz
  • 位深度:16-bit
  • 声道:单声道
  • 编码:PCM (raw)
格式不对会导致识别失败或者效果很差。这点儿规矩还是要守的。
4. 安全考虑


  • 敏感凭证只存在后端配置里
  • 实施连接数限制防止资源耗尽
  • 生产环境用 HTTPS/WSS
安全无小事,且行且珍惜罢。
5. 性能优化


  • 用异步操作避免阻塞
  • 适当调整缓冲区大小(默认 4096 字节)
  • 考虑连接池和复用策略
这些优化手段,能用上的就用上罢。
部署建议


  • Docker 部署:把代理服务打包成容器,方便扩展和管理
  • 负载均衡:用 Nginx 或 Envoy 做 WebSocket 反向代理
  • 健康检查:实现心跳机制监控服务可用性
  • 日志聚合:把日志发送到集中式日志系统(如 ELK、Loki)
部署这事儿吧,说简单也简单,说复杂也复杂。也就是因人而异,因地制宜罢。
总结

WebSocket 代理方案解决了浏览器 WebSocket API 不支持自定义 header 的根本问题。在 HagiCode 项目中,这个方案从 playground 验证到生产环境部署,证明了它的可行性和稳定性。
关键点总结:

  • 后端代理可以安全地传递认证信息
  • 原生 WebSocket 轻量高效,适合简单场景
  • "每连接单会话"简化了实现和调试
  • 前后端消息协议分离控制信号和音频数据
如果你也在做需要 WebSocket 认证的功能,希望这个方案能给你一些启发。
有什么问题的话,欢迎来讨论。毕竟技术这东西嘛,都是在交流中进步的。
参考资料


  • HagiCode 项目仓库
  • MDN Web API - WebSocket
  • 豆包语音识别文档

感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册