Generating grains (.NET)
The recommended way of creating a grain is by using a Proto.Cluster.CodeGen
package, which generates most of the grain’s code for use from a .proto
file.
This approach:
-
Mitigates a lot of gotchas related to developing grains by hand, e.g. by preventing us from not returning a response from a request or not returning any response at all (which is almost always required by grains).
-
Reduces boilerplate code and lets you focus on functionality.
Prerequisites
To generate grains, you’ll need the Proto.Cluster.CodeGen
NuGet package:
dotnet add package Proto.Cluster.CodeGen
Generating a grain
Grains are defined and generated from .proto
files, similarly to gRPC services.
Example
CounterGrain.proto
:
syntax = "proto3";
option csharp_namespace = "MyProject";
import "google/protobuf/empty.proto";
service CounterGrain {
rpc Increment (google.protobuf.Empty) returns (google.protobuf.Empty);
}
You can define multiple messages and services in a single .proto
file.
Code generation is performed by the ProtoGrain
MSBuild task. It needs to be defined in a project file.
Example
MyProject.csproj
:
<ItemGroup>
<ProtoGrain Include="CounterGrain.proto" />
</ItemGroup>
Importing messages
To use messages defined in a different .proto
file, you’ll need to import it.
The ProtoGrain
MSBuild task should know where to look for additional .proto
files.
Example
MyProject.csproj
:
<ItemGroup>
<ProtoGrain Include="CounterGrain.proto" AdditionalImportDirs="." />
</ItemGroup>
AdditionalImportDirs
is a semicolon-separated list of directories, which should contain .proto
files to be imported.
Now messages can be imported in a grain definition file.
Example
Assuming there’s a CounterGrainMessages.proto
definition with a CounterValue
message:
CounterGrain.proto
:
syntax = "proto3";
option csharp_namespace = "MyProject";
import "google/protobuf/empty.proto";
import "CounterGrainMessages.proto";
service CounterGrain {
rpc Increment (google.protobuf.Empty) returns (google.protobuf.Empty);
rpc GetCurrentValue (google.protobuf.Empty) returns (CounterValue);
}
Generated code
Building the project should result in generating a <grain name>-<hash>.cs
file in <project>\obj\<configuration>\<target>\protopotato
directory.
Example
MyProject\obj\Debug\net6.0\protopotato\CounterGrain-9DAD25B670A612931CE9F63A07C26BDF.cs
The generated .cs
file includes:
-
<grain>Base
abstract class, that needs to be implemented. More on implementing it below. -
<grain>Actor
class, which wraps implementation of<grain>Base
. It’s the actual actor that’s activated by Proto.Cluster. More on using it below. -
<grain>Client
class for communicating with a grain.Cluster
andIContext
extension methods are also generated for creating a client. More on using them below.
Example
CounterGrain.proto
would generate the following classes:
CounterGrainBase
CounterGrainActor
CounterGrainClient
It would also generate GetSmartBulbGrain
extension methods for Cluster
and IContext
.
Implementing a grain
To implement the actual grain logic, we need to implement generated <grain>Base
abstract class.
Example
CounterGrain.cs
:
using Proto;
using Proto.Cluster;
namespace MyProject;
public class CounterGrain : CounterGrainBase
{
private int _value = 0;
public CounterGrain(IContext context) : base(context)
{
}
public override Task Increment()
{
_value++;
return Task.CompletedTask;
}
public override Task<CounterValue> GetCurrentValue()
{
return Task.FromResult(new CounterValue
{
Value = _value
});
}
}
Mind, that <grain>Base
abstract class looks like an actor, but it doesn’t implement IActor
interface. To use it, it must be wrapped in <grain>Actor
class.
Registering a grain
Grain has to be registered in ClusterConfiguration
using a WithClusterKind
method.
Example
using Proto;
using Proto.Cluster;
var clusterConfig = ClusterConfig
.Setup(/* ... */)
.WithClusterKind(
kind: CounterGrainActor.Kind,
prop: Props.FromProducer(() =>
new CounterGrainActor(
(context, clusterIdentity) => new CounterGrain(context)
)
)
);
<grain>Actor.Kind
constant instead of inline strings, as invalid grain kinds lead to difficult to find errors.
Sending messages to grains
Messages should be sent to a grain using a <grain>Client
class. To get it, use Get<grain>(identity)
extension method on Cluster
or IContext
.
Example
CounterGrainClient client = cluster.GetCounterGrain("click-counter");
// or
CounterGrainClient client = context.GetCounterGrain("click-counter");
A client class defines a method for each message type handled by a grain.
Example
Empty? incrementResponse = await client.Increment(
ct: CancellationTokens.FromSeconds(5)
);
CounterValue? getCurrentValueResponse = await client.GetCurrentValue(
ct: CancellationTokens.FromSeconds(5)
);
Timeouts
The result will be null
if a request timeouts, so this should always be checked.
Timeouts should be handled using cancellation tokens. It’s recommended to use Proto.Actor’s CancellationTokens
utility for this purpose.
Exception handling
If a grain implementation throws an exception when handling a request:
-
A
GrainErrorResponse
will be sent as a response. -
<grain>Client
will receive aGrainErrorResponse
and throw anException
.
Context
In contrast to classical actors, context is not passed as a parameter, but available as a property in <grain>Base
class.
Example
public override Task Increment()
{
IContext context = Context;
// ...
}
Cluster identity
The grain’s cluster identity (i.e. grain’s kind and identity) can be obtained from the context:
using Proto;
using Proto.Cluster;
// grain implementation:
ClusterIdentity clusterIdentity = Context.ClusterIdentity()!;
Example
public override Task Increment()
{
var clusterIdentity = Context.ClusterIdentity()!;
Console.WriteLine($"Incrementing {clusterIdentity.Kind} / {clusterIdentity.Identity}");
// ...
}
Alternatively, cluster identity can be injected during activation.
Example
Cluster configuration:
var clusterConfig = ClusterConfig
.Setup(/* ... */)
.WithClusterKind(
kind: CounterGrainActor.Kind,
prop: Props.FromProducer(() =>
new CounterGrainActor(
(context, clusterIdentity) => new CounterGrain(context, clusterIdentity)
)
)
);
Grain implementation:
public class CounterGrain : CounterGrainBase
{
private readonly ClusterIdentity _clusterIdentity;
public CounterGrain(IContext context, ClusterIdentity clusterIdentity) : base(context)
{
_clusterIdentity = clusterIdentity;
}
}
Context.Self
) or ID (Context.Self.Id
).
Lifecycle hooks
Grain implementation can override a few lifecycle methods:
Task OnStarted()
is called when<grain>Actor
receivesStared
event.Task OnStopping()
is called when<grain>Actor
receivesStopping
event.Task OnStopped()
is called when<grain>Actor
receivesStopped
event.
Example
public override Task OnStarted()
{
Console.WriteLine("Starting counter");
return Task.CompletedTask;
}
public override Task OnStopping()
{
Console.WriteLine("Stopping counter");
return Task.CompletedTask;
}
public override Task OnStopped()
{
Console.WriteLine("Stopped counter");
return Task.CompletedTask;
}
You can read more about the actor lifecycle here.
Receiving messages outside of grain’s contract
Sometimes there is a need to handle messages that are outside of grain’s contract, i.e. are not defined in grain service in .proto
file. This can be done via overriding Task OnReceive()
method:
public override async Task OnReceive()
{
switch (Context.Message)
{
// ...
}
}
The use cases of such approach include, but are not limited to:
-
Receiving messages from grain’s child actors.
-
Subscribing to the event stream, e.g.
Context.System.EventStream.Subscribe<SomeMessage>(Context.System.Root, Context.Self);
. -
Detecting inactive grains, see receive timeout.
Dependency injection
A convenient way of utilizing dependency injection with grains is using ActivatorUtilities.CreateInstance
and IServiceProvider
when configuring grain’s props.
Example
Service registration:
services.AddSingleton<INotificationSender, SlackNotificationSender>();
Cluster configuration:
using Microsoft.Extensions.DependencyInjection;
var clusterConfig = ClusterConfig
.Setup(/* ... */)
.WithClusterKind(
kind: CounterGrainActor.Kind,
prop: Props.FromProducer(() =>
new CounterGrainActor((context, _) =>
ActivatorUtilities.CreateInstance<CounterGrain>(provider, context)
)
)
);
Grain implementation:
public class CounterGrain : CounterGrainBase
{
private readonly INotificationSender _notificationSender;
public CounterGrain(IContext context, INotificationSender notificationSender) : base(context)
{
_notificationSender = notificationSender;
}
}