1. 程式人生 > 其它 >[Abp vNext 原始碼分析] - 20. 電子郵件與簡訊支援

[Abp vNext 原始碼分析] - 20. 電子郵件與簡訊支援

一、簡介

ABP vNext 使用 Volo.Abp.Sms 包和 Volo.Abp.Emailing 包將簡訊和電子郵件作為基礎設施進行了抽象,開發人員僅需要在使用的時候注入 ISmsSenderIEmailSender 即可實現簡訊傳送和郵件傳送。

二、原始碼分析

2.1 啟動模組

簡訊傳送的抽象層比較簡單,AbpSmsModule 模組內部並無任何操作,僅作為空模組進行定義。

電子郵件的 AbpEmailingModule 模組內,主要添加了一些本地化資源支援。另一個動作就是添加了一個 BackgroundEmailSendingJob 後臺作業,這個後臺作業主要是用於後續傳送電子郵件使用。因為郵件傳送這個動作實時性要求並不高,在實際的業務實踐當中,我們基本會將其加入到一個後臺佇列慢慢傳送,所以這裡 ABP 為我們實現了 BackgroundEmailSendingJob

BackgroundEmailSendingJob.cs:

public class BackgroundEmailSendingJob : AsyncBackgroundJob<BackgroundEmailSendingJobArgs>, ITransientDependency
{
    protected IEmailSender EmailSender { get; }

    public BackgroundEmailSendingJob(IEmailSender emailSender)
    {
        EmailSender = emailSender;
    }

    public override async Task ExecuteAsync(BackgroundEmailSendingJobArgs args)
    {
        if (args.From.IsNullOrWhiteSpace())
        {
            await EmailSender.SendAsync(args.To, args.Subject, args.Body, args.IsBodyHtml);
        }
        else
        {
            await EmailSender.SendAsync(args.From, args.To, args.Subject, args.Body, args.IsBodyHtml);
        }
    }
}

這個後臺任務的邏輯也不復雜,就使用 IEmailSender 傳送郵件,我們在任何地方需要後臺傳送郵件的時,只需要注入 IBackgroundJobManager,使用 BackgroundEmailSendingJobArgs 作為引數新增入隊一個後臺作業即可。

使用 IBackgroundJobManager 新增一個新的郵件傳送歡迎郵件:

public class DemoClass
{
    private readonly IBackgroundJobManager _backgroundJobManager;
    private readonly IUserInfoRepository _userRep;

    public DemoClass(IBackgroundJobManager backgroundJobManager,
        IUserInfoRepository userRep)
    {
        _backgroundJobManager = backgroundJobManager;
        _userRep = userRep;
    }

    public async Task SendWelcomeEmailAsync(Guid userId)
    {
        var userInfo = await _userRep.GetByIdAsync(userId);

        await _backgroundJobManager.EnqueueAsync(new BackgroundEmailSendingJobArgs
        {
            To = userInfo.EmailAddress,
            Subject = "Welcome",
            Body = "Welcome, Hello World!",
            IsBodyHtml = false;
        });
    }
}

注意

目前 BackgroundEmailSendingJobArgs 引數不支援傳送附件,ABP 可能在以後的版本會進行實現。

2.2 Email 的核心元件

ABP 定義了一個 IEmailSender 介面,定義了多個 SendAsync() 方法過載,用於直接傳送電子郵件。同時也提供了 QueueAsync() 方法,通過後臺任務佇列來發送郵件。

public interface IEmailSender
{
    Task SendAsync(
        string to,
        string subject,
        string body,
        bool isBodyHtml = true
    );

    Task SendAsync(
        string from,
        string to,
        string subject,
        string body,
        bool isBodyHtml = true
    );

    Task SendAsync(
        MailMessage mail,
        bool normalize = true
    );

    Task QueueAsync(
        string to,
        string subject,
        string body,
        bool isBodyHtml = true
    );

    Task QueueAsync(
        string from,
        string to,
        string subject,
        string body,
        bool isBodyHtml = true
    );

    //TODO: 準備新增的 QueueAsync 方法。目前存在的問題: MailMessage 不能夠被序列化,所以不能加入到後臺任務隊列當中。
}

ABP 實際擁有兩種 Email Sender 實現,分別是 SmtpEmailSenderMailkitEmailSender,各個型別的關係如下。

UML 類圖:

classDiagram class IEmailSender{ <<Interface>> +SendAsync(string,string,string,bool=true) Task +SendAsync(string,string,string,string,bool=true) Task +SendAsync(MailMessage,bool=true) Task +QueueAsync(string,string,string,bool=true) Task +QueueAsync(string,string,string,string,bool=true) Task } class ISmtpEmailSender{ <<Interface>> ...... +BuildClientAsync() Task~SmtpClient~ } class IMailKitSmtpEmailSemder{ <<Interface>> ...... +BuildClientAsync() Task~SmtpClient~ } class EmailSenderBase{ <<Abstract>> ...... } class SmtpEmailSender{ ...... } class MailKitSmtpEmailSender{ ...... } class NullEmailSender{ ...... } ISmtpEmailSender --|> IEmailSender: 繼承 IMailKitSmtpEmailSemder --|> IEmailSender: 繼承 EmailSenderBase ..|> IEmailSender: 實現 SmtpEmailSender ..|> ISmtpEmailSender: 實現 SmtpEmailSender --|> EmailSenderBase: 繼承 NullEmailSender --|> EmailSenderBase: 繼承 MailKitSmtpEmailSender ..|> IMailKitSmtpEmailSemder: 實現 MailKitSmtpEmailSender --|> EmailSenderBase: 繼承

可以從 UML 類圖看出,每個 EmailSender 實現都與一個 IXXXConfiguration 對應,這個配置類儲存了基於 Smtp 發件的必須配置。因為 MailKit 本身也是基於 Smtp 傳送郵件的,所以沒有重新定義新的配置類,而是直接複用的 ISmtpEmailSenderConfiguration 介面與實現。

EmailSenderBase 基類當中,基本實現了 IEmailSender 介面的所有方法的邏輯,只留下了 SendEmailAsync(MailMessage mail) 作為一個抽象方法等待子類實現。也就是說其他的方法最終都是使用該方法來最終傳送郵件。

public abstract class EmailSenderBase : IEmailSender
{
    protected IEmailSenderConfiguration Configuration { get; }

    protected IBackgroundJobManager BackgroundJobManager { get; }

    protected EmailSenderBase(IEmailSenderConfiguration configuration, IBackgroundJobManager backgroundJobManager)
    {
        Configuration = configuration;
        BackgroundJobManager = backgroundJobManager;
    }

    // ... 實現的介面方法

    protected abstract Task SendEmailAsync(MailMessage mail);

    // 使用 Configuration 裡面的引數,統一處理郵件資料。
    protected virtual async Task NormalizeMailAsync(MailMessage mail)
    {
        if (mail.From == null || mail.From.Address.IsNullOrEmpty())
        {
            mail.From = new MailAddress(
                await Configuration.GetDefaultFromAddressAsync(),
                await Configuration.GetDefaultFromDisplayNameAsync(),
                Encoding.UTF8
                );
        }

        if (mail.HeadersEncoding == null)
        {
            mail.HeadersEncoding = Encoding.UTF8;
        }

        if (mail.SubjectEncoding == null)
        {
            mail.SubjectEncoding = Encoding.UTF8;
        }

        if (mail.BodyEncoding == null)
        {
            mail.BodyEncoding = Encoding.UTF8;
        }
    }
}

ABP 預設可用的郵件傳送元件是 SmtpEmailSender,它使用的是 .NET 自帶的郵件傳送元件,本質上就是構建了一個 SmtpClient 客戶端,然後呼叫它的發件方法進行郵件傳送。

public class SmtpEmailSender : EmailSenderBase, ISmtpEmailSender, ITransientDependency
{
    // ... 省略的程式碼。
    public async Task<SmtpClient> BuildClientAsync()
    {
        var host = await SmtpConfiguration.GetHostAsync();
        var port = await SmtpConfiguration.GetPortAsync();

        var smtpClient = new SmtpClient(host, port);

        // 從 SettingProvider 中獲取各個配置引數,構建 Client 進行傳送。
        try
        {
            if (await SmtpConfiguration.GetEnableSslAsync())
            {
                smtpClient.EnableSsl = true;
            }

            if (await SmtpConfiguration.GetUseDefaultCredentialsAsync())
            {
                smtpClient.UseDefaultCredentials = true;
            }
            else
            {
                smtpClient.UseDefaultCredentials = false;

                var userName = await SmtpConfiguration.GetUserNameAsync();
                if (!userName.IsNullOrEmpty())
                {
                    var password = await SmtpConfiguration.GetPasswordAsync();
                    var domain = await SmtpConfiguration.GetDomainAsync();
                    smtpClient.Credentials = !domain.IsNullOrEmpty()
                        ? new NetworkCredential(userName, password, domain)
                        : new NetworkCredential(userName, password);
                }
            }

            return smtpClient;
        }
        catch
        {
            smtpClient.Dispose();
            throw;
        }
    }

    protected override async Task SendEmailAsync(MailMessage mail)
    {
        // 呼叫構建方法,構建 Client,用於傳送 mail 資料。
        using (var smtpClient = await BuildClientAsync())
        {
            await smtpClient.SendMailAsync(mail);
        }
    }
}

針對屬性注入失敗的情況,ABP 提供了 NullEmailSender 作為預設實現,在傳送郵件的時候會使用 Logger 列印具體的資訊。

public class NullEmailSender : EmailSenderBase
{
    public ILogger<NullEmailSender> Logger { get; set; }

    public NullEmailSender(IEmailSenderConfiguration configuration, IBackgroundJobManager backgroundJobManager)
        : base(configuration, backgroundJobManager)
    {
        Logger = NullLogger<NullEmailSender>.Instance;
    }

    protected override Task SendEmailAsync(MailMessage mail)
    {
        Logger.LogWarning("USING NullEmailSender!");
        Logger.LogDebug("SendEmailAsync:");
        LogEmail(mail);
        return Task.FromResult(0);
    }

    // ... 其他方法。
}

2.3 Email 的配置儲存

EmailSenderBase 裡面可以看到,它從 IEmailSenderConfiguration 當中獲取發件人的郵箱地址和展示名稱,它的 UML 類圖關係如下。

classDiagram class IEmailSenderConfiguration{ <<Interface>> +GetDefaultFromAddressAsync() Task~string~ +GetDefaultFromDisplayNameAsync() Task~string~ } class ISmtpEmailSenderConfiguration{ <<Interface>> +GetHostAsync() Task~string~ +GetPortAsync() Task~int~ +GetUserNameAsync() Task~string~ +GetPasswordAsync() Task~string~ +GetDomainAsync() Task~string~ +GetEnableSslAsync() Task~bool~ +GetUseDefaultCredentialsAsync() Task~bool~ } class EmailSenderConfiguration{ #GetNotEmptySettingValueAsync(string name) Task~string~ } class SmtpEmailSenderConfiguration{ } class ISettingProvider{ <<Interface>> +GetOrNullAsync(string name) Task~string~ } ISmtpEmailSenderConfiguration --|> IEmailSenderConfiguration: 繼承 EmailSenderConfiguration ..|> IEmailSenderConfiguration: 實現 EmailSenderConfiguration ..> ISettingProvider: 依賴 SmtpEmailSenderConfiguration --|> EmailSenderConfiguration: 繼承 SmtpEmailSenderConfiguration ..|> ISmtpEmailSenderConfiguration: 實現

可以看到配置檔案時通過 ISettingProvider 獲取的,這樣就可以保證從不同租戶甚至是使用者來獲取發件人的配置資訊。這裡值得注意的是在 EmailSenderConfiguration 中,實現了一個 GetNotEmptySettingValueAsync(string name) 方法,該方法主要是封裝了獲取邏輯,當值不存在的時候丟擲 AbpException 異常。

protected async Task<string> GetNotEmptySettingValueAsync(string name)
{
    var value = await SettingProvider.GetOrNullAsync(name);

    if (value.IsNullOrEmpty())
    {
        throw new AbpException($"Setting value for '{name}' is null or empty!");
    }

    return value;
}

至於 SmtpEmailSenderConfiguration,只是提供了其他的屬性獲取(密碼、埠等)而已,本質上還是呼叫的 GetNotEmptySettingValueAsync() 方法從 SettingProvider 中獲取具體的配置資訊。

sequenceDiagram 傳送郵件 ->> Smtp 配置類: 1.GetHostAsync() Smtp 配置類 ->> Email 配置類: 2.GetNotEmptySettingValueAsync("HotsItem") Email 配置類 ->> Setting Provider: 3.GetOrNullAsync("HotsItem") Setting Provider -->> 傳送郵件: 4.獲得主機資料。

關於配置名稱的常量,都在 EmailSettingNames 裡面進行定義,並使用 EmailSettingProvider 將其註冊到 ABP 的配置模組當中:

EmailSettingNames.cs

namespace Volo.Abp.Emailing
{
    public static class EmailSettingNames
    {
        public const string DefaultFromAddress = "Abp.Mailing.DefaultFromAddress";

        public const string DefaultFromDisplayName = "Abp.Mailing.DefaultFromDisplayName";

        public static class Smtp
        {
            public const string Host = "Abp.Mailing.Smtp.Host";

            public const string Port = "Abp.Mailing.Smtp.Port";

            // ... 其他常量定義。
        }
    }
}

EmailSettingProvider.cs

internal class EmailSettingProvider : SettingDefinitionProvider
{
    public override void Define(ISettingDefinitionContext context)
    {
        context.Add(
            new SettingDefinition(
                EmailSettingNames.Smtp.Host, 
                "127.0.0.1", 
                L("DisplayName:Abp.Mailing.Smtp.Host"), 
                L("Description:Abp.Mailing.Smtp.Host")),

            new SettingDefinition(EmailSettingNames.Smtp.Port, 
                "25", 
                L("DisplayName:Abp.Mailing.Smtp.Port"), 
                L("Description:Abp.Mailing.Smtp.Port")),
                // ... 其他配置引數。
        );
    }

    private static LocalizableString L(string name)
    {
        return LocalizableString.Create<EmailingResource>(name);
    }
}

2.4 郵件模板

文字模板是 ABP 後續提供的一個新的模組,它可以讓開發人員預先定義文字模板,然後使用時根據物件資料替換模板中的內容,並且 ABP 提供的文字模板還支援本地化。關於文字模板的功能,我們後續單獨會寫一篇文章進行說明,在這裡只是大概 Mail 是如何使用的。

在專案當中,ABP 僅定義了兩個 *.tpl 的模板檔案,分別是控制佈局的 Layout.tpl,還有渲染具體訊息的 Message.tpl。同許可權、Setting 一樣,模板也會使用一個 StandardEmailTemplates 型別定義模板的編碼常量,並且實現一個 XXXDefinitionProvider 型別將其注入到 ABP 框架當中。

StandardEmailTemplates.cs

public static class StandardEmailTemplates
{
    public const string Layout = "Abp.StandardEmailTemplates.Layout";
    public const string Message = "Abp.StandardEmailTemplates.Message";
}

StandardEmailTemplateDefinitionProvider.cs

public class StandardEmailTemplateDefinitionProvider : TemplateDefinitionProvider
{
    public override void Define(ITemplateDefinitionContext context)
    {
        context.Add(
            new TemplateDefinition(
                StandardEmailTemplates.Layout,
                displayName: LocalizableString.Create<EmailingResource>("TextTemplate:StandardEmailTemplates.Layout"),
                isLayout: true
            ).WithVirtualFilePath("/Volo/Abp/Emailing/Templates/Layout.tpl", true)
        );

        context.Add(
            new TemplateDefinition(
                StandardEmailTemplates.Message,
                displayName: LocalizableString.Create<EmailingResource>("TextTemplate:StandardEmailTemplates.Message"),
                layout: StandardEmailTemplates.Layout
            ).WithVirtualFilePath("/Volo/Abp/Emailing/Templates/Message.tpl", true)
        );
    }
}

2.5 MailKit 整合

MailKit 是一個優秀跨平臺的 .NET 郵件操作庫,它的官方 GitHub 地址為 https://github.com/jstedfast/MailKit ,支援很多高階特性,這裡我就不再詳細介紹 MailKit 的其他特性,只是講解一下 MailKit 同 ABP 自帶的郵件模組是如何整合的。

官方的 Volo.Abp.MailKit 包僅包含 4 個檔案,它們分別是 AbpMailKitModule.cs (空模組,佔位)、AbpMailKitOptions.cs (MailKit 的特殊配置)、IMailKitSmtpEmailSender.cs (實現了 IEmailSender 基類的一個介面)、MailKitSmtpEmailSender.cs (具體的傳送邏輯實現)。

需要注意一下,這裡針對 MailKit 的特殊配置是使用的 IConfiguration 裡面的資料(通常是 appsetting.json),而不是從 Abp.Settings 裡面獲取的。

MailKitSmtpEmailSender.cs

[Dependency(ServiceLifetime.Transient, ReplaceServices = true)]
public class MailKitSmtpEmailSender : EmailSenderBase, IMailKitSmtpEmailSender
{
    protected AbpMailKitOptions AbpMailKitOptions { get; }

    protected ISmtpEmailSenderConfiguration SmtpConfiguration { get; }

    // ... 建構函式。

    protected override async Task SendEmailAsync(MailMessage mail)
    {
        using (var client = await BuildClientAsync())
        {
            // 使用了 mail 引數來構造 MailKit 的物件。
            var message = MimeMessage.CreateFromMailMessage(mail);
            await client.SendAsync(message);
            await client.DisconnectAsync(true);
        }
    }

    // 構造 MailKit 所需要的 Client 物件。
    public async Task<SmtpClient> BuildClientAsync()
    {
        var client = new SmtpClient();

        try
        {
            await ConfigureClient(client);
            return client;
        }
        catch
        {
            client.Dispose();
            throw;
        }
    }

    // 進行一些基本配置,比如伺服器資訊和密碼資訊等。
    protected virtual async Task ConfigureClient(SmtpClient client)
    {
        await client.ConnectAsync(
            await SmtpConfiguration.GetHostAsync(),
            await SmtpConfiguration.GetPortAsync(),
            await GetSecureSocketOption()
        );

        if (await SmtpConfiguration.GetUseDefaultCredentialsAsync())
        {
            return;
        }

        await client.AuthenticateAsync(
            await SmtpConfiguration.GetUserNameAsync(),
            await SmtpConfiguration.GetPasswordAsync()
        );
    }

    // 根據 Option 的值獲取一些安全配置。
    protected virtual async Task<SecureSocketOptions> GetSecureSocketOption()
    {
        if (AbpMailKitOptions.SecureSocketOption.HasValue)
        {
            return AbpMailKitOptions.SecureSocketOption.Value;
        }

        return await SmtpConfiguration.GetEnableSslAsync()
            ? SecureSocketOptions.SslOnConnect
            : SecureSocketOptions.StartTlsWhenAvailable;
    }
}

三、總結

ABP 將 Email 這塊功能封裝成了單獨的模組,便於開發人員進行郵件傳送。並且官方也提供了 MailKit 的支援,我們可以根據自己的需求來替換不同的實現。只不過針對於一些非同步郵件傳送的場景,目前還不能很好的支援(主要是使用了 MailMessage 無法序列化)。

我覺得 ABP 應該自己定義一個 Context 型別,反轉依賴,在具體的實現當中確定郵件傳送的物件型別。或者是將預設的 Smtp 傳送者獨立出來一個模組,就跟 MailKit 一樣,使用 ABP 的 Context 型別來構造 MailMessage 物件。

四、總目錄

歡迎翻閱作者的其他文章,請 點選我 進行跳轉,如果你覺得本篇文章對你有幫助,請點選文章末尾的 推薦按鈕

最後更新時間: 2021年6月27日 23點31分