在ASP.NET MVC 中发送邮件¶

们从一个简单的例子开始:您正在使用ASP.NET MVC构建您自己的博客,并希望收到每个相关文章评论的电子邮件通知。 我们将使用简单好用的 Postal 库发送邮件。

Tip

我准备了一个只有评论列表的简单应用程序, 您可以 下载源码 开始教程。

您已经有一个控制器操作来创建新的评论,并希望添加通知功能。

  1. // ~/HomeController.cs
  2.  
  3. [HttpPost]
  4. public ActionResult Create(Comment model)
  5. {
  6. if (ModelState.IsValid)
  7. {
  8. _db.Comments.Add(model);
  9. _db.SaveChanges();
  10. }
  11.  
  12. return RedirectToAction("Index");
  13. }

安装 Postal¶

首先, 安装 Postal 软件包:

  1. Install-Package Postal.Mvc5

然后, 如下文创建 ~/Models/NewCommentEmail.cs 文件:

  1. using Postal;
  2.  
  3. namespace Hangfire.Mailer.Models
  4. {
  5. public class NewCommentEmail : Email
  6. {
  7. public string To { get; set; }
  8. public string UserName { get; set; }
  9. public string Comment { get; set; }
  10. }
  11. }

添加 ~/Views/Emails/NewComment.cshtml 文件,为此电子邮件创建相应的模板:

  1. @model Hangfire.Mailer.Models.NewCommentEmail
    To: @Model.To
    From: mailer@example.com
    Subject: New comment posted

  2. Hello,
    There is a new comment from @Model.UserName:

  3. @Model.Comment

  4. <3

通过 Create 控制器调用Postal发送电子邮件:

  1. [HttpPost]
  2. public ActionResult Create(Comment model)
  3. {
  4. if (ModelState.IsValid)
  5. {
  6. _db.Comments.Add(model);
  7. _db.SaveChanges();
  8.  
  9. var email = new NewCommentEmail
  10. {
  11. To = "yourmail@example.com",
  12. UserName = model.UserName,
  13. Comment = model.Text
  14. };
  15.  
  16. email.Send();
  17. }
  18.  
  19. return RedirectToAction("Index");
  20. }

然后在 web.config 文件中配置调用方法( (默认情况下,本教程使用 C:\Temp 目录来存储发送出去的邮件):

  1. <system.net>
  2. <mailSettings>
  3. <smtp deliveryMethod="SpecifiedPickupDirectory">
  4. <specifiedPickupDirectory pickupDirectoryLocation="C:\Temp\" />
  5. </smtp>
  6. </mailSettings>
  7. </system.net>

就这样。尝试发表一些评论,您将在目录中看到通知。

进一步思考¶

为什么让用户等待通知发送? 应该使用某些方法在后台异步发送电子邮件,以便尽快向响应用户请求。

然而, 异步 控制器在这种情况下 没有帮助 , 因为它们在等待异步操作完成时不会立即响应用户请求。它们只解决与线程池和应用程序的内部问题。

后台线程同样也有 很大的问题 。您必须在ASP.NET应用程序中使用线程池线程或自定义线程池。然而在应用程序回收线程时您会丢失电子邮件 (即使您在ASP.NET 中实现了 IRegisteredObject 接口).

而您不太可能想要安装外部Windows服务或使用带控制台应用程序的 Windows Scheduler 来解决这个简单的问题 (只是个人博客项目,又不是电子商务解决方案)。

安装 Hangfire¶

为了能够将任务放在后台,在应用程序重新启动期间不会丢失任务,我们将使用 Hangfire 。它可以在ASP.NET应用程序中以可靠的方式处理后台作业,而无需外部Windows服务或Windows Scheduler。

  1. Install-Package Hangfire

Hangfire使用 SQL Server 或者 Redis 来存储有关后台任务的信息。配置它并在项目根目录新增一个 Startup 类:

  1. public class Startup
  2. {
  3. public void Configuration(IAppBuilder app)
  4. {
  5. GlobalConfiguration.Configuration
  6. .UseSqlServerStorage(
  7. "MailerDb",
  8. new SqlServerStorageOptions { QueuePollInterval = TimeSpan.FromSeconds(1) });
  9.  
  10.  
  11. app.UseHangfireDashboard();
  12. app.UseHangfireServer();
  13.  
  14.  
  15. }
  16.  
  17. }

SqlServerStorage 类会在应用程序启动时自动安装所有数据库表(但你也可以手工)。

现在我们可以使用 Hangfire 了。 我们封装一个在后台执行的公共方法:

  1. [HttpPost]
  2. public ActionResult Create(Comment model)
  3. {
  4. if (ModelState.IsValid)
  5. {
  6. _db.Comments.Add(model);
  7. _db.SaveChanges();
  8.  
  9. BackgroundJob.Enqueue(() => NotifyNewComment(model.Id));
  10. }
  11.  
  12. return RedirectToAction("Index");
  13. }

注意,我们传递的是一个评论的标识符而不是评论的全部信息 – Hangfire 将序列化所有的参数为字符串。默认情况下, serializer 不需要序列化整个的 Comment 类。另外,使用标识符以比完整的评论实体占用更小的空间。

现在,我们需要准备在后台调用的 NotifyNewComment 方法。请注意, HttpContext.Current 在这种情况下不可用,但是 Postal 库却可以 在 ASP.NET 请求之外 使用。 在此之前先安装另一个软件包 (Postal 版本需要为0.9.2, 参阅 issue) 。我们来更新包并引入RazorEngine。

  1. Update-Package -save
  1. public static void NotifyNewComment(int commentId)
  2. {
  3. // Prepare Postal classes to work outside of ASP.NET request
  4. var viewsPath = Path.GetFullPath(HostingEnvironment.MapPath(@"~/Views/Emails"));
  5. var engines = new ViewEngineCollection();
  6. engines.Add(new FileSystemRazorViewEngine(viewsPath));
  7.  
  8. var emailService = new EmailService(engines);
  9.  
  10. // Get comment and send a notification.
  11. using (var db = new MailerDbContext())
  12. {
  13. var comment = db.Comments.Find(commentId);
  14.  
  15. var email = new NewCommentEmail
  16. {
  17. To = "yourmail@example.com",
  18. UserName = comment.UserName,
  19. Comment = comment.Text
  20. };
  21.  
  22. emailService.Send(email);
  23. }
  24. }

这是一个简单的C#静态方法。 我们正在创建一个 EmailService 实例,找到指定的评论并使用 Postal 发送邮件。足够简单吧,特别是与自定义的Windows服务解决方案相比。

Warning

电子邮件在请求管道之外发送。由于Postal 1.0.0, 存在以下 限制: 您不能使用 views 和 ViewBag, 必须是 Model ;同样的,嵌入图像也是 不支持

就这样!尝试发布一些评论并查看 C:\Temp 路径。你也可以在 http://<your-app>/hangfire 检查你的后台任务。如果您有任何问题,欢迎使用下面的评论表。

Note

如果遇到程序集加载异常,请从 web.config 文件中删除以下部分 (我忘了这样做,但不想重新创建存储库):

  1. <dependentAssembly>
  2. <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
  3. <bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0" />
  4. </dependentAssembly>
  5. <dependentAssembly>
  6. <assemblyIdentity name="Common.Logging" publicKeyToken="af08829b84f0328e" culture="neutral" />
  7. <bindingRedirect oldVersion="0.0.0.0-2.2.0.0" newVersion="2.2.0.0" />
  8. </dependentAssembly>

自动重试¶

emailService.Send 方法引发异常时,Hangfire会在延迟一段时间(每次重试都会增加)后自动重试。重试次数(默认 10 次 )有限, 但您可以增加它。只需将 AutomaticRetryAttribute 加到 NotifyNewComment 方法:

  1. [AutomaticRetry( Attempts = 20 )]
  2. public static void NotifyNewComment(int commentId)
  3. {
  4. /* ... */
  5. }

日志¶

当超过最大重试次数时,可以记录日志。尝试创建以下类:

  1. public class LogFailureAttribute : JobFilterAttribute, IApplyStateFilter
  2. {
  3. private static readonly ILog Logger = LogProvider.GetCurrentClassLogger();
  4.  
  5. public void OnStateApplied(ApplyStateContext context, IWriteOnlyTransaction transaction)
  6. {
  7. var failedState = context.NewState as FailedState;
  8. if (failedState != null)
  9. {
  10. Logger.ErrorException(
  11. String.Format("Background job #{0} was failed with an exception.", context.JobId),
  12. failedState.Exception);
  13. }
  14. }
  15.  
  16. public void OnStateUnapplied(ApplyStateContext context, IWriteOnlyTransaction transaction)
  17. {
  18. }
  19. }

再添加:

通过在应用程序启动时调用以下方法来达到全局效果:

  1. public void Configuration(IAppBuilder app)
  2. {
  3. GlobalConfiguration.Configuration
  4. .UseSqlServerStorage(
  5. "MailerDb",
  6. new SqlServerStorageOptions { QueuePollInterval = TimeSpan.FromSeconds(1) })
  7. .UseFilter(new LogFailureAttribute());
  8.  
  9. app.UseHangfireDashboard();
  10. app.UseHangfireServer();
  11. }

或者局部应用于一个方法:

  1. [LogFailure]
  2. public static void NotifyNewComment(int commentId)
  3. {
  4. /* ... */
  5. }

当LogFailureAttribute命中一个方法时将会有新的日志。

使用您喜欢的任何常见的日志库,并且再做任何事情。以NLog为例。安装NLog(当前版本:4.2.3)。

  1. Install-Package NLog

将新的 Nlog.config 文件加到项目的根目录中。

  1.  

<?xml version=”1.0” encoding=”utf-8” ?><nlog xmlns=”http://www.nlog-project.org/schemas/NLog.xsd

xmlns:xsi=”http://www.w3.org/2001/XMLSchema-instance”autoReload=”true”throwExceptions=”false”&gt;
<variable name=”appName” value=”HangFire.Mailer” />
<targets async=”true”>
<target xsi:type=”File”
name=”default”layout=”- {longdate} - - {level:uppercase=true}: - {message}- {onexception:- {newline}EXCEPTION: - {exception:format=ToString}}”fileName=”- {specialfolder:ApplicationData}- {appName}Debug.log”keepFileOpen=”false”archiveFileName=”- {specialfolder:ApplicationData}- {appName}Debug_- {shortdate}.{##}.log”archiveNumbering=”Sequence”archiveEvery=”Day”maxArchiveFiles=”30”/>
<target xsi:type=”EventLog”
name=”eventlog”source=”- {appName}”layout=”- {message}- {newline}- {exception:format=ToString}”/>
</targets><rules>
<logger name=”” writeTo=”default” minlevel=”Info” /><logger name=”” writeTo=”eventlog” minlevel=”Error” />
</rules>

</nlog>

运行应用程序后 新的日志文件可以 %appdata%HangFire.MailerDebug.log 找到。

修复重新部署¶

如果在 NotifyNewComment 方法中出错, 您可以尝试并通过Web界面启动失败的后台任务来修复它:

  1. // Break background job by setting null to emailService:
  2. EmailService emailService = null;

编译一个项目,发布一个评论,然后打开 http://<your-app>/hangfire 的网页。超过所有自动重试的限制次数,然后修复任务中的bug,重新启动应用程序,最后点击 Failed jobs 页面上的 Retry 按钮。

保存语言区域¶

如果您为请求设置了自定义语言区域,则Hang​​fire将在后台作业执行期间存储和设置它。尝试以下:

  1. // HomeController/Create action
  2. Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("es-ES");
  3. BackgroundJob.Enqueue(() => NotifyNewComment(model.Id));

并在后台任务中检查:

  1. public static void NotifyNewComment(int commentId)
  2. {
  3. var currentCultureName = Thread.CurrentThread.CurrentCulture.Name;
  4. if (currentCultureName != "es-ES")
  5. {
  6. throw new InvalidOperationException(String.Format("Current culture is {0}", currentCultureName));
  7. }
  8. // ...

原文:

http://hangfirezh.zhs.press/tutorials/send-email.html