Reading, Writing, and Versioning Messages Edit on GitHub


Jasper ultimately needs to be able to dehydrate any published message to a byte[] then ship that information along with the related header metadata to the receiving application that will ultimately hydrate that byte[] back to a .Net object. Out of the box, Jasper comes with support for using Newtonsoft.Json to serialize and deserialize objects for transport across the wire. The easiest, and likely starting point, for using Jasper for messaging is to use a shared DTO (Data Transfer Object) library that exposes the shared message types that can easily be serialized and deserialized from and to Json.

This is probably going to be fine in many circumstances, but you could easily need to:

  • Use a more efficient or at least different serialization mechanism
  • Avoid having to share DTO types between services to prevent the coupling that causes
  • Support the concept of versioned messages so that your system can evolve without breaking other systems that either subscribe to or publish to your system
  • Use some kind of custom reader or writer code against your message objects that doesn't necessarily use a serializer of some sort

Fortunately, Jasper has some mechanisms explained in this topic to address the use cases above.

Note! The way that Jasper chooses how to read and write message data is largely influenced by the concept of content negotiation from HTTP

First though, it might help to understand how Jasper reads the message when it receives a new Envelope:

  1. It first looks at the message-type header in the incoming Envelope
  2. Using that value, it finds all the available IMessageSerializer strategies that match that message type, and tries to select one that matches the value of the content-type header
  3. Invoke the matching reader to read the raw byte[] data into a .Net object
  4. Now that you know the actual .Net type for the message, select the proper message handler and off it goes

Also see Dynamic Subscriptions for more information about how the content-type data is used to match up subscriptions.

Message Type Identity

Let's say that you have a basic message structure like this:


public class PersonBorn
{
    public string FirstName { get; set; }
    public string LastName { get; set; }

    // This is obviously a contrived example
    // so just let this go for now;)
    public int Day { get; set; }
    public int Month { get; set; }
    public int Year { get; set; }
}

By default, Jasper will identify this type by just using the .Net full name like so:


[Fact]
public void message_alias_is_fullname_by_default()
{
    new Envelope(new PersonBorn())
        .MessageType.ShouldBe(typeof(PersonBorn).FullName);
}

However, if you want to explicitly control the message type because you aren't sharing the DTO types or for some other reason (readability? diagnostics?), you can override the message type alias with an attribute:


[MessageAlias("person-born")]
public class PersonBorn
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public int Day { get; set; }
    public int Month { get; set; }
    public int Year { get; set; }
}

Which now gives you different behavior:


[Fact]
public void message_alias_is_fullname_by_default()
{
    new Envelope(new PersonBorn())
        .MessageType.ShouldBe("person-born");
}

Versioning

By default, Jasper will just assume that any message is "V1" unless marked otherwise. Going back to the original PersonBorn message class in previous sections, let's say that you create a new version of that message that is no longer structurally equivalent to the original message:


[MessageAlias("person-born"), Version("V2")]
public class PersonBornV2
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTime Birthday { get; set; }
}

The [Version("V2")] attribute usage tells Jasper that this class is "V2" for the message-type = "person-born."

Jasper will now accept or publish this message using the built in Json serialization with the content type of application/vnd.person-born.v2+json. Any custom serializers should follow some kind of naming convention for content types that identify versioned representations.

Disallowing Non-Versioned Messages

As an easy on ramp, Jasper will publish and accept messages with the default application/json content type using Newtonsoft.Json serialization. If instead you'd like to enforce versions on all messages being sent or received, you can configure Jasper to only use the versioned content types:


public class NoUnVersionedMessages : JasperRegistry
{
    public NoUnVersionedMessages()
    {
        Advanced.MediaSelectionMode = MediaSelectionMode.VersionedOnly;
    }
}

Message Serializers and Deserializers

You can create custom message deserializers for a message by providing your own implementation of the IMessageDeserializer interface from Jasper:


public interface IMessageDeserializer
{
    string MessageType { get; }
    Type DotNetType { get; }

    string ContentType { get; }
    object ReadFromData(byte[] data);
    Task<T> ReadFromRequest<T>(HttpRequest request);
}

The easiest way to do this is to just subclass the base MessageDeserializerBase<T> class as shown below:


public class BlueTextReader : MessageDeserializerBase<BlueMessage>
{
    public BlueTextReader() : base("text/plain")
    {
    }

    public override BlueMessage ReadData(byte[] data)
    {
        var name = Encoding.UTF8.GetString(data);
        return new BlueMessage {Name = name};
    }

    protected override async Task<BlueMessage> ReadData(Stream stream)
    {
        var name = await stream.ReadAllTextAsync();
        return new BlueMessage {Name = name};
    }
}

Likewise, to provide a custom message serializer for a message type, you need to implement the IMessageSerializer interface shown below:


public interface IMessageSerializer
{
    Type DotNetType { get; }

    string ContentType { get; }
    byte[] Write(object model);
    Task WriteToStream(object model, HttpResponse response);
}

Again, the easiest way to implement this interface is to subclass the MessageSerializerBase<T> class as shown below:


public class GreenTextWriter : MessageSerializerBase<GreenMessage>
{
    public GreenTextWriter() : base("text/plain")
    {
    }

    public override byte[] Write(GreenMessage model)
    {
        return Encoding.UTF8.GetBytes(model.Name);
    }

    public override Task WriteToStream(GreenMessage model, HttpResponse response)
    {
        return response.WriteAsync(model.Name);
    }
}

IMessageDeserializer and IMessageSerializer classes in the main application assembly are automatically discovered and applied by Jasper. If you need to add custom reader or writers from another assembly, you just need to add them to the underlying IoC container like so:


public class RegisteringCustomReadersAndWriters : JasperRegistry
{
    public RegisteringCustomReadersAndWriters()
    {
        Services.AddTransient<IMessageSerializer, MyCustomWriter>();
        Services.AddTransient<IMessageDeserializer, MyCustomReader>();
    }
}

Custom Serializers

To use additional .Net serializers, you just need to create a new implementation of the Jasper.Conneg.ISerializerFactory interface and register that into the IoC service container.


public interface ISerializerFactory
{
    object Deserialize(Stream message);

    string ContentType { get; }

    IMessageDeserializer[] ReadersFor(Type messageType, MediaSelectionMode mode);
    IMessageSerializer[] WritersFor(Type messageType, MediaSelectionMode mode);
    IMessageDeserializer VersionedReaderFor(Type incomingType);
}

See the built in Newtonsoft.Json adapter for an example usage.

Versioned Message Forwarding

If you make breaking changes to an incoming message in a later version, you can simply handle both versions of that message separately:


public class PersonCreatedHandler
{
    public static void Handle(PersonBorn person)
    {
        // do something w/ the message
    }

    public static void Handle(PersonBornV2 person)
    {
        // do something w/ the message
    }
}

Or you could use a custom IMessageDeserializer to read incoming messages from V1 into the new V2 message type, or you can take advantage of message forwarding so you only need to handle one message type using the IForwardsTo<T> interface as shown below:


[Version("V1")]
public class PersonBorn : IForwardsTo<PersonBornV2>
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public int Day { get; set; }
    public int Month { get; set; }
    public int Year { get; set; }

    public PersonBornV2 Transform()
    {
        return new PersonBornV2
        {
            FirstName = FirstName,
            LastName = LastName,
            Birthday = new DateTime(Year, Month, Day)
        };
    }
}

Which forwards to the current message type:


[MessageAlias("person-born"), Version("V2")]
public class PersonBornV2
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTime Birthday { get; set; }
}

Using this strategy, other systems could still send your system the original application/vnd.person-born.v1+json formatted message, and on the receiving end, Jasper would know to deserialize the Json data into the PersonBorn object, then call its Transform() method to build out the PersonBornV2 type that matches up with your message handler.

Customizing Json Serialization

Just in case the default Json serialization isn't quite what you need, you can customize the Json serialization inside of your JasperRegistry class like so:


public class CustomizingJsonSerialization : JasperRegistry
{
    public CustomizingJsonSerialization()
    {
        Settings.Alter<MessagingSettings>(_ =>
        {
            // Edit the JsonSerializerSettings used by the messaging serialization
            _.JsonSerialization.ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor;
        });
    }
}