Skip to content

Commit

Permalink
Use recommended NATS.Net client for Nats package (#2336)
Browse files Browse the repository at this point in the history
* Use recommnded NATS.Net client for nats package

* Try resolve NatsConnection before INatsConnection

* Update readme
  • Loading branch information
Alirexaa authored Dec 13, 2024
1 parent 4fe6a0a commit c291a6a
Show file tree
Hide file tree
Showing 9 changed files with 171 additions and 347 deletions.
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
<PackageVersion Include="MongoDB.Driver" Version="3.0.0" />
<PackageVersion Include="MySqlConnector" Version="2.3.1" />
<PackageVersion Include="MySqlConnector.DependencyInjection" Version="2.3.1" />
<PackageVersion Include="NATS.Client" Version="1.1.1" />
<PackageVersion Include="NATS.Net" Version="2.5.4" />
<PackageVersion Include="Npgsql" Version="8.0.3" />
<PackageVersion Include="Npgsql.DependencyInjection" Version="8.0.1" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using HealthChecks.Nats;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using NATS.Client.Core;

namespace Microsoft.Extensions.DependencyInjection;

Expand All @@ -11,81 +12,41 @@ public static class NatsHealthCheckBuilderExtensions
internal const string NAME = "nats";

/// <summary>
/// Add a health check for Nats.
/// Add a health check for Nats services.
/// </summary>
/// <param name="builder">The <see cref="IHealthChecksBuilder"/>.</param>
/// <param name="setup">The action to configure the Nats setup.</param>
/// <param name="name">
/// The health check name.
/// Optional.
/// If <see langword="null"/> the type name 'nats' will be used for the name.
/// </param>
/// <param name="clientFactory">
/// An optional factory to obtain <see cref="INatsConnection" /> instance.
/// When not provided, <see cref="INatsConnection" /> is simply resolved from <see cref="IServiceProvider"/>.</param>
/// <param name="name">The health check name. Optional. If <c>null</c> the type name 'nats' will be used for the name.</param>
/// <param name="failureStatus">
/// The <see cref="HealthStatus"/> that should be reported when the health check fails.
/// Optional.
/// If <see langword="null"/> then the default status of <see cref="HealthStatus.Unhealthy"/> will be reported.
/// </param>
/// <param name="tags">A list of tags that can be used to filter sets of health checks. Optional.</param>
/// <param name="timeout">An optional System.TimeSpan representing the timeout of the check.</param>
/// <returns>The <see cref="IHealthChecksBuilder"/>.</returns>
public static IHealthChecksBuilder AddNats(
this IHealthChecksBuilder builder,
Action<NatsOptions>? setup,
string? name = default,
HealthStatus? failureStatus = default,
IEnumerable<string>? tags = default,
TimeSpan? timeout = default)
{
var options = new NatsOptions();
setup?.Invoke(options);

builder.Services.AddSingleton(_ => new NatsHealthCheck(options));

return builder.Add(new HealthCheckRegistration(
name ?? NAME,
sp => sp.GetRequiredService<NatsHealthCheck>(),
failureStatus,
tags,
timeout));
}

/// <summary>
/// Add a health check for Nats.
/// </summary>
/// <param name="builder">The <see cref="IHealthChecksBuilder"/>.</param>
/// <param name="setup">The factory to configure the Nats setup.</param>
/// <param name="name">
/// The health check name.
/// Optional.
/// If <see langword="null"/> the type name 'nats' will be used for the name.
/// </param>
/// <param name="failureStatus">
/// The <see cref="HealthStatus"/> that should be reported when the health check fails.
/// Optional.
/// If <see langword="null"/> then the default status of <see cref="HealthStatus.Unhealthy"/> will be reported.
/// The <see cref="HealthStatus"/> that should be reported when the health check fails. Optional. If <c>null</c> then
/// the default status of <see cref="HealthStatus.Unhealthy"/> will be reported.
/// </param>
/// <param name="tags">A list of tags that can be used to filter sets of health checks. Optional.</param>
/// <param name="timeout">An optional <see cref="TimeSpan"/> representing the timeout of the check.</param>
/// <returns>The <see cref="IHealthChecksBuilder"/>.</returns>
/// <returns>The specified <paramref name="builder"/>.</returns>
public static IHealthChecksBuilder AddNats(
this IHealthChecksBuilder builder,
Action<IServiceProvider, NatsOptions>? setup,
string? name = default,
Func<IServiceProvider, INatsConnection>? clientFactory = default,
string? name = NAME,
HealthStatus? failureStatus = default,
IEnumerable<string>? tags = default,
TimeSpan? timeout = default)
{
return builder.Add(new HealthCheckRegistration(
name ?? NAME,
sp =>
{
var options = new NatsOptions();
setup?.Invoke(sp, options);

return new NatsHealthCheck(options);
},
sp => Factory(clientFactory, sp),
failureStatus,
tags,
timeout));

static NatsHealthCheck Factory(Func<IServiceProvider, INatsConnection>? clientFactory, IServiceProvider sp)
{
// The user might have registered a factory for NatsConnection type, but not for the abstraction (INatsConnection).
// That is why we try to resolve NatsConnection first.
INatsConnection client = clientFactory?.Invoke(sp) ?? sp.GetService<NatsConnection>() ?? sp.GetRequiredService<INatsConnection>();
return new(client);
}
}
}
2 changes: 1 addition & 1 deletion src/HealthChecks.Nats/HealthChecks.Nats.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="NATS.Client" />
<PackageReference Include="NATS.Net" />
</ItemGroup>

</Project>
103 changes: 9 additions & 94 deletions src/HealthChecks.Nats/NatsHealthCheck.cs
Original file line number Diff line number Diff line change
@@ -1,111 +1,26 @@
using System.Text;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using NATS.Client;
using NATS.Client.Core;

namespace HealthChecks.Nats;

/// <summary>
/// Health check for Nats Server.
/// </summary>
/// <remarks>
/// Relies on a static <see cref="ConnectionFactory"/> which provides factory methods to create
/// connections to NATS Servers, and a <see cref="IConnection"/> object connected to the NATS server.
/// </remarks>
public sealed class NatsHealthCheck : IHealthCheck, IDisposable
public sealed class NatsHealthCheck(INatsConnection connection) : IHealthCheck
{
private static readonly ConnectionFactory _connectionFactory = new();
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) =>
await TryConnectAsync(connection).ConfigureAwait(false);

private readonly NatsOptions _options;

private IConnection? _connection;

public NatsHealthCheck(NatsOptions natsOptions)
{
_options = Guard.ThrowIfNull(natsOptions);
}

/// <inheritdoc />
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
private static async Task<HealthCheckResult> TryConnectAsync(INatsConnection natsConnection)
{
try
{
// Create new connection if there is no existing one
IConnection? connection = _connection;
if (connection == null)
{
#pragma warning disable IDISP001 // Dispose created [false positive, https://github.com/DotNetAnalyzers/IDisposableAnalyzers/issues/515]
connection = CreateConnection(_options);
#pragma warning restore IDISP001 // Dispose created
var exchanged = Interlocked.CompareExchange(ref _connection, connection, null);
if (exchanged != null) // was set by other thread
{
connection.Dispose();
connection = exchanged;
}
}

// reset connection in case of stuck so the next HC call will establish it again
// https://github.com/Xabaril/AspNetCore.Diagnostics.HealthChecks/issues/1544
if (connection.State == ConnState.DISCONNECTED || connection.State == ConnState.CLOSED)
_connection = null;

var healthCheckResult = GetHealthCheckResultFromState(connection);
return Task.FromResult(healthCheckResult);
await natsConnection.ConnectAsync().ConfigureAwait(false);
return HealthCheckResult.Healthy();
}
catch (Exception ex)
{
var unhealthy = new HealthCheckResult(context.Registration.FailureStatus, exception: ex);
return Task.FromResult(unhealthy);
}

IConnection CreateConnection(NatsOptions options)
catch (Exception)
{
if (!string.IsNullOrWhiteSpace(options.CredentialsPath))
return _connectionFactory.CreateConnection(options.Url, options.CredentialsPath);
if (!string.IsNullOrWhiteSpace(options.Jwt) && !string.IsNullOrWhiteSpace(options.PrivateNKey))
return _connectionFactory.CreateConnection(options.Url, options.Jwt, options.PrivateNKey);
return _connectionFactory.CreateConnection(options.Url);
return HealthCheckResult.Unhealthy();
}

HealthCheckResult GetHealthCheckResultFromState(IConnection connection)
{
string description = GetDescription(connection);

return connection.State switch
{
ConnState.CONNECTED => HealthCheckResult.Healthy(description, GetStatsData(connection)),
ConnState.CONNECTING
or ConnState.RECONNECTING
or ConnState.DRAINING_SUBS
or ConnState.DRAINING_PUBS => HealthCheckResult.Degraded(description),
ConnState.CLOSED
or ConnState.DISCONNECTED => HealthCheckResult.Unhealthy(description),
_ => new HealthCheckResult(context.Registration.FailureStatus, description),
};
}

static string GetDescription(IConnection connection)
{
var sb = new StringBuilder();
sb.AppendFormat("{0}: {1}; ", nameof(connection.ClientIP), connection.ClientIP);
if (!string.IsNullOrWhiteSpace(connection.ConnectedUrl))
sb.AppendFormat("{0}: {1}; ", nameof(connection.ConnectedUrl), connection.ConnectedUrl);
sb.AppendFormat("{0}: {1}; ", nameof(connection.State), connection.State);
if (connection.SubscriptionCount != default)
sb.AppendFormat("{0}: {1}", nameof(connection.SubscriptionCount), connection.SubscriptionCount);
return sb.ToString();
}

static IReadOnlyDictionary<string, object> GetStatsData(IConnection connection) =>
new Dictionary<string, object>
{
[nameof(connection.Stats.InMsgs)] = connection.Stats.InMsgs,
[nameof(connection.Stats.OutMsgs)] = connection.Stats.OutMsgs,
[nameof(connection.Stats.InBytes)] = connection.Stats.InBytes,
[nameof(connection.Stats.OutBytes)] = connection.Stats.OutBytes,
[nameof(connection.Stats.Reconnects)] = connection.Stats.Reconnects
};
}

public void Dispose() => _connection?.Dispose();
}
30 changes: 0 additions & 30 deletions src/HealthChecks.Nats/NatsOptions.cs

This file was deleted.

32 changes: 9 additions & 23 deletions src/HealthChecks.Nats/README.md
Original file line number Diff line number Diff line change
@@ -1,50 +1,36 @@
# NATS Health Check

This health check verifies the ability to communicate with a [NATS server](https://nats.io/about/). \
It relies on `NATS.Client` package. \
It relies on `NATS.Net` package. \
Latest tag for the [official dockerhub image](https://hub.docker.com/_/nats/) is `2.6.6`.

## Builder Extension

```csharp
public void ConfigureServices(IServiceCollection services)
{
var options = NatsOpts.Default with
{
Url = "nats://demo.nats.io:4222"
};

services
.AddSingleton<INatsConnection>(new NatsConnection(options))
.AddHealthChecks()
.AddNats(options =>
{
options.Url = "nats://demo.nats.io:4222";
options.CredentialsPath = "The full path to a chained credentials file.";
options.Jwt = "The path to a user's public JWT credentials.";
options.PrivateNKey = "The path to a file for user user's private Nkey seed.";
});
.AddNats();
}
```

`Url` property is NATS server url and is **mandatory**. \
There is a demo instance `nats://demo.nats.io:4222` managed by nats.io and this is the default value for the url property. \
The rest of the properties in `NatsOptions` are optional. \
The rest of the properties in `NatsOpts` are optional. \
Docker image produces `nats://localhost:4222`. \
Url might also be a string containing multiple URLs to the NATS Server, e.g. `nats://localhost:4222, nats://localhost:8222`.

See [NKeys](https://docs.nats.io/running-a-nats-service/configuration/securing_nats/auth_intro/nkey_auth) for reference to the `PrivateNKey` and `Jwt` properties.

See [Authenticating with a Credentials File](https://docs.nats.io/using-nats/developer/connecting/creds) for details related to the `CredentialsPath` property.

The setup action used by the extension method caters for all three overloads supplied by the Nats client to create a connection off of a connection factory.

```csharp
namespace NATS.Client
{
public sealed class ConnectionFactory
{
public IConnection CreateConnection(string url);
public IConnection CreateConnection(string url, string credentialsPath);
public IConnection CreateConnection(string url, string jwt, string privateNkey);
}
}
```

Like all `IHealthChecksBuilder` extensions, all the following parameters have type `default` values and may be overridden:

- `name`: The health check name. Default if not specified is `nats`.
Expand Down
Loading

0 comments on commit c291a6a

Please sign in to comment.