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在多協議連接的場景的使用方式。一句話,中間件的使用,使得這些場景變得簡單,那問題來了,什么是中間件,你理解了嗎?