Skip to content

Commit

Permalink
Add deserialization/snapshot constructor
Browse files Browse the repository at this point in the history
This simplifies authoring by not requiring users to annotate each and every property with a private setter with the `[JsonInclude]` attribute.
  • Loading branch information
kzu committed Aug 8, 2023
1 parent fa8a0b0 commit 66bd704
Show file tree
Hide file tree
Showing 11 changed files with 212 additions and 67 deletions.
70 changes: 66 additions & 4 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,14 @@ public partial record Deposit(decimal Amount) : IActorCommand; // 👈 marker i
public partial record Withdraw(decimal Amount) : IActorCommand;

[GenerateSerializer]
public partial record Close() : IActorCommand<decimal>; // 👈 marker interface for value-returning commands
public partial record Close(CloseReason Reason = CloseReason.Customer) : IActorCommand<decimal>; // 👈 marker interface for value-returning commands
public enum CloseReason
{
Customer,
Fraud,
Other
}

[GenerateSerializer]
public partial record GetBalance() : IActorQuery<decimal>; // 👈 marker interface for queries (a.k.a. readonly methods)
Expand Down Expand Up @@ -91,6 +98,7 @@ public class Account // 👈 no need to inherit or implement anything by defa
public string Id { get; }
public decimal Balance { get; private set; }
public bool IsClosed { get; private set; }
public CloseReason Reason { get; private set; }

//public void Execute(Deposit command) // 👈 methods can be overloads of message types
//{
Expand All @@ -116,11 +124,12 @@ public class Account // 👈 no need to inherit or implement anything by defa
// Showcases value-returning operation with custom name.
// In this case, closing the account returns the final balance.
// As above, this can be async or not.
public decimal Close(Close _)
public decimal Close(Close command)
{
var balance = Balance;
Balance = 0;
IsClosed = true;
Reason = command.Reason;
return balance;
}

Expand All @@ -129,6 +138,10 @@ public class Account // 👈 no need to inherit or implement anything by defa
}
```

> NOTE: properties with private setters do not need any additional attributes in order
> to be properly deserialized when reading the latest state from storage. A source generator
> provides a constructor with those for use in deserialization
On the hosting side, an `AddCloudActors` extension method is provided to register the
automatically generated grains to route invocations to the actors:

Expand Down Expand Up @@ -285,6 +298,43 @@ as non-generic overloads, such as:

![query overloads](https://raw.githubusercontent.com/devlooped/CloudActors/main/assets/img/query-overloads.png?raw=true)

## State Deserialization

The above `Account` class only provides a single constructor receiving the account
identifier. After various operations are performed on it, however, the state will
be changed via private property setters, which are not available to the deserializer
by default. .NET 7+ adds JSON support for setting these properties via the
[JsonInclude](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.serialization.jsonincludeattribute?view=net-7.0#remarks)
attribute, but it's not very intuitive that you need to add it to all such properties.

The equivalent in JSON.NET is the [JsonProperty](https://www.newtonsoft.com/json/help/html/T_Newtonsoft_Json_JsonPropertyAttribute.htm),
which suffers from the same drawback.

To help uses fall in the pit of success, the library automatically generates a
constructor annotated with `[JsonConstructor]` for the actor class, which will be used
to deserialize the state. In the above `Account` example, the generated constructor looks like
the following:

```csharp
partial class Account
{
[EditorBrowsable(EditorBrowsableState.Never)]
[JsonConstructor]
public Account(string id, System.Decimal balance, System.Boolean isClosed, Tests.CloseReason reason)
: this(id)
{
this.Balance = balance;
this.IsClosed = isClosed;
this.Reason = reason;
}
}
```

The fact that the constructor is annotated with `[JsonContructor]` does not necessarily
mean that the state has to be serialized as JSON. It's up to the storage provider to
invoke this constructor with the appropriate values. If it does happens to use
`System.Text.Json` for serialization, then the constructor will be used automatically.

## Event Sourcing

Quite a natural extension of the message-passing style of programming for these actors,
Expand Down Expand Up @@ -339,17 +389,19 @@ public partial class Account : IEventSourced // 👈 interface is *not* impleme
{
if (IsClosed)
throw new InvalidOperationException("Account is closed");
if (command.Amount > Balance)
throw new InvalidOperationException("Insufficient funds.");

Raise(new Withdrawn(command.Amount));
}

public decimal Execute(Close _)
public decimal Execute(Close command)
{
if (IsClosed)
throw new InvalidOperationException("Account is closed");

var balance = Balance;
Raise(new Closed(Balance));
Raise(new Closed(Balance, command.Reason));
return balance;
}

Expand All @@ -365,6 +417,7 @@ public partial class Account : IEventSourced // 👈 interface is *not* impleme
{
Balance = 0;
IsClosed = true;
Reason = @event.Reason;
}
}
```
Expand Down Expand Up @@ -430,6 +483,15 @@ partial class Account

Note how there's no dynamic dispatch here either 💯.

An important colorary of this project is that the design of a library and particularly
its implementation details, will vary greatly if it can assume source generators will
play a role in its consumption. In this particular case, many design decisions
were different initially before I had the generators in place, and the result afterwards
was a simplification in many aspects, with less base types in the main library/interfaces
project, and more incremental behavior addded as users opt-in to certain features.



<!-- #sponsors -->
<!-- include https://github.com/devlooped/sponsors/raw/main/footer.md -->
# Sponsors
Expand Down
11 changes: 3 additions & 8 deletions src/CloudActors.CodeAnaysis/ActorGrain.sbntxt
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
// <auto-generated/>
#nullable enable annotations
#nullable disable warnings

using System;
using System.CodeDom.Compiler;
Expand Down
1 change: 0 additions & 1 deletion src/CloudActors.CodeAnaysis/ActorGrainGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using Microsoft.CodeAnalysis;
using Scriban;
using static Devlooped.CloudActors.Diagnostics;
Expand Down
35 changes: 35 additions & 0 deletions src/CloudActors.CodeAnaysis/ActorSnapshot.sbntxt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// <auto-generated/>
#nullable enable annotations
#nullable disable warnings

using System;
using System.CodeDom.Compiler;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Orleans;
using Orleans.Concurrency;
using Orleans.Runtime;
using Devlooped.CloudActors;

namespace {{ Namespace }}
{
partial class {{ Name }}
{
/// <summary>
/// Intended for snapshot deserialization only.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
[JsonConstructor]
{{~ param(x) = x.Type + " " + x.Name ~}}
public {{ Name }}(string id, {{ Parameters | array.each @param | array.join ", " }})
: this(id)
{
{{~ for arg in Parameters ~}}
this.{{ arg.Property }} = {{ arg.Name }};
{{~ end ~}}
}
}
}
56 changes: 56 additions & 0 deletions src/CloudActors.CodeAnaysis/ActorSnapshotGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
using Scriban;
using static Devlooped.CloudActors.Diagnostics;

namespace Devlooped.CloudActors;

/// <summary>
/// A source generator that creates a [JsonConstructor] containing the id plus
/// all properties with private constructors, so restoring their values when snapshots
/// are read does not require any additional attributes applied to them.
/// </summary>
[Generator(LanguageNames.CSharp)]
public class ActorSnapshotGenerator : IIncrementalGenerator
{
static readonly Template template = Template.Parse(ThisAssembly.Resources.ActorSnapshot.Text);

public void Initialize(IncrementalGeneratorInitializationContext context)
{
var source = context.CompilationProvider
.SelectMany((x, _) => x.Assembly.GetAllTypes().OfType<INamedTypeSymbol>())
.Where(x => x.GetAttributes().Any(IsActor));

context.RegisterImplementationSourceOutput(source, (ctx, actor) =>
{
var props = actor.GetMembers()
.OfType<IPropertySymbol>()
.Where(x =>
x.CanBeReferencedByName &&
!x.IsIndexer &&
!x.IsAbstract &&
x.SetMethod is { } setter &&
setter.DeclaredAccessibility == Accessibility.Private)
.ToArray();

if (props.Length > 0)
{
var model = new SnapshotModel(
Namespace: actor.ContainingNamespace.ToDisplayString(),
Name: actor.Name,
Parameters: props.Select(x => new Parameter(
char.ToLowerInvariant(x.Name[0]) + new string(x.Name.Skip(1).ToArray()),
x.Type.ToDisplayString(FullName), x.Name)));

var output = template.Render(model, member => member.Name);

ctx.AddSource($"{actor.ToFileName()}.g.cs", output);
}
});
}

record Parameter(string Name, string Type, string Property);

record SnapshotModel(string Namespace, string Name, IEnumerable<Parameter> Parameters);
}
8 changes: 8 additions & 0 deletions src/CloudActors.CodeAnaysis/CloudActors.CodeAnaysis.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
<PackageScribanIncludeSource>true</PackageScribanIncludeSource>
</PropertyGroup>

<ItemGroup>
<None Remove="ActorSnapshot.sbntxt" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="NuGetizer" Version="1.0.5" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Pack="false" Version="4.5.0" />
Expand All @@ -26,6 +30,10 @@

<ItemGroup>
<EmbeddedResource Include="@(None -&gt; WithMetadataValue('Extension', '.sbntxt'))" Kind="text" />
<EmbeddedResource Include="ActorSnapshot.sbntxt">
<Generator></Generator>
<Kind>text</Kind>
</EmbeddedResource>
</ItemGroup>

</Project>
12 changes: 3 additions & 9 deletions src/CloudActors.CodeAnaysis/EventSourced.sbntxt
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
#nullable enable
// <auto-generated/>
#nullable enable annotations
#nullable disable warnings

using System;
using System.CodeDom.Compiler;
Expand Down
11 changes: 3 additions & 8 deletions src/CloudActors.CodeAnaysis/SiloBuilder.sbntxt
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
// <auto-generated/>
#nullable enable annotations
#nullable disable warnings

using System;
using System.CodeDom.Compiler;
Expand Down
12 changes: 7 additions & 5 deletions src/CloudActors.Streamstone/StreamstoneStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Concurrent;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.Azure.Cosmos.Table;
using Orleans;
Expand All @@ -16,8 +17,9 @@ public class StreamstoneOptions
static readonly JsonSerializerOptions options = new()
{
AllowTrailingCommas = true,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
PreferredObjectCreationHandling = System.Text.Json.Serialization.JsonObjectCreationHandling.Populate,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate,
Converters = { new JsonStringEnumConverter() },
};

/// <summary>
Expand Down Expand Up @@ -67,20 +69,19 @@ public async Task ReadStateAsync<T>(string stateName, GrainId grainId, IGrainSta
var stream = await Stream.TryOpenAsync(partition);
if (!stream.Found)
{
state.LoadEvents(Enumerable.Empty<object>());
grainState.ETag = "0";
return;
}

if (options.AutoSnapshot)
{
// See if we can quickly load from most recent snapshot.
var result = await table.ExecuteAsync(TableOperation.Retrieve<EventEntity>(table.Name, typeof(T).FullName));
var result = await table.ExecuteAsync(TableOperation.Retrieve<EventEntity>(rowId, typeof(T).FullName));
if (result.HttpStatusCode == 200 &&
result.Result is EventEntity entity &&
typeof(T).Assembly.GetName() is { } asm &&
// We only apply snapshots where major.minor matches the current version, otherwise,
// we might be losing important business logic changes.
typeof(T).Assembly.GetName() is { } asm &&
new Version(asm.Version?.Major ?? 0, asm.Version?.Minor ?? 0).ToString() == entity.DataVersion &&
entity.Data is string data &&
JsonSerializer.Deserialize<T>(data, options.JsonOptions) is { } instance)
Expand Down Expand Up @@ -109,6 +110,7 @@ entity.Data is string data &&
if (result.HttpStatusCode == 404 ||
result.Result is not EventEntity entity ||
entity.Data is not string data ||
// TODO: how to deal with versioning in this case?
JsonSerializer.Deserialize<T>(data, options.JsonOptions) is not { } instance)
return;

Expand Down
Loading

0 comments on commit 66bd704

Please sign in to comment.