發送郵件幾乎是軟件系統中必不可少的功能,在Asp.Net Core 中我們可以使用MailKit發送郵件,MailKit發送郵件比較簡單,網上有許多可以參考的文章,但是應該注意附件名長度,和附件名不能出現中文的問題,如果你遇到了這樣的問題可以參考我之前寫的這篇博客Asp.Net Core MailKit 完美附件(中文名、長文件名)。
在我們簡單搜索網絡,並成功解決了附件的問題之后,我們已經能夠發送郵件啦!不過另一個問題顯現出來——發送郵件太慢了,沒錯,在我使用QQ郵箱發送時,單封郵件發送大概要用1.5秒左右,用戶可能難以忍受請求發生1.5秒的延遲。
所以,我們必須解決這個問題,我們的解決辦法就是使用郵件隊列來發送郵件
設計郵件隊列
Ok, 第一步就是規划我們的郵件隊列有什么
EmailOptions
我們得有一個郵件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資源
然后我們還需要的就是 一個用於存儲郵件的隊列,或者叫隊列提供器,總之我們要將郵件存儲起來。以及一個發送器,發送器不斷的從隊列中讀取郵件並發送。還需要一個郵件寫入工具,想要發送郵件的代碼使用寫入工具將郵件轉儲到隊列中。
那么我們設計的郵件隊列事實上就有了三個部分:
- 隊列存儲提供器(郵件的事實存儲)
- 郵件發送機 (不斷讀取隊列中的郵件,並發送)
- 郵件服務 (想法送郵件時,調用郵件服務,郵件服務會將郵件寫入隊列)
隊列存儲提供器設計
那么我們設計的郵件隊列提供器接口如下:
public interface IMailQueueProvider
{
void Enqueue(MailBox mailBox);
bool TryDequeue(out MailBox mailBox);
int Count { get; }
bool IsEmpty { get; }
...
四個方法,入隊、出隊、隊列剩余郵件數量、隊列是否是空,我們對隊列的基本需求就是這樣。
MailBox是對郵件的封裝,並不復雜,稍后會介紹到
郵件服務設計
public interface IMailQueueService
{
void Enqueue(MailBox box);
對於想要發送郵件的組件或者代碼部分來講,只需要將郵件入隊,這就足夠了
郵件發送機(兼郵件隊列管理器)設計
public interface IMailQueueManager
{
void Run();
void Stop();
bool IsRunning { get; }
int Count { get; }
啟動隊列,停止隊列,隊列運行中狀態,郵件計數
現在,三個主要部分就設計好了,我們先看下MailBox
,接下來就去實現這三個接口
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[]數據可以輕松的轉換成 內存流,所以沒有寫這種
MemoryStreamAttechment
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
郵件服務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
歡迎轉載,不過要著名原作者和出處
覺得寫的不錯的話幫忙點個贊撒 😄