Grains and Silos - building Microsoft Orleans distributed apps with C# and .NET
This is the second in a series of articles about developing .NET applications using the distributed application framework, MS Orleans.
You can start with the series by following this link.
Orleans Developer Cookbook
So, if you by now, got to know what virtual actor model and Microsoft Orleans are, it's time to create some code!
First thing you should decide where you would like to host your code, as grains will be situated on silos which are situated somewhere. For building an MS Orleans application, you typically use an ASP.NET Core Web API project. This setup allows you to leverage the scalability and distributed capabilities of Orleans while integrating with the robust features of ASP.NET Core for web development.
Here's a quick order of the steps:
The code implemented in this article is available here:
Hello Orleans!
In this example, we will replace the existing ASP.NET Web API implementation of weather forecast service with our exciting Orleans' grain, so that we could prove on how the app is easily built.
Step 1: Create new ASP.NET Web API 9 project (with Minimal API)
Firstly, I will open my Visual Studio and create new ASP.NET Web API 9 project (with Minimal API). Make sure that the project builds. Test it by firing requests on the default weather forecast API endpoint.
Next, install Microsoft.Orleans.Server NuGet package (current version is 9.0.1).
At this point, you created a basic minimal API with Orleans included.
Step 2: Define Orleans Grain Interfaces and Implementations
Create a new folder named Grains and add your grain interfaces and implementations.
Add new interface, with a method that will be used to fetch the weather forecast, as follows:
public interface IWeatherForecastGrain : IGrainWithGuidKey
{
Task<WeatherForecast[]> GetWeatherForecastAsync();
}
Orleans grains should utilize an interface to define their methods and properties. Also, if grains use some objects to store or transfer the data, these objects should be marked as serializable. So, move and change the accessibility of the existing C# record to:
[GenerateSerializer]
public record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
The?Grain?base class provides essential functionality for the internal behaviors of Orleans.
Implement the grain in WeatherForecastGrain.cs by implementing the interface and base Grain class:
public class WeatherForecastGrain : Grain, IWeatherForecastGrain
{
private static readonly string[] summaries =
[
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
];
public Task<WeatherForecast[]> GetWeatherForecastAsync()
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return Task.FromResult(forecast);
}
}
After adding a new file, we moved summaries variable into the grain, and change its type to accommodate accessibility. The body of minimal API method is moved into grain method and its returning type has changed to Task.FromResult. This is necessary because all methods of grains should be asynchronous, and therefore, some variant of Task, Task<T> or ValueTask should be returned by their methods.
Step 3: Configure Orleans in Program.cs
Add Orleans in Program.cs:
builder.Host.UseOrleans(siloBuilder =>
{
siloBuilder.UseLocalhostClustering();
});
Make note that we've added Orleans configuration before calling Build() method of the host.
Fill out the minimal API endpoint with calling of grain factory and executing its method.
Now, correct mapping of the endpoint, with adding the body of the endpoint to get (create) grain and call the method asynchronously:
app.MapGet("/weatherforecast", async (IGrainFactory grainFactory) =>
{
var grain = grainFactory.GetGrain<IWeatherForecastGrain>(Guid.NewGuid());
return await grain.GetWeatherForecastAsync();
});
Step 4: Run and test the app
Now we are ready to roll! Start the app and show the console output.
Use *.WebAPI.http file to test the API.
State management
When working with grains, it's crucial to persist state to keep your data secure during application restarts, grain deactivations, and other scenarios. Storage providers can include traditional SQL databases, Azure services like Blob Storage, and other cloud resources such as Azure CosmosDB or Amazon DynamoDb.
Grains use the IPersistentState<TState> interface to implement persistent state, where TState is the type of object you want to store. You define the objects to persist in state by declaring them in the grain's constructor and marking them with the attribute. Objects with this attribute can access the API methods.
Step 1: Add memory storage
Let's extend the example to include persistent state using Orleans' in-memory storage. This will allow the WeatherForecastGrain to maintain state across activations. In this scenario, we use in-memory storage for simplicity. However, keep in mind that once your code is set up, switching to a more permanent store such as Azure Blob Storage is as simple as changing the connection configuration.
In Program.cs, configure Orleans with:
siloBuilder.AddMemoryGrainStorage("forecasts");
领英推荐
Step 2: Add state to the grain
Modify the WeatherForecastGrain to use the persistent state.
Add persistent state in newly created grain's constructor:
public class WeatherForecastGrain(
[PersistentState("forecast", "forecasts")] IPersistentState<List<WeatherForecast>> state
) : Grain, IWeatherForecastGrain
In grain class' method, add checking whether the grain with it's state already exists, and at the end of the block, add setting and storing the state. The resulting method content should look like this:
public async Task<WeatherForecast[]> GetWeatherForecastAsync()
{
if (!state.RecordExists)
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
state.State = forecast.ToList();
await state.WriteStateAsync();
}
return state.State.ToArray();
}
Step 3: Make sure to properly invoke grain instance
Since we'll store the weather forecast data per city (or location), we will change the underlying IWeatherForecastGrain interface accepts string as a key:
public interface IWeatherForecastGrain : IGrainWithStringKey
And, add some code to call the API for a random location, retrieving its weather forecast, i.e., modify the call to the grain's method, using the new location variable.
Add this to the start of the endpoint method:
string[] _locations =
[
"New York", "London", "Zagreb", "Madrid", "Moscow"
];
var location = _locations[Random.Shared.Next(0, _locations.Length - 1)];
var grain = grainFactory.GetGrain<IWeatherForecastGrain>(location);
Additionally, you can change return object of the API endpoint to see to which location returned weather forecast belongs to. To do this, make the method to return anonymous type instead of array of types:
return new { Location = location, Forecast = await grain.GetWeatherForecastAsync() };
With these changes, our WeatherForecastGrain now uses in-memory storage to persist its state. This means that the weather forecasts will be retained across grain activations.
Orleans Dashboard
With the advent of Microsoft Orleans, there are lot of contribution from the community with the custom-implemented functionality that backs already rich set of Orleans' capabilities. One of the most used is OrleansDashboard library, that adds a visual tool for tracking grain, silo and cluster data, with useful statistics.
Adding the dashboard support is quite easy, by following these steps:
Step 1: Install the package
Install NuGet package OrleansDashboard (latest version) and the configuration to Orleans setup:
siloBuilder.UseDashboard();
After that, start the silo, and open the link in your browser location to see the dashboard: https://localhost:8080
You should see something like this:
You can even customize and create your own GUI for this dashboard, as the component is very flexible and extensible.
Step 2: Self-hosting the dashboard
In some cases you may wish to disable the dashboard's own web server, and host the dashboard on your own web application. You can configure to include the dashboard as middleware.
Change the config of the dashboard to:
siloBuilder.UseDashboard(x => x.HostSelf = true);
And add additional endpoint to enable dashboard on the same site:
app.Map("/dashboard", x => x.UseOrleansDashboard());
With that, you can co-locate the dashboard UI on your own ASP.NET Web API project.
The summary
With this simple MS Orleans C# example, we proved that the implementation of grains in Microsoft Orleans is quite easy and understandable.
I showed how to create and use grains, which are the fundamental building blocks in Orleans. This includes defining grain interfaces and implementing grain classes. We upgraded the grains with persisting state using the IPersistentState<TState> interface, ensuring data durability across application restarts and grain deactivations. And lastly, I demonstrated on how easily integrate one of Orleans' extensions - in this case, quite useful dashboard component, with which you can monitor and see your virtual actor model in action.
I hope these things provided you with a solid foundation for building more complex and robust cloud-native applications using Orleans, as we will see in the next posts - where I'll start building a game by using .NET and MS Orleans.
Stay tuned!