Skip to content

Commit

Permalink
Serialization docs
Browse files Browse the repository at this point in the history
  • Loading branch information
mtmk committed Oct 30, 2023
1 parent e577da9 commit 2b69bea
Show file tree
Hide file tree
Showing 7 changed files with 436 additions and 14 deletions.
7 changes: 7 additions & 0 deletions NATS.Client.sln
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.NativeAot", "sandbo
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nats.Client.NativeAotTests", "tests\Nats.Client.NativeAotTests\Nats.Client.NativeAotTests.csproj", "{CF44A42E-C075-4C5C-BE8B-3DF266FC617B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.ProtoBufMessages", "sandbox\Example.ProtoBufMessages\Example.ProtoBufMessages.csproj", "{9FCD9377-FE5F-4D94-BDCF-54427DB6487D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -237,6 +239,10 @@ Global
{CF44A42E-C075-4C5C-BE8B-3DF266FC617B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CF44A42E-C075-4C5C-BE8B-3DF266FC617B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CF44A42E-C075-4C5C-BE8B-3DF266FC617B}.Release|Any CPU.Build.0 = Release|Any CPU
{9FCD9377-FE5F-4D94-BDCF-54427DB6487D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9FCD9377-FE5F-4D94-BDCF-54427DB6487D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9FCD9377-FE5F-4D94-BDCF-54427DB6487D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9FCD9377-FE5F-4D94-BDCF-54427DB6487D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -278,6 +284,7 @@ Global
{88625045-978F-417F-9F51-A4E3A9718945} = {95A69671-16CA-4133-981C-CC381B7AAA30}
{51362D87-49C8-414C-AAB7-E51B946231E7} = {95A69671-16CA-4133-981C-CC381B7AAA30}
{CF44A42E-C075-4C5C-BE8B-3DF266FC617B} = {C526E8AB-739A-48D7-8FC4-048978C9B650}
{9FCD9377-FE5F-4D94-BDCF-54427DB6487D} = {95A69671-16CA-4133-981C-CC381B7AAA30}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {8CBB7278-D093-448E-B3DE-B5991209A1AA}
Expand Down
237 changes: 237 additions & 0 deletions docs/documentation/serialization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
# Serialization

NATS .NET Client supports serialization of messages using a simple interface `INatsSerializer`. By default, the client
uses the `NatsDefaultSerializer` which can handle binary data, UTF8 strings and numbers. You can provide your own
serializer by implementing the `INatsSerializer` interface or using the `NatsJsonContextSerializer` for generated
JSON serialization. Serializers can also be chained together to provide multiple serialization formats typically
depending on the types being used.

## Default Serializer

Default serializer is used when no serializer is provided to the connection options. It can handle binary data, UTF8
strings and numbers. It uses the following rules to determine the type of the data:

- If the data is a byte array, `Memory<byte>`, `IMemoryOwner` or similar it is treated as binary data.
- If the data is a string or similar it is treated as UTF8 string.
- If the data is an `int` or `double` it is treated as a number encoded as a UTF8 string.
- For any other type, the serializer will throw an exception.

```csharp
// Same as not specifying a serializer.
var natsOpts = NatsOpts.Default with { Serializer = NatsDefaultSerializer.Default };

await using var nats = new NatsConnection(natsOpts);

await using INatsSub<string> sub = await nats.SubscribeAsync<string>(subject: "foo");

// Flush the the network buffers to make sure the subscription request has been processed.
await nats.PingAsync();

await nats.PublishAsync<string>(subject: "foo", data: "Hello World");

NatsMsg<string?> msg = await sub.Msgs.ReadAsync();

// Outputs 'Hello World'
Console.WriteLine(msg.Data);
```

The default serializer is designed to be used by developers who want to only work with binary data, and provide an out
of the box experience for basic use cases like sending and receiving UTF8 strings.

## Using JSON Serializer Context

The `NatsJsonContextSerializer` uses the `System.Text.Json` serializer to serialize and deserialize messages. It relies
on the [`System.Text.Json` source generator](https://devblogs.microsoft.com/dotnet/try-the-new-system-text-json-source-generator/)
to generate the serialization code at compile time. This is the recommended JSON serializer for most use cases and it's
required for Native AOT compilation.

First you need to define your JSON classes and a context to generate the serialization code:
```csharp
public record MyData
{
[JsonPropertyName("id")]
public int Id { get; set; }

[JsonPropertyName("name")]
public string? Name { get; set; }
}

[JsonSerializable(typeof(MyData))]
internal partial class MyJsonContext : JsonSerializerContext;
```

Then you can use the `NatsJsonContextSerializer` to serialize and deserialize messages:
```csharp
// Set the custom serializer as the default for the connection.
var natsOpts = NatsOpts.Default with { Serializer = new NatsJsonContextSerializer(MyJsonContext.Default) };

await using var nats = new NatsConnection(natsOpts);

await using INatsSub<MyData> sub = await nats.SubscribeAsync<MyData>(subject: "foo");

// Flush the the network buffers to make sure the subscription request has been processed.
await nats.PingAsync();

await nats.PublishAsync<MyData>(subject: "foo", data: new MyData { Id = 1, Name = "bar" });

NatsMsg<MyData?> msg = await sub.Msgs.ReadAsync();

// Outputs 'MyData { Id = 1, Name = bar }'
Console.WriteLine(msg.Data);
```

You can also set the serializer for a specific subscription or publish call:
```csharp
await using var nats = new NatsConnection();

var natsSubOpts = new NatsSubOpts { Serializer = new NatsJsonContextSerializer(MyJsonContext.Default) };
await using INatsSub<MyData> sub = await nats.SubscribeAsync<MyData>(subject: "foo", opts: natsSubOpts);

// Flush the the network buffers to make sure the subscription request has been processed.
await nats.PingAsync();

var natsPubOpts = new NatsPubOpts { Serializer = new NatsJsonContextSerializer(MyJsonContext.Default) };
await nats.PublishAsync<MyData>(subject: "foo", data: new MyData { Id = 1, Name = "bar" }, opts: natsPubOpts);

NatsMsg<MyData?> msg = await sub.Msgs.ReadAsync();

// Outputs 'MyData { Id = 1, Name = bar }'
Console.WriteLine(msg.Data);
```

## Using Custom Serializer

You can also provide your own serializer by implementing the `INatsSerializer` interface. This is useful if you need to
support a custom serialization format or if you need to support multiple serialization formats.

Here is an example of a custom serializer that uses the Google ProtoBuf serializer to serialize and deserialize:

```csharp
public class MyProtoBufSerializer : INatsSerializer
{
public static readonly INatsSerializer Default = new MyProtoBufSerializer();

public INatsSerializer? Next => default;

public void Serialize<T>(IBufferWriter<byte> bufferWriter, T value)
{
if (value is IMessage message)
{
message.WriteTo(bufferWriter);
}
else
{
throw new NatsException($"Can't serialize {typeof(T)}");
}
}

public T? Deserialize<T>(in ReadOnlySequence<byte> buffer)
{
if (typeof(T) == typeof(Greeting))
{
return (T)(object)Greeting.Parser.ParseFrom(buffer);
}

throw new NatsException($"Can't deserialize {typeof(T)}");
}
}
```

You can then use the custom serializer as the default for the connection:

```csharp
var natsOpts = NatsOpts.Default with { Serializer = MyProtoBufSerializer.Default };

await using var nats = new NatsConnection(natsOpts);

await using var sub = await nats.SubscribeAsync<Greeting>(subject: "foo");

// Flush the the network buffers to make sure the subscription request has been processed.
await nats.PingAsync();

await nats.PublishAsync(subject: "foo", data: new Greeting { Id = 42, Name = "Marvin" });

var msg = await sub.Msgs.ReadAsync();

// Outputs '{ "id": 42, "name": "Marvin" }'
Console.WriteLine(msg.Data);
```

## Using Multiple Serializers (chaining)

You can also chain multiple serializers together to support multiple serialization formats. The first serializer in the
chain that can handle the data will be used. This is useful if you need to support multiple serialization formats and
reuse them.

Here is an example of a serializer that uses the Google ProtoBuf serializer and the `NatsJsonContextSerializer` to
serialize and deserialize messages based on the type:

```csharp
var serializers = new NatsJsonContextSerializer(MyJsonContext.Default, next: MyProtoBufSerializer.Default);
var natsOpts = NatsOpts.Default with { Serializer = serializers };

await using var nats = new NatsConnection(natsOpts);

await using var sub1 = await nats.SubscribeAsync<Greeting>(subject: "greet");
await using var sub2 = await nats.SubscribeAsync<MyData>(subject: "data");

// Flush the the network buffers to make sure the subscription request has been processed.
await nats.PingAsync();

await nats.PublishAsync(subject: "greet", data: new Greeting { Id = 42, Name = "Marvin" });
await nats.PublishAsync(subject: "data", data: new MyData { Id = 1, Name = "Bob" });

var msg1 = await sub1.Msgs.ReadAsync();
var msg2 = await sub2.Msgs.ReadAsync();

// Outputs '{ "id": 42, "name": "Marvin" }'
Console.WriteLine(msg1.Data);

// Outputs 'MyData { Id = 1, Name = bar }'
Console.WriteLine(msg2.Data);
```

## Dealing with Binary Data and Buffers

The default serializer can handle binary data and buffers. This is typically archived by using `IMemoryOwner`
implementations. NATS .NET Client provides a `NatsMemoryOwner` implementation that can be used to allocate buffers.
The `NatsMemoryOwner` and `NatsBufferWriter` (adapted from [.NET Community Toolkit](https://learn.microsoft.com/en-us/dotnet/communitytoolkit/high-performance/memoryowner))
are `IMemoryOwner` and `IBufferWriter` implementations that use the [`ArrayPool`](https://learn.microsoft.com/dotnet/api/system.buffers.arraypool-1)
to allocate buffers. They can be used with the default serializer.

```csharp
// Same as not specifying a serializer.
var natsOpts = NatsOpts.Default with { Serializer = NatsDefaultSerializer.Default };

await using var nats = new NatsConnection(natsOpts);

await using var sub = await nats.SubscribeAsync<NatsMemoryOwner<byte>>(subject: "foo");

// Flush the the network buffers to make sure the subscription request has been processed.
await nats.PingAsync();

// Don't reuse NatsBufferWriter, it's disposed and returned to the pool
// by the publisher after being written to network.
var bw = new NatsBufferWriter<byte>();
var memory = bw.GetMemory(2);
memory.Span[0] = (byte)'H';
memory.Span[1] = (byte)'i';
bw.Advance(2);

await nats.PublishAsync(subject: "foo", data: bw);

var msg = await sub.Msgs.ReadAsync();

// Dispose the memory owner after using it so it can be retunrned to the pool.
using (var memoryOwner = msg.Data)
{
// Outputs 'Hi'
Console.WriteLine(Encoding.ASCII.GetString(memoryOwner.Memory.Span));
}
```

Advantage of using `NatsMemoryOwner` and `NatsBufferWriter` is that they can be used with the default serializer and
they can be used to allocate buffers from the `ArrayPool` which can be reused. This is useful if you need to allocate
buffers for binary data and you want to avoid allocating buffers on for every operation (e.g. `new byte[]`) reducing
garbage collection pressure. They may also be useful for example, if your subscription may receive messages with
different formats and the only way to determine the format is by reading the message.
6 changes: 6 additions & 0 deletions docs/documentation/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,11 @@
- name: Object Store
href: object-store/intro.md

- name: Services
href: services/intro.md

- name: Serialization
href: serialization.md

- name: Updating Documentation
href: update-docs.md
1 change: 1 addition & 0 deletions sandbox/Example.NativeAot/Example.NativeAot.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

<ItemGroup>
<ProjectReference Include="..\..\src\NATS.Client.Core\NATS.Client.Core.csproj" />
<ProjectReference Include="..\Example.ProtoBufMessages\Example.ProtoBufMessages.csproj" />
</ItemGroup>

</Project>
Loading

0 comments on commit 2b69bea

Please sign in to comment.