1 前言
之所以寫本文章,是因為在我停止維護多年前寫的NetworkSocket元件兩年多來,還是有一些開發者在關注這個專案,我希望有類似需求的開發者明白為什麼要停止更新,可以使用什麼更好的方式來替換(其實很大原因是我把時間花在開發WebApiClient上面了)。那時.netcore還沒有生下來,asp.net除了蝸居在iis裡處理http,其它什麼也不能幹,而NetworkSocket是這樣定義的:
NetworkSocket是一個以中介軟體(middleware)擴充套件通訊協議,以外掛(plug)擴充套件伺服器功能的支援SSL安全傳輸的通訊框架;目前支援http、websocket、fast、flex策略與silverlight策略協議。
2 Kestrel是什麼
談到asp.netcore,人們自然就想到它的預設伺服器kestrel,在很多場景中,人們甚至認為kestrel等於Web伺服器,或者說它只能處理http和http之上的東西。本文先在此下個定義:Kestrel是一款基於中介軟體來處理tcp連線的伺服器,並內建了http(包含websocket、SignalR)解析中介軟體。也就是說,我們完全可以給kestrel新增其它中介軟體,用來處理非http的連線的業務場景,讓kestrel使用一個埠支援多種協議或多協議一個埠一種協議的要求。
2.1 Kestrel的中介軟體是什麼
在asp.netcore的Startup裡,我們使用app.UseXXX的擴充套件方法來應用各種中介軟體,比如UseRouting、UseStaticFiles等等,它本質上還是呼叫了IApplicationBuilder.Use(Func<RequestDelegate, RequestDelegate> middleware)
,也就說Func<RequestDelegate, RequestDelegate>
就是一箇中介軟體。
對應的,在kestrel世界裡,也有一個IConnectionBuilder.Use(Func<ConnectionDelegate, ConnectionDelegate> middleware)
,Func<ConnectionDelegate, ConnectionDelegate>
就是kestrel的中介軟體,我們可以如下安裝kestrel的中介軟體:
kestrel.ListenAnyIP(port: 80, listen =>
{
listen.Use(next => context =>
{
if(true)
{
// 中介軟體1的邏輯
}else
{
return next(context);
}
})
.Use(next => context =>
{
if(true)
{
// 中介軟體2的邏輯
}else
{
return next(context);
}
});
});
值得注意的是,kestrel的最後一箇中間處理者是http中介軟體,以上程式碼,實際的kestrel已經包含3種處理者(文章後部分有中介軟體的篇幅,然後就容易理解了),邏輯1、邏輯2和http解析,我們可以簡單理解為Startup的app物件,對應kestrel的內建的那個最後中介軟體。
2.2 Kestrel的ConnectionContext
在kestrel中介軟體裡,最重要的物件就是ConnectionDelegate,它等同於Func<ConnectionContext,Task>
,我們可以理解為它就是一個Hanlder,傳入連線上下文,剩下就是我們要乾的工作了,而中介軟體是除了這個Handler之外,我們還能拿到一個叫next的Handler,我們可以選擇是否呼叫它,如果不呼叫,流程終止。
ConnectionContext是kestrel的一個Tcp連線抽象,其核心屬性是Transport,表示雙工傳輸層的操作物件,另外提供Abort()方法用於服務端主動關閉連線。基於ConnectionContext,很容易實現一個自定義協議的tcp雙工通訊伺服器,相比從Socket寫起,我們可能可以減少100倍程式碼量,而得到的是更高效能的服務。
3 基於Kestrel的SignalR+Redis的推送服務
本實戰中,我們使用asp.netcore內建的SignalR功能,外加自己實現的部分Redis協議(只簡單實現釋出訂閱功能),來做一個訊息從雲端推送到客戶端的服務,我們的服務對客戶端支援redis協議訂閱或Signal協議訂閱,同時我們提供redis+signalR+http三種協議介面給雲端其它微服務來發布訊息,釋出者不用關心客戶端是什麼協議,只需要選擇自己喜歡的協議的釋出介面來呼叫釋出。
3.1 協議與ConnectionContext的關係
在我們的這個應用裡,一個連線不允許同時使用SignalR和Redis並存協議,也就是說,一個連線在發起第一個請求裡,就確定了它整個生命週期裡的協議。所以,我們需要分析連線讀取到的第一個資料包,確定它是否為Redis協議,如果不是redis協議,我們要將ConnectionContext傳達到下一個中介軟體(即http中介軟體)。
3.2 使用Redis中介軟體
如下程式碼,Use裡面就是Redis中介軟體,裡面的個協議分析邏輯:
kestrel.ListenAnyIP(options.Port, listen =>
{
listen.Use(next => async context =>
{
if (await Protocol.IsRedisAsync(context))
{
logger.LogDebug($"{context.RemoteEndPoint} {nameof(ClientType.Redis)} 連線");
await redis.HandleAsync(context);
logger.LogDebug($"{context.RemoteEndPoint} {nameof(ClientType.Redis)} 斷開");
}
else
{
logger.LogDebug($"{context.RemoteEndPoint} {nameof(ClientType.SignalR)} 連線");
await next(context);
logger.LogDebug($"{context.RemoteEndPoint} {nameof(ClientType.SignalR)} 斷開");
}
});
});
Protocol類
/// <summary>
/// 連線的協議判斷
/// </summary>
public static class Protocol
{
/// <summary>
/// 返回連線是否為redis協議
/// </summary>
/// <param name="connection"></param>
/// <returns></returns>
public static async Task<bool> IsRedisAsync(ConnectionContext connection)
{
var result = await connection.Transport.Input.ReadAsync();
var state = IsRedis(result);
connection.Transport.Input.AdvanceTo(result.Buffer.Start);
return state;
}
/// <summary>
/// 返回資料是否為redis協議
/// 這裡不必嚴格檢查,只要能區分是http還是redis就行
/// </summary>
/// <param name="result"></param>
/// <returns></returns>
private static bool IsRedis(ReadResult result)
{
if (result.Buffer.IsEmpty)
{
return false;
}
var span = result.Buffer.FirstSpan;
return span.Length > 0 && span[0] == '*';
}
}
3.3 RedisConnectionHandler
在3.2程式碼裡,有一個await redis.HandleAsync(context);這個redis就是RedisConnectionHandler例項,它的功能是處理一個redis連線從建立成功之後到斷開的所有邏輯。
我們知道,Redis有好幾十個命令,單單是實現釋出和訂閱功能,我們也要實現必要的8個命令。說到這裡,我的腦海裡又閃現出一個長長的switch(收到的cmd) case xxx的程式碼了,我們甚至還需要在switch之前寫公共性的程式碼,比如列印收到的cmd內容,還需要在switch裡特別強調default分支:我們不支援這個命令。。。
既然kestrel基於連線處理中介軟體,上層的asp.netcore也是基於請求處理中介軟體,我們完全也可以也依葫蘆畫瓢,造一個Redis命令中介軟體Builder,最後將所有Redis中介軟體串起來,Buid得一個Redis處理委託。
var builder = new PipelineBuilder<RedisContext>(appServices, context =>
{
// 沒有handler來處理
return context.Client.ResponseAsync(RedisResponse.Error("unsupported cmd"));
})
.Use((context, next) =>
{
this.logger.LogDebug(context.ToString());
// 驗證客戶端是否已授權
return context.Cmd.Name != RedisCmdName.Auth && context.Client.IsAuthed == false
? context.Client.ResponseAsync(RedisResponse.Error("need auth password"))
: next();
});
// 新增各個cmd對應的handler條件分支
appServices
.GetServices<IRedisCmdHanler>()
.ForEach(item => builder.When(item.CanHandle, item.HandleAsync));
this.handler = builder.Build();
在RedisConnectionHandler,每收一個Redis命令,將命令包裝為RedisContext,然後使用build出來的handler物件來處理這個RedisContext就行。剩下的工作,就是我們一個命令實現一個IRedisCmdHanler物件就行,邏輯完全分開。
IRedisCmdHanler介面:
/// <summary>
/// 定義redis命令處理者
/// </summary>
interface IRedisCmdHanler
{
/// <summary>
/// 返回是否可以處理
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
bool CanHandle(RedisContext context);
/// <summary>
/// 處理
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
Task HandleAsync(RedisContext context);
}
3.4 統一Redis和Signal客戶端操作介面
在Signal和Redis訂閱之後,我們將他們的連線包裝為統一介面的IClient物件,IClient提供PublishAsync()方法用於釋出訊息。
/// <summary>
/// 定義客戶端的介面
/// </summary>
public interface IClient
{
/// <summary>
/// 獲取唯一標識
/// </summary>
string Id { get; }
/// <summary>
/// 獲取連線時間
/// </summary>
DateTime ConnectedTime { get; }
/// <summary>
/// 獲取客戶端型別
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
ClientType ClientType { get; }
/// <summary>
/// 傳送訊息
/// </summary>
/// <param name="message"></param>
/// <returns></returns>
Task<bool> SendMessageAsync(Message message);
}
3.5 IClient管理器
我們還需要維護一份單例的IClient管理器物件,用於維護正在訂閱的客戶端,在釋出訊息時,從這個管理器裡查詢IClient,並呼叫SendMessageAsync()方法釋出訊息內容。
3.6 SignalR部分
由於SignalR的內容非常簡單,官方文件細節齊全,這裡將不作任何講解了。
4 總結
由於要講解的內部比較多,篇幅和時間都有限,本文就只從思路上大概講解Kestrel在多協議連線的場景的使用方式。一句話,中介軟體的使用,使得這些場景變得簡單,那問題來了,什麼是中介軟體,你理解了嗎?