In the last post of this blog series, I made the case in favor of the various ways that the release of the
System.Text.Json library is shaping the latest and greatest in .NET serialization and what that brings to the table for serverless executables in particular.
This one's hands-on
In this post, I'll take this to town: we'll explore a vertical slice of a .NET Standard 2.1 library providing data access to DynamoDB using the latest AWS .NET SDK (v3.x), exploring existing
Newtonsoft.Json use cases and replacing them along the way with
System.Text.Json suitable equivalents. Before I start though, a kind reminder: this is not a "how-to" guide on migrating from
System.Text.Json. There are numerous blog posts out there on how to achieve that; however you should be (mostly) good with the official documentation's migration guide; it covers all the basics as well as some more sophisticated use cases:
Setting our carpaccio up
Suppose a data-layer (or entity, if that's your lingo) class modelling a job listing (related: C# 9.0 records can't come soon enough) , like the following:
This is a typical example of a class whose sole purpose is to persist and retrieve entities of this type from a DynamoDB table. There are various properties of this class that are .NET System.Runtime objects themselves (like
Guid) and for that purpose we have custom-built converters that handle the specifics of converting these types from and to DynamoDB primitives. As one might guess from their names though, none of these converters are handling JSON payloads so they are in no way affected by replacing
System.Text.Json and thus not our focal point today. All, except the very last property called
Interview is a string and stored as such in DynamoDB too. In fact,
Interview is a JSON string which, based on business logic requirements, we may or may not want to deserialize to a suitable .NET class:
As we can see,
Newtonsoft.JSON doesn't even need
JsonProperty annotations to make something as trivial as this work. It will usually ignore case for properties' names and successfully handle the whole deal, obscuring (for better or for worse) the implementation details in the process.
Newtonsoft.Json, a bare minimum
JsonSerializer would look like this:
And a naive, optimistic usage of this class would be as simple as:
var interview = JsonSerializer.Deserialize<Interview>(job.Interview);
Moving symmetrically backwards and replacing
Newtonsoft.Json, a bare minimum
System.Text.Json would look like this:
Without exploring the ins and outs of the
JsonSerializerOptions class for now, that's where all the fine tuning for a particular de/serializing operation takes place. Once you realize just how much
Newtonsoft.Json was handling behind the scenes (tip: everything you know is a lie), then you will find yourself spending some time with these set of options:
Interview class we saw above will need it's own set of enhancements before it's able to be successfully de/serialized using this new
A couple of things were added there:
JsonProperty(you can get away with not adding these if you adjust the
- we had to add custom converter classes for certain properties.
Let's examine why that was necessary. In the
Question class, it turns out that both the
Attempts as well as the
ResponseTime properties were persisted in the database as either an integer or a string...not exactly a tour de force in data engineering but it is what it is.
Newtonsoft.JSON handles that without blinking twice and you wouldn't know but for the rest of us, here's how to write a custom
JsonSerializer class that handles both types for a property as such using
Now, what's really going on here is that, considering the data predicament as described above, we need to manually account for these two possibilities. Handily,
System.Text.Json includes an enum called
JsonTokenType in it's namespace that is a representation of the underlying primitive data type (read: not .NET Type) of a particular value. These are the current members of that enum as of .NET Core 3.1:
This is extremely useful as it allows us to write converters tailored for a wide range of scenarios. Every class that implements the abstract
JsonConverter<T> class must override the
Write methods and the return type of the
Read method is constrained to
T. The design choices of the .NET team when creating this library allow for some great cases of self-documenting code in our codebases.
Another similarly trivial case is the
Pretty staightforward, right?
Further reading on how to write JsonConverters that can handle just about any possible scenario can be found in the relevant official documentation article:
Some(what) advanced gotchas
One prevalent point that I made sure to mention in the closing remarks of my previous post too was that
System.Text.Json doesn't have feature parity with
Newtonsoft.Json, at least not for the time being.
A frequent use case of
Newtonsoft.Json is loading a
JObject from a JSON string. There are numerous reasons one might want to do that including (but not limited) to resulting object nodes manipulation (add/remove) or simply not having the time or care about type safety and just
dynamic everything because it's a throwaway weekend project.
Regardless of the reason, using the
Newtonsoft.Json.Linq namespace like:
So how do we achieve the same result using
This snippet starts by creating a new instance of the
JsonSerializerOptions class with some parameterized options, we've covered as much already. So what's that
JsonDocumentParser class in the following line?
JsonDocument is a
System.Text.Json's class that provides a mechanism for examining the structural content of a JSON value without automatically instantiating data values => less memory allocations! However, there's a subtle catch: observe how
JsonDocument.Parse() needs a
using statement? This class utilizes resources from pooled memory to minimize the impact of the garbage collector (GC) in high-usage scenarios. Failure to properly dispose this object will result in the memory not being returned to the pool, which will increase GC impact across various parts of the framework.
Furthermore, on the subsequent line, there's a
RootElement.Clone() method. A
typeof(JsonElement) and as you might deduce it's the root element of a JSON document. The
Clone() method returns a copy of this
JsonElement that can be safely stored beyond the lifetime of the original
JsonElement is a struct that represents a specific JSON value within a
JsonDocument. Using this approach, practically every node within a JSON object is a
JsonElement as well as the whole JSON object itself.
This turned out somewhat longer than what I'd originally planned but I felt I should point out as much as possible for people pondering over the specifics of making this move. It's overall not an easy thing to do; it will inevitably cost development time and you might still encounter unforeseen conversions (especially with JSON payloads coming from third-party providers) even after extensive testing. This actually happened to us on a production environment, it was a fun day.
Hopefully this goes some way into helping some of you see beyond the migration veil into the promised land of performance benefits (as highlighted on the previous blog post of this series).
Next time round for the final entry in this series, we'll cover the new ReadyToRun .NET Core feature and how to ride the new wave of Ahead Of Time compiling (AOT) in AWS Lambda .NET projects to reap that sweet performance nectar.