發送郵件幾乎是軟件系統中必不可少的功能,在Asp.Net Core 中咱們可使用MailKit發送郵件,MailKit發送郵件比較簡單,網上有許多能夠參考的文章,可是應該注意附件名長度,和附件名不能出現中文的問題,若是你遇到了這樣的問題能夠參考我以前寫的這篇博客Asp.Net Core MailKit 完美附件(中文名、長文件名)。html
在咱們簡單搜索網絡,併成功解決了附件的問題以後,咱們已經可以發送郵件啦!不過另外一個問題顯現出來——發送郵件太慢了,沒錯,在我使用QQ郵箱發送時,單封郵件發送大概要用1.5秒左右,用戶可能難以忍受請求發生1.5秒的延遲。數據庫
因此,咱們必須解決這個問題,咱們的解決辦法就是使用郵件隊列來發送郵件編程
Ok, 第一步就是規劃咱們的郵件隊列有什麼安全
咱們得有一個郵件Options類,來存儲郵件相關的選項網絡
/// <summary> /// 郵件選項 /// </summary> public class EmailOptions { public bool DisableOAuth { get; set; } public string DisplayName { get; set; } public string Host { get; set; } // 郵件主機地址 public string Password { get; set; } public int Port { get; set; } public string UserName { get; set; } public int SleepInterval { get; set; } = 3000; ...
SleepInterval
是睡眠間隔,由於目前咱們實現的隊列是進程內的獨立線程,發送器會循環讀取隊列,當隊列是空的時候,咱們應該讓線程休息一會,否則無限循環會消耗大量CPU資源併發
而後咱們還須要的就是 一個用於存儲郵件的隊列,或者叫隊列提供器,總之咱們要將郵件存儲起來。以及一個發送器,發送器不斷的從隊列中讀取郵件併發送。還須要一個郵件寫入工具,想要發送郵件的代碼使用寫入工具將郵件轉儲到隊列中。ide
那麼咱們設計的郵件隊列事實上就有了三個部分:函數
那麼咱們設計的郵件隊列提供器接口以下:工具
public interface IMailQueueProvider { void Enqueue(MailBox mailBox); bool TryDequeue(out MailBox mailBox); int Count { get; } bool IsEmpty { get; } ...
四個方法,入隊、出隊、隊列剩餘郵件數量、隊列是不是空,咱們對隊列的基本需求就是這樣。ui
MailBox是對郵件的封裝,並不複雜,稍後會介紹到
public interface IMailQueueService { void Enqueue(MailBox box);
對於想要發送郵件的組件或者代碼部分來說,只須要將郵件入隊,這就足夠了
public interface IMailQueueManager { void Run(); void Stop(); bool IsRunning { get; } int Count { get; }
啓動隊列,中止隊列,隊列運行中狀態,郵件計數
如今,三個主要部分就設計好了,咱們先看下MailBox
,接下來就去實現這三個接口
MailBox 以下:
public class MailBox { public IEnumerable<IAttachment> Attachments { get; set; } public string Body { get; set; } public IEnumerable<string> Cc { get; set; } public bool IsHtml { get; set; } public string Subject { get; set; } public IEnumerable<string> To { get; set; } ...
這裏面沒什麼特殊的,你們一看便能理解,除了IEnumerable<IAttachment> Attachments { get; set; }
。
在發送郵件中最複雜的就是附件了,由於附件體積大,每每還涉及非託管資源(例如:文件),因此附件處理必定要當心,避免留下漏洞和bug。
在MailKit中附件其實是流Stream
,例以下面的代碼:
attachment = new MimePart(contentType) { Content = new MimeContent(fs), ContentDisposition = new ContentDisposition(ContentDisposition.Attachment), ContentTransferEncoding = ContentEncoding.Base64, };
其中new MimeContent(fs)
是建立的Content,fs是Stream
,MimeContent的構造函數以下:
public MimeContent(Stream stream, ContentEncoding encoding = ContentEncoding.Default)
因此咱們的設計的附件是基於Stream
的。
通常狀況附件是磁盤上的文件,或者內存流MemoryStream
或者 byte[]數據。附件須要實際的文件的流Stream
和一個附件名,因此附件接口設計以下:
public interface IAttachment : IDisposable { Stream GetFileStream(); string GetName();
那麼咱們默認實現了兩中附件類型 物理文件附件和內存文件附件,byte[]數據能夠輕鬆的轉換成 內存流,因此沒有寫這種
public class MemoryStreamAttechment : IAttachment { private readonly MemoryStream _stream; private readonly string _fileName; public MemoryStreamAttechment(MemoryStream stream, string fileName) { _stream = stream; _fileName = fileName; } public void Dispose() => _stream.Dispose(); public Stream GetFileStream() => _stream; public string GetName() => _fileName;
內存流附件實現要求在建立時傳遞一個 MemoryStream和附件名稱,比較簡單
public class PhysicalFileAttachment : IAttachment { public PhysicalFileAttachment(string absolutePath) { if (!File.Exists(absolutePath)) { throw new FileNotFoundException("文件未找到", absolutePath); } AbsolutePath = absolutePath; } private FileStream _stream; public string AbsolutePath { get; } public void Dispose() { _stream.Dispose(); } public Stream GetFileStream() { if (_stream == null) { _stream = new FileStream(AbsolutePath, FileMode.Open); } return _stream; } public string GetName() { return System.IO.Path.GetFileName(AbsolutePath); ...
這裏,咱們要注意的是建立FileStream的時機,是在請求GetFileStream
方法時,而不是構造函數中,由於建立FileStream
FileStream會佔用文件,若是咱們發兩封郵件使用了同一個附件,那麼會拋出異常。而寫在GetFileStream
方法中相對比較安全(除非發送器是並行的)
在咱們這篇文章中,咱們實現的隊列提供器是基於內存的,往後呢咱們還能夠實現其它的基於其它存儲模式的,好比數據庫,外部持久性隊列等等,另外基於內存的實現不是持久的,一旦程序崩潰。未發出的郵件就會boom而後消失 XD...
IMailQueueProvider
實現代碼以下:
public class MailQueueProvider : IMailQueueProvider { private static readonly ConcurrentQueue<MailBox> _mailQueue = new ConcurrentQueue<MailBox>(); public int Count => _mailQueue.Count; public bool IsEmpty => _mailQueue.IsEmpty; public void Enqueue(MailBox mailBox) { _mailQueue.Enqueue(mailBox); } public bool TryDequeue(out MailBox mailBox) { return _mailQueue.TryDequeue(out mailBox); }
本文的實現是一個 ConcurrentQueue<T> ,這是爲了不資源競爭帶來問題,寫入隊列和出隊不在同一個線程中
IMailQueueService
實現代碼以下:
public class MailQueueService : IMailQueueService { private readonly IMailQueueProvider _provider; /// <summary> /// 初始化實例 /// </summary> /// <param name="provider"></param> public MailQueueService(IMailQueueProvider provider) { _provider = provider; } /// <summary> /// 入隊 /// </summary> /// <param name="box"></param> public void Enqueue(MailBox box) { _provider.Enqueue(box); }
這裏,咱們的服務依賴於IMailQueueProvider
,使用了其入隊功能
IMailQueueManager
實現這個相對比較複雜,咱們先看下完整的類,再逐步解釋:
public class MailQueueManager : IMailQueueManager { private readonly SmtpClient _client; private readonly IMailQueueProvider _provider; private readonly ILogger<MailQueueManager> _logger; private readonly EmailOptions _options; private bool _isRunning = false; private bool _tryStop = false; private Thread _thread; /// <summary> /// 初始化實例 /// </summary> /// <param name="provider"></param> /// <param name="options"></param> /// <param name="logger"></param> public MailQueueManager(IMailQueueProvider provider, IOptions<EmailOptions> options, ILogger<MailQueueManager> logger) { _options = options.Value; _client = new SmtpClient { // For demo-purposes, accept all SSL certificates (in case the server supports STARTTLS) ServerCertificateValidationCallback = (s, c, h, e) => true }; // Note: since we don't have an OAuth2 token, disable // the XOAUTH2 authentication mechanism. if (_options.DisableOAuth) { _client.AuthenticationMechanisms.Remove("XOAUTH2"); } _provider = provider; _logger = logger; } /// <summary> /// 正在運行 /// </summary> public bool IsRunning => _isRunning; /// <summary> /// 計數 /// </summary> public int Count => _provider.Count; /// <summary> /// 啓動隊列 /// </summary> public void Run() { if (_isRunning || (_thread != null && _thread.IsAlive)) { _logger.LogWarning("已經運行,又被啓動了,新線程啓動已經取消"); return; } _isRunning = true; _thread = new Thread(StartSendMail) { Name = "PmpEmailQueue", IsBackground = true, }; _logger.LogInformation("線程即將啓動"); _thread.Start(); _logger.LogInformation("線程已經啓動,線程Id是:{0}", _thread.ManagedThreadId); } /// <summary> /// 中止隊列 /// </summary> public void Stop() { if (_tryStop) { return; } _tryStop = true; } private void StartSendMail() { var sw = new Stopwatch(); try { while (true) { if (_tryStop) { break; } if (_provider.IsEmpty) { _logger.LogTrace("隊列是空,開始睡眠"); Thread.Sleep(_options.SleepInterval); continue; } if (_provider.TryDequeue(out MailBox box)) { _logger.LogInformation("開始發送郵件 標題:{0},收件人 {1}", box.Subject, box.To.First()); sw.Restart(); SendMail(box); sw.Stop(); _logger.LogInformation("發送郵件結束標題:{0},收件人 {1},耗時{2}", box.Subject, box.To.First(), sw.Elapsed.TotalSeconds); } } } catch (Exception ex) { _logger.LogError(ex, "循環中出錯,線程即將結束"); _isRunning = false; } _logger.LogInformation("郵件發送線程即將中止,人爲跳出循環,沒有異常發生"); _tryStop = false; _isRunning = false; } private void SendMail(MailBox box) { if (box == null) { throw new ArgumentNullException(nameof(box)); } try { MimeMessage message = ConvertToMimeMessage(box); SendMail(message); } catch (Exception exception) { _logger.LogError(exception, "發送郵件發生異常主題:{0},收件人:{1}", box.Subject, box.To.First()); } finally { if (box.Attachments != null && box.Attachments.Any()) { foreach (var item in box.Attachments) { item.Dispose(); } } } } private MimeMessage ConvertToMimeMessage(MailBox box) { var message = new MimeMessage(); var from = InternetAddress.Parse(_options.UserName); from.Name = _options.DisplayName; message.From.Add(from); if (!box.To.Any()) { throw new ArgumentNullException("to必須含有值"); } message.To.AddRange(box.To.Convert()); if (box.Cc != null && box.Cc.Any()) { message.Cc.AddRange(box.Cc.Convert()); } message.Subject = box.Subject; var builder = new BodyBuilder(); if (box.IsHtml) { builder.HtmlBody = box.Body; } else { builder.TextBody = box.Body; } if (box.Attachments != null && box.Attachments.Any()) { foreach (var item in GetAttechments(box.Attachments)) { builder.Attachments.Add(item); } } message.Body = builder.ToMessageBody(); return message; } private void SendMail(MimeMessage message) { if (message == null) { throw new ArgumentNullException(nameof(message)); } try { _client.Connect(_options.Host, _options.Port, false); // Note: only needed if the SMTP server requires authentication if (!_client.IsAuthenticated) { _client.Authenticate(_options.UserName, _options.Password); } _client.Send(message); } finally { _client.Disconnect(false); } } private AttachmentCollection GetAttechments(IEnumerable<IAttachment> attachments) { if (attachments == null) { throw new ArgumentNullException(nameof(attachments)); } AttachmentCollection collection = new AttachmentCollection(); List<Stream> list = new List<Stream>(attachments.Count()); foreach (var item in attachments) { var fileName = item.GetName(); var fileType = MimeTypes.GetMimeType(fileName); var contentTypeArr = fileType.Split('/'); var contentType = new ContentType(contentTypeArr[0], contentTypeArr[1]); MimePart attachment = null; Stream fs = null; try { fs = item.GetFileStream(); list.Add(fs); } catch (Exception ex) { _logger.LogError(ex, "讀取文件流發生異常"); fs?.Dispose(); continue; } attachment = new MimePart(contentType) { Content = new MimeContent(fs), ContentDisposition = new ContentDisposition(ContentDisposition.Attachment), ContentTransferEncoding = ContentEncoding.Base64, }; var charset = "UTF-8"; attachment.ContentType.Parameters.Add(charset, "name", fileName); attachment.ContentDisposition.Parameters.Add(charset, "filename", fileName); foreach (var param in attachment.ContentDisposition.Parameters) { param.EncodingMethod = ParameterEncodingMethod.Rfc2047; } foreach (var param in attachment.ContentType.Parameters) { param.EncodingMethod = ParameterEncodingMethod.Rfc2047; } collection.Add(attachment); } return collection; } }
在構造函數中請求了另外三個服務,而且初始化了SmtpClient
(這是MailKit中的)
public MailQueueManager( IMailQueueProvider provider, IOptions<EmailOptions> options, ILogger<MailQueueManager> logger) { _options = options.Value; _client = new SmtpClient { // For demo-purposes, accept all SSL certificates (in case the server supports STARTTLS) ServerCertificateValidationCallback = (s, c, h, e) => true }; // Note: since we don't have an OAuth2 token, disable // the XOAUTH2 authentication mechanism. if (_options.DisableOAuth) { _client.AuthenticationMechanisms.Remove("XOAUTH2"); } _provider = provider; _logger = logger; }
啓動隊列時建立了新的線程,而且將線程句柄保存起來:
public void Run() { if (_isRunning || (_thread != null && _thread.IsAlive)) { _logger.LogWarning("已經運行,又被啓動了,新線程啓動已經取消"); return; } _isRunning = true; _thread = new Thread(StartSendMail) { Name = "PmpEmailQueue", IsBackground = true, }; _logger.LogInformation("線程即將啓動"); _thread.Start(); _logger.LogInformation("線程已經啓動,線程Id是:{0}", _thread.ManagedThreadId); }
線程啓動時運行了方法StartSendMail
:
private void StartSendMail() { var sw = new Stopwatch(); try { while (true) { if (_tryStop) { break; } if (_provider.IsEmpty) { _logger.LogTrace("隊列是空,開始睡眠"); Thread.Sleep(_options.SleepInterval); continue; } if (_provider.TryDequeue(out MailBox box)) { _logger.LogInformation("開始發送郵件 標題:{0},收件人 {1}", box.Subject, box.To.First()); sw.Restart(); SendMail(box); sw.Stop(); _logger.LogInformation("發送郵件結束標題:{0},收件人 {1},耗時{2}", box.Subject, box.To.First(), sw.Elapsed.TotalSeconds); } } } catch (Exception ex) { _logger.LogError(ex, "循環中出錯,線程即將結束"); _isRunning = false; } _logger.LogInformation("郵件發送線程即將中止,人爲跳出循環,沒有異常發生"); _tryStop = false; _isRunning = false; }
這個方法不斷的從隊列讀取郵件併發送,當 遇到異常,或者_tryStop
爲true
時跳出循環,此時線程結束,注意咱們會讓線程睡眠,在適當的時候。
接下來就是方法SendMail
了:
private void SendMail(MailBox box) { if (box == null) { throw new ArgumentNullException(nameof(box)); } try { MimeMessage message = ConvertToMimeMessage(box); SendMail(message); } catch (Exception exception) { _logger.LogError(exception, "發送郵件發生異常主題:{0},收件人:{1}", box.Subject, box.To.First()); } finally { if (box.Attachments != null && box.Attachments.Any()) { foreach (var item in box.Attachments) { item.Dispose(); ...
這裏有一個特別要注意的就是在發送以後釋放附件(非託管資源):
foreach (var item in box.Attachments) { item.Dispose(); ...
發送郵件的核心代碼只有兩行:
MimeMessage message = ConvertToMimeMessage(box); SendMail(message);
第一行將mailbox轉換成 MailKit使用的MimeMessage實體,第二步切實的發送郵件
爲何,咱們的接口中沒有直接使用MimeMessage而是使用MailBox?由於MimeMessage比較繁雜,並且附件的問題不易處理,因此咱們設計接口時單獨封裝MailBox簡化了編程接口
轉換一共兩步,1是主體轉換,比較簡單。二是附件的處理這裏涉及到附件名中文編碼的問題。
private MimeMessage ConvertToMimeMessage(MailBox box) { var message = new MimeMessage(); var from = InternetAddress.Parse(_options.UserName); from.Name = _options.DisplayName; message.From.Add(from); if (!box.To.Any()) { throw new ArgumentNullException("to必須含有值"); } message.To.AddRange(box.To.Convert()); if (box.Cc != null && box.Cc.Any()) { message.Cc.AddRange(box.Cc.Convert()); } message.Subject = box.Subject; var builder = new BodyBuilder(); if (box.IsHtml) { builder.HtmlBody = box.Body; } else { builder.TextBody = box.Body; } if (box.Attachments != null && box.Attachments.Any()) { foreach (var item in GetAttechments(box.Attachments)) { builder.Attachments.Add(item); } } message.Body = builder.ToMessageBody(); return message; } private AttachmentCollection GetAttechments(IEnumerable<IAttachment> attachments) { if (attachments == null) { throw new ArgumentNullException(nameof(attachments)); } AttachmentCollection collection = new AttachmentCollection(); List<Stream> list = new List<Stream>(attachments.Count()); foreach (var item in attachments) { var fileName = item.GetName(); var fileType = MimeTypes.GetMimeType(fileName); var contentTypeArr = fileType.Split('/'); var contentType = new ContentType(contentTypeArr[0], contentTypeArr[1]); MimePart attachment = null; Stream fs = null; try { fs = item.GetFileStream(); list.Add(fs); } catch (Exception ex) { _logger.LogError(ex, "讀取文件流發生異常"); fs?.Dispose(); continue; } attachment = new MimePart(contentType) { Content = new MimeContent(fs), ContentDisposition = new ContentDisposition(ContentDisposition.Attachment), ContentTransferEncoding = ContentEncoding.Base64, }; var charset = "UTF-8"; attachment.ContentType.Parameters.Add(charset, "name", fileName); attachment.ContentDisposition.Parameters.Add(charset, "filename", fileName); foreach (var param in attachment.ContentDisposition.Parameters) { param.EncodingMethod = ParameterEncodingMethod.Rfc2047; } foreach (var param in attachment.ContentType.Parameters) { param.EncodingMethod = ParameterEncodingMethod.Rfc2047; } collection.Add(attachment); } return collection; }
在轉化附件時下面的代碼用來處理附件名編碼問題:
var charset = "UTF-8"; attachment.ContentType.Parameters.Add(charset, "name", fileName); attachment.ContentDisposition.Parameters.Add(charset, "filename", fileName); foreach (var param in attachment.ContentDisposition.Parameters) { param.EncodingMethod = ParameterEncodingMethod.Rfc2047; } foreach (var param in attachment.ContentType.Parameters) { param.EncodingMethod = ParameterEncodingMethod.Rfc2047; }
到這了咱們的郵件隊列就基本完成了,接下來就是在程序啓動後,啓動隊列,找到 Program.cs文件,並稍做改寫以下:
var host = BuildWebHost(args); var provider = host.Services; provider.GetRequiredService<IMailQueueManager>().Run(); host.Run();
這裏在host.Run()
主機啓動以前,咱們獲取了IMailQueueManager
並啓動隊列(別忘了註冊服務)。
運行程序咱們會看到控制檯每隔3秒就會打出日誌:
info: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[0] User profile is available. Using 'C:\Users\Administrator\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest. info: MailQueueManager[0] 線程即將啓動 info: MailQueueManager[0] 線程已經啓動,線程Id是:9 trce: MailQueueManager[0] 隊列是空,開始睡眠 Hosting environment: Development Content root path: D:\publish Now listening on: http://[::]:5000 Application started. Press Ctrl+C to shut down. trce: MailQueueManager[0] 隊列是空,開始睡眠 trce: MailQueueManager[0] 隊列是空,開始睡眠
到此,咱們的郵件隊列就完成了! :D
歡迎轉載,不過要著名原做者和出處以爲寫的不錯的話幫忙點個贊撒 :D