Skip to content

Easy to use serializable models with AOT compilation support and System.Text.Json compatibility.

License

Notifications You must be signed in to change notification settings

chickensoft-games/Serialization

Repository files navigation

πŸ’Ύ Serialization

Chickensoft Badge Discord Read the docs line coverage branch coverage

System.Text.Json-compatible source generator with automatic support for derived types and polymorphic serialization.


Chickensoft.Serialization

πŸ“• Background

  • βœ… Support 0-configuration polymorphic serialization in AOT builds.
  • βœ… Support versioning and upgrading outdated models.
  • βœ… Allow types to access and customize their own JSON representation via serialization/deserialization hooks.
  • βœ… Support abstract types.
  • βœ… Support nested types.

The Chickensoft Serialization system allows you to easily declare serializable types that will work when compiled for ahead-of-time (AOT) environments, like iOS. It can be easily used alongside the System.Text.Json source generators for more complex usage scenarios.

  [Meta, Id("book")]
  public partial record Book {
    [Save("title")]
    public required string Title { get; set; }

    [Save("author")]
    public required string Author { get; set; }

    [Save("related_books")]
    public Dictionary<string, List<HashSet<string>>>? RelatedBooks { get; set; }
  }

  [Meta, Id("bookcase")]
  public partial record Bookcase {
    [Save("books")]
    public required List<Book> Books { get; set; }
  }

Example model:

var book = new Book {
  Title = "The Book",
  Author = "The Author",
  RelatedBooks = new Dictionary<string, List<HashSet<string>>> {
    ["Title A"] = [["Author A", "Author B"]],
  }
};

Serialized JSON:

{
  "$type": "book",
  "$v": 1,
  "author": "The Author",
  "related_books": {
    "Title A": [
      [
        "Author A",
        "Author B"
      ]
    ]
  },
  "title": "The Book"
}

πŸ₯³ Overview

The serialization system is designed to be simple and easy to use. Under the hood, it leverages the Chickensoft Introspection generator to avoid using reflection that isn't supported when targeting AOT builds. The Chickensoft Introspection generator is also decently fast, since it only uses syntax nodes instead of relying on analyzer symbol data, which can be very slow.

The serialization system uses the same, somewhat obscure (but public) API's that the generated output of the System.Text.Json source generators use to define metadata about serializable types.

Annoyingly, System.Text.Json requires you to tag derived types on the generation context, which makes refactoring type hierarchies painful and prone to human error if you forget to update. The Chickensoft serialization system automatically handles derived types so you don't have to think about polymorphic serialization and maintain a list of types anywhere.

βœ‹ Intentional Limitations

  • ❌ Generic types are not supported.
  • ❌ Models must have parameterless constructors.
  • ❌ Serializable types must be partial.
  • ❌ Only collections supported are HashSet<T>, List<T>, and Dictionary<TKey, TValue>.

The Chickensoft serializer has strong opinions about how JSON serialization should be done. It's primarily intended to simplify the process of defining models for game save files, but you can use it any C# project which supports C# >= 11.

If you must do something fancy, the serialization system integrates seamlessly with System.Text.Json and generated serializer contexts. The Chickensoft serialization system is essentially just a special IJsonTypeInfoResolver and JsonConverter<object> working together.

πŸ₯š Installation

You'll need the serialization package, as well as the Introspection package and its source generator.

Make sure you get the latest versions of the packages here on nuget: Chickensoft.Introspection, Chickensoft.Introspection.Generator, Chickensoft.Serialization.

<PackageReference Include="Chickensoft.Serialization" Version=... />
<PackageReference Include="Chickensoft.Introspection" Version=... />
<PackageReference Include="Chickensoft.Introspection.Generator" Version=... PrivateAssets="all" OutputItemType="analyzer" />

Warning

Don't forget the PrivateAssets="all" OutputItemType="analyzer" when including a source generator package in your project.

πŸ’Ύ Serializable Types

𝚫 Defining a Serializable Type

To declare a serializable model, add the [Meta] and [Id] attributes to a type.

When your project is built, the Introspection generator will produce a registry of all the types visible from the global scope of your project, as well as varying levels of metadata about the types based on whether they are instantiable, introspective, versioned, and/or identifiable. For more information, check out the Introspection generator readme.

using Chickensoft.Introspection;

[Meta, Id("model")]
public partial class Model { }

Caution

Note that a model's id needs to be globally unique across all serializable types in every assembly that your project uses. The id is used as the model's type discriminator for polymorphic deserialization.

πŸ“Ό Serializing and Deserializing

The serialization system leverages the serialization infrastructure provided by System.Text.Json. To use it, simply create a JsonSerializerOptions instance with a SerializableTypeResolver and SerializableTypeConverter.

var options = new JsonSerializerOptions {
  WriteIndented = true,
  TypeInfoResolver = new SerializableTypeResolver(),
  Converters = { new SerializableTypeConverter() }
};

var model = new Model();

var json = JsonSerializer.Serialize(model, options);

var modelAgain = JsonSerializer.Deserialize<Model>(json, options);

β˜‘οΈ Defining Serializable Properties

To define a serializable property, add the [Save] attribute to the property, specifying its json name.

[Meta, Id("model")]
public partial class Model {
  [Save("name")]
  public required string Name { get; init; } // required allows it to be non-nullable

  [Save("description")]
  public string? Description { get; init; } // not required, should be nullable 
}

Tip

By default, properties are not serialized. This omit-by-default policy enables you to inherit functionality from other types while adding support for serialization in scenarios where you do not fully control the type hierarchy.

Fields are never serialized.

For best results, mark non-nullable properties as [required] and use init properties for models.

πŸͺ† Polymorphism

Abstract types are supported. Serializable types inherit serializable properties from base types.

Tip

Instead of placing an [Id] on the abstract type, place it on each derived type.

[Meta]
public abstract partial class Person {
  [Save("name")]
  public required string Name { get; init; }
}

[Meta, Id("doctor")]
public partial class Doctor : Person {
  [Save("specialty")]
  public required string Specialty { get; init; }
}

[Meta, Id("lawyer")]
public partial class Lawyer : Person {
  [Save("cases_won")]
  public required int CasesWon { get; init; }  
}

⏳ Versioning

The serialization system provides support for versioning models when requirements inevitably change.

There are some situations where adding non-required fields to an existing model is not possible, such as when the type of a field changes or you want to introduce a required property.

Fortunately, the serialization system allows you to declare multiple versions of the same model. Version numbers are simple integer values.

πŸ‘―β€β™€οΈ Defining Multiple Versions of a Type

The following LogEntry model extends a non-serializable type SystemLogEntry. We will introduce a change to the Type property, making it a LogType enum instead of a string.

[Meta, Id("log_entry")]
public abstract partial class LogEntry : SystemLogEntry {
  [Save("text")]
  public required string Text { get; init; }

  [Save("type")]
  public required string Type { get; init; }
}

To introduce a new version, you first need to create a common base type for all the versions.

We first rename the current LogEntry to LogEntry1 and introduce a new abstract type which extends SystemLogEntry β€”Β a type that we don't have direct control over. Then, we simply update the LogEntry1 model to inherit from the abstract LogEntry.

By default, instantiable introspective types have a default version of 1. We will go ahead and add the [Version] attribute anyways to make it more clear.

// We make an abstract type that the specific versions extend.
[Meta, Id("log_entry")]
public abstract partial class LogEntry : SystemLogEntry { }

// Used to be LogEntry, but is now LogEntry1.
[Meta, Version(1)]
public partial class LogEntry1 : LogEntry {
  [Save("text")]
  public required string Text { get; init; }

  [Save("type")]
  public required string Type { get; init; }
}

Tip

Note that the [Id] attribute is only on the abstract base log entry type.

Finally, we can introduce a new version:

public enum LogType {
  Info,
  Warning,
  Error
}

[Meta, Version(2)]
public partial class LogEntry2 : LogEntry {
  [Save("text")]
  public required string Text { get; init; }

  [Save("type")]
  public required LogType Type { get; init; }
}

✨ Never Out of Date

When deserializing older versions of models, the serialization system will automatically upgrade models that implement the IOutdated interface. The IOutdated interface requires that we implement an Upgrade method.

We can update the previous example by marking the first model as outdated:

[Meta, Id("log_entry")]
public abstract partial class LogEntry { }

[Meta, Version(1)]
public partial class LogEntry1 : LogEntry, IOutdated {
  [Save("text")]
  public required string Text { get; init; }

  [Save("type")]
  public required string Type { get; init; }

  public object Upgrade(IReadOnlyBlackboard deps) => new LogEntry2() {
    Text = Text,
    Type = Type switch {
      "info" => LogType.Info,
      "warning" => LogType.Warning,
      "error" or _ => LogType.Error,
    }
  };
}

Tip

Types will continue to be upgraded until a type that is not IOutdated is returned.

The upgrade method receives a blackboard which can be used to lookup dependencies the type might need to upgrade itself. When setting up the serialization system, you must provide the blackboard.

// If our types need access to a service to upgrade themselves, we can
// set that up here when creating the serialization options.
var upgradeDependencies = new Blackboard();
upgradeDependencies.Set(new MyService());

var options = new JsonSerializerOptions {
  WriteIndented = true,
  TypeInfoResolver = new SerializableTypeResolver(),
  Converters = { new IdentifiableTypeConverter(new Blackboard()) }
};

var model = JsonSerializer.Deserialize<LogEntry>(json, options);

πŸ“‹ Enums

You can use enums inside your models. If you're intending to target ahead-of-time compilation, you'll also need to create a System.Text.Json context to register your enum types on so it can generate the relevant serialization metadata needed to serialize and deserialize your enum type.

public enum ModelType {
  Basic,
  Advanced,
  Complex
}

// Register the enum on a System.Text.Json context so it will get metadata
// generated for it.
[JsonSerializable(typeof(ModelType))]
// [JsonSerializable(typeof(AnotherEnum))] // you can have as many as you want
public partial class ModelWithEnumContext : JsonSerializerContext;

[Meta, Id("model_with_enum")]
public partial record ModelWithEnum {
  // Use the enum type in your model, same as with any other type
  [Save("c_type")]
  public ModelType CType { get; init; }
}

Elsewhere, you will need to add your vanilla System.Text.Json context to your serialization options, along with the Chickensoft type resolver and converter.

var options = new JsonSerializerOptions {
  WriteIndented = true,
  TypeInfoResolver = JsonTypeInfoResolver.Combine(
    // Vanilla System.Text.Json context that has the enum registered
    ModelWithEnumContext.Default,
    // Chickensoft type resolver
    new SerializableTypeResolver() 
  ),
  Converters = {
    // You'll need to specify a converter for your enum β€” there's also
    // JsonNumberEnumConverter<TEnum>
    new JsonStringEnumConverter<ModelType>(),
    // Chickensoft type converter
    new SerializableTypeConverter()
  },
};

πŸͺ Serialization Hooks

Types can implement ICustomSerializable to customize how they are serialized and deserialized.

  [Meta, Id("custom_serializable")]
  public partial class CustomSerializable : ICustomSerializable {
    public int Value { get; set; }

    public object OnDeserialized(
      IdentifiableTypeMetadata metadata,
      JsonObject json,
      JsonSerializerOptions options
    ) {
      Value = json["value"]?.GetValue<int>() ?? -1;

      return this;
    }

    public void OnSerialized(
      IdentifiableTypeMetadata metadata,
      JsonObject json,
      JsonSerializerOptions options
    ) {
      // Even though our property doesn't have the [Save] attribute, we
      // can save it manually.
      json["value"] = Value;
    }
  }

The OnDeserialized and OnSerialized methods each receive the type's generated introspection metadata, the JsonObject node, and the JsonSerializerOptions.

Types can add, modify, or remove properties during OnSerialized. Likewise, OnDeserialized allows a type to read data directly from the Json nodes that it is being deserialized from.

πŸ’¬ Registering Converters

You can inform the serializer about types which have custom converters.

public class MyCustomJsonConverter : JsonConverter<T> { ... }

Serializer.AddConverter(new MyCustomJsonConverter());

Converters registered this way do not need to be specified in the JsonSerializerOptions, which allows other libraries to extend the serialization system without requiring additional effort from the developer using the library.

πŸ’Œ Built-in Types

The serialization system has built-in support for a number of types. If a type is not on this list, you will have to make your own JsonConverter<T> for it and register it with the serialization system (or else you will get a runtime error during serialization/deserialization).

πŸ«™ Collections

  • HashSet<T>
  • List<T>
  • Dictionary<TKey, TValue>

🧰 Basic Types

The following basic types and their nullable counterparts are supported:

  • bool
  • byte[]
  • byte
  • char
  • DateTime
  • DateTimeOffset
  • decimal
  • double
  • Guid
  • short
  • int
  • long
  • JsonArray
  • JsonDocument
  • JsonElement
  • JsonNode
  • JsonObject
  • JsonValue
  • Memory<byte>
  • object
  • ReadOnlyMemory<byte>
  • sbyte
  • float
  • string
  • TimeSpan
  • ushort
  • uint
  • ulong
  • Uri
  • Version

🐣 Package generated from a 🐀 Chickensoft Template β€” https://chickensoft.games