Asp.Net Coreクイックメールキューの設計と実現


送信メールはほとんどソフトウェアシステムに不可欠な機能であり、Asp.Net CoreではMailKitを使ってメールを送ることができます.MailKitはメールを送るのが簡単です.ネット上には参考になる文章がたくさんありますが、添付ファイル名の長さや添付ファイル名に中国語の問題が発生しないことに注意してください.もしこのような問題があったら、私が前に書いたブログAspを参考にしてください.Net Core MailKitの完全な添付ファイル(中国語名、長いファイル名).
ネットワークを簡単に検索し、添付ファイルの問題を解決した後、メールを送信することができます.しかし、もう一つの問題が現れています.メールの送信が遅すぎます.間違いありません.私がQQメールを使って送信するとき、1通のメールの送信は1.5秒ぐらいかかります.ユーザーは要求に1.5秒の遅延に耐えられないかもしれません.
だから、私たちはこの問題を解決しなければなりません.私たちの解決策はメールキューを使ってメールを送信することです.

メールキューの設計


Ok、最初のステップは私たちのメールキューに何があるかを計画することです.

EmailOptions


メール関連のオプションを格納するメールOptionsクラスが必要です
/// 
///     
/// 
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リソースを消費します.
次に必要なのは、メールを格納するキュー、またはキュープロバイダと呼びます.つまり、メールを格納します.キューからメールを読み出して送信する送信機と.メールを送信するコードを書き込みツールでキューにダンプするメール書き込みツールも必要です.
では、私たちが設計したメールキューには実際に3つの部分があります.
  • キューストレージプロバイダ(メールの事実ストレージ)
  • メール送信機(キュー内のメールを読み続け、送信)
  • メールサービス(メールを送りたい場合、メールサービスを呼び出すと、メールサービスはメールをキューに書き込む)
  • キューストレージプロバイダ設計


    では、私たちが設計したメールキュープロバイダインタフェースは次のとおりです.
    public interface IMailQueueProvider
    {
        void Enqueue(MailBox mailBox);
        bool TryDequeue(out MailBox mailBox);
        int Count { get; }
        bool IsEmpty { get; }
        ...
    

    4つの方法は、エンキュー、デキュー、キューの残りのメール数、キューが空いているかどうか、キューに対する基本的なニーズです.
    MailBoxはメールのパッケージで、複雑ではありません.後で紹介します.

    メールサービス設計

    public interface IMailQueueService
    {
        void Enqueue(MailBox box);
    

    メールを送信したいコンポーネントやコードの部分では、メールをエンキューするだけで十分です.

    メール送信機(兼メールキューマネージャ)設計

    public interface IMailQueueManager
    {
        void Run();
        void Stop();
        bool IsRunning { get; }
        int Count { get; }
        

    起動キュー、停止キュー、キュー実行中ステータス、メール数
    では、3つの主要な部分が設計されています.まずMailBoxを見てみましょう.次に、この3つのインタフェースを実現します.

    MailBox


    MailBoxは次のとおりです.
    public class MailBox
    {
        public IEnumerable Attachments { get; set; }
        public string Body { get; set; }
        public IEnumerable Cc { get; set; }
        public bool IsHtml { get; set; }
        public string Subject { get; set; }
        public IEnumerable To { get; set; }
        ...
    

    この中には特別なものはありません.IEnumerable Attachments { get; set; }を除いて、みんなは見ると理解できます.

    添付ファイルの処理


    メールの送信で最も複雑なのは添付ファイルです.添付ファイルの体積が大きいため、管理されていないリソース(例えば、ファイル)にも関連することが多いので、添付ファイルの処理には注意して、脆弱性やバグを残さないようにしてください.
    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();
    

    では、デフォルトでは、2つの添付ファイルタイプの物理ファイル添付ファイルとメモリファイル添付ファイルが実装されています.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を作成するとファイルが占有されるため、2通のメールで同じ添付ファイルを使用すると、例外が放出されます.一方、GetFileStreamメソッドに記載されている方が比較的安全です(送信機が並列でない限り)

    メールキューの実装


    私たちのこの文章では、私たちが実装したキュープロバイダはメモリベースであり、後で、データベース、外部持続性キューなど、他のストレージモードベースの他のストレージモードを実装することもできます.また、メモリベースの実装は永続的ではありません.プログラムが崩壊すると.送信されていないメールはboomから消えてXD...

    メールキュープロバイダIMailQueProvider実装


    コードは次のとおりです.
    public class MailQueueProvider : IMailQueueProvider
    {
        private static readonly ConcurrentQueue _mailQueue = new ConcurrentQueue();
        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;
    
        /// 
        ///      
        /// 
        /// 
        public MailQueueService(IMailQueueProvider provider)
        {
            _provider = provider;
        }
    
        /// 
        ///   
        /// 
        /// 
        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 _logger;
        private readonly EmailOptions _options;
        private bool _isRunning = false;
        private bool _tryStop = false;
        private Thread _thread;
    
        /// 
        ///      
        /// 
        /// 
        /// 
        /// 
        public MailQueueManager(IMailQueueProvider provider, IOptions options, ILogger 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 bool IsRunning => _isRunning;
    
        /// 
        ///   
        /// 
        public int Count => _provider.Count;
    
        /// 
        ///     
        /// 
        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);
        }
    
        /// 
        ///     
        /// 
        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 attachments)
        {
            if (attachments == null)
            {
                throw new ArgumentNullException(nameof(attachments));
            }
    
            AttachmentCollection collection = new AttachmentCollection();
            List list = new List(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;
        }
    }
    
    

    コンストラクション関数で他の3つのサービスが要求され、SmtpClientが初期化された(これはMailKitのものである).
        public MailQueueManager(
            IMailQueueProvider provider, 
            IOptions options, 
            ILogger 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();
        ...
    

    メールを送信するコアコードは2行しかありません.
    MimeMessage message = ConvertToMimeMessage(box);
    SendMail(message);
    

    1行目はmailboxをMailKitが使用するMimeMessageエンティティに変換し、2ステップ目は確実にメールを送信
    なぜ、私たちのインタフェースではMimeMessageではなくMailBoxを直接使用していますか?
    MimeMessageは複雑で、添付ファイルの問題が扱いにくいため、インタフェースを設計する際にMailBoxを単独でカプセル化することでプログラミングインタフェースを簡素化しました.
    変換は全部で2ステップで、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 attachments)
        {
            if (attachments == null)
            {
                throw new ArgumentNullException(nameof(attachments));
            }
    
            AttachmentCollection collection = new AttachmentCollection();
            List list = new List(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().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
    転載を歓迎して、しかし有名な原作者と出典を要します
    うまく書けたらいいな