深入理解kestrel的應用

jiulang發表於2020-04-25

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

相關文章