创建自定义 .NET Aspireclient 集成

本文是接续文章《创建自定义 .NET.NET Aspire 托管集成》。 它指导你创建一个 .NET Aspireclient 集成,该集成使用 MailKit 发送电子邮件。 然后将此集成添加到之前生成的新闻稿应用中。 前面的示例省略了创建 client 集成,而是依赖于现有的 .NETSmtpClient。 使用 MailKit 的 SmtpClient 发送电子邮件比通过官方 .NETSmtpClient 更好,因为它更现代,支持更多功能和协议。 有关详细信息,请参阅 .NET SmtpClient:备注

先决条件

如果您正在跟随步骤操作,您应该从创建自定义 .NET.NET Aspire 托管集成 一文中的步骤中获取新闻简报应用。

提示

本文灵感来自现有的 .NET.NET Aspire 集成,并基于团队的官方指导。 在某些地方,这种指南有所不同,了解差异背后的原因很重要。 有关详细信息,请参阅 .NET.NET Aspire 集成要求

创建集成库

.NET .NET Aspire 集成 作为 NuGet 包提供,但在此示例中,发布 NuGet 包超出了本文讨论的范围。 而是创建一个类库项目,其中包含集成并将其引用为项目。 .NET Aspire 集成包旨在包装 client 库,例如 MailKit,并提供生产就绪遥测、运行状况检查、可配置性和可测试性。 首先创建一个新的类库项目。

  1. 在与上一篇文章中的 MailDevResource.sln 相同的目录中创建名为 MailKit.Client 的新类库项目。

    dotnet new classlib -o MailKit.Client
    
  2. 将项目添加到解决方案。

    dotnet sln ./MailDevResource.sln add MailKit.Client/MailKit.Client.csproj
    

下一步是添加集成依赖的所有 NuGet 包。 与其从 .NET CLI 逐个添加每个包,不如将以下 XML 复制并粘贴到 MailKit 中。Client.csproj 文件。

<ItemGroup>
  <PackageReference Include="MailKit" Version="4.9.0" />
  <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
  <PackageReference Include="Microsoft.Extensions.Resilience" Version="9.0.0" />
  <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.0" />
  <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.0" />
  <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.10.0" />
</ItemGroup>

定义集成设置

每当创建.NET Aspire集成时,最好先了解你要映射到的client库。 使用 MailKit 时,需要了解连接到简单邮件传输协议(SMTP)server所需的配置设置。 但还必须了解库是否支持 运行状况检查跟踪指标。 MailKit 通过其 Telemetry.SmtpClient支持 跟踪指标。 在添加 运行状况检查时,应尽可能使用已建立或现有的运行状况检查。 否则,可以考虑在集成中自己实现。 将以下代码添加到名为 MailKitClientSettings.cs的文件中的 MailKit.Client 项目中:

using System.Data.Common;

namespace MailKit.Client;

/// <summary>
/// Provides the client configuration settings for connecting MailKit to an SMTP server.
/// </summary>
public sealed class MailKitClientSettings
{
    internal const string DefaultConfigSectionName = "MailKit:Client";

    /// <summary>
    /// Gets or sets the SMTP server <see cref="Uri"/>.
    /// </summary>
    /// <value>
    /// The default value is <see langword="null"/>.
    /// </value>
    public Uri? Endpoint { get; set; }

    /// <summary>
    /// Gets or sets a boolean value that indicates whether the database health check is disabled or not.
    /// </summary>
    /// <value>
    /// The default value is <see langword="false"/>.
    /// </value>
    public bool DisableHealthChecks { get; set; }

    /// <summary>
    /// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not.
    /// </summary>
    /// <value>
    /// The default value is <see langword="false"/>.
    /// </value>
    public bool DisableTracing { get; set; }

    /// <summary>
    /// Gets or sets a boolean value that indicates whether the OpenTelemetry metrics are disabled or not.
    /// </summary>
    /// <value>
    /// The default value is <see langword="false"/>.
    /// </value>
    public bool DisableMetrics { get; set; }

    internal void ParseConnectionString(string? connectionString)
    {
        if (string.IsNullOrWhiteSpace(connectionString))
        {
            throw new InvalidOperationException($"""
                    ConnectionString is missing.
                    It should be provided in 'ConnectionStrings:<connectionName>'
                    or '{DefaultConfigSectionName}:Endpoint' key.'
                    configuration section.
                    """);
        }

        if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri))
        {
            Endpoint = uri;
        }
        else
        {
            var builder = new DbConnectionStringBuilder
            {
                ConnectionString = connectionString
            };
            
            if (builder.TryGetValue("Endpoint", out var endpoint) is false)
            {
                throw new InvalidOperationException($"""
                        The 'ConnectionStrings:<connectionName>' (or 'Endpoint' key in
                        '{DefaultConfigSectionName}') is missing.
                        """);
            }

            if (Uri.TryCreate(endpoint.ToString(), UriKind.Absolute, out uri) is false)
            {
                throw new InvalidOperationException($"""
                        The 'ConnectionStrings:<connectionName>' (or 'Endpoint' key in
                        '{DefaultConfigSectionName}') isn't a valid URI.
                        """);
            }

            Endpoint = uri;
        }
    }
}

前面的代码使用以下命令定义 MailKitClientSettings 类:

  • Endpoint 属性,表示 SMTP server的连接字符串。
  • DisableHealthChecks 属性,用于确定是否启用健康检查。
  • DisableTracing 属性,用于确定是否启用跟踪。
  • DisableMetrics 属性,用于确定是否启用指标。

分析连接字符串逻辑

设置类还包含一个 ParseConnectionString 方法,该方法将连接字符串分析为有效的 Uri。 配置预期应以以下格式提供:

  • ConnectionStrings:<connectionName>:SMTP server的连接字符串。
  • MailKit:Client:ConnectionString:SMTP server的连接字符串。

如果未提供任意一个值,则会引发异常。

公开 client 功能

.NET Aspire 集成的目标是通过依赖项注入向使用者公开基础 client 库。 在 MailKit 与这个示例中,需要公开的就是 SmtpClient 类。 你不会包装任何功能,而是将配置设置映射到 SmtpClient 类。 通常会将标准服务注册和带键的服务注册都公开用于集成。 当只有一个服务实例时使用标准注册,并且当有多个服务实例时,将使用密钥服务注册。 有时,为了实现同一类型的多个实例注册,可以使用工厂模式。 将以下代码添加到名为 MailKitClientFactory.cs的文件中的 MailKit.Client 项目:

using MailKit.Net.Smtp;

namespace MailKit.Client;

/// <summary>
/// A factory for creating <see cref="ISmtpClient"/> instances
/// given a <paramref name="smtpUri"/> (and optional <paramref name="credentials"/>).
/// </summary>
/// <param name="settings">
/// The <see cref="MailKitClientSettings"/> settings for the SMTP server
/// </param>
public sealed class MailKitClientFactory(MailKitClientSettings settings) : IDisposable
{
    private readonly SemaphoreSlim _semaphore = new(1, 1);

    private SmtpClient? _client;

    /// <summary>
    /// Gets an <see cref="ISmtpClient"/> instance in the connected state
    /// (and that's been authenticated if configured).
    /// </summary>
    /// <param name="cancellationToken">Used to abort client creation and connection.</param>
    /// <returns>A connected (and authenticated) <see cref="ISmtpClient"/> instance.</returns>
    /// <remarks>
    /// Since both the connection and authentication are considered expensive operations,
    /// the <see cref="ISmtpClient"/> returned is intended to be used for the duration of a request
    /// (registered as 'Scoped') and is automatically disposed of.
    /// </remarks>
    public async Task<ISmtpClient> GetSmtpClientAsync(
        CancellationToken cancellationToken = default)
    {
        await _semaphore.WaitAsync(cancellationToken);

        try
        {
            if (_client is null)
            {
                _client = new SmtpClient();

                await _client.ConnectAsync(settings.Endpoint, cancellationToken)
                             .ConfigureAwait(false);
            }
        }
        finally
        {
            _semaphore.Release();
        }       

        return _client;
    }

    public void Dispose()
    {
        _client?.Dispose();
        _semaphore.Dispose();
    }
}

MailKitClientFactory 类是一个工厂,它基于配置设置创建 ISmtpClient 实例。 它负责返回一个 ISmtpClient 实现,该实现与配置的 SMTP server有活动连接。 接下来,您需要提供功能,供用户向依赖注入容器注册该工厂。 将以下代码添加到 MailKit.Client 项目中的名为 MailKitExtensions.cs的文件中:

using MailKit;
using MailKit.Client;
using MailKit.Net.Smtp;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.Extensions.Hosting;

/// <summary>
/// Provides extension methods for registering a <see cref="SmtpClient"/> as a
/// scoped-lifetime service in the services provided by the <see cref="IHostApplicationBuilder"/>.
/// </summary>
public static class MailKitExtensions
{
    /// <summary>
    /// Registers 'Scoped' <see cref="MailKitClientFactory" /> for creating
    /// connected <see cref="SmtpClient"/> instance for sending emails.
    /// </summary>
    /// <param name="builder">
    /// The <see cref="IHostApplicationBuilder" /> to read config from and add services to.
    /// </param>
    /// <param name="connectionName">
    /// A name used to retrieve the connection string from the ConnectionStrings configuration section.
    /// </param>
    /// <param name="configureSettings">
    /// An optional delegate that can be used for customizing options.
    /// It's invoked after the settings are read from the configuration.
    /// </param>
    public static void AddMailKitClient(
        this IHostApplicationBuilder builder,
        string connectionName,
        Action<MailKitClientSettings>? configureSettings = null) =>
        AddMailKitClient(
            builder,
            MailKitClientSettings.DefaultConfigSectionName,
            configureSettings,
            connectionName,
            serviceKey: null);

    /// <summary>
    /// Registers 'Scoped' <see cref="MailKitClientFactory" /> for creating
    /// connected <see cref="SmtpClient"/> instance for sending emails.
    /// </summary>
    /// <param name="builder">
    /// The <see cref="IHostApplicationBuilder" /> to read config from and add services to.
    /// </param>
    /// <param name="name">
    /// The name of the component, which is used as the <see cref="ServiceDescriptor.ServiceKey"/> of the
    /// service and also to retrieve the connection string from the ConnectionStrings configuration section.
    /// </param>
    /// <param name="configureSettings">
    /// An optional method that can be used for customizing options. It's invoked after the settings are
    /// read from the configuration.
    /// </param>
    public static void AddKeyedMailKitClient(
        this IHostApplicationBuilder builder,
        string name,
        Action<MailKitClientSettings>? configureSettings = null)
    {
        ArgumentNullException.ThrowIfNull(name);

        AddMailKitClient(
            builder,
            $"{MailKitClientSettings.DefaultConfigSectionName}:{name}",
            configureSettings,
            connectionName: name,
            serviceKey: name);
    }

    private static void AddMailKitClient(
        this IHostApplicationBuilder builder,
        string configurationSectionName,
        Action<MailKitClientSettings>? configureSettings,
        string connectionName,
        object? serviceKey)
    {
        ArgumentNullException.ThrowIfNull(builder);

        var settings = new MailKitClientSettings();

        builder.Configuration
               .GetSection(configurationSectionName)
               .Bind(settings);

        if (builder.Configuration.GetConnectionString(connectionName) is string connectionString)
        {
            settings.ParseConnectionString(connectionString);
        }

        configureSettings?.Invoke(settings);

        if (serviceKey is null)
        {
            builder.Services.AddScoped(CreateMailKitClientFactory);
        }
        else
        {
            builder.Services.AddKeyedScoped(serviceKey, (sp, key) => CreateMailKitClientFactory(sp));
        }

        MailKitClientFactory CreateMailKitClientFactory(IServiceProvider _)
        {
            return new MailKitClientFactory(settings);
        }

        if (settings.DisableHealthChecks is false)
        {
            builder.Services.AddHealthChecks()
                .AddCheck<MailKitHealthCheck>(
                    name: serviceKey is null ? "MailKit" : $"MailKit_{connectionName}",
                    failureStatus: default,
                    tags: []);
        }

        if (settings.DisableTracing is false)
        {
            builder.Services.AddOpenTelemetry()
                .WithTracing(
                    traceBuilder => traceBuilder.AddSource(
                        Telemetry.SmtpClient.ActivitySourceName));
        }

        if (settings.DisableMetrics is false)
        {
            // Required by MailKit to enable metrics
            Telemetry.SmtpClient.Configure();

            builder.Services.AddOpenTelemetry()
                .WithMetrics(
                    metricsBuilder => metricsBuilder.AddMeter(
                        Telemetry.SmtpClient.MeterName));
        }
    }
}

前面的代码在 IHostApplicationBuilder 类型中添加两种扩展方法,一个用于 MailKit 的标准注册,另一个用于 MailKit 的键式注册。

提示

.NET .NET Aspire 集成的扩展方法应对 IHostApplicationBuilder 类型进行扩展,并遵循 Add<MeaningfulName> 命名约定,其中 <MeaningfulName> 是您要添加的类型或功能。 在本文中,AddMailKitClient 扩展方法用于添加 MailKit client。 使用 AddMailKitSmtpClient 而不是 AddMailKitClient的官方指南可能更符合这一点,因为这只会注册 SmtpClient 而不是整个 MailKit 库。

这两个扩展最终都依赖于专用 AddMailKitClient 方法将 MailKitClientFactory 注册到依赖项注入容器作为 范围服务。 将 MailKitClientFactory 注册为作用域服务的原因是,因为连接操作被认为耗费资源,并且应在可能的情况下在同一范围内重复利用。 换句话说,对于单个请求,应使用相同的 ISmtpClient 实例。 工厂会保留其创建的 SmtpClient 实例,并在适当时候予以释放。

配置绑定

AddMailKitClient 方法的私有实现的第一项是将配置设置绑定到 MailKitClientSettings 类。 设置类被实例化,然后使用配置的特定部分调用 Bind。 然后,使用当前设置调用可选的 configureSettings 委托。 这样,使用者就可以进一步配置设置,确保手动代码设置在配置设置上得到遵守。 之后,根据是否提供了 serviceKey 值,应将 MailKitClientFactory 注册到依赖项注入容器中作为标准服务或密钥服务。

重要

注册服务时会有意调用 implementationFactory 重载。 CreateMailKitClientFactory 方法在配置无效时引发。 这可确保将 MailKitClientFactory 的创建推迟到需要,并防止应用在日志记录可用之前出错。

以下各节将更详细地介绍健康检查和遥测的注册过程。

添加健康检查

运行状况检查 是监视集成运行状况的方法。 使用 MailKit,可以检查与 SMTP server 的连接是否正常。 将以下代码添加到名为 MailKitHealthCheck.cs的文件中的 MailKit.Client 项目:

using Microsoft.Extensions.Diagnostics.HealthChecks;

namespace MailKit.Client;

internal sealed class MailKitHealthCheck(MailKitClientFactory factory) : IHealthCheck
{
    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        try
        {
            // The factory connects (and authenticates).
            _ = await factory.GetSmtpClientAsync(cancellationToken);

            return HealthCheckResult.Healthy();
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy(exception: ex);
        }
    }
}

之前的健康检查实现:

  • 实现 IHealthCheck 接口。
  • 接受 MailKitClientFactory 作为主构造函数参数。
  • 通过以下方式满足 CheckHealthAsync 方法:
    • 尝试从 factory获取 ISmtpClient 实例。 如果成功,它将返回 HealthCheckResult.Healthy
    • 如果引发异常,它将返回 HealthCheckResult.Unhealthy

依据之前在 MailKitClientFactory注册时分享的信息,MailKitHealthCheck 已被有条件注册到 IHeathChecksBuilder:

if (settings.DisableHealthChecks is false)
{
    builder.Services.AddHealthChecks()
        .AddCheck<MailKitHealthCheck>(
            name: serviceKey is null ? "MailKit" : $"MailKit_{connectionName}",
            failureStatus: default,
            tags: []);
}

消费者可以通过在配置中将 DisableHealthChecks 属性设置为 true 来选择省略健康检查。 集成的常见模式是具有可选功能,.NET.NET Aspire 集成强烈鼓励这些类型的配置。 有关运行状况检查和包含用户界面的工作示例的详细信息,请参阅 .NET AspireASP.NET Core HealthChecksUI 示例

连接遥测数据

最佳做法是,MailKit client 库公开遥测。 .NET .NET Aspire 可以利用此遥测数据并将其显示在 .NET.NET Aspire 仪表板中。 根据是否启用跟踪和指标工具,遥测被接入,如下代码片段所示:

if (settings.DisableTracing is false)
{
    builder.Services.AddOpenTelemetry()
        .WithTracing(
            traceBuilder => traceBuilder.AddSource(
                Telemetry.SmtpClient.ActivitySourceName));
}

if (settings.DisableMetrics is false)
{
    // Required by MailKit to enable metrics
    Telemetry.SmtpClient.Configure();

    builder.Services.AddOpenTelemetry()
        .WithMetrics(
            metricsBuilder => metricsBuilder.AddMeter(
                Telemetry.SmtpClient.MeterName));
}

更新新闻稿服务

创建集成库后,现在可以更新新闻稿服务以使用 MailKit client。 第一步是添加对 MailKit.Client 项目的引用。 将 MailKit、Client.csproj 项目引用添加到 MailDevResource.NewsletterService 项目中:

dotnet add ./MailDevResource.NewsletterService/MailDevResource.NewsletterService.csproj reference MailKit.Client/MailKit.Client.csproj

接下来,添加对 ServiceDefaults 项目的引用:

dotnet add ./MailDevResource.NewsletterService/MailDevResource.NewsletterService.csproj reference MailDevResource.ServiceDefaults/MailDevResource.ServiceDefaults.csproj

最后一步是将 MailDevResource.NewsletterService 项目中的现有 Program.cs 文件替换为以下 C# 代码:

using System.Net.Mail;
using MailKit.Client;
using MailKit.Net.Smtp;
using MimeKit;

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Add services to the container.
builder.AddMailKitClient("maildev");

var app = builder.Build();

app.MapDefaultEndpoints();

// Configure the HTTP request pipeline.

app.UseSwagger();
app.UseSwaggerUI();
app.UseHttpsRedirection();

app.MapPost("/subscribe",
    async (MailKitClientFactory factory, string email) =>
{
    ISmtpClient client = await factory.GetSmtpClientAsync();

    using var message = new MailMessage("newsletter@yourcompany.com", email)
    {
        Subject = "Welcome to our newsletter!",
        Body = "Thank you for subscribing to our newsletter!"
    };

    await client.SendAsync(MimeMessage.CreateFromMailMessage(message));
});

app.MapPost("/unsubscribe",
    async (MailKitClientFactory factory, string email) =>
{
    ISmtpClient client = await factory.GetSmtpClientAsync();

    using var message = new MailMessage("newsletter@yourcompany.com", email)
    {
        Subject = "You are unsubscribed from our newsletter!",
        Body = "Sorry to see you go. We hope you will come back soon!"
    };

    await client.SendAsync(MimeMessage.CreateFromMailMessage(message));
});

app.Run();

上述代码中最值得注意的更改是:

  • 更新的 using 语句,其中包括 MailKit.ClientMailKit.Net.SmtpMimeKit 命名空间。
  • 使用调用 AddMailKitClient 扩展方法替换官方 .NETSmtpClient 的注册。
  • 替换 /subscribe/unsubscribe 地图发布调用,以改为注入 MailKitClientFactory,并使用 ISmtpClient 实例发送电子邮件。

运行示例

创建 MailKit client 集成并更新新闻稿服务以使用它后,即可运行示例。 在 IDE 中,选择 F5 或从解决方案的根目录中运行 dotnet run 以启动应用程序—应会看到 .NET.NET Aspire 仪表板

.NET Aspire 仪表板:MailDev 和新闻稿资源正在运行。

应用程序运行后,请访问 https://localhost:7251/swagger 上的 Swagger UI,并测试 /subscribe/unsubscribe 端点。 选择向下箭头以展开终结点:

Swagger UI:订阅端点。

然后选择 Try it out 按钮。 输入电子邮件地址,然后选择 Execute 按钮。

Swagger UI:使用电子邮件地址订阅端点。

多次重复此操作,添加多个电子邮件地址。 应会看到发送到 MailDev 收件箱的电子邮件:

MailDev 包含多个电子邮件的收件箱。

通过在运行应用程序的终端窗口中选择 ctrl+C,或者通过在 IDE 中选择停止按钮来停止应用程序。

查看 MailKit 遥测

MailKit client 库公开可在 .NET Aspire 仪表板中查看的遥测数据。 若要查看遥测数据,请在 https://localhost:7251导航到 .NET.NET Aspire 仪表板。 选择 newsletter 资源以查看 指标 页上的遥测数据:

.NET.NET Aspire 仪表板:MailKit 遥测。

再次打开 Swagger UI,并向 /subscribe/unsubscribe 终结点发出一些请求。 然后,导航回 .NET.NET Aspire 仪表板并选择 newsletter 资源。 选择 mailkit.net.smtp 节点下的指标,例如 mailkit.net.smtp.client.operation.count。 你应该看到 MailKit 的遥测 client:

.NET.NET Aspire 仪表板:用于操作计数的 MailKit 遥测。

总结

本文介绍了如何创建使用 MailKit 发送电子邮件的 .NET.NET Aspire 集成。 你还了解了如何将此集成集成到之前生成的新闻稿应用中。 你了解了 .NET Aspire 集成的核心原则,例如通过依赖项注入向使用者公开基础 client 库,以及如何向集成添加运行状况检查和遥测。 你还了解了如何更新新闻稿服务以使用 MailKit client。

请去创建自己的 .NET.NET Aspire 集成。 如果你认为要构建的集成中有足够的社区价值,请考虑将其发布为 NuGet 包 供其他人使用。 此外,请考虑将拉取请求提交到 .NET AspireGitHub 存储库,以便考虑是否包含在官方 .NET.NET Aspire 集成中。

后续步骤