diff --git a/src/BootstrapBlazor/Extensions/HostEnvironmentExtensions.cs b/src/BootstrapBlazor/Extensions/HostEnvironmentExtensions.cs new file mode 100644 index 00000000000..15819a28492 --- /dev/null +++ b/src/BootstrapBlazor/Extensions/HostEnvironmentExtensions.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.Extensions.Hosting; + +namespace BootstrapBlazor.Components; + +/// +/// 扩展方法" +/// +public static class HostEnvironmentExtensions +{ + /// + /// 当前程序是否为 WebAssembly 环境 + /// + /// + /// + public static bool IsWasm(this IHostEnvironment hostEnvironment) => hostEnvironment is MockWasmHostEnvironment; +} diff --git a/src/BootstrapBlazor/Services/TcpSocket/DataPackage/DataPackageHandlerBase.cs b/src/BootstrapBlazor/Services/TcpSocket/DataPackage/DataPackageHandlerBase.cs new file mode 100644 index 00000000000..cba5c6e3156 --- /dev/null +++ b/src/BootstrapBlazor/Services/TcpSocket/DataPackage/DataPackageHandlerBase.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace BootstrapBlazor.Components; + +/// +/// Provides a base implementation for handling data packages in a communication system. +/// +/// This abstract class defines the core contract for receiving and sending data packages. Derived +/// classes should override and extend its functionality to implement specific data handling logic. The default +/// implementation simply returns the provided data. +public abstract class DataPackageHandlerBase : IDataPackageHandler +{ + private Memory _lastReceiveBuffer = Memory.Empty; + + /// + /// 当接收数据处理完成后,回调该函数执行接收 + /// + public Func, Task>? ReceivedCallBack { get; set; } + + /// + /// Sends the specified data asynchronously to the target destination. + /// + /// The method performs an asynchronous operation to send the provided data. The caller must + /// ensure that the data is valid and non-empty. The returned memory block may contain a response or acknowledgment + /// depending on the implementation of the target destination. + /// The data to be sent, represented as a block of memory. + /// A task that represents the asynchronous operation. The task result contains a of representing the response or acknowledgment received from the target destination. + public virtual Task> SendAsync(Memory data) + { + return Task.FromResult(data); + } + + /// + /// Processes the received data asynchronously. + /// + /// A memory buffer containing the data to be processed. The buffer must not be empty. + /// A task that represents the asynchronous operation. + public virtual Task ReceiveAsync(Memory data) + { + return Task.CompletedTask; + } + + /// + /// Handles the processing of a sticky package by adjusting the provided buffer and length. + /// + /// This method processes the portion of the buffer beyond the specified length and updates the + /// internal state accordingly. The caller must ensure that the contains sufficient data + /// for the specified . + /// The memory buffer containing the data to process. + /// The length of the valid data within the buffer. + protected void SlicePackage(Memory buffer, int length) + { + _lastReceiveBuffer = buffer[length..].ToArray().AsMemory(); + } + + /// + /// Concatenates the provided buffer with any previously stored data and returns the combined result. + /// + /// This method combines the provided buffer with any data stored in the internal buffer. After + /// concatenation, the internal buffer is cleared. The returned memory block is allocated from a shared memory pool + /// and should be used promptly to avoid holding onto pooled resources. + /// The buffer to concatenate with the previously stored data. Must not be empty. + /// A instance containing the concatenated data. If no previously stored data exists, the + /// method returns the input . + protected Memory ConcatBuffer(Memory buffer) + { + if (_lastReceiveBuffer.IsEmpty) + { + return buffer; + } + + // 计算缓存区长度 + Memory merged = new byte[_lastReceiveBuffer.Length + buffer.Length]; + _lastReceiveBuffer.CopyTo(merged); + buffer.CopyTo(merged[_lastReceiveBuffer.Length..]); + + // Clear the sticky buffer + _lastReceiveBuffer = Memory.Empty; + return merged; + } +} diff --git a/src/BootstrapBlazor/Services/TcpSocket/DataPackage/FixLengthDataPackageHandler.cs b/src/BootstrapBlazor/Services/TcpSocket/DataPackage/FixLengthDataPackageHandler.cs new file mode 100644 index 00000000000..3efbe4dee7b --- /dev/null +++ b/src/BootstrapBlazor/Services/TcpSocket/DataPackage/FixLengthDataPackageHandler.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace BootstrapBlazor.Components; + +/// +/// Handles fixed-length data packages by processing incoming data of a specified length. +/// +/// This class is designed to handle data packages with a fixed length, as specified during +/// initialization. It extends and overrides its behavior to process fixed-length +/// data. +/// The data package total data length. +public class FixLengthDataPackageHandler(int length) : DataPackageHandlerBase +{ + private readonly Memory _data = new byte[length]; + + private int _receivedLength; + + /// + /// + /// + /// + /// + public override async Task ReceiveAsync(Memory data) + { + // 处理上次粘包数据 + data = ConcatBuffer(data); + + // 拷贝数据 + var len = length - _receivedLength; + var segment = data.Length > len ? data[..len] : data; + segment.CopyTo(_data[_receivedLength..]); + + if (data.Length > len) + { + SlicePackage(data, data.Length - len); + } + + // 更新已接收长度 + _receivedLength += segment.Length; + + // 如果已接收长度等于总长度则触发回调 + if (_receivedLength == length) + { + // 重置已接收长度 + _receivedLength = 0; + if (ReceivedCallBack != null) + { + await ReceivedCallBack(_data); + } + } + } +} diff --git a/src/BootstrapBlazor/Services/TcpSocket/DataPackage/IDataPackageHandler.cs b/src/BootstrapBlazor/Services/TcpSocket/DataPackage/IDataPackageHandler.cs new file mode 100644 index 00000000000..89c9f13d0be --- /dev/null +++ b/src/BootstrapBlazor/Services/TcpSocket/DataPackage/IDataPackageHandler.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace BootstrapBlazor.Components; + +/// +/// Defines an interface for adapting data packages to and from a TCP socket connection. +/// +/// Implementations of this interface are responsible for converting raw data received from a TCP socket +/// into structured data packages and vice versa. This allows for custom serialization and deserialization logic +/// tailored to specific application protocols. +public interface IDataPackageHandler +{ + /// + /// Gets or sets the callback function to be invoked when data is received asynchronously. + /// + Func, Task>? ReceivedCallBack { get; set; } + + /// + /// Sends the specified data asynchronously to the target destination. + /// + /// The method performs an asynchronous operation to send the provided data. The caller must + /// ensure that the data is valid and non-empty. The returned memory block may contain a response or acknowledgment + /// depending on the implementation of the target destination. + /// The data to be sent, represented as a block of memory. + /// A task that represents the asynchronous operation. The task result contains a of representing the response or acknowledgment received from the target destination. + Task> SendAsync(Memory data); + + /// + /// Asynchronously receives data from a source and writes it into the provided memory buffer. + /// + /// This method does not guarantee that the entire buffer will be filled. The number of bytes + /// written depends on the availability of data. + /// The memory buffer to store the received data. The buffer must be writable and have sufficient capacity. + /// A task that represents the asynchronous operation. The task result contains the number of bytes written to the + /// buffer. Returns 0 if the end of the data stream is reached. + Task ReceiveAsync(Memory data); +} diff --git a/src/BootstrapBlazor/Services/TcpSocket/DefaultTcpSocketClient.cs b/src/BootstrapBlazor/Services/TcpSocket/DefaultTcpSocketClient.cs new file mode 100644 index 00000000000..3701ac82f18 --- /dev/null +++ b/src/BootstrapBlazor/Services/TcpSocket/DefaultTcpSocketClient.cs @@ -0,0 +1,192 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.Extensions.Logging; +using System.Buffers; +using System.Net; +using System.Net.Sockets; +using System.Runtime.Versioning; + +namespace BootstrapBlazor.Components; + +[UnsupportedOSPlatform("browser")] +class DefaultTcpSocketClient : ITcpSocketClient +{ + private TcpClient? _client; + private IDataPackageHandler? _dataPackageHandler; + private CancellationTokenSource? _receiveCancellationTokenSource; + private IPEndPoint? _remoteEndPoint; + + public bool IsConnected => _client?.Connected ?? false; + + public IPEndPoint LocalEndPoint { get; set; } + + [NotNull] + public ILogger? Logger { get; set; } + + public int ReceiveBufferSize { get; set; } = 1024 * 10; + + public DefaultTcpSocketClient(string host, int port = 0) + { + LocalEndPoint = new IPEndPoint(GetIPAddress(host), port); + } + + private static IPAddress GetIPAddress(string host) => host.Equals("localhost", StringComparison.OrdinalIgnoreCase) + ? IPAddress.Loopback + : IPAddress.TryParse(host, out var ip) ? ip : IPAddressByHostName; + + [ExcludeFromCodeCoverage] + private static IPAddress IPAddressByHostName => Dns.GetHostAddresses(Dns.GetHostName(), AddressFamily.InterNetwork).FirstOrDefault() ?? IPAddress.Loopback; + + public void SetDataHandler(IDataPackageHandler handler) + { + _dataPackageHandler = handler; + } + + public Task ConnectAsync(string host, int port, CancellationToken token = default) + { + var endPoint = new IPEndPoint(GetIPAddress(host), port); + return ConnectAsync(endPoint, token); + } + + public async Task ConnectAsync(IPEndPoint endPoint, CancellationToken token = default) + { + var ret = false; + try + { + // 释放资源 + Close(); + + // 创建新的 TcpClient 实例 + _client ??= new TcpClient(LocalEndPoint); + await _client.ConnectAsync(endPoint, token); + + // 开始接收数据 + _ = Task.Run(ReceiveAsync, token); + + LocalEndPoint = (IPEndPoint)_client.Client.LocalEndPoint!; + _remoteEndPoint = endPoint; + ret = true; + } + catch (OperationCanceledException ex) + { + Logger.LogWarning(ex, "TCP Socket connect operation was canceled from {LocalEndPoint} to {RemoteEndPoint}", LocalEndPoint, endPoint); + } + catch (Exception ex) + { + Logger.LogError(ex, "TCP Socket connection failed from {LocalEndPoint} to {RemoteEndPoint}", LocalEndPoint, endPoint); + } + return ret; + } + + public async Task SendAsync(Memory data, CancellationToken token = default) + { + if (_client is not { Connected: true }) + { + throw new InvalidOperationException($"TCP Socket is not connected {LocalEndPoint}"); + } + + var ret = false; + try + { + if (_dataPackageHandler != null) + { + data = await _dataPackageHandler.SendAsync(data); + } + var stream = _client.GetStream(); + await stream.WriteAsync(data, token); + ret = true; + } + catch (OperationCanceledException ex) + { + Logger.LogWarning(ex, "TCP Socket send operation was canceled from {LocalEndPoint} to {RemoteEndPoint}", LocalEndPoint, _remoteEndPoint); + } + catch (Exception ex) + { + Logger.LogError(ex, "TCP Socket send failed from {LocalEndPoint} to {RemoteEndPoint}", LocalEndPoint, _remoteEndPoint); + } + return ret; + } + + private async Task ReceiveAsync() + { + _receiveCancellationTokenSource ??= new(); + while (_receiveCancellationTokenSource is { IsCancellationRequested: false }) + { + if (_client is not { Connected: true }) + { + throw new InvalidOperationException($"TCP Socket is not connected {LocalEndPoint}"); + } + + try + { + using var block = MemoryPool.Shared.Rent(ReceiveBufferSize); + var buffer = block.Memory; + var stream = _client.GetStream(); + var len = await stream.ReadAsync(buffer, _receiveCancellationTokenSource.Token); + if (len == 0) + { + // 远端主机关闭链路 + Logger.LogInformation("TCP Socket {LocalEndPoint} received 0 data closed by {RemoteEndPoint}", LocalEndPoint, _remoteEndPoint); + break; + } + else + { + buffer = buffer[..len]; + + if (_dataPackageHandler != null) + { + await _dataPackageHandler.ReceiveAsync(buffer); + } + } + } + catch (OperationCanceledException ex) + { + Logger.LogWarning(ex, "TCP Socket receive operation was canceled from {LocalEndPoint} to {RemoteEndPoint}", LocalEndPoint, _remoteEndPoint); + } + catch (Exception ex) + { + Logger.LogError(ex, "TCP Socket receive failed from {LocalEndPoint} to {RemoteEndPoint}", LocalEndPoint, _remoteEndPoint); + } + } + } + + public void Close() + { + Dispose(true); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + _remoteEndPoint = null; + + // 取消接收数据的任务 + if (_receiveCancellationTokenSource is not null) + { + _receiveCancellationTokenSource.Cancel(); + _receiveCancellationTokenSource.Dispose(); + _receiveCancellationTokenSource = null; + } + + // 释放 TcpClient 资源 + if (_client != null) + { + _client.Close(); + _client = null; + } + } + } + + /// + /// + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } +} diff --git a/src/BootstrapBlazor/Services/TcpSocket/DefaultTcpSocketFactory.cs b/src/BootstrapBlazor/Services/TcpSocket/DefaultTcpSocketFactory.cs new file mode 100644 index 00000000000..8f52e380cd6 --- /dev/null +++ b/src/BootstrapBlazor/Services/TcpSocket/DefaultTcpSocketFactory.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using System.Runtime.Versioning; + +namespace BootstrapBlazor.Components; + +[UnsupportedOSPlatform("browser")] +class DefaultTcpSocketFactory(IServiceProvider provider) : ITcpSocketFactory +{ + private readonly ConcurrentDictionary _pool = new(); + + public ITcpSocketClient GetOrCreate(string host, int port = 0) + { + return _pool.GetOrAdd($"{host}:{port}", key => + { + var client = new DefaultTcpSocketClient(host, port) + { + Logger = provider.GetService>() + }; + return client; + }); + } + + public ITcpSocketClient? Remove(string host, int port) + { + ITcpSocketClient? client = null; + if (_pool.TryRemove($"{host}:{port}", out var c)) + { + client = c; + } + return client; + } + + private void Dispose(bool disposing) + { + if (disposing) + { + // 释放托管资源 + foreach (var socket in _pool.Values) + { + socket.Dispose(); + } + _pool.Clear(); + } + } + + /// + /// + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } +} diff --git a/src/BootstrapBlazor/Services/TcpSocket/Extensions/TcpSocketExtensions.cs b/src/BootstrapBlazor/Services/TcpSocket/Extensions/TcpSocketExtensions.cs new file mode 100644 index 00000000000..81fde060e97 --- /dev/null +++ b/src/BootstrapBlazor/Services/TcpSocket/Extensions/TcpSocketExtensions.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using System.Runtime.Versioning; + +namespace BootstrapBlazor.Components; + +/// +/// TcpSocket 扩展方法 +/// +[UnsupportedOSPlatform("browser")] +public static class TcpSocketExtensions +{ + /// + /// 增加 + /// + /// + /// + public static IServiceCollection AddBootstrapBlazorTcpSocketFactory(this IServiceCollection services) + { + // 添加 ITcpSocket 实现 + services.TryAddSingleton(); + + return services; + } +} diff --git a/src/BootstrapBlazor/Services/TcpSocket/ITcpSocketClient.cs b/src/BootstrapBlazor/Services/TcpSocket/ITcpSocketClient.cs new file mode 100644 index 00000000000..3c89a34b1db --- /dev/null +++ b/src/BootstrapBlazor/Services/TcpSocket/ITcpSocketClient.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using System.Net; + +namespace BootstrapBlazor.Components; + +/// +/// Represents a TCP socket for network communication. +/// +public interface ITcpSocketClient : IDisposable +{ + /// + /// Gets or sets the size, in bytes, of the receive buffer used for network operations. + /// + int ReceiveBufferSize { get; set; } + + /// + /// Gets a value indicating whether the system is currently connected. + /// + bool IsConnected { get; } + + /// + /// Gets the local network endpoint that the socket is bound to. + /// + /// This property provides information about the local endpoint of the socket, which is typically + /// used to identify the local address and port being used for communication. If the socket is not bound to a + /// specific local endpoint, this property may return . + IPEndPoint LocalEndPoint { get; } + + /// + /// Configures the data handler to process incoming data packages. + /// + /// The handler responsible for processing data packages. Cannot be null. + void SetDataHandler(IDataPackageHandler handler); + + /// + /// Establishes an asynchronous connection to the specified host and port. + /// + /// The hostname or IP address of the server to connect to. Cannot be null or empty. + /// The port number on the server to connect to. Must be a valid port number between 0 and 65535. + /// An optional to cancel the connection attempt. Defaults to if not provided. + /// A task that represents the asynchronous operation. The task result is if the connection + /// is successfully established; otherwise, . + Task ConnectAsync(string host, int port, CancellationToken token = default); + + /// + /// Establishes an asynchronous connection to the specified endpoint. + /// + /// This method attempts to establish a connection to the specified endpoint. If the connection + /// cannot be established, the method returns rather than throwing an exception. + /// The representing the remote endpoint to connect to. Cannot be null. + /// A that can be used to cancel the connection attempt. Defaults to if not provided. + /// A task that represents the asynchronous operation. The task result is if the connection + /// is successfully established; otherwise, . + Task ConnectAsync(IPEndPoint endPoint, CancellationToken token = default); + + /// + /// Sends the specified data asynchronously to the target endpoint. + /// + /// This method performs a non-blocking operation to send data. If the operation is canceled via + /// the , the task will complete with a canceled state. Ensure the connection is properly + /// initialized before calling this method. + /// The byte array containing the data to be sent. Cannot be null or empty. + /// An optional to observe while waiting for the operation to complete. + /// A task that represents the asynchronous operation. The task result is if the data was + /// sent successfully; otherwise, . + Task SendAsync(Memory data, CancellationToken token = default); + + /// + /// Closes the current connection or resource, releasing any associated resources. + /// + /// Once the connection or resource is closed, it cannot be reopened. Ensure that all necessary + /// operations are completed before calling this method. This method is typically used to clean up resources when + /// they are no longer needed. + void Close(); +} diff --git a/src/BootstrapBlazor/Services/TcpSocket/ITcpSocketFactory.cs b/src/BootstrapBlazor/Services/TcpSocket/ITcpSocketFactory.cs new file mode 100644 index 00000000000..b72069e2f43 --- /dev/null +++ b/src/BootstrapBlazor/Services/TcpSocket/ITcpSocketFactory.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace BootstrapBlazor.Components; + +/// +/// ITcpSocketFactory Interface +/// +public interface ITcpSocketFactory : IDisposable +{ + /// + /// Retrieves an existing TCP socket associated with the specified host and port, or creates a new one if none + /// exists. + /// + /// The hostname or IP address of the remote endpoint. Cannot be null or empty. + /// The port number of the remote endpoint. Must be a valid port number between 0 and 65535. + /// An instance representing the TCP socket for the specified host and port. + ITcpSocketClient GetOrCreate(string host, int port); + + /// + /// Removes the specified host and port combination from the collection. + /// + /// If the specified host and port combination does not exist in the collection, the method has + /// no effect. + /// The hostname to remove. Cannot be null or empty. + /// The port number associated with the host to remove. Must be a valid port number (0-65535). + /// An instance representing the TCP socket for the specified host and port. + ITcpSocketClient? Remove(string host, int port); +} diff --git a/test/UnitTest/Extensions/HostEnvironmentExtensionsTest.cs b/test/UnitTest/Extensions/HostEnvironmentExtensionsTest.cs new file mode 100644 index 00000000000..1f6f0652ad9 --- /dev/null +++ b/test/UnitTest/Extensions/HostEnvironmentExtensionsTest.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; + +namespace UnitTest.Extensions; + +public class HostEnvironmentExtensionsTest +{ + [Fact] + public void IsWasm_Ok() + { + var hostEnvironment = new MockWasmHostEnvironment(); + Assert.False(hostEnvironment.IsWasm()); + } + + class MockWasmHostEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } = "Development"; + + public string ApplicationName { get; set; } = "BootstrapBlazor"; + + public string ContentRootPath { get; set; } = ""; + + public IFileProvider ContentRootFileProvider { get; set; } = null!; + } +} diff --git a/test/UnitTest/Services/TcpSocketFactoryTest.cs b/test/UnitTest/Services/TcpSocketFactoryTest.cs new file mode 100644 index 00000000000..cf3fe492878 --- /dev/null +++ b/test/UnitTest/Services/TcpSocketFactoryTest.cs @@ -0,0 +1,420 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.Extensions.Logging; +using System.Net; +using System.Net.Sockets; + +namespace UnitTest.Services; + +public class TcpSocketFactoryTest +{ + [Fact] + public void GetOrCreate_Ok() + { + // 测试 GetOrCreate 方法创建的 Client 销毁后继续 GetOrCreate 得到的对象是否可用 + var sc = new ServiceCollection(); + sc.AddLogging(builder => + { + builder.AddProvider(new MockLoggerProvider()); + }); + sc.AddBootstrapBlazorTcpSocketFactory(); + + var provider = sc.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + var client1 = factory.GetOrCreate("localhost", 0); + client1.Close(); + + var client2 = factory.GetOrCreate("localhost", 0); + Assert.Equal(client1, client2); + + var ip = Dns.GetHostAddresses(Dns.GetHostName(), AddressFamily.InterNetwork).FirstOrDefault() ?? IPAddress.Loopback; + var client3 = factory.GetOrCreate(ip.ToString(), 0); + + // 测试不合格 IP 地址 + var client4 = factory.GetOrCreate("256.0.0.1", 0); + + var client5 = factory.Remove("256.0.0.1", 0); + Assert.Equal(client4, client5); + Assert.NotNull(client5); + + client5.Dispose(); + + factory.Dispose(); + } + + [Fact] + public async Task ConnectAsync_Cancel() + { + var client = CreateClient(); + + // 测试 ConnectAsync 方法连接取消逻辑 + var cst = new CancellationTokenSource(); + cst.Cancel(); + var connect = await client.ConnectAsync("localhost", 9999, cst.Token); + Assert.False(connect); + } + + [Fact] + public async Task ConnectAsync_Failed() + { + var client = CreateClient(); + + // 测试 ConnectAsync 方法连接失败 + var connect = await client.ConnectAsync("localhost", 9999); + Assert.False(connect); + } + + [Fact] + public async Task SendAsync_Error() + { + var client = CreateClient(); + + // 测试未建立连接前调用 SendAsync 方法报异常逻辑 + var data = new Memory([1, 2, 3, 4, 5]); + var ex = await Assert.ThrowsAsync(() => client.SendAsync(data)); + Assert.Equal("TCP Socket is not connected 127.0.0.1:0", ex.Message); + } + + [Fact] + public async Task SendAsync_Cancel() + { + var port = 8881; + var server = StartTcpServer(port, MockSplitPackageAsync); + + var client = CreateClient(); + Assert.False(client.IsConnected); + + // 连接 TCP Server + await client.ConnectAsync("localhost", port); + Assert.True(client.IsConnected); + + // 测试 SendAsync 方法发送取消逻辑 + var cst = new CancellationTokenSource(); + cst.Cancel(); + + var data = new Memory([1, 2, 3, 4, 5]); + var result = await client.SendAsync(data, cst.Token); + Assert.False(result); + + // 设置延时发送适配器 + // 延时发送期间关闭 Socket 连接导致内部报错 + client.SetDataHandler(new MockSendErrorHandler() + { + Socket = client + }); + + var tcs = new TaskCompletionSource(); + bool? sendResult = null; + // 测试发送失败逻辑 + _ = Task.Run(async () => + { + sendResult = await client.SendAsync(data); + tcs.SetResult(); + }); + + await tcs.Task; + Assert.False(sendResult); + + // 关闭连接 + StopTcpServer(server); + } + + [Fact] + public async Task ReceiveAsync_Error() + { + var client = CreateClient(); + + // 测试未建立连接前调用 ReceiveAsync 方法报异常逻辑 + var methodInfo = client.GetType().GetMethod("ReceiveAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Assert.NotNull(methodInfo); + + var task = (Task)methodInfo.Invoke(client, null)!; + var ex = await Assert.ThrowsAsync(async () => await task); + Assert.NotNull(ex); + + var port = 8882; + var server = StartTcpServer(port, MockSplitPackageAsync); + + Assert.Equal(1024 * 10, client.ReceiveBufferSize); + + client.ReceiveBufferSize = 1024 * 20; + Assert.Equal(1024 * 20, client.ReceiveBufferSize); + + client.SetDataHandler(new MockReceiveErrorHandler()); + await client.ConnectAsync("localhost", port); + + // 发送数据导致接收数据异常 + var data = new Memory([1, 2, 3, 4, 5]); + await client.SendAsync(data); + + // 关闭连接 + StopTcpServer(server); + } + + [Fact] + public async Task CloseByRemote_Ok() + { + var client = CreateClient(); + + var port = 8883; + var server = StartTcpServer(port, MockAutoClosePackageAsync); + + client.SetDataHandler(new MockReceiveErrorHandler()); + + // 连接 TCP Server + await client.ConnectAsync("localhost", port); + + // 发送数据 + await client.SendAsync(new Memory([1, 2, 3, 4, 5])); + + // 关闭连接 + StopTcpServer(server); + } + + [Fact] + public async Task FixLengthDataPackageHandler_Ok() + { + var port = 8888; + var server = StartTcpServer(port, MockSplitPackageAsync); + var client = CreateClient(); + + // 测试 ConnectAsync 方法 + var connect = await client.ConnectAsync("localhost", port); + Assert.True(connect); + Assert.True(client.IsConnected); + + var tcs = new TaskCompletionSource(); + Memory receivedBuffer = Memory.Empty; + + // 增加数据处理适配器 + client.SetDataHandler(new FixLengthDataPackageHandler(7) + { + ReceivedCallBack = buffer => + { + receivedBuffer = buffer; + tcs.SetResult(); + return Task.CompletedTask; + } + }); + + // 测试 SendAsync 方法 + var data = new Memory([1, 2, 3, 4, 5]); + var result = await client.SendAsync(data); + Assert.True(result); + + await tcs.Task; + Assert.Equal(receivedBuffer.ToArray(), [1, 2, 3, 4, 5, 3, 4]); + + // 模拟延时等待内部继续读取逻辑完成,测试内部 _receiveCancellationTokenSource 取消逻辑 + await Task.Delay(10); + + // 关闭连接 + client.Close(); + StopTcpServer(server); + } + + [Fact] + public async Task FixLengthDataPackageHandler_Sticky() + { + var port = 8899; + var server = StartTcpServer(port, MockStickyPackageAsync); + var client = CreateClient(); + + // 连接 TCP Server + var connect = await client.ConnectAsync("localhost", port); + + var tcs = new TaskCompletionSource(); + Memory receivedBuffer = Memory.Empty; + + // 增加数据库处理适配器 + client.SetDataHandler(new FixLengthDataPackageHandler(7) + { + ReceivedCallBack = buffer => + { + receivedBuffer = buffer; + tcs.SetResult(); + return Task.CompletedTask; + } + }); + + // 发送数据 + var data = new Memory([1, 2, 3, 4, 5]); + await client.SendAsync(data); + + // 等待接收数据处理完成 + await tcs.Task; + + // 验证接收到的数据 + Assert.Equal(receivedBuffer.ToArray(), [1, 2, 3, 4, 5, 3, 4]); + + // 等待第二次数据 + receivedBuffer = Memory.Empty; + tcs = new TaskCompletionSource(); + await tcs.Task; + + // 验证第二次收到的数据 + Assert.Equal(receivedBuffer.ToArray(), [1, 2, 3, 4, 5, 6, 7]); + + // 关闭连接 + client.Close(); + StopTcpServer(server); + } + + private static TcpListener StartTcpServer(int port, Func handler) + { + var server = new TcpListener(IPAddress.Loopback, port); + server.Start(); + Task.Run(() => AcceptClientsAsync(server, handler)); + return server; + } + + private static async Task AcceptClientsAsync(TcpListener server, Func handler) + { + while (true) + { + var client = await server.AcceptTcpClientAsync(); + _ = Task.Run(() => handler(client)); + } + } + + private static async Task MockSplitPackageAsync(TcpClient client) + { + using var stream = client.GetStream(); + while (true) + { + var buffer = new byte[10240]; + var len = await stream.ReadAsync(buffer); + if (len == 0) + { + break; + } + + // 回写数据到客户端 + var block = new Memory(buffer, 0, len); + await stream.WriteAsync(block, CancellationToken.None); + + // 模拟延时 + await Task.Delay(50); + + // 模拟拆包发送第二段数据 + await stream.WriteAsync(new byte[] { 0x3, 0x4 }, CancellationToken.None); + } + } + + private static async Task MockStickyPackageAsync(TcpClient client) + { + using var stream = client.GetStream(); + while (true) + { + var buffer = new byte[10240]; + var len = await stream.ReadAsync(buffer); + if (len == 0) + { + break; + } + + // 回写数据到客户端 + var block = new Memory(buffer, 0, len); + await stream.WriteAsync(block, CancellationToken.None); + + // 模拟延时 + await Task.Delay(50); + + // 模拟拆包发送第二段数据 + await stream.WriteAsync(new byte[] { 0x3, 0x4, 0x1, 0x2 }, CancellationToken.None); + + // 模拟延时 + await Task.Delay(50); + + // 模拟粘包发送后续数据 + await stream.WriteAsync(new byte[] { 0x3, 0x4, 0x5, 0x6, 0x7 }, CancellationToken.None); + } + } + + private static Task MockAutoClosePackageAsync(TcpClient client) + { + client.Close(); + return Task.CompletedTask; + } + + private static void StopTcpServer(TcpListener server) + { + server?.Stop(); + } + + private static ITcpSocketClient CreateClient() + { + var sc = new ServiceCollection(); + sc.AddLogging(builder => + { + builder.AddProvider(new MockLoggerProvider()); + }); + sc.AddBootstrapBlazorTcpSocketFactory(); + var provider = sc.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + var client = factory.GetOrCreate("localhost", 0); + return client; + } + + class MockLoggerProvider : ILoggerProvider + { + public ILogger CreateLogger(string categoryName) + { + return new MockLogger(); + } + + public void Dispose() + { + + } + } + + class MockLogger : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + + } + } + + class MockSendErrorHandler : DataPackageHandlerBase + { + public ITcpSocketClient? Socket { get; set; } + + public override async Task> SendAsync(Memory data) + { + Socket?.Close(); + await Task.Delay(10); + return data; + } + } + + class MockReceiveErrorHandler : DataPackageHandlerBase + { + public override Task> SendAsync(Memory data) + { + return Task.FromResult(data); + } + + public override async Task ReceiveAsync(Memory data) + { + await base.ReceiveAsync(data); + + // 模拟接收数据时报错 + throw new InvalidOperationException("Test Error"); + } + } +}