Andrew Lock | .NET Escapades

In this post I show how you can return an XML response from a minimal API instead of the typical JSON response. I also look at ways to reduce the overhead introduced by MemoryStream in the implementation.

The constraints of Minimal APIs in .NET 6

One of the headline features of .NET 6 was the addition of minimal APIs. Minimal APIs provide a low-ceremony and high-performance alternative to the MVC/Web API framework that's been available in .NET Core since version 1.0.

Minimal APIs were designed with performance as a primary goal, and as such they are less of a "kitchen-sink" framework than MVC. Rather than including features that cater to every use case, they are designed with specific patterns in mind.

Minimal APIs in .NET 7 are getting a few important extra features like Filters and route groups.

One such use case is that by default, minimal APIs only return JSON; Web APIs in comparison support Content Negotiation (conneg). For example, let's say you have a simple Web API action that looks something like this:

public class PersonController { [HttpGet("person/{id}")] public Person? GetById(long id) => _person.GetById(id); } 

By default, when a caller invokes this API the Person object will be serialized to JSON. But if the caller sets the Accept header on their HTTP request to application/xml (and you've configured the XML formatters for your application), then the Person object will be serialized to XML instead.

Minimal APIs don't support conneg so if that's a feature you really need then it's probably best to use Web APIs instead. But what if you don't need conneg, and all you need is for a couple of your minimal APIs to return XML? If it's literally just one or two APIs, then it probably makes sense to "manually" serialize to XML, but if it's more than that then it might make sense to create a custom IResult that handles the serialization for you.

Creating a custom IResult

Let's start with a basic minimal API that serializes a POCO object to JSON:

using System.Xml.Serialization; using Microsoft.IO; var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapGet("/", () => new Person { FirstName = "Andrew", LastName = "Lock", }); app.Run(); public class Person { public string? FirstName { get; init; } public string? LastName { get; init; } } 

When you call this API, sure enough, you get back JSON:

{"firstName":"Andrew","lastName":"Lock"} 

Now lets create a custom IResult that will return XML instead of JSON.

The following is based on a demo from Stephen Halter given on a .NET community standup. It contains a potential performance issue, which I'll discuss later.

using using System.Xml.Serialization; public class XmlResult<T> : IResult { // Create the serializer that will actually perform the XML serialization private static readonly XmlSerializer Serializer = new(typeof(T)); // The object to serialize private readonly T _result; public XmlResult(T result) { _result = result; } public async Task ExecuteAsync(HttpContext httpContext) { // NOTE: best practice would be to pull this, we'll look at this shortly using var ms = new MemoryStream(); // Serialize the object synchronously then rewind the stream Serializer.Serialize(ms, _result); ms.Position = 0; httpContext.Response.ContentType = "application/xml"; await ms.CopyToAsync(httpContext.Response.Body); } } 

The XmlResult<T> type implements IResult, which requires the ExecuteAsync method. In this method it uses an XmlSerializer to serialize the provided object to XML to a MemoryStream and then copies this to the response Body.

Note you can't serialize directly to the Body stream, as that would be a synchronous operation, which is disallowed by default for performance reasons. Later in the post we'll look at the performance implications of creating a new MemoryStream for every response.

To use the new result type, create an instance of it in your API. For example:

app.MapGet("/", () => new XmlResult<Person>(new Person { FirstName = "Andrew", LastName = "Lock", })); 

Now when consumers hit the API they'll get the following XML instead of JSON:

<Person xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> <FirstName>Andrew</FirstName> <LastName>Lock</LastName> </Person> 

Note that this is not doing content negotiation—this API will now always return XML.

One obvious downside to using a custom IResult like this is that it's a bit clunky (you have to explicitly specify the generic parameter as Person) and it's not very discoverable. Luckily minimal APIs have an elegant extension point to improve things.

Registering a custom IResult with IResultExtensions

Minimal APIs typically use the IResult objects exposed on the static Results type. For example you can return a 404 response using Results.NotFound() or a 201 response using Results.Created() for example. These are analogous to the NotFound() and Created() methods available on ControllerBase in WebAPIs.

To make it easier to add custom result types, the static Results type includes a property called Extensions:

public static partial class Results { public static IResultExtensions Extensions { get; } = new ResultExtensions(); // ... } 

This property is of type IResultExtensions which is just an empty marker interface:

namespace Microsoft.AspNetCore.Http; public interface IResultExtensions { } 

So how does that help us? Well, we can't add methods to the Results type because it's static, but we can add them to the IResultExtensions marker interface, for example:

static class XmlResultExtensions { public static IResult Xml<T>(this IResultExtensions _, T result) => new XmlResult<T>() } 

With this simple extension method we avoid having to explicitly specify the generic type parameter as Person and we know where to look for the available custom IResult types (they'll all be on Results.Extensions):

so our final XML API looks like the following:

app.MapGet("/", () => Results.Extensions.Xml(new Person { FirstName = "Andrew", LastName = "Lock", })); 

Improving performance by pooling MemoryStream

The XmlResult I presented earlier will likely be absolutely fine if you are only calling the API occasionally. However, if you are calling this API repeatedly, then you may find that the naïve use of MemoryStream causes additional garbage collection pressure, resulting in performance issues.

MemoryStream is backed by byte[] buffers. If you're serializing a lot of data in the XmlResult, the MemoryStream may need to create multiple byte[] buffers, copying the data between them. At the end of the request, all those byte[] buffers need to be collected as garbage. Do that many times a second and you can see where the performance issues could come from.

ASP.NET Core has many types to work around this issue internally. For example the XmlSerializerOutputFormatter used for XML formatting in Web API uses the helper class FileBufferingWriteStream. This class uses ArrayPool<byte> (via an internal type, PagedByteBuffer to reduce the buffer allocations, and avoid unnecessary copies, falling back to buffering the content to disk if you try and serialize huge objects.

We can rewrite our code to use the FileBufferingWriteStream by using it instead of MemoryStream and calling DrainBufferAsync() instead of CopyToAsync():

using System.Xml.Serialization; using Microsoft.AspNetCore.WebUtilities; public class XmlResult<T> : IResult { private static readonly XmlSerializer Serializer = new(typeof(T)); private readonly T _result; public XmlResult(T result) { _result = result; } public async Task ExecuteAsync(HttpContext httpContext) { // 👇Create a FileBufferingWriteStream instead of a MemoryStream here using var ms = new FileBufferingWriteStream(); Serializer.Serialize(ms, _result); httpContext.Response.ContentType = "application/xml"; await ms.DrainBufferAsync(httpContext.Response.Body); // 👆 Call DrainBufferAsync instead of CopyToAsync, and don't call ms.Position = 0 } } 

The combination of buffer pooling and avoiding creating huge buffers in memory should reduce the pressure on the GC.

Another alternative option is to use the Microsoft.IO.RecyclableMemoryStream NuGet package. This package is meant as a drop-in replacement for MemoryStream and provides many of the same benefits as FileBufferingWriteStream, namely that it uses buffer pooling and handles large buffers better.

I won't go into details about this library in this post, but the README on the GitHub repository has a great explainer about why it's useful, how to use it, and things to consider.

To get started, install the NuGet package in your project:

dotnet add package Microsoft.IO.RecyclableMemoryStream 

RecyclableMemoryStream is intended pretty much as a drop-in replacement for MemoryStream, and behind the scenes it takes care of all of the pooling and buffer handling for you. All you need is a shared RecyclableMemoryStreamManager instance, and you call GetStream() wherever you would normally create a MemoryStream. We can then rewrite our XmlResult as follows:

using System.Xml.Serialization; using Microsoft.IO; public class XmlResult<T> : IResult { // 👇 Create a shared RecyclableMemoryStreamManager instance private static readonly RecyclableMemoryStreamManager StreamManager = new(); private static readonly XmlSerializer Serializer = new(typeof(T)); private readonly T _result; public XmlResult(T result) { _result = result; } public async Task ExecuteAsync(HttpContext httpContext) { // 👇Use in place of the MemoryStream here using var ms = StreamManager.GetStream(); Serializer.Serialize(ms, _result); httpContext.Response.ContentType = "application/xml"; ms.Position = 0; await ms.CopyToAsync(httpContext.Response.Body); } } 

Whichever approach you choose, make sure you dispose the Stream instance so the rented buffers are returned to the pool correctly.

Summary

In this post I showed how you could return XML from a minimal API by creating a custom IResult type called XmlResult<T>. I then showed that you can create an extension method so that the result is exposed as Results.Extensions.Xml() for convenience. Finally, I showed two different ways you could replace the usage of MemoryStream by using pooling of buffers to reduce pressure on the GC.

]]>
Salesforce