基於DotNetty實現一個介面自動釋出工具 - 通訊實現

Broadm發表於2023-12-04

基於 DotNetty 實現通訊

DotNetty : 是微軟的 Azure 團隊,使用 C#實現的 Netty 的版本釋出。是.NET 平臺的優秀網路庫。

專案介紹

OpenDeploy.Communication 類庫專案,是通訊相關基礎設施層

image

  • Codec 模組實現編碼解碼
  • Convention 模組定義約定,比如抽象的業務 Handler, 訊息載體 NettyMessage, 訊息上下文 'NettyContext' 等

自定義訊息格式

訊息類為 NettyMessage ,封裝了訊息頭 NettyHeader 和訊息體 Body

image

NettyMessage

封裝了訊息頭 NettyHeader 和訊息體 Body

NettyMessage 點選檢視程式碼
/// <summary> Netty訊息 </summary>
public class NettyMessage
{
    /// <summary> 訊息頭 </summary>
    public NettyHeader Header { get; init; } = default!;

    /// <summary> 訊息體(可空,可根據具體業務而定) </summary>
    public byte[]? Body { get; init; }

    /// <summary> 訊息頭轉為位元組陣列 </summary>
    public byte[] GetHeaderBytes()
    {
        var headerString = Header.ToString();
        return Encoding.UTF8.GetBytes(headerString);
    }

    /// <summary> 是否同步訊息 </summary>
    public bool IsSync() => Header.Sync;

    /// <summary> 建立Netty訊息工廠方法 </summary>
    public static NettyMessage Create(string endpoint, bool sync = false, byte[]? body = null)
    {
        return new NettyMessage
        {
            Header = new NettyHeader { EndPoint = endpoint, Sync = sync },
            Body = body
        };
    }

    /// <summary> 序列化為JSON字串 </summary>
    public override string ToString() => Header.ToString();
}

NettyHeader

訊息頭,包含請求唯一標識,是否同步訊息,終結點等, 在傳輸資料時會序列化為 JSON

NettyHeader 點選檢視程式碼
/// <summary> Netty訊息頭 </summary>
public class NettyHeader
{
    /// <summary> 請求訊息唯一標識 </summary>
    public Guid RequestId { get; init; } = Guid.NewGuid();

    /// <summary> 是否同步訊息, 預設false是非同步訊息 </summary>
    public bool Sync { get; init; }

    /// <summary> 終結點 (借鑑MVC,約定為Control/Action模式) </summary>
    public string EndPoint { get; init; } = string.Empty;

    /// <summary> 序列化為JSON字串 </summary>
    public override string ToString() => this.ToJsonString();
}

  • 請求訊息唯一標識 RequestId , 用來唯一標識訊息, 主要用於 傳送同步請求, 因為預設的訊息是非同步的,只管發出去,不需要等待響應
  • 是否同步訊息 Sync , 可以不需要,主要為了視覺化,便於除錯
  • 終結點 EndPoint , (借鑑 MVC,約定為 Control/Action 模式), 服務端直接解析出對應的處理器

編碼器

DefaultEncoder 點選檢視程式碼
public class DefaultEncoder : MessageToByteEncoder<NettyMessage>
{
    protected override void Encode(IChannelHandlerContext context, NettyMessage message, IByteBuffer output)
    {
        //訊息頭轉為位元組陣列
        var headerBytes = message.GetHeaderBytes();

        //寫入訊息頭長度
        output.WriteInt(headerBytes.Length);

        //寫入訊息頭位元組陣列
        output.WriteBytes(headerBytes);

        //寫入訊息體位元組陣列
        if (message.Body != null && message.Body.Length > 0)
        {
            output.WriteBytes(message.Body);
        }
    }
}

解碼器

DefaultDecoder 點選檢視程式碼
public class DefaultDecoder : MessageToMessageDecoder<IByteBuffer>
{
    protected override void Decode(IChannelHandlerContext context, IByteBuffer input, List<object> output)
    {
        //訊息總長度
        var totalLength = input.ReadableBytes;

        //訊息頭長度
        var headerLength = input.GetInt(input.ReaderIndex);

        //訊息體長度
        var bodyLength = totalLength - 4 - headerLength;

        //讀取訊息頭位元組陣列
        var headerBytes = new byte[headerLength];
        input.GetBytes(input.ReaderIndex + 4, headerBytes, 0, headerLength);

        byte[]? bodyBytes = null;
        string? rawHeaderString = null;
        NettyHeader? header;

        try
        {
            //把訊息頭位元組陣列,反序列化為JSON
            rawHeaderString = Encoding.UTF8.GetString(headerBytes);
            header = JsonSerializer.Deserialize<NettyHeader>(rawHeaderString);
        }
        catch (Exception ex)
        {
            Logger.Error($"解碼失敗: {rawHeaderString}, {ex}");
            return;
        }

        if (header is null)
        {
            Logger.Error($"解碼失敗: {rawHeaderString}");
            return;
        }

        //讀取訊息體位元組陣列
        if (bodyLength > 0)
        {
            bodyBytes = new byte[bodyLength];
            input.GetBytes(input.ReaderIndex + 4 + headerLength, bodyBytes, 0, bodyLength);
        }

        //封裝為NettyMessage物件
        var message = new NettyMessage
        {
            Header = header,
            Body = bodyBytes,
        };

        output.Add(message);
    }
}

NettyServer 實現

NettyServer 點選檢視程式碼
public static class NettyServer
{
    /// <summary>
    /// 開啟Netty服務
    /// </summary>
    public static async Task RunAsync(int port = 20007)
    {
        var bossGroup = new MultithreadEventLoopGroup(1);
        var workerGroup = new MultithreadEventLoopGroup();

        try
        {
            var bootstrap = new ServerBootstrap().Group(bossGroup, workerGroup);

            bootstrap
                .Channel<TcpServerSocketChannel>()
                .Option(ChannelOption.SoBacklog, 100)
                .Option(ChannelOption.SoReuseaddr, true)
                .Option(ChannelOption.SoReuseport, true)
                .ChildHandler(new ActionChannelInitializer<IChannel>(channel =>
                {
                    IChannelPipeline pipeline = channel.Pipeline;
                    pipeline.AddLast("framing-enc", new LengthFieldPrepender(4));
                    pipeline.AddLast("framing-dec", new LengthFieldBasedFrameDecoder(int.MaxValue, 0, 4, 0, 4));
                    pipeline.AddLast("decoder", new DefaultDecoder());
                    pipeline.AddLast("encoder", new DefaultEncoder());
                    pipeline.AddLast("handler", new ServerMessageEntry());
                }));

            var boundChannel = await bootstrap.BindAsync(port);

            Logger.Info($"NettyServer啟動成功...{boundChannel}");

            Console.ReadLine();

            await boundChannel.CloseAsync();

            Logger.Info($"NettyServer關閉監聽了...{boundChannel}");
        }
        finally
        {
            await Task.WhenAll(
                bossGroup.ShutdownGracefullyAsync(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(1)),
                workerGroup.ShutdownGracefullyAsync(TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(1))
            );

            Logger.Info($"NettyServer退出了...");
        }

    }
}
  • 服務端管道最後我們新增了 ServerMessageEntry ,作為訊息處理的入口
ServerMessageEntry 點選檢視程式碼
public class ServerMessageEntry : ChannelHandlerAdapter
{
    /// <summary> Netty處理器選擇器 </summary>
    private readonly DefaultNettyHandlerSelector handlerSelector = new();

    public ServerMessageEntry()
    {
        //註冊Netty處理器
        handlerSelector.RegisterHandlerTypes(typeof(EchoHandler).Assembly.GetTypes());
    }

    /// <summary> 通道啟用 </summary>
    public override void ChannelActive(IChannelHandlerContext context)
    {
        Logger.Warn($"ChannelActive: {context.Channel}");
    }

    /// <summary> 通道關閉 </summary>
    public override void ChannelInactive(IChannelHandlerContext context)
    {
        Logger.Warn($"ChannelInactive: {context.Channel}");
    }

    /// <summary> 收到客戶端的訊息 </summary>
    public override async void ChannelRead(IChannelHandlerContext context, object message)
    {
        if (message is not NettyMessage nettyMessage)
        {
            Logger.Error("從客戶端接收訊息為空");
            return;
        }

        try
        {
            Logger.Info($"收到客戶端的訊息: {nettyMessage}");

            //封裝請求
            var nettyContext = new NettyContext(context.Channel, nettyMessage);

            //選擇處理器
            AbstractNettyHandler handler = handlerSelector.SelectHandler(nettyContext);

            //處理請求
            await handler.ProcessAsync();
        }
        catch(Exception ex)
        {
            Logger.Error($"ServerMessageEntry.ChannelRead: {ex}");
        }
    }
}
  • 按照約定, 把繼承 AbstractNettyHandler 的類視為業務處理器

  • ServerMessageEntry 拿到訊息後,首先把訊息封裝為 NettyContext, 類似與 MVC 中的 HttpContext, 封裝了請求和響應物件, 內部解析請求的 EndPoint, 拆分為 HandlerName, ActionName

  • DefaultNettyHandlerSelector 提供註冊處理器的方法 RegisterHandlerTypes, 和選擇處理器的方法 SelectHandler

  • SelectHandler, 預設規則是查詢已註冊的處理器中以 HandlerName 開頭的型別

  • AbstractNettyHandlerProcessAsync 方法,透過 ActionName, 反射拿到 MethodInfo, 呼叫終結點

NettyClient 實現

NettyClient 點選檢視程式碼
public sealed class NettyClient(string serverHost, int serverPort) : IDisposable
{
    public EndPoint ServerEndPoint { get; } = new IPEndPoint(IPAddress.Parse(serverHost), serverPort);

    private static readonly Bootstrap bootstrap = new();
    private static readonly IEventLoopGroup eventLoopGroup = new SingleThreadEventLoop();

    private bool _disposed;
    private IChannel? _channel;
    public bool IsConnected => _channel != null && _channel.Open;
    public bool IsWritable => _channel != null && _channel.IsWritable;

    static NettyClient()
    {
        bootstrap
            .Group(eventLoopGroup)
            .Channel<TcpSocketChannel>()
            .Option(ChannelOption.SoReuseaddr, true)
            .Option(ChannelOption.SoReuseport, true)
            .Handler(new ActionChannelInitializer<ISocketChannel>(channel =>
            {
                IChannelPipeline pipeline = channel.Pipeline;
                //pipeline.AddLast("ping", new IdleStateHandler(0, 5, 0));
                pipeline.AddLast("framing-enc", new LengthFieldPrepender(4));
                pipeline.AddLast("framing-dec", new LengthFieldBasedFrameDecoder(int.MaxValue, 0, 4, 0, 4));
                pipeline.AddLast("decoder", new DefaultDecoder());
                pipeline.AddLast("encoder", new DefaultEncoder());
                pipeline.AddLast("handler", new ClientMessageEntry());
            }));
    }

    /// <summary> 連線伺服器 </summary>
    private async Task TryConnectAsync()
    {
        try
        {
            if (IsConnected) { return; }
            _channel = await bootstrap.ConnectAsync(ServerEndPoint);
        }
        catch (Exception ex)
        {
            throw new Exception($"連線伺服器失敗 : {ServerEndPoint} {ex.Message}");
        }
    }

    /// <summary>
    /// 傳送訊息
    /// </summary>
    /// <param name="endpoint">終結點</param>
    /// <param name="sync">是否同步等待響應</param>
    /// <param name="body">正文</param>
    public async Task SendAsync(string endpoint, bool sync = false, byte[]? body = null)
    {
        var message = NettyMessage.Create(endpoint, sync, body);
        if (sync)
        {
            var task = ClientMessageSynchronizer.TryAdd(message);
            try
            {
                await SendAsync(message);
                await task;
            }
            catch
            {
                ClientMessageSynchronizer.TryRemove(message);
                throw;
            }
        }
        else
        {
            await SendAsync(message);
        }
    }

    /// <summary>
    /// 傳送訊息
    /// </summary>
    private async Task SendAsync(NettyMessage message)
    {
        await TryConnectAsync();
        await _channel!.WriteAndFlushAsync(message);
    }

    /// <summary> 釋放連線(程式設計師手動釋放, 一般在程式碼使用using語句,或在finally裡面Dispose) </summary>
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    /// <summary> 釋放連線 </summary>
    private void Dispose(bool disposing)
    {
        if (_disposed)
        {
            return;
        }

        //釋放託管資源,比如巢狀的物件
        if (disposing)
        {

        }

        //釋放非託管資源
        if (_channel != null)
        {
            _channel.CloseAsync();
            _channel = null;
        }

        _disposed = true;
    }

    ~NettyClient()
    {
        Dispose(true);
    }
}
  • NettyClient 封裝了 Netty 客戶端邏輯,提供傳送非同步請求(預設)和釋出同步請求方法
  • DotNetty 預設不提供同步請求,但是有些情況我們需要同步等待伺服器的響應,所有需要自行實現,實現也很簡單,把訊息 ID 快取起來,收到伺服器響應後啟用就行了,具體實現在訊息同步器 ClientMessageSynchronizer, 就不貼了

總結

至此,我們實現了基於 DotNetty 搭建通訊模組, 實現了客戶端和伺服器的編解碼,處理器選擇,客戶端實現了同步訊息等,大家可以在 ConsoleHost 結尾的控制檯專案中,測試下同步和非同步的訊息,實現的簡單的 Echo 模式

程式碼倉庫

專案暫且就叫 OpenDeploy

歡迎大家拍磚,Star

下一步

計劃下一步,基於WPF的客戶端, 實現介面專案的配置與發現

相關文章