Asp.Net Core 快速郵件隊列設計與實現

發送郵件幾乎是軟件系統中必不可少的功能,在Asp.Net Core 中咱們可使用MailKit發送郵件,MailKit發送郵件比較簡單,網上有許多能夠參考的文章,可是應該注意附件名長度,和附件名不能出現中文的問題,若是你遇到了這樣的問題能夠參考我以前寫的這篇博客Asp.Net Core MailKit 完美附件(中文名、長文件名)html

在咱們簡單搜索網絡,併成功解決了附件的問題以後,咱們已經可以發送郵件啦!不過另外一個問題顯現出來——發送郵件太慢了,沒錯,在我使用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資源併發

而後咱們還須要的就是 一個用於存儲郵件的隊列,或者叫隊列提供器,總之咱們要將郵件存儲起來。以及一個發送器,發送器不斷的從隊列中讀取郵件併發送。還須要一個郵件寫入工具,想要發送郵件的代碼使用寫入工具將郵件轉儲到隊列中。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

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方法時,而不是構造函數中,由於建立FileStreamFileStream會佔用文件,若是咱們發兩封郵件使用了同一個附件,那麼會拋出異常。而寫在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;
    }

這個方法不斷的從隊列讀取郵件併發送,當 遇到異常,或者_tryStoptrue時跳出循環,此時線程結束,注意咱們會讓線程睡眠,在適當的時候。

接下來就是方法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

相關文章
相關標籤/搜索