1 文章目的
本文講解基於kestrel開發實現了部分redis命令的redis偽伺服器的過程,讓讀者瞭解kestrel網路程式設計的完整步驟,其中redis通訊協議需要讀者自行查閱,文章裡不做具體解析。
2 開發順序
- 建立Kestrel的Redis協議處理者
- 配置監聽的EndPoint並使用Redis處理者
- 設計互動上下文RedisContext
- 設計Redis命令處理者
- 設計Redis中介軟體
- 編排Redis中介軟體構建應用
3. 建立Redis協議處理者
在Kestrel中,末級的中介軟體是一個沒有next的特殊中介軟體,基表現出來就是一個ConnectionHandler的行為。我們開發redis應用只需要繼承ConnectionHandler這個抽象類來,當kestrel接收到新的連線時將連線交給我們來處理,我們處理完成之後,不再有下一個處理者來處理這個連線了。
/// <summary>
/// 表示Redis連線處理者
/// </summary>
sealed class RedisConnectionHandler : ConnectionHandler
{
/// <summary>
/// 處理Redis連線
/// </summary>
/// <param name="context">redis連線上下文</param>
/// <returns></returns>
public async override Task OnConnectedAsync(ConnectionContext context)
{
// 開始處理這個redis連線
...
// 直到redis連線斷開後結束
}
}
4. 配置監聽的EndPoint
4.1 json配置檔案
我們在配置檔案裡指定監聽本機的5007埠來做伺服器,當然你可以指定本機具體的某個IP或任意IP。
{
"Kestrel": {
"Endpoints": {
"Redis": { // redis協議伺服器,只監聽loopback的IP
"Url": "http://localhost:5007"
}
}
}
}
{
"Kestrel": {
"Endpoints": {
"Redis": { // redis協議伺服器,監聽所有IP
"Url": "http://*:5007"
}
}
}
}
4.2 在程式碼中配置Redis處理者
為Redis這個節點關聯上RedisConnectionHandler
,當redis客戶端連線到5007這個埠之後,OnConnectedAsync()
方法就得到觸發且收到連線上下文物件。
builder.WebHost.ConfigureKestrel((context, kestrel) =>
{
var section = context.Configuration.GetSection("Kestrel");
kestrel.Configure(section).Endpoint("Redis", endpoint =>
{
endpoint.ListenOptions.UseConnectionHandler<RedisConnectionHandler>();
});
});
5 設計RedisContext
在asp.netcore裡,我們知道應用層每次http請求都建立一個HttpContext物件,裡面就塞著各種與本次請求有關的物件。對於Redis的請求,我們也可以這麼抄襲asp.netcore來設計Redis。
5.1 RedisContext
Redis請求上下文,包含Client、Request、Response和Features物件,我們要知道是收到了哪個Redis客戶端的什麼請求,從而請求命令處理者可以向它響應對應的內容。
/// <summary>
/// 表示redis上下文
/// </summary>
sealed class RedisContext : ApplicationContext
{
/// <summary>
/// 獲取redis客戶端
/// </summary>
public RedisClient Client { get; }
/// <summary>
/// 獲取redis請求
/// </summary>
public RedisRequest Reqeust { get; }
/// <summary>
/// 獲取redis響應
/// </summary>
public RedisResponse Response { get; }
/// <summary>
/// redis上下文
/// </summary>
/// <param name="client"></param>
/// <param name="request"></param>
/// <param name="response"></param>
/// <param name="features"></param>
public RedisContext(RedisClient client, RedisRequest request, RedisResponse response, IFeatureCollection features)
: base(features)
{
this.Client = client;
this.Reqeust = request;
this.Response = response;
}
public override string ToString()
{
return $"{this.Client} {this.Reqeust}";
}
}
5.2 ApplicationContext
這是抽象的應用層上下文,它強調Features,做為多箇中介軟體之間的溝通渠道。
/// <summary>
/// 表示應用程式請求上下文
/// </summary>
public abstract class ApplicationContext
{
/// <summary>
/// 獲取特徵集合
/// </summary>
public IFeatureCollection Features { get; }
/// <summary>
/// 應用程式請求上下文
/// </summary>
/// <param name="features"></param>
public ApplicationContext(IFeatureCollection features)
{
this.Features = new FeatureCollection(features);
}
}
5.3 RedisRequest
一個redis請求包含請求的命令和0到多個引數值。
/// <summary>
/// 表示Redis請求
/// </summary>
sealed class RedisRequest
{
private readonly List<RedisValue> values = new();
/// <summary>
/// 獲取命令名稱
/// </summary>
public RedisCmd Cmd { get; private set; }
/// <summary>
/// 獲取引數數量
/// </summary>
public int ArgumentCount => this.values.Count - 1;
/// <summary>
/// 獲取引數
/// </summary>
/// <param name="index"></param>
/// <returns></returns>
public RedisValue Argument(int index)
{
return this.values[index + 1];
}
}
RedisRequest的解析:
/// <summary>
/// 從記憶體中解析
/// </summary>
/// <param name="memory"></param>
/// <param name="request"></param>
/// <exception cref="RedisProtocolException"></exception>
/// <returns></returns>
private static bool TryParse(ReadOnlyMemory<byte> memory, [MaybeNullWhen(false)] out RedisRequest request)
{
request = default;
if (memory.IsEmpty == true)
{
return false;
}
var span = memory.Span;
if (span[0] != '*')
{
throw new RedisProtocolException();
}
if (span.Length < 4)
{
return false;
}
var lineLength = span.IndexOf((byte)'\n') + 1;
if (lineLength < 4)
{
throw new RedisProtocolException();
}
var lineCountSpan = span.Slice(1, lineLength - 3);
var lineCountString = Encoding.ASCII.GetString(lineCountSpan);
if (int.TryParse(lineCountString, out var lineCount) == false || lineCount < 0)
{
throw new RedisProtocolException();
}
request = new RedisRequest();
span = span.Slice(lineLength);
for (var i = 0; i < lineCount; i++)
{
if (span[0] != '$')
{
throw new RedisProtocolException();
}
lineLength = span.IndexOf((byte)'\n') + 1;
if (lineLength < 4)
{
throw new RedisProtocolException();
}
var lineContentLengthSpan = span.Slice(1, lineLength - 3);
var lineContentLengthString = Encoding.ASCII.GetString(lineContentLengthSpan);
if (int.TryParse(lineContentLengthString, out var lineContentLength) == false)
{
throw new RedisProtocolException();
}
span = span.Slice(lineLength);
if (span.Length < lineContentLength + 2)
{
return false;
}
var lineContentBytes = span.Slice(0, lineContentLength).ToArray();
var value = new RedisValue(lineContentBytes);
request.values.Add(value);
span = span.Slice(lineContentLength + 2);
}
request.Size = memory.Span.Length - span.Length;
Enum.TryParse<RedisCmd>(request.values[0].ToString(), ignoreCase: true, out var name);
request.Cmd = name;
return true;
}
5.4 RedisResponse
/// <summary>
/// 表示redis回覆
/// </summary>
sealed class RedisResponse
{
private readonly PipeWriter writer;
public RedisResponse(PipeWriter writer)
{
this.writer = writer;
}
/// <summary>
/// 寫入\r\n
/// </summary>
/// <returns></returns>
public RedisResponse WriteLine()
{
this.writer.WriteCRLF();
return this;
}
public RedisResponse Write(char value)
{
this.writer.Write((byte)value);
return this;
}
public RedisResponse Write(ReadOnlySpan<char> value)
{
this.writer.Write(value, Encoding.UTF8);
return this;
}
public RedisResponse Write(ReadOnlyMemory<byte> value)
{
this.writer.Write(value.Span);
return this;
}
public ValueTask<FlushResult> FlushAsync()
{
return this.writer.FlushAsync();
}
public ValueTask<FlushResult> WriteAsync(ResponseContent content)
{
return this.writer.WriteAsync(content.ToMemory());
}
}
5.5 RedisClient
Redis是有狀態的長連線協議,所以在服務端,我把連線接收到的連線包裝為RedisClient的概念,方便我們業務理解。對於連線級生命週期的物件屬性,我們都應該放到RedisClient上,比如是否已認證授權等。
/// <summary>
/// 表示Redis客戶端
/// </summary>
sealed class RedisClient
{
private readonly ConnectionContext context;
/// <summary>
/// 獲取或設定是否已授權
/// </summary>
public bool? IsAuthed { get; set; }
/// <summary>
/// 獲取遠端終結點
/// </summary>
public EndPoint? RemoteEndPoint => context.RemoteEndPoint;
/// <summary>
/// Redis客戶端
/// </summary>
/// <param name="context"></param>
public RedisClient(ConnectionContext context)
{
this.context = context;
}
/// <summary>
/// 關閉連線
/// </summary>
public void Close()
{
this.context.Abort();
}
/// <summary>
/// 轉換為字串
/// </summary>
/// <returns></returns>
public override string? ToString()
{
return this.RemoteEndPoint?.ToString();
}
}
6. 設計Redis命令處理者
redis命令非常多,我們希望有一一對應的cmdHandler來對應處理,來各盡其責。所以我們要設計cmdHandler的介面,然後每個命令增加一個實現型別,最後使用一箇中介軟體來聚合這些cmdHandler。
6.1 IRedisCmdHanler介面
/// <summary>
/// 定義redis請求處理者
/// </summary>
interface IRedisCmdHanler
{
/// <summary>
/// 獲取能處理的請求命令
/// </summary>
RedisCmd Cmd { get; }
/// <summary>
/// 處理請求
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
ValueTask HandleAsync(RedisContext context);
}
6.2 IRedisCmdHanler實現
由於實現型別特別多,這裡只舉個例子
/// <summary>
/// Ping處理者
/// </summary>
sealed class PingHandler : IRedisCmdHanler
{
public RedisCmd Cmd => RedisCmd.Ping;
/// <summary>
/// 處理請求
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public async ValueTask HandleAsync(RedisContext context)
{
await context.Response.WriteAsync(ResponseContent.Pong);
}
}
7.設計Redis中介軟體
對於Redis伺服器應用而言,我們處理一個請求需要經過多個大的步驟:
- 如果伺服器要求Auth的話,驗證連線是否已Auth
- 如果Auth驗證透過之後,則查詢與請求對應的IRedisCmdHanler來處理請求
- 如果沒有IRedisCmdHanler來處理,則告訴客戶端命令不支援。
7.1 中介軟體介面
/// <summary>
/// redis中介軟體
/// </summary>
interface IRedisMiddleware : IApplicationMiddleware<RedisContext>
{
}
/// <summary>
/// 應用程式中介軟體的介面
/// </summary>
/// <typeparam name="TContext"></typeparam>
public interface IApplicationMiddleware<TContext>
{
/// <summary>
/// 執行中介軟體
/// </summary>
/// <param name="next">下一個中介軟體</param>
/// <param name="context">上下文</param>
/// <returns></returns>
Task InvokeAsync(ApplicationDelegate<TContext> next, TContext context);
}
7.2 命令處理者中介軟體
這裡只拿重要的命令處理者中介軟體來做程式碼說明,其它中介軟體也是一樣處理方式。
/// <summary>
/// 命令處理中介軟體
/// </summary>
sealed class CmdMiddleware : IRedisMiddleware
{
private readonly Dictionary<RedisCmd, IRedisCmdHanler> cmdHandlers;
public CmdMiddleware(IEnumerable<IRedisCmdHanler> cmdHanlers)
{
this.cmdHandlers = cmdHanlers.ToDictionary(item => item.Cmd, item => item);
}
public async Task InvokeAsync(ApplicationDelegate<RedisContext> next, RedisContext context)
{
if (this.cmdHandlers.TryGetValue(context.Reqeust.Cmd, out var hanler))
{
// 這裡是本中介軟體要乾的活
await hanler.HandleAsync(context);
}
else
{
// 本中介軟體幹不了,留給下一個中介軟體來幹
await next(context);
}
}
}
8 編排Redis中介軟體
回到RedisConnectionHandler,我們需要實現它,實現邏輯是編排Redis中介軟體並建立可以處理應用請求的委託application
,再將收到的redis請求建立RedisContext物件的例項,最後使用application
來執行RedisContext例項即可。
8.1 構建application委託
sealed class RedisConnectionHandler : ConnectionHandler
{
private readonly ILogger<RedisConnectionHandler> logger;
private readonly ApplicationDelegate<RedisContext> application;
/// <summary>
/// Redis連線處理者
/// </summary>
/// <param name="appServices"></param>
/// <param name="logger"></param>
public RedisConnectionHandler(
IServiceProvider appServices,
ILogger<RedisConnectionHandler> logger)
{
this.logger = logger;
this.application = new ApplicationBuilder<RedisContext>(appServices)
.Use<AuthMiddleware>()
.Use<CmdMiddleware>()
.Use<FallbackMiddlware>()
.Build();
}
}
8.2 使用application委託處理請求
sealed class RedisConnectionHandler : ConnectionHandler
{
/// <summary>
/// 處理Redis連線
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public async override Task OnConnectedAsync(ConnectionContext context)
{
try
{
await this.HandleRequestsAsync(context);
}
catch (Exception ex)
{
this.logger.LogDebug(ex.Message);
}
finally
{
await context.DisposeAsync();
}
}
/// <summary>
/// 處理redis請求
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
private async Task HandleRequestsAsync(ConnectionContext context)
{
var input = context.Transport.Input;
var client = new RedisClient(context);
var response = new RedisResponse(context.Transport.Output);
while (context.ConnectionClosed.IsCancellationRequested == false)
{
var result = await input.ReadAsync();
if (result.IsCanceled)
{
break;
}
var requests = RedisRequest.Parse(result.Buffer, out var consumed);
if (requests.Count > 0)
{
foreach (var request in requests)
{
var redisContext = new RedisContext(client, request, response, context.Features);
await this.application.Invoke(redisContext);
}
input.AdvanceTo(consumed);
}
else
{
input.AdvanceTo(result.Buffer.Start, result.Buffer.End);
}
if (result.IsCompleted)
{
break;
}
}
}
}
9 文章總結
在還沒有進入閱讀本文章之前,您可能會覺得我會大量講解Socket知識內容,例如Socket Bind
、Socket Accept
、Socket Send
、Socket Receive
等。但實際上沒完全沒有任何涉及,因為終結點的監聽、連線的接收、緩衝區的處理、資料接收與傳送等這些基礎而複雜的網路底層kestrel已經幫我處理好,我們關注是我們的應用協議層的解析、還有應用本身功能的開發兩個本質問題。
您可能發也現了,本文章的RedisRequest解析,也沒有多少行程式碼!反而文章中都是抽象的中介軟體、處理者、上下文等概念。實際上這不但不會帶來專案複雜度,反而讓專案更好的解耦,比如要增加一個新的指令的支援,只需要增加一個xxxRedisCmdHanler的檔案,其它地方都不用任何修改。
本文章是KestrelApp專案裡面的一個demo的講解,希望對您有用。