一、简介

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;
    }
}

2.6 短信发送的核心组件

短信发送仅提供了一个 ismssender 接口,该接口有提供一个发送方法,abp 官方提供了 aliyun 的短信发送功能(volo.abp.sms.aliyun)。

uml 图:

classdiagram class ismssender{ <<interface>> sendasync(smsmessage smsmessage) task } class nullsmssender{ } class smsmessage{ +string phonenumber +string text +idictionary~string, object~ properties } nullsmssender ..|> ismssender: 实现 ismssender ..> smsmessage: 依赖

功能比较简单,重点是 smsmessage 里面的参数,第一个是发送的号码,第二个是发送的内容。仅凭上述参数肯定不够,所以 abp 提供了一个属性字典,便于我们传入一些特定的参数。

三、总结

abp 将 email 这块功能封装成了单独的模块,便于开发人员进行邮件发送。并且官方也提供了 mailkit 的支持,我们可以根据自己的需求来替换不同的实现。只不过针对于一些异步邮件发送的场景,目前还不能很好的支持(主要是使用了 mailmessage 无法序列化)。

我觉得 abp 应该自己定义一个 context 类型,反转依赖,在具体的实现当中确定邮件发送的对象类型。或者是将默认的 smtp 发送者独立出来一个模块,就跟 mailkit 一样,使用 abp 的 context 类型来构造 mailmessage 对象。

四、总目录

欢迎翻阅作者的其他文章,请 进行跳转,如果你觉得本篇文章对你有帮助,请点击文章末尾的 推荐按钮

最后更新时间: 2021年6月27日 23点31分