6
\$\begingroup\$

After all partial views are rendered and all styles are inlined my RESTful service is sending them via email. I implemented this feature with a MailerMiddleware.

This middleware dumps the response body and uses it as the body of the email. I pass the recipients and the subject via the HttpContext.Items property from the controller to the middleware. It uses the <system.net> element in the app.config for sending emails.

public class MailerMiddleware
{
    private readonly RequestDelegate _next;

    private readonly IEmailClient _emailClient;

    public MailerMiddleware(RequestDelegate next, IEmailClient emailClient)
    {
        _next = next;
        _emailClient = emailClient;
    }

    public async Task Invoke(HttpContext context)
    {
        if (context.Request.Method == "POST")
        {
            var bodyBackup = context.Response.Body;

            using (var memory = new MemoryStream())
            {
                context.Response.Body = memory;

                await _next(context);

                memory.Seek(0, SeekOrigin.Begin);
                using (var reader = new StreamReader(memory))
                {
                    var recipients = (string)context.Items["Recipients"];
                    var subject = (string)context.Items["Subject"];
                    var body = await reader.ReadToEndAsync();

                    memory.Seek(0, SeekOrigin.Begin);

                    var restoreBody = memory.CopyToAsync(bodyBackup);
                    var sendEmail = _emailClient.SendAsync(new Email<EmailSubject, EmailBody>
                    {
                        To = recipients,
                        Subject = new PlainTextSubject(subject),
                        Body = new ParialViewEmailBody(body),
                    });

                    await Task.WhenAll(restoreBody, sendEmail);
                }
            }
        }
        else
        {
            await _next(context);
        }
    }
}

Inside the action method:

[HttpPost("TestReport")]
[ActionName("TestReport")]
public IActionResult PostTestReport([FromBody] TestReportBody body)
{
    HttpContext.Items["Recipients"] = "example@email.com"; // todo use body
    HttpContext.Items["Subject"] = "Test email"; // todo use body
    return PartialView(body);
}

This solution is working great but is there anything that could still be done better?

\$\endgroup\$
3
\$\begingroup\$

Common practice with custom middleware when manipulating the response body is to replace original stream with one you can rewind as once you start writing to the original stream everything is sent to the client.

You should put the backup body back into the context response so header values like content length can be calculated accurately. A disposed stream was being left in the response.

public async Task Invoke(HttpContext context) {
    if (context.Request.Method == "POST") {
        // Hold on to original body for downstream calls
        var originalBody = context.Response.Body;           
        // buffer the response stream in order to intercept writes in the pipeline
        using (var responseBuffer = new MemoryStream()) {
            // replace stream
            context.Response.Body = responseBuffer;
            // Call the next delegate/middleware in the pipeline
            await _next(context);
            // buffer now holds the response data               
            var recipients = (string)context.Items["Recipients"];
            var subject = (string)context.Items["Subject"];
            var body = string.Empty;                
            // rewind buffer to read data written while upstream
            responseBuffer.Seek(0, SeekOrigin.Begin);
            using (var reader = new StreamReader(responseBuffer)) {
                body = await reader.ReadToEndAsync();                   
            }               
            // rewind buffer again to copy data to original stream
            responseBuffer.Seek(0, SeekOrigin.Begin);
            var restoreBody = responseBuffer.CopyToAsync(originalBody);
            var sendEmail = _emailClient.SendAsync(new Email<EmailSubject, EmailBody> {
                To = recipients,
                Subject = new PlainTextSubject(subject),
                Body = new ParialViewEmailBody(body),
            });
            await Task.WhenAll(restoreBody, sendEmail);                 
            // and finally, reset the stream for the 
            // previous delegate/middleware in the pipeline
            context.Response.Body = originalBody;
        }
    } else {
        await _next(context);
    }
}
\$\endgroup\$
  • \$\begingroup\$ Now I see my mistake. Making a bakup of the original stream but not restoring it. Also your variable names are much better. Thanks! ;-) \$\endgroup\$ – t3chb0t Feb 27 '18 at 5:03
  • \$\begingroup\$ I've posted an improved version. \$\endgroup\$ – t3chb0t Mar 4 '18 at 10:44
4
\$\begingroup\$

Implementing Nkosi's suggestions was a great step forward. This way the client can also recieve the response. However, there was one more thing that could be greatly improved. While reading about Queued background tasks I realized that this is exactly what I need to speed up my middleware becasue sending emails made it hang for a short time. Queueing the task in a background service allows the middleware to return immediately and take its time for emails.

I adjusted the example code for my needs by not using a logger here. Instead I placed the Debug.Fail inside the catch clause (and changed a couple of names).

public interface IWorkItemQueue
{
    void Enqueue(Func<CancellationToken, Task> workItem);

    Task<Func<CancellationToken, Task>> DequeueAsync(CancellationToken cancellationToken);
}

public class WorkItemQueue : IWorkItemQueue
{
    private readonly ConcurrentQueue<Func<CancellationToken, Task>> _workItemQueue = new ConcurrentQueue<Func<CancellationToken, Task>>();

    private readonly SemaphoreSlim _signal = new SemaphoreSlim(0);

    public void Enqueue(Func<CancellationToken, Task> workItem)
    {
        if (workItem == null) { throw new ArgumentNullException(nameof(workItem)); }

        _workItemQueue.Enqueue(workItem);
        _signal.Release();
    }

    public async Task<Func<CancellationToken, Task>> DequeueAsync(CancellationToken cancellationToken)
    {
        await _signal.WaitAsync(cancellationToken);
        _workItemQueue.TryDequeue(out var workItem);

        return workItem;
    }
}

public class WorkItemQueueService : IHostedService
{
    private readonly IWorkItemQueue _workItemQueue;

    private readonly CancellationTokenSource _shutdown = new CancellationTokenSource();

    private Task _backgroundTask;

    public WorkItemQueueService(IWorkItemQueue workItemQueue)
    {
        _workItemQueue = workItemQueue;
    }

    #region IHostedService

    public Task StartAsync(CancellationToken cancellationToken)
    {
        // ReSharper disable once MethodSupportsCancellation - this task is not supposted to be cancelled until shutdown
        _backgroundTask = Task.Run(BackgroundProceessing);

        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _shutdown.Cancel();
        return Task.WhenAny(_backgroundTask, Task.Delay(Timeout.Infinite, cancellationToken));
    }

    #endregion

    public void Enqueue(Func<CancellationToken, Task> workItem)
    {
        _workItemQueue.Enqueue(workItem);
    }

    private async Task BackgroundProceessing()
    {
        while (!_shutdown.IsCancellationRequested)
        {
            var workItem = await _workItemQueue.DequeueAsync(_shutdown.Token);

            try
            {
                await workItem(_shutdown.Token);
            }
            catch (Exception)
            {
                Debug.Fail("Work item should handle its own exceptions.");
            }
        }
    }
}

The updated MailerMiddleware now enqueues tasks sending emails on that queue so that the service can handle them later and also handles its own exceptions.

public class MailerMiddleware
{
    private readonly RequestDelegate _next;

    private readonly ILogger _logger;

    private readonly IWorkItemQueue _workItemQueue;

    private readonly IEmailClient _emailClient;

    public MailerMiddleware(RequestDelegate next, ILoggerFactory loggerFactory, IWorkItemQueue workItemQueue, IEmailClient emailClient)
    {
        _next = next;
        _logger = loggerFactory.CreateLogger<MailerMiddleware>();
        _workItemQueue = workItemQueue;
        _emailClient = emailClient;
    }

    public async Task Invoke(HttpContext context)
    {
        var bodyBackup = context.Response.Body;

        using (var memory = new MemoryStream())
        {
            context.Response.Body = memory;

            await _next(context);

            memory.Seek(0, SeekOrigin.Begin);
            using (var reader = new StreamReader(memory))
            {
                var body = await reader.ReadToEndAsync();

                var email = context.Email();
                if (!(email.To is null || email.Subject is null))
                {
                    _workItemQueue.Enqueue(async cancellationToken =>
                    {
                        try
                        {
                            await _emailClient.SendAsync(new Email<EmailSubject, EmailBody>
                            {
                                To = email.To,
                                Subject = new PlainTextSubject(email.Subject),
                                Body = new ParialViewEmailBody(body),
                            });
                        }
                        catch (Exception ex)
                        {
                            _logger.Log(Abstraction.Layer.Network().Action().Failed(nameof(IEmailClient.SendAsync)), ex);
                        }
                    });
                }

                // Restore Response.Body
                memory.Seek(0, SeekOrigin.Begin);
                await memory.CopyToAsync(bodyBackup);
                context.Response.Body = bodyBackup;
            }
        }
    }

    private class ParialViewEmailBody : EmailBody
    {
        private readonly string _body;

        public ParialViewEmailBody(string body)
        {
            _body = body;
            IsHtml = true;
            Encoding = System.Text.Encoding.UTF8;
        }
        public override string ToString()
        {
            return _body;
        }
    }
}

ASP.NET-Core is really cool ;-)

\$\endgroup\$
  • \$\begingroup\$ This is a very good improvement over the initial suggestion provided. Queuing the task was well thought out and would definitely improve the response time. I like this code. \$\endgroup\$ – Nkosi Mar 4 '18 at 12:39
  • \$\begingroup\$ Curious though. Why use ContinueWith for the Enqueue work item function instead of a try catch wrapping an await? Enqueue takes a function returning a task which would mean it can be awaited. \$\endgroup\$ – Nkosi Mar 4 '18 at 12:45
  • \$\begingroup\$ @Nkosi I guess it's because how Tasks work (still a little bit confusing to me). I initially had a try/catch around SendAsync there but this did not work. A test exception was risen in the queue-processor and the Debug.Fail was executed. The exception got out of the try/catch and ended up behing handled by DequeueAsync. \$\endgroup\$ – t3chb0t Mar 4 '18 at 12:49
  • 1
    \$\begingroup\$ @Nkosi ok, I got it working without ContinueWith (and updated the code). My mistake was to return the Task for SendAsync instead of awaiting it. \$\endgroup\$ – t3chb0t Mar 4 '18 at 15:47
  • 1
    \$\begingroup\$ Here is a nice recent video on async await that I believe you will find interesting. Youtube - On.Net async/await best practices \$\endgroup\$ – Nkosi Mar 4 '18 at 15:52

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.