feat(lmxproxy): phase 6 — client extras (builder, factory, DI, streaming extensions)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,17 @@ namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
/// <summary>
|
||||
/// Configuration options for the LmxProxy client, typically set via the builder.
|
||||
/// </summary>
|
||||
public class ClientConfiguration
|
||||
internal class ClientConfiguration
|
||||
{
|
||||
/// <summary>Maximum number of retry attempts for transient failures.</summary>
|
||||
public int MaxRetryAttempts { get; set; } = 0;
|
||||
public int MaxRetryAttempts { get; set; }
|
||||
|
||||
/// <summary>Base delay between retries (exponential backoff applied).</summary>
|
||||
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
public TimeSpan RetryDelay { get; set; }
|
||||
|
||||
/// <summary>Whether client-side metrics collection is enabled.</summary>
|
||||
public bool EnableMetrics { get; set; }
|
||||
|
||||
/// <summary>Optional header name for correlation ID propagation.</summary>
|
||||
public string? CorrelationIdHeader { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating <see cref="LmxProxyClient"/> instances.
|
||||
/// </summary>
|
||||
public interface ILmxProxyClientFactory
|
||||
{
|
||||
/// <summary>Creates a client from the default "LmxProxy" configuration section.</summary>
|
||||
LmxProxyClient CreateClient();
|
||||
|
||||
/// <summary>Creates a client from a named configuration section.</summary>
|
||||
LmxProxyClient CreateClient(string configName);
|
||||
|
||||
/// <summary>Creates a client using a builder configuration action.</summary>
|
||||
LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ILmxProxyClientFactory"/> that reads from IConfiguration.
|
||||
/// </summary>
|
||||
public class LmxProxyClientFactory : ILmxProxyClientFactory
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
/// <summary>Creates a new factory with the specified configuration.</summary>
|
||||
public LmxProxyClientFactory(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public LmxProxyClient CreateClient() => CreateClient("LmxProxy");
|
||||
|
||||
/// <inheritdoc />
|
||||
public LmxProxyClient CreateClient(string configName)
|
||||
{
|
||||
IConfigurationSection section = _configuration.GetSection(configName);
|
||||
var options = new LmxProxyClientOptions();
|
||||
section.Bind(options);
|
||||
return BuildFromOptions(options);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction)
|
||||
{
|
||||
var builder = new LmxProxyClientBuilder();
|
||||
builderAction(builder);
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static LmxProxyClient BuildFromOptions(LmxProxyClientOptions options)
|
||||
{
|
||||
var builder = new LmxProxyClientBuilder()
|
||||
.WithHost(options.Host)
|
||||
.WithPort(options.Port)
|
||||
.WithTimeout(options.Timeout)
|
||||
.WithRetryPolicy(options.Retry.MaxAttempts, options.Retry.Delay);
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ApiKey))
|
||||
builder.WithApiKey(options.ApiKey);
|
||||
|
||||
if (options.EnableMetrics)
|
||||
builder.WithMetrics();
|
||||
|
||||
if (!string.IsNullOrEmpty(options.CorrelationIdHeader))
|
||||
builder.WithCorrelationIdHeader(options.CorrelationIdHeader);
|
||||
|
||||
if (options.UseSsl)
|
||||
{
|
||||
builder.WithTlsConfiguration(new ClientTlsConfiguration
|
||||
{
|
||||
UseTls = true,
|
||||
ServerCaCertificatePath = options.CertificatePath
|
||||
});
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
157
lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs
Normal file
157
lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs
Normal file
@@ -0,0 +1,157 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Fluent builder for creating configured <see cref="LmxProxyClient"/> instances.
|
||||
/// </summary>
|
||||
public class LmxProxyClientBuilder
|
||||
{
|
||||
private string? _host;
|
||||
private int _port = 50051;
|
||||
private string? _apiKey;
|
||||
private ILogger<LmxProxyClient>? _logger;
|
||||
private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
|
||||
private int _maxRetryAttempts = 3;
|
||||
private TimeSpan _retryDelay = TimeSpan.FromSeconds(1);
|
||||
private bool _enableMetrics;
|
||||
private string? _correlationIdHeader;
|
||||
private ClientTlsConfiguration? _tlsConfiguration;
|
||||
|
||||
/// <summary>Sets the host address of the LmxProxy server. Required.</summary>
|
||||
public LmxProxyClientBuilder WithHost(string host)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
throw new ArgumentException("Host must not be null or empty.", nameof(host));
|
||||
_host = host;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Sets the port of the LmxProxy server. Default is 50051.</summary>
|
||||
public LmxProxyClientBuilder WithPort(int port)
|
||||
{
|
||||
if (port < 1 || port > 65535)
|
||||
throw new ArgumentOutOfRangeException(nameof(port), "Port must be between 1 and 65535.");
|
||||
_port = port;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Sets the API key for authentication.</summary>
|
||||
public LmxProxyClientBuilder WithApiKey(string? apiKey)
|
||||
{
|
||||
_apiKey = apiKey;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Sets the logger instance for the client.</summary>
|
||||
public LmxProxyClientBuilder WithLogger(ILogger<LmxProxyClient> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Sets the default timeout for operations. Must be between 1 second and 10 minutes.</summary>
|
||||
public LmxProxyClientBuilder WithTimeout(TimeSpan timeout)
|
||||
{
|
||||
if (timeout <= TimeSpan.Zero || timeout > TimeSpan.FromMinutes(10))
|
||||
throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be greater than zero and at most 10 minutes.");
|
||||
_defaultTimeout = timeout;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Enables TLS with an optional server CA certificate path.</summary>
|
||||
public LmxProxyClientBuilder WithSslCredentials(string? certificatePath)
|
||||
{
|
||||
_tlsConfiguration ??= new ClientTlsConfiguration();
|
||||
_tlsConfiguration.UseTls = true;
|
||||
_tlsConfiguration.ServerCaCertificatePath = certificatePath;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Sets a full TLS configuration.</summary>
|
||||
public LmxProxyClientBuilder WithTlsConfiguration(ClientTlsConfiguration config)
|
||||
{
|
||||
_tlsConfiguration = config ?? throw new ArgumentNullException(nameof(config));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Configures the retry policy. maxAttempts must be positive, retryDelay must be positive.</summary>
|
||||
public LmxProxyClientBuilder WithRetryPolicy(int maxAttempts, TimeSpan retryDelay)
|
||||
{
|
||||
if (maxAttempts <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxAttempts), "Max retry attempts must be greater than zero.");
|
||||
if (retryDelay <= TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(retryDelay), "Retry delay must be greater than zero.");
|
||||
_maxRetryAttempts = maxAttempts;
|
||||
_retryDelay = retryDelay;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Enables client-side metrics collection.</summary>
|
||||
public LmxProxyClientBuilder WithMetrics()
|
||||
{
|
||||
_enableMetrics = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Sets the correlation ID header name for request tracing.</summary>
|
||||
public LmxProxyClientBuilder WithCorrelationIdHeader(string headerName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(headerName))
|
||||
throw new ArgumentException("Header name must not be null or empty.", nameof(headerName));
|
||||
_correlationIdHeader = headerName;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and returns a configured <see cref="LmxProxyClient"/> instance.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown when host is not set.</exception>
|
||||
/// <exception cref="FileNotFoundException">Thrown when TLS certificate paths don't exist.</exception>
|
||||
public LmxProxyClient Build()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_host))
|
||||
throw new InvalidOperationException("Host must be specified. Call WithHost() before Build().");
|
||||
|
||||
ValidateTlsConfiguration();
|
||||
|
||||
var client = new LmxProxyClient(_host, _port, _apiKey, _tlsConfiguration, _logger)
|
||||
{
|
||||
DefaultTimeout = _defaultTimeout
|
||||
};
|
||||
|
||||
client.SetBuilderConfiguration(new ClientConfiguration
|
||||
{
|
||||
MaxRetryAttempts = _maxRetryAttempts,
|
||||
RetryDelay = _retryDelay,
|
||||
EnableMetrics = _enableMetrics,
|
||||
CorrelationIdHeader = _correlationIdHeader
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private void ValidateTlsConfiguration()
|
||||
{
|
||||
if (_tlsConfiguration?.UseTls != true)
|
||||
return;
|
||||
|
||||
if (!string.IsNullOrEmpty(_tlsConfiguration.ServerCaCertificatePath) &&
|
||||
!File.Exists(_tlsConfiguration.ServerCaCertificatePath))
|
||||
throw new FileNotFoundException(
|
||||
$"Server CA certificate not found: {_tlsConfiguration.ServerCaCertificatePath}",
|
||||
_tlsConfiguration.ServerCaCertificatePath);
|
||||
|
||||
if (!string.IsNullOrEmpty(_tlsConfiguration.ClientCertificatePath) &&
|
||||
!File.Exists(_tlsConfiguration.ClientCertificatePath))
|
||||
throw new FileNotFoundException(
|
||||
$"Client certificate not found: {_tlsConfiguration.ClientCertificatePath}",
|
||||
_tlsConfiguration.ClientCertificatePath);
|
||||
|
||||
if (!string.IsNullOrEmpty(_tlsConfiguration.ClientKeyPath) &&
|
||||
!File.Exists(_tlsConfiguration.ClientKeyPath))
|
||||
throw new FileNotFoundException(
|
||||
$"Client key not found: {_tlsConfiguration.ClientKeyPath}",
|
||||
_tlsConfiguration.ClientKeyPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for creating an LmxProxy client from IConfiguration sections.
|
||||
/// </summary>
|
||||
public class LmxProxyClientOptions
|
||||
{
|
||||
/// <summary>Host address of the LmxProxy server.</summary>
|
||||
public string Host { get; set; } = "localhost";
|
||||
|
||||
/// <summary>Port of the LmxProxy server.</summary>
|
||||
public int Port { get; set; } = 50051;
|
||||
|
||||
/// <summary>API key for authentication.</summary>
|
||||
public string? ApiKey { get; set; }
|
||||
|
||||
/// <summary>Default timeout for operations.</summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Whether to use TLS for the connection.</summary>
|
||||
public bool UseSsl { get; set; }
|
||||
|
||||
/// <summary>Path to the server CA certificate for TLS.</summary>
|
||||
public string? CertificatePath { get; set; }
|
||||
|
||||
/// <summary>Whether to enable client-side metrics collection.</summary>
|
||||
public bool EnableMetrics { get; set; }
|
||||
|
||||
/// <summary>Optional header name for correlation ID propagation.</summary>
|
||||
public string? CorrelationIdHeader { get; set; }
|
||||
|
||||
/// <summary>Retry policy options.</summary>
|
||||
public RetryOptions Retry { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retry policy configuration options.
|
||||
/// </summary>
|
||||
public class RetryOptions
|
||||
{
|
||||
/// <summary>Maximum number of retry attempts.</summary>
|
||||
public int MaxAttempts { get; set; } = 3;
|
||||
|
||||
/// <summary>Base delay between retries.</summary>
|
||||
public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering LmxProxy client services in the DI container.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Registers a singleton ILmxProxyClient from the "LmxProxy" config section.</summary>
|
||||
public static IServiceCollection AddLmxProxyClient(
|
||||
this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
return services.AddLmxProxyClient(configuration, "LmxProxy");
|
||||
}
|
||||
|
||||
/// <summary>Registers a singleton ILmxProxyClient from a named config section.</summary>
|
||||
public static IServiceCollection AddLmxProxyClient(
|
||||
this IServiceCollection services, IConfiguration configuration, string sectionName)
|
||||
{
|
||||
services.AddSingleton<ILmxProxyClientFactory>(
|
||||
sp => new LmxProxyClientFactory(configuration));
|
||||
services.AddSingleton<ILmxProxyClient>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<ILmxProxyClientFactory>();
|
||||
return factory.CreateClient(sectionName);
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Registers a singleton ILmxProxyClient via builder action.</summary>
|
||||
public static IServiceCollection AddLmxProxyClient(
|
||||
this IServiceCollection services, Action<LmxProxyClientBuilder> configure)
|
||||
{
|
||||
services.AddSingleton<ILmxProxyClient>(sp =>
|
||||
{
|
||||
var builder = new LmxProxyClientBuilder();
|
||||
configure(builder);
|
||||
return builder.Build();
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Registers a scoped ILmxProxyClient from the "LmxProxy" config section.</summary>
|
||||
public static IServiceCollection AddScopedLmxProxyClient(
|
||||
this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddSingleton<ILmxProxyClientFactory>(
|
||||
sp => new LmxProxyClientFactory(configuration));
|
||||
services.AddScoped<ILmxProxyClient>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<ILmxProxyClientFactory>();
|
||||
return factory.CreateClient();
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Registers a keyed singleton ILmxProxyClient.</summary>
|
||||
public static IServiceCollection AddNamedLmxProxyClient(
|
||||
this IServiceCollection services, string name, Action<LmxProxyClientBuilder> configure)
|
||||
{
|
||||
services.AddKeyedSingleton<ILmxProxyClient>(name, (sp, key) =>
|
||||
{
|
||||
var builder = new LmxProxyClientBuilder();
|
||||
configure(builder);
|
||||
return builder.Build();
|
||||
});
|
||||
return services;
|
||||
}
|
||||
}
|
||||
218
lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs
Normal file
218
lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs
Normal file
@@ -0,0 +1,218 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for streaming reads, writes, and subscriptions over ILmxProxyClient.
|
||||
/// </summary>
|
||||
public static class StreamingExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads multiple tags as an async stream in batches.
|
||||
/// Retries up to 2 times per batch. Aborts after 3 consecutive batch errors.
|
||||
/// </summary>
|
||||
public static async IAsyncEnumerable<KeyValuePair<string, Vtq>> ReadStreamAsync(
|
||||
this ILmxProxyClient client,
|
||||
IEnumerable<string> addresses,
|
||||
int batchSize = 100,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(addresses);
|
||||
if (batchSize <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize));
|
||||
|
||||
var batch = new List<string>(batchSize);
|
||||
int consecutiveErrors = 0;
|
||||
const int maxConsecutiveErrors = 3;
|
||||
const int maxRetries = 2;
|
||||
|
||||
foreach (string address in addresses)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
batch.Add(address);
|
||||
|
||||
if (batch.Count >= batchSize)
|
||||
{
|
||||
bool success = false;
|
||||
await foreach (var kvp in ReadBatchWithRetry(
|
||||
client, batch, maxRetries, cancellationToken))
|
||||
{
|
||||
consecutiveErrors = 0;
|
||||
success = true;
|
||||
yield return kvp;
|
||||
}
|
||||
if (!success)
|
||||
{
|
||||
consecutiveErrors++;
|
||||
if (consecutiveErrors >= maxConsecutiveErrors)
|
||||
yield break;
|
||||
}
|
||||
batch.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Process remaining
|
||||
if (batch.Count > 0)
|
||||
{
|
||||
await foreach (var kvp in ReadBatchWithRetry(
|
||||
client, batch, maxRetries, cancellationToken))
|
||||
{
|
||||
yield return kvp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<KeyValuePair<string, Vtq>> ReadBatchWithRetry(
|
||||
ILmxProxyClient client,
|
||||
List<string> batch,
|
||||
int maxRetries,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
int retries = 0;
|
||||
while (retries <= maxRetries)
|
||||
{
|
||||
IDictionary<string, Vtq>? results = null;
|
||||
try
|
||||
{
|
||||
results = await client.ReadBatchAsync(batch, ct);
|
||||
}
|
||||
catch when (retries < maxRetries)
|
||||
{
|
||||
retries++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (results is not null)
|
||||
{
|
||||
foreach (var kvp in results)
|
||||
yield return kvp;
|
||||
yield break;
|
||||
}
|
||||
retries++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes values from an async enumerable in batches. Returns total count written.
|
||||
/// </summary>
|
||||
public static async Task<int> WriteStreamAsync(
|
||||
this ILmxProxyClient client,
|
||||
IAsyncEnumerable<KeyValuePair<string, TypedValue>> values,
|
||||
int batchSize = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
if (batchSize <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize));
|
||||
|
||||
var batch = new Dictionary<string, TypedValue>(batchSize);
|
||||
int totalWritten = 0;
|
||||
|
||||
await foreach (var kvp in values.WithCancellation(cancellationToken))
|
||||
{
|
||||
batch[kvp.Key] = kvp.Value;
|
||||
|
||||
if (batch.Count >= batchSize)
|
||||
{
|
||||
await client.WriteBatchAsync(batch, cancellationToken);
|
||||
totalWritten += batch.Count;
|
||||
batch.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.Count > 0)
|
||||
{
|
||||
await client.WriteBatchAsync(batch, cancellationToken);
|
||||
totalWritten += batch.Count;
|
||||
}
|
||||
|
||||
return totalWritten;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes items in parallel with a configurable max concurrency (default 4).
|
||||
/// </summary>
|
||||
public static async Task ProcessInParallelAsync<T>(
|
||||
this IAsyncEnumerable<T> source,
|
||||
Func<T, CancellationToken, Task> processor,
|
||||
int maxConcurrency = 4,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(processor);
|
||||
if (maxConcurrency <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxConcurrency));
|
||||
|
||||
using var semaphore = new SemaphoreSlim(maxConcurrency);
|
||||
var tasks = new List<Task>();
|
||||
|
||||
await foreach (T item in source.WithCancellation(cancellationToken))
|
||||
{
|
||||
await semaphore.WaitAsync(cancellationToken);
|
||||
|
||||
tasks.Add(Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await processor(item, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}, cancellationToken));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a callback-based subscription into an IAsyncEnumerable via System.Threading.Channels.
|
||||
/// </summary>
|
||||
public static async IAsyncEnumerable<(string Tag, Vtq Vtq)> SubscribeStreamAsync(
|
||||
this ILmxProxyClient client,
|
||||
IEnumerable<string> addresses,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(addresses);
|
||||
|
||||
var channel = Channel.CreateBounded<(string, Vtq)>(
|
||||
new BoundedChannelOptions(1000)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
LmxProxyClient.ISubscription? subscription = null;
|
||||
try
|
||||
{
|
||||
subscription = await client.SubscribeAsync(
|
||||
addresses,
|
||||
(tag, vtq) =>
|
||||
{
|
||||
channel.Writer.TryWrite((tag, vtq));
|
||||
},
|
||||
ex =>
|
||||
{
|
||||
channel.Writer.TryComplete(ex);
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
subscription?.Dispose();
|
||||
channel.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Hand-written fake implementation of ILmxProxyClient for unit testing streaming extensions.
|
||||
/// </summary>
|
||||
internal class FakeLmxProxyClient : ILmxProxyClient
|
||||
{
|
||||
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Track calls
|
||||
public List<List<string>> ReadBatchCalls { get; } = [];
|
||||
public List<IDictionary<string, TypedValue>> WriteBatchCalls { get; } = [];
|
||||
public List<IEnumerable<string>> SubscribeCalls { get; } = [];
|
||||
|
||||
// Configurable responses
|
||||
public Func<IEnumerable<string>, CancellationToken, Task<IDictionary<string, Vtq>>>? ReadBatchHandler { get; set; }
|
||||
public Exception? ReadBatchExceptionToThrow { get; set; }
|
||||
public int ReadBatchExceptionCount { get; set; }
|
||||
private int _readBatchCallCount;
|
||||
|
||||
// Subscription support
|
||||
public Action<string, Vtq>? CapturedOnUpdate { get; private set; }
|
||||
public Action<Exception>? CapturedOnError { get; private set; }
|
||||
|
||||
public Task ConnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task DisconnectAsync() => Task.CompletedTask;
|
||||
public Task<bool> IsConnectedAsync() => Task.FromResult(true);
|
||||
|
||||
public Task<Vtq> ReadAsync(string address, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new Vtq(null, DateTime.UtcNow, Quality.Good));
|
||||
|
||||
public Task<IDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var addressList = addresses.ToList();
|
||||
ReadBatchCalls.Add(addressList);
|
||||
_readBatchCallCount++;
|
||||
|
||||
if (ReadBatchExceptionToThrow is not null && _readBatchCallCount <= ReadBatchExceptionCount)
|
||||
throw ReadBatchExceptionToThrow;
|
||||
|
||||
if (ReadBatchHandler is not null)
|
||||
return ReadBatchHandler(addressList, cancellationToken);
|
||||
|
||||
var result = new Dictionary<string, Vtq>();
|
||||
foreach (var addr in addressList)
|
||||
result[addr] = new Vtq(42.0, DateTime.UtcNow, Quality.Good);
|
||||
return Task.FromResult<IDictionary<string, Vtq>>(result);
|
||||
}
|
||||
|
||||
public Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task WriteBatchAsync(IDictionary<string, TypedValue> values, CancellationToken cancellationToken = default)
|
||||
{
|
||||
WriteBatchCalls.Add(new Dictionary<string, TypedValue>(values));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(
|
||||
IDictionary<string, TypedValue> values, string flagTag, TypedValue flagValue,
|
||||
int timeoutMs = 5000, int pollIntervalMs = 100, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new WriteBatchAndWaitResponse { Success = true });
|
||||
|
||||
public Task<LmxProxyClient.ISubscription> SubscribeAsync(
|
||||
IEnumerable<string> addresses,
|
||||
Action<string, Vtq> onUpdate,
|
||||
Action<Exception>? onStreamError = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SubscribeCalls.Add(addresses);
|
||||
CapturedOnUpdate = onUpdate;
|
||||
CapturedOnError = onStreamError;
|
||||
return Task.FromResult<LmxProxyClient.ISubscription>(new FakeSubscription());
|
||||
}
|
||||
|
||||
public Task<LmxProxyClient.ApiKeyInfo> CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new LmxProxyClient.ApiKeyInfo { IsValid = true });
|
||||
|
||||
public Dictionary<string, object> GetMetrics() => [];
|
||||
|
||||
public void Dispose() { }
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
private class FakeSubscription : LmxProxyClient.ISubscription
|
||||
{
|
||||
public void Dispose() { }
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
|
||||
|
||||
public class LmxProxyClientBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_ThrowsWhenHostNotSet()
|
||||
{
|
||||
var builder = new LmxProxyClientBuilder();
|
||||
Assert.Throws<InvalidOperationException>(() => builder.Build());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DefaultPort_Is50051()
|
||||
{
|
||||
var client = new LmxProxyClientBuilder()
|
||||
.WithHost("localhost")
|
||||
.Build();
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithPort_ThrowsOnZero()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new LmxProxyClientBuilder().WithPort(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithPort_ThrowsOn65536()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new LmxProxyClientBuilder().WithPort(65536));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithTimeout_ThrowsOnNegative()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromSeconds(-1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithTimeout_ThrowsOver10Minutes()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromMinutes(11)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithRetryPolicy_ThrowsOnZeroAttempts()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new LmxProxyClientBuilder().WithRetryPolicy(0, TimeSpan.FromSeconds(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithRetryPolicy_ThrowsOnZeroDelay()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new LmxProxyClientBuilder().WithRetryPolicy(3, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithAllOptions_Succeeds()
|
||||
{
|
||||
var client = new LmxProxyClientBuilder()
|
||||
.WithHost("10.100.0.48")
|
||||
.WithPort(50051)
|
||||
.WithApiKey("test-key")
|
||||
.WithTimeout(TimeSpan.FromSeconds(15))
|
||||
.WithRetryPolicy(5, TimeSpan.FromSeconds(2))
|
||||
.WithMetrics()
|
||||
.WithCorrelationIdHeader("X-Correlation-ID")
|
||||
.Build();
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithTls_ValidatesCertificatePaths()
|
||||
{
|
||||
var builder = new LmxProxyClientBuilder()
|
||||
.WithHost("localhost")
|
||||
.WithTlsConfiguration(new ClientTlsConfiguration
|
||||
{
|
||||
UseTls = true,
|
||||
ServerCaCertificatePath = "/nonexistent/cert.pem"
|
||||
});
|
||||
Assert.Throws<FileNotFoundException>(() => builder.Build());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithHost_ThrowsOnNull()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new LmxProxyClientBuilder().WithHost(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithHost_ThrowsOnEmpty()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new LmxProxyClientBuilder().WithHost(""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
|
||||
|
||||
public class LmxProxyClientFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateClient_BindsFromConfiguration()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["LmxProxy:Host"] = "10.100.0.48",
|
||||
["LmxProxy:Port"] = "50052",
|
||||
["LmxProxy:ApiKey"] = "test-key",
|
||||
["LmxProxy:Retry:MaxAttempts"] = "5",
|
||||
["LmxProxy:Retry:Delay"] = "00:00:02",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var factory = new LmxProxyClientFactory(config);
|
||||
var client = factory.CreateClient();
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateClient_NamedSection()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MyProxy:Host"] = "10.100.0.48",
|
||||
["MyProxy:Port"] = "50052",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var factory = new LmxProxyClientFactory(config);
|
||||
var client = factory.CreateClient("MyProxy");
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateClient_BuilderAction()
|
||||
{
|
||||
var config = new ConfigurationBuilder().Build();
|
||||
var factory = new LmxProxyClientFactory(config);
|
||||
var client = factory.CreateClient(b => b.WithHost("localhost").WithPort(50051));
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
|
||||
|
||||
public class ServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddLmxProxyClient_WithConfiguration_RegistersSingleton()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["LmxProxy:Host"] = "localhost",
|
||||
["LmxProxy:Port"] = "50051",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLmxProxyClient(config);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var client = provider.GetRequiredService<ILmxProxyClient>();
|
||||
Assert.NotNull(client);
|
||||
Assert.IsType<LmxProxyClient>(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLmxProxyClient_WithBuilderAction_RegistersSingleton()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLmxProxyClient(b => b.WithHost("localhost").WithPort(50051));
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var client = provider.GetRequiredService<ILmxProxyClient>();
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLmxProxyClient_WithNamedSection_RegistersSingleton()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["CustomProxy:Host"] = "10.0.0.1",
|
||||
["CustomProxy:Port"] = "50052",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLmxProxyClient(config, "CustomProxy");
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var client = provider.GetRequiredService<ILmxProxyClient>();
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddScopedLmxProxyClient_RegistersScoped()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["LmxProxy:Host"] = "localhost",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScopedLmxProxyClient(config);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
using var scope = provider.CreateScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<ILmxProxyClient>();
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddNamedLmxProxyClient_RegistersKeyedSingleton()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddNamedLmxProxyClient("primary", b => b.WithHost("host-a").WithPort(50051));
|
||||
services.AddNamedLmxProxyClient("secondary", b => b.WithHost("host-b").WithPort(50052));
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var primary = provider.GetRequiredKeyedService<ILmxProxyClient>("primary");
|
||||
var secondary = provider.GetRequiredKeyedService<ILmxProxyClient>("secondary");
|
||||
Assert.NotNull(primary);
|
||||
Assert.NotNull(secondary);
|
||||
Assert.NotSame(primary, secondary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
|
||||
|
||||
public class StreamingExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadStreamAsync_BatchesCorrectly()
|
||||
{
|
||||
var fake = new FakeLmxProxyClient();
|
||||
var addresses = Enumerable.Range(0, 250).Select(i => $"tag{i}").ToList();
|
||||
|
||||
var results = new List<KeyValuePair<string, Vtq>>();
|
||||
await foreach (var kvp in fake.ReadStreamAsync(addresses, batchSize: 100))
|
||||
{
|
||||
results.Add(kvp);
|
||||
}
|
||||
|
||||
// 250 tags at batchSize=100 => 3 batch calls (100, 100, 50)
|
||||
Assert.Equal(3, fake.ReadBatchCalls.Count);
|
||||
Assert.Equal(100, fake.ReadBatchCalls[0].Count);
|
||||
Assert.Equal(100, fake.ReadBatchCalls[1].Count);
|
||||
Assert.Equal(50, fake.ReadBatchCalls[2].Count);
|
||||
Assert.Equal(250, results.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadStreamAsync_RetriesOnError()
|
||||
{
|
||||
var fake = new FakeLmxProxyClient
|
||||
{
|
||||
ReadBatchExceptionToThrow = new InvalidOperationException("transient"),
|
||||
ReadBatchExceptionCount = 1 // First call throws, second succeeds
|
||||
};
|
||||
|
||||
var addresses = Enumerable.Range(0, 5).Select(i => $"tag{i}").ToList();
|
||||
var results = new List<KeyValuePair<string, Vtq>>();
|
||||
await foreach (var kvp in fake.ReadStreamAsync(addresses, batchSize: 10))
|
||||
{
|
||||
results.Add(kvp);
|
||||
}
|
||||
|
||||
// Should retry: first call throws, second succeeds
|
||||
Assert.Equal(2, fake.ReadBatchCalls.Count);
|
||||
Assert.Equal(5, results.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteStreamAsync_BatchesAndReturnsCount()
|
||||
{
|
||||
var fake = new FakeLmxProxyClient();
|
||||
var values = GenerateWriteValues(250);
|
||||
|
||||
int total = await fake.WriteStreamAsync(values, batchSize: 100);
|
||||
|
||||
Assert.Equal(250, total);
|
||||
Assert.Equal(3, fake.WriteBatchCalls.Count);
|
||||
Assert.Equal(100, fake.WriteBatchCalls[0].Count);
|
||||
Assert.Equal(100, fake.WriteBatchCalls[1].Count);
|
||||
Assert.Equal(50, fake.WriteBatchCalls[2].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInParallelAsync_RespectsMaxConcurrency()
|
||||
{
|
||||
int maxConcurrency = 2;
|
||||
int currentConcurrency = 0;
|
||||
int maxObservedConcurrency = 0;
|
||||
var lockObj = new object();
|
||||
|
||||
var source = GenerateAsyncSequence(10);
|
||||
|
||||
await source.ProcessInParallelAsync(async (item, ct) =>
|
||||
{
|
||||
int current;
|
||||
lock (lockObj)
|
||||
{
|
||||
currentConcurrency++;
|
||||
current = currentConcurrency;
|
||||
if (current > maxObservedConcurrency)
|
||||
maxObservedConcurrency = current;
|
||||
}
|
||||
|
||||
await Task.Delay(50, ct);
|
||||
|
||||
lock (lockObj)
|
||||
{
|
||||
currentConcurrency--;
|
||||
}
|
||||
}, maxConcurrency: maxConcurrency);
|
||||
|
||||
Assert.True(maxObservedConcurrency <= maxConcurrency,
|
||||
$"Max observed concurrency {maxObservedConcurrency} exceeded limit {maxConcurrency}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeStreamAsync_YieldsFromChannel()
|
||||
{
|
||||
var fake = new FakeLmxProxyClient();
|
||||
var addresses = new[] { "tag1", "tag2" };
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
var results = new List<(string Tag, Vtq Vtq)>();
|
||||
|
||||
// Start the subscription stream in a background task
|
||||
var streamTask = Task.Run(async () =>
|
||||
{
|
||||
await foreach (var item in fake.SubscribeStreamAsync(addresses, cts.Token))
|
||||
{
|
||||
results.Add(item);
|
||||
if (results.Count >= 3)
|
||||
await cts.CancelAsync();
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for subscribe to be called with a polling loop
|
||||
for (int i = 0; i < 50 && fake.CapturedOnUpdate is null; i++)
|
||||
await Task.Delay(50);
|
||||
|
||||
// Simulate updates via captured callback
|
||||
Assert.NotNull(fake.CapturedOnUpdate);
|
||||
fake.CapturedOnUpdate!("tag1", new Vtq(1.0, DateTime.UtcNow, Quality.Good));
|
||||
fake.CapturedOnUpdate!("tag2", new Vtq(2.0, DateTime.UtcNow, Quality.Good));
|
||||
fake.CapturedOnUpdate!("tag1", new Vtq(3.0, DateTime.UtcNow, Quality.Good));
|
||||
|
||||
// Wait for stream task to complete (cancelled after 3 items)
|
||||
try { await streamTask; }
|
||||
catch (OperationCanceledException) { }
|
||||
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.Equal("tag1", results[0].Tag);
|
||||
Assert.Equal("tag2", results[1].Tag);
|
||||
Assert.Equal("tag1", results[2].Tag);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<KeyValuePair<string, TypedValue>> GenerateWriteValues(int count)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
yield return new KeyValuePair<string, TypedValue>(
|
||||
$"tag{i}",
|
||||
new TypedValue { DoubleValue = i * 1.0 });
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<int> GenerateAsyncSequence(int count)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
yield return i;
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.29.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Grpc.Tools" Version="2.68.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
Reference in New Issue
Block a user