August 17th, 2022

Using the new JSON writer in OData

Clément Habinshuti
Senior Software Engineer, OData, Microsoft Graph

Microsoft.OData.Core version 7.12.2 has introduced a new JSON writer that’s based on .NET’s Utf8JsonWriter. The current JSON writer used in Microsoft.OData.Core, internally called JsonWriter, is a custom implementation that uses TextWriter to write the JSON output to the destination stream. We decided to write a new implementation to take advantage of the performance optimizations in Utf8JsonWriter.

In order not to disrupt existing customers, people will have to explicitly “opt-in” to using the new writer. Currently, ODataMessageWriter allows you to override the default IJsonWriter used for writing JSON output by providing a custom IJsonWriterFactory to the dependency-injection container that’s attached to the message instance (IODataResponseMessage or IODataRequestMessage). However, the existing IJsonWriterFactory is tightly coupled to TextWriter. We introduced a new factory interface that allows writing directly to the output Stream instead of TextWriter. We call it IStreamBasedJsonWriterFactory. The default implementation of this factory is called DefaultStreamBasedJsonWriter. It creates an instance of the new JSON Writer, internally called ODataUtf8JsonWriter. To opt-in to using the new writer, you need to add this factory as a service to the dependency-injection container. We’ll demonstrate how to do that in OData Web API 7.x and OData Web API 8.x libraries and when using ODataMessageWriter directly.

OData Web API 8.x

You can use the new writer starting with Microsoft.AspNetCore.OData 8.0.11. You can configure custom services when registering new OData route components using the AddRouteComponens method. Here’s an example:

Add the following using statement to the Startup.cs file, or the file where you configure application services:

using Microsoft.OData.Json;

Then register the new factory inside the Startup.ConfigureServices method or the method where you’re configuring application services if not Startup.ConfigureServices:

services.AddControllers()
    .AddOData(options =>
    options.AddRouteCompontents(“odata”, model, services =>
    {
        Services.AddSingleton<IStreamBasedJsonWriterFactory>(_ => DefaultStreamBasedJsonWriterFactory.Default);
    });

OData Web API 7.x

This feature is available in Microsoft.AspNetCore.OData 7.5.17. There are different methods to configure custom OData services depending on whether you’re using endpoint routing or classic MVC routing.

This feature was initially announced with Microsoft.AspNetCore.OData version 7.5.16. However, customers reported issues with that release. As result, 7.5.16 has been unlisted on Nuget and 7.5.17 was released to address the issues.

Endpoint routing

If you’re using Endpoint routing, then you configure the OData route using endpoints.MapODataRoute in the action passed to app.UseEndpoints in Startup.Configure. There’s an overload of MapODataRoute that allows you to configure custom OData services. We’ll use this to inject the new factory class into the internal service container. We’ll also need to manually register the default routing conventions since this overload of MapODataRoute does not handle that automatically, otherwise routing will not work properly.

Example:

Ensure you have the following using statements to the Startup.cs file

using System.Collections.Generic;
using Microsoft.AspNet.OData.Routing.Conventions;
using Microsoft.OData;
using Microsoft.OData.Json;
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{

    IEdmModel model = GetEdmModel();

    app.UseODataBatching();

    app.UseRouting();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapODataRoute(
            "odata", “odata”,
            container =>
            {
                container.AddService(Microsoft.OData.ServiceLifetime.Singleton, sp => model);
                container.AddService<IStreamBasedJsonWriterFactory>(Microsoft.OData.ServiceLifetime.Singleton, sp => DefaultStreamBasedJsonWriterFactory.Default);
                container.AddService<IEnumerable<IODataRoutingConvention>>(Microsoft.OData.ServiceLifetime.Singleton,
                    sp => ODataRoutingConventions.CreateDefaultWithAttributeRouting("nullPrefix", endpoints.ServiceProvider));
            });
    });
}

MVC routing

If you’re using MVC routing, you can configure custom OData services inside the Startup.Configure method by passing configuration action to the MapODataServiceRoute method. Using this overload of MapODataServiceRoute will also require you to manually register the default routing conventions, otherwise routing will not work properly.

Add the following using statements to the Startup.cs file.

using System.Collections.Generic;
using Microsoft.AspNet.OData.Routing.Conventions;
using Microsoft.OData;
using Microsoft.OData.Json;
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    IEdmModel model = GetEdmModel();

    app.UseMvc(builder =>
    {
        builder.Select().Expand().Filter().OrderBy().MaxTop(100).Count();

        builder.MapODataServiceRoute("odata", "odata", container =>
        {
            container.AddService(Microsoft.OData.ServiceLifetime.Singleton, sp => model)
                .AddService<IStreamBasedJsonWriterFactory>(Microsoft.OData.ServiceLifetime.Singleton, sp => DefaultStreamBasedJsonWriterFactory.Default)
                .AddService<IEnumerable<IODataRoutingConvention>>(Microsoft.OData.ServiceLifetime.Singleton, sp =>
                    ODataRoutingConventions.CreateDefaultWithAttributeRouting("odata", builder));
        });
    });
}

 

ODataMessageWriter

If you’re using ODataMessageWriter directly from Microsot.OData.Core to write JSON payloads, it takes a few extra steps to configure.

To write a message, you need to provide an implementation of IODataResponseMessage when writing a response and IODataRequestMessage when writing a request body. Whatever I mention next about IODataResponseMessage also applies to IODataRequestMessage. To make your custom services accessible to the ODataMessageWriter, your implementation of IODataResponseMessage must also implement IContainerProvider. This is a simple interface that exposes a Container property of type IServiceProvider. ODataMessageWriter will use this property (if it has been defined on the message instance) to request services it needs, like an implementation of the IStreamBasedJsonWriterFactory.

For example, an IODataResponseMessage implementation could look like

class MyResponseMessage : IODataResponseMessage, IContainerProvider 
{ 
    //… other methods omitted for brevity
    IServiceProvider Container { get; init; };
}

You must also implement IContainerBuilder. This is a Microsoft.OData.Core interface that provides an abstraction for service containers, without tying itself to any specific dependency injection library. If you don’t already have an existing implementation in your code, you can copy or refer to the DefaultContainerBuilder implementation in Microsoft.AspNetCore.ODatawhich is based on Microsoft.Extensions.DependencyInjection library. To learn more about dependency injection in the OData core library, visit this page.

Then use the following steps to configure the container builder.

You will need the following using statements:

using Microsoft.OData;
using Microsoft.OData.Json;
var containerBuilder = new DefaultContainerBuild();
containerBuilder.AddDefaultODataServices(); 
containerBuilder.Services.Add<IStreamBasedJsonWriterFactory>(DefaultStreamBasedJsonWrtiterFactory.Default);

Then create the service provider:

IServiceProvider services = containerBuilder.BuildContainer();

And here’s how to set it on the response message when using ODataMessageWriter:

MyResponseMessage message = new MyResponseMessage(…) { Container = services };
ODataMessageWriterSettings settings = …;
IEdmModel model = …;

ODataMessageWriter writer = new ODataMessageWriter(message, settings, model);
await  writer.WriteStartAsync(new ODataResourceSet());

Performance

Based on some of our benchmarks, ODataUtf8JsonWriter is about 38% faster than the default JsonWriter and uses less memory. It can increase the speed of the overall ODataMessageWriter by about 5-7% and reduces its memory usage by 2.5% when using the synchronous API. When using the asynchronous API, it makes ODataMessageWriter about 47% faster and reduces memory usage by almost 33%.

Limitations and other considerations

Support for streaming writes

The default JsonWriter implements the IJsonStreamWriter interface. This interface allows you to pipe data directly from an input stream to the output destination. This may be useful when writing the contents of large binary or text file into the JSON output without reading everything in memory first. The new ODataUtf8JsonWriter does not yet implement this interface, which means input from a stream will be buffered entirely in memory before being written out to the destination.

Supported .NET versions

This feature is only available with Microsoft.OData.Core on .NET Core 3.1 and above (i.e. .NET 6 etc.). It is not supported on Microsoft.OData.Core versions running on .NET Framework.

Choosing a JavaScriptEncoder

The DefaultStreamBasedJsonWriterFactory.Default uses the default JavaScriptEncoder used by Utf8JsonWriter to encode and escape output text. The default encoder escapes control characters, non-ASCII characters and HTML-sensitive characters like ‘<’. This differs from OData’s default behavior which does not escape HTML-sensitive characters.

You can provide a different JavaScriptEncoder. Instead of injecting DefaultStreamBasedJsonWriterFactory.Default, you can create a new instance of the factory and pass the encoder to the constructor. The following snippet uses the JavaScriptEncoder.UnsafeRelaxedJsonEscaping which does not escape HTML-sensitive characters. This is the default encoder used by ASP.NET Core.

IStreamBasedJsonWriterFactory factory = new DefaultStreamBasedJsonWriterFactory(JavaScriptEncoder.UnsafeRelaxedJsonEscaping);

Note that there’s no built-in JavaScriptEncoder that exactly matches the escaping implementation of OData’s default JsonWriter. The raw output of the two writers will be different. But if the client is compliant, this should not be a problem. Nevertheless, this is something you should take into consideration before adopting the new writer. In general, you should not be highly dependent on a whether certain characters are escaped or not because the internal implementation may change from one version of .NET to another (See relevant discussions here and here).

Conclusion

We have introduced the new ODataUtf8JsonWriter that ships with Microsoft.OData.Core 7.12.2 and showed how to configure OData serializers and writers to use it. This is part of a larger effort to make the core libraries more performant and efficient. We invite you to try out the new writer and share feedback with us. Let us know what you think, whether you observe any performance improvements (or regressions) and what other improvements we can make.

Category
OData

Author

Clément Habinshuti
Senior Software Engineer, OData, Microsoft Graph

0 comments

Discussion are closed.