Orleans: Introduction

Some time ago I stumbled on an interesting framework called Orleans. It's a "framework that provides a straightforward approach to building distributed high-scale computing applications". Distributed high-scale application? That sounds like big systems. And I like big systems! So immediately I jumped into it. I spend some time reading and playing with Orleans and there is much to talk about. In order to avoid TL;DR situation I will keep this post simple just to show the basic concepts. I will create something more complicated in my future post.
So, Orleans is a "simple" way to achieve scalable, fast service between the client and your database (and not only). How fast is it? Well, it is used for all of Halo 4 and Halo 5 cloud services. I think you will agree that being able to process data for online first person shooter is quite nice.
Let's imagine a situation where probably a lot of you have been. You have a heavily used database. You need to retrieve data from it very quickly and in big numbers. You try to scale it as much as possible but you can only go so far. Synchronization between replicas is taking too long and the network for the master server is choking. You try to cache some of the data but refreshing it is a pain in the arse. You are running out of options and your boss/client is telling you "more More MORE!!!!".
In this example the obvious thing is that the architecture of such solution will not work in the long run. At some point you will reach either your database limit, network limit or your will to live limit. When you are planning to have such system you might want to consider creating "smart cash" using Orleans. And what is so smart about it? You will see.
Let's create new .Net Core project. Since Orleans 2.0 is on .Net Standard we might as well use .Net Framework. Our solution will consist of 4 projects.


I believe that client and server are self explanatory. But what are grains? Well, in case of our cache grains are basically your data. Each grain type represents your entity type. Each grain represents your data unit. So for example we can have UserGrain which represents our user data type and for each users we will have one instance of UserGrain. Before we will create our UserGrain implementation we first need to create a grain interface in the GrainInterfaces project.

public interface IUserGrain: IGrainWithGuidKey
{
    Task<string> GetUserName();
    Task<string> GetUserFamilyName();
    Task UpdateUser(string Name, string FamilyName);
}

Be warned. According to Orleans 2.0 documentation you must add Microsoft.Orleans.OrleansCodeGenerator.Build package to both GrainCollection and GrainInterfaces project (and that's it). But in my case this was not enough because I also needed to add Microsoft.Orleans.Core.Abstractions to those projects in order to make IGrainWithGuidKey visible. I don't know if documentation missed something or what but it took me a moment to figure that our. I hate bad documentations!!

Interface is quite simple. Every grain interface must implement one of available IGrain interfaces. We have IGrainWithGuidKey, IGrainWithGuidCompoundKey, IGrainWithIntegerKey, IGrainWithIntegerCompoundKey, IGrainWithStringKey. This is because our grains have keys just like in database. And the IGrainWith interface determine the type of key grain will have. But now let's have a look at our implementation in the GrainCollection:

public class UserGrain : Grain, IUserGrain
{
    private string _name;
    private string _familyName;

    public Task<string> GetUserName()
    {
        return Task.FromResult(_name);
    }

    public Task<string> GetUserFamilyName()
    {
        return Task.FromResult(_familyName);
    }

    public Task UpdateUser(string Name, string FamilyName)
    {
        UpdateUserInDatabase(this.GetPrimaryKey(), Name, FamilyName);
        _name = Name;
        _familyName = FamilyName;
        return Task.CompletedTask;
    }

    private void UpdateUserInDatabase(Guid userId, string name, string familyName)
    {
        //some code to save user in database
    }
}

Implementation is quite simple. Notice that when we want to retrieve user data we are not asking the data base but instead we are using data that is stored in our private fields (in memory). We are only touching our data base when we want to update user data but we are also updating our in memory data so that next time we ask for it it will be up to date. Also notice this.GetPrimaryKey() method. This method retrieves the key of out grain (which in this case is a Guid because our IUserGrain implements IGrainWithGuidKey). So it is not hard to guess that we can only have one grain with the same key, just like in the database. So we are basically storing our data base in the memory. So you might think: "Duh! The cache is going to be fast but it will be RAM hungry process. No need for framework to do such a trick". But Orleans give us more than that. When grain is not called for some time it is deactivated (removed from memory). Thanks to such grain politics we are saving memory as much as possible because we are not storing every record in the database. And finally notice that every public method returns Task. It is because Orleans is meant to work in parallel.
But now let's have a look at our server:

Install-Package Microsoft.Orleans.Server

class Program
{
    public static async Task Main(string[] args)
    {
         try
         {
              var host = await StartSilo();
              Console.WriteLine("Press Enter to terminate...");
              Console.ReadLine();
              await host.StopAsync();
              return;
         }
         catch (Exception ex)
         {
              Console.WriteLine(ex);
               return;
          }
    }

    private static async Task<ISiloHost> StartSilo()
    {
        var builder = new SiloHostBuilder()
            .UseLocalhostClustering()
            .Configure<ClusterOptions>(options =>
            {
                options.ClusterId = "clusterId";
                options.ServiceId = "MyAwesomeService";
            })
            .Configure<EndpointOptions>(options => options.AdvertisedIPAddress = IPAddress.Loopback)
            .ConfigureLogging(logging => logging.AddConsole());

            var host = builder.Build();
            await host.StartAsync();
            return host;
    }
}

This is a simple localhost server. I will not get into details in this blog post because I think this looks simple enough.
Now the client:

Install-Package Microsoft.Orleans.Client

class Program
{
    static async Task Main(string[] args)
    {
        var builder = new ClientBuilder()
            .UseLocalhostClustering()
            .Configure<ClusterOptions>(options =>
            {
                options.ClusterId = "clusterId";
                options.ServiceId = "MyAwesomeService";
            });

        var client = builder.Build();
        await client.Connect();

        //Rest of console code
        var IsExiting = false;
        var grainId = Guid.NewGuid();
        IUserGrain ourGrain = null;
        var commandDictionary = new Dictionary<string, Action>()
        {
            ["Exit"] = () => IsExiting = true,
            ["Show"] = async () =>
            {
                ourGrain = ourGrain ?? client.GetGrain<IUserGrain>(grainId);
                Console.WriteLine($"GrainId: {ourGrain.GetPrimaryKey()}, Name: {await ourGrain.GetUserName()}, FamiliName: {await ourGrain.GetUserFamilyName()}");
            },
            ["Update"] = async () =>
            {
                Console.WriteLine("Write New Name");
                var newName = Console.ReadLine();
                Console.WriteLine("Write New FamilyName");
                var newFamilyName = Console.ReadLine();
                ourGrain = ourGrain ?? client.GetGrain<IUserGrain>(grainId);
                await ourGrain.UpdateUser(newName, newFamilyName);
            }
        };

        while(!IsExiting)
        {
            Console.WriteLine("Please write command:");
            var command = Console.ReadLine();
            var action = commandDictionary.ContainsKey(command) ? commandDictionary[command] : () => Console.WriteLine("Unknown command");
            action();
        }
    }
}

I believe the client is also very simple. In order to retrieve our grains we are using our client object.
We run both the server and the client and we can get his output from the client:

Please write command:
Update
Write New Name
Greg
Write New FamilyName
Naj
Please write command:
Show
Please write command:
GrainId: d48de79e-3e56-494e-8d17-445c8cbd70a6, Name: Greg, FamiliName: Naj
Update
Write New Name
Ewe
Write New FamilyName
SuperNaj
Please write command:
Show
Please write command:
GrainId: d48de79e-3e56-494e-8d17-445c8cbd70a6, Name: Ewe, FamiliName: SuperNaj

Nice thing about Orleans is that we actually don't really have to take care of persistence of grain data like in te example! It can do it for us. Data can be stored in SQL, AzureStorage etc. You can find more in the Orleans documentation (https://dotnet.github.io/orleans/Documentation/Core-Features/Grain-Persistence.html)
Since I have so much to write about Orleans I will keep this article at an introduction level. But in my next article (or maybe multiple) I will do some fun stuff :)

Komentarze