.NET鬥魚直播彈幕客戶端(2021)
.NET鬥魚直播彈幕客戶端(2021)
離之前更新的兩篇《.NET鬥魚直播彈幕客戶端》已經有一段時間,近期有許多客戶向我回饋剛好有這方面的需求,但之前的程式碼不能用了——但網上許多流傳的Node.js
、Python
腳本卻可以用,這豈能忍?(剛好我終於找回了我的發布密碼😂)因此我有動力重新對此進行好(xie)好(xie)研(bo)究(ke)。
為何之前的不能用了
重新運行之前的C#
腳本,發現是在這一行報錯的:
using var client = new TcpClient();
await client.ConnectAsync("openbarrage.douyutv.com", 8601); // 這裡報錯
網上查了查,發現鬥魚確實已經停止使用openbarrage.douyutv.com:8601
了。進一步查資料顯示,新url
改成了danmuproxy.douyu.com
,鬥魚已經統一使用WebSocket
協議(之前為TCP
協議),經過進一步對比新協議程式碼示例,發現協議過程沒有任何區別,序列化也依然用的STT
演算法。
私貨時間:
我認為鬥魚這樣做合理,因為WebSocket性能不差,且不需再為瀏覽器和第三方介面各自維護兩套不同的程式碼。
具體過程如下:
- 建立
WebSocket
連接 - 發送登錄請求(可匿名)
- 加入指定的房間號
- 每隔45秒,響應一次心跳包
- (此時,即可)正常接收彈幕數據
新程式碼實現
.NET
中有許多提供WebSocket
功能的庫和第三方包,之前我經常用websocket-sharp
,這是第三方包。現在我們盡量不用第三方包,官方提供的WebSocket
客戶端叫System.Net.WebSockets.ClientWebSocket
,同時支援.NET 4.5
和.NET Core
。
按正常的思路,我們會這樣寫:
return Observable.Create<string>(async (roomId, cancellationToken) =>
{
using var ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri("wss://danmuproxy.douyu.com:8506/"), cancellationToken);
await MsgTool.LoginAsync(ws, roomId, cancellationToken);
// other codes
});
但實際運行卻不行,會報這個錯:
WebSocketException:
The 'Sec-WebSocket-Accept' header value 'Kfh9QIsMVZcl6xEPYxPHzW8SZ8w=' is invalid.
相信我,如果你仔細對比Node/Python
和.NET
程式碼,整個程式碼中沒任何區別,但打開Fiddler
仔細分析協議,發現事情沒這麼簡單,這是一個無法成功連上伺服器的包:
請求:
GET //danmuproxy.douyu.com:8506/ HTTP/1.1
Host: danmuproxy.douyu.com:8506
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: VsPg1/SSskKrbYouGm3ROQ==
響應:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: Kfh9QIsMVZcl6xEPYxPHzW8SZ8w=
Sec-WebSocket-Version: 13
EndTime: 09:37:44.958
ReceivedBytes: 0
SentBytes: 0
研究原因
其中請注意看請求中的Sec-WebSocket-Key
項,和響應中的Sec-WebSocket-Accept
項。
按照WebSocket
協議(//tools.ietf.org/html/rfc6455#section-11.3.3),伺服器響應頭Sec-WebSocket-Accept
項的值,應該為請求頭Sec-WebSocket-Key
項字元串追加固定值"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
,然後計算其SHA1
哈希值,再求Base64
,用C#
程式碼說,這一過程如下:
string WebSocketComputeAccept(string key)
{
using var sha = SHA1.Create();
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"));
return Convert.ToBase64String(hash);
}
如上的VsPg1/SSskKrbYouGm3ROQ==
按這個計算過程,它應該返回VrPdUdxpPeBXDi1ttGN607h8ct0=
,但實際卻是Kfh9QIsMVZcl6xEPYxPHzW8SZ8w=
,這就是為何C#
會報錯,因此服務端返回了錯誤值。
進一步研究原因
我嘗試了許多次,C#
用客戶端連接時,總是會生成隨機的Sec-WebSocket-Key
值,但不管值如何,服務端總是會返回相同的值——但一旦切換為Node.js
,返回的值就完全正常。
我仔細分析了其它語言的WebSocket
頭與.NET
的區別,發現一個重要因素:.NET
客戶端請求中的Sec-WebSocket-Key
項,一定是最後一條,但其它語言中不是最後一條。
如果我們使用Fiddler
手動發送握手請求,將Sec-WebSocket-Key
與Sec-WebSocket-Version
順序對調一下,發現響應值如下(伺服器響應匹配):
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: VrPdUdxpPeBXDi1ttGN607h8ct0=
Sec-WebSocket-Version: 13
然而用ClientWebSocket
是無法控制請求頭順序的,這一點可以在源程式碼中找到。
最終答案
雖然無法控制請求頭順序,但可以控制Sec-WebSocket-Key
不是最後一個,只需添加一個子協議頭,值無所謂:ws.Options.AddSubProtocol("-");
,因此重點程式碼如下(完整程式碼請見LINQPad腳本——douyu-2020.linq
):
using var ws = new ClientWebSocket();
ws.Options.AddSubProtocol("-");
await ws.ConnectAsync(new Uri("wss://danmuproxy.douyu.com:8506"), QueryCancelToken);
await ws.SendAsync(SerializeDouyu($"type@=loginreq/roomid@=74751/ver@=20190610/"), WebSocketMessageType.Binary, false, QueryCancelToken);
await ws.SendAsync(SerializeDouyu($"type@=joingroup/rid@=74751/gid@=-9999/"), WebSocketMessageType.Binary, false, QueryCancelToken);
_ = Task.Run(async () =>
{
while (!QueryCancelToken.IsCancellationRequested)
{
await Task.Delay(45000, QueryCancelToken);
await ws.SendAsync(SerializeDouyu($"type@=mrkl/"), WebSocketMessageType.Binary, false, QueryCancelToken);
}
});
while (!QueryCancelToken.IsCancellationRequested)
{
var buffer = new byte[4096];
WebSocketReceiveResult r = await ws.ReceiveAsync(buffer, QueryCancelToken);
string result = DeserializeDouyu(new Memory<byte>(buffer, 0, r.Count), QueryCancelToken);
DecodeStringToJObject(result).Dump();
}
運行效果:
封裝優化
之前我是基於System.Reactive
庫做的封裝,但C# 9.0
已經發布許久,這次我重新基於IAsyncEnumerable
寫了一版,這個以C# 9.0
作為非同步流的基礎,擴展可以用System.Linq.Async
,從而獲得與正常的LINQ
完全一致的體驗,核心程式碼如下:
public class DouyuBarrage
{
static HttpClient http = new HttpClient();
public static async IAsyncEnumerable<string> RawFromUrl(string url, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
HttpResponseMessage html = await http.GetAsync(url, cancellationToken);
var roomId = Regex.Match(await html.Content.ReadAsStringAsync(), @"\$ROOM.room_id[ ]?=[ ]?(\d+);").Groups[1].Value;
using var ws = new ClientWebSocket();
ws.Options.AddSubProtocol("-");
await ws.ConnectAsync(new Uri("wss://danmuproxy.douyu.com:8506/"), cancellationToken);
await MsgTool.LoginAsync(ws, roomId, cancellationToken);
await MsgTool.JoinGroupAsync(ws, roomId, cancellationToken);
var task = Task.Run(async () =>
{
while (!cancellationToken.IsCancellationRequested)
{
await MsgTool.SendTick(ws, cancellationToken);
await Task.Delay(45000, cancellationToken);
}
}, cancellationToken);
while (ws.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
yield return await MsgTool.RecieveAsync(ws, cancellationToken);
}
GC.KeepAlive(task);
await MsgTool.Logout(ws, cancellationToken);
}
public static IAsyncEnumerable<JToken> JObjectFromUrl(string url) => RawFromUrl(url)
.Select(MsgTool.DecodeStringToJObject);
public static IAsyncEnumerable<Barrage> ChatMessageFromUrl(string url) => JObjectFromUrl(url)
.Where(x => x["type"].Value<string>() == "chatmsg")
.Select(Barrage.FromJToken);
}
見最後兩個方法JObjectFromUrl
、ChatMessageFromUrl
,基於IAsyncEnumerable
,可以獲得與LINQ
、System.Reactive
完全一致的開發體驗,一行程式碼即可完成非同步流的篩選、數據轉換。
說在最後
以上所有的完整程式碼和示例,都已經上傳到我的部落格專用Github
倉庫,各位可以自行前往下載://github.com/sdcb/blog-data/tree/master/2021/20191011-douyu-barrage-with-dotnet
喜歡的朋友 請關注我的微信公眾號:【DotNet騷操作】