[Abp vNext 源码分析] - 20. 电子邮件与短信支持
一、简介
abp vnext 使用 volo.abp.sms 包和 volo.abp.emailing 包将短信和电子邮件作为基础设施进行了抽象,开发人员仅需要在使用的时候注入 ismssender
或 iemailsender
即可实现短信发送和邮件发送。
二、源码分析
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 实现,分别是 smtpemailsender
和 mailkitemailsender
,各个类型的关系如下。
uml 类图:
可以从 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 类图关系如下。
可以看到配置文件时通过 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
中获取具体的配置信息。
关于配置名称的常量,都在 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 图:
功能比较简单,重点是 smsmessage
里面的参数,第一个是发送的号码,第二个是发送的内容。仅凭上述参数肯定不够,所以 abp 提供了一个属性字典,便于我们传入一些特定的参数。
三、总结
abp 将 email 这块功能封装成了单独的模块,便于开发人员进行邮件发送。并且官方也提供了 mailkit 的支持,我们可以根据自己的需求来替换不同的实现。只不过针对于一些异步邮件发送的场景,目前还不能很好的支持(主要是使用了 mailmessage
无法序列化)。
我觉得 abp 应该自己定义一个 context 类型,反转依赖,在具体的实现当中确定邮件发送的对象类型。或者是将默认的 smtp 发送者独立出来一个模块,就跟 mailkit 一样,使用 abp 的 context 类型来构造 mailmessage
对象。
四、总目录
欢迎翻阅作者的其他文章,请 进行跳转,如果你觉得本篇文章对你有帮助,请点击文章末尾的 推荐按钮。
最后更新时间: 2021年6月27日 23点31分
推荐阅读
-
[Abp vNext 源码分析] - 7. 权限与验证
-
[Abp vNext 源码分析] - 6. DDD 的应用层支持 (应用服务)
-
[Abp vNext 源码分析] - 13. 本地事件总线与分布式事件总线 (Rabbit MQ)
-
[Abp vNext 源码分析] - 12. 后台作业与后台工作者
-
[Abp vNext 源码分析] - 3. 依赖注入与拦截器
-
[Abp vNext 源码分析] - 20. 电子邮件与短信支持
-
[Abp vNext 源码分析] - 5. DDD 的领域层支持(仓储、实体、值对象)
-
[Abp vNext 源码分析] - 6. DDD 的应用层支持 (应用服务)
-
[Abp vNext 源码分析] - 13. 本地事件总线与分布式事件总线 (Rabbit MQ)
-
[Abp vNext 源码分析] - 20. 电子邮件与短信支持