Skip to content

Building Your First Microdot Service

Allon Guralnek edited this page Sep 3, 2017 · 5 revisions

This document describe how to create a service with Microdot, step-by-step. We will create simple service called "InventoryService". This guide is written for Visual Studio 2017 with projects targeted at .NET Framework 4.7.

InventoryService is an extremely simple service used to manage inventory for products. You can ship items, which decrease the current stock, and you can restock, which increase the current stock.

InventoryService Class Diagram

Class diagram describing the main classes in the solution, to what project they belong and what attributes are applied to each.

  • Boxes in purple are types that are included in the Orleans assemblies
  • The yellow box is the service interface, separated into its own project and typically published as a NuGet package to be consumed by client of that service (e.g. frontend, other service).
  • Boxes in orange are grains and their interfaces, managed and run by Orleans. The two left ones are required by the Microdot architecture to be implemented that way - the service grain must implement a service grain interface (and must inherit from Grain, as required by Orleans), which in turn includes two interfaces: IGrainWithIntegerKey and the service interface. Other grains can be implements as desired.

Using Paket

Paket is an alternative to Microsoft's NuGet Package Manager which makes managing NuGets much easier. It allows specifying packages at the solution level instead of the project level, which keeps version synchronized (no more package consolidation).

  1. Please follow the four steps outlined in the Downloading Paket and its Bootstrapper section of the Getting Started guide. You should end up with a .paket folder under your solution folder that contains paket.exe.
  2. Create an empty paket.dependencies file in the root of your solution. The is the solution-wide equivalent of packages.config - you will specify any package you consume here. You can add this file to your solution so you can easily edit it from within Visual Studio.
  3. Add the following text to the paket.dependencies file:
source https://www.nuget.org/api/v2/
framework: auto-detect

nuget Gigya.ServiceContract ~> 2.0
nuget Gigya.Microdot.Interfaces ~> 1.0
nuget Gigya.Microdot.Orleans.Hosting ~> 1.0
nuget Gigya.Microdot.Orleans.Ninject.Host ~> 1.0
nuget Gigya.Microdot.Logging.NLog ~> 1.0
nuget Gigya.Microdot.Ninject ~> 1.0

This tells Paket we're using six Microdot NuGets in this solution, limiting their version to specific major versions (e.g. ~> 1.0 is equivalent to the NuGet version range of [1.0,2.0)) and two Orleans NuGets with their versions not specified (will be constrained by the Microdot NuGet dependency requirements). When using paket update command, Paket will update the packages to the highest non-prerelease versions that have a major version of 1. We don't want other major versions because they can contain breaking changes (as per Semantic Versioning).

Service Interface project

The service interface defines what operations your service provides to its callers. It is put in its own project so it can be published as a NuGet package, which can then be consumed by others, allowing them to call the service using the ServiceProxy. In this example, it contains two simple methods - one used for shipping items out of inventory and one for restocking it.

  1. Create a new class library project named InventoryService.Interface.
  2. Add a new text file named paket.references with a single line: Gigya.ServiceContract. This instructs Paket to add the Gigya.ServiceContract NuGet package to this project.
  3. Add a new text file named paket.template with a single line: type project. This instructs Paket that this project should be packaged as a NuGet.
  4. Run paket.exe install (in the .paket folder) to download and install the Gigya.ServiceContract in the project.
  5. Create a new C# interface file named IInventoryService.cs with the following content:
using System;
using System.Threading.Tasks;
using Gigya.Common.Contracts.HttpService;

namespace InventoryService.Interface
{
    [HttpService(10000)]
    public interface IInventoryService
    {
        Task ShipItems(Product product, int quantity);
        Task RestockItems(Product product, int quantity);
    }

    public class Product
    {
        public Guid Id { get; set; }
        public string Name { get; set; }
    }
}
  1. Any custom exceptions you throw from your service also need to be defined in the service interface project. Create a new C# file named OutOfStockException.cs with the following content:
using Gigya.Common.Contracts.Exceptions;
using System;
using System.Runtime.Serialization;

namespace InventoryService.Interface
{
    [Serializable]
    public class OutOfStockException : RequestException
    {
        public OutOfStockException(string message) : base(message) { }
        protected OutOfStockException(SerializationInfo info, StreamingContext context) : base(info, context) { }
    }
}

See the documentation of SerializableException for guidelines on how to properly create an exception for use in Microdot.

Remember

Your service interface must:

  • Be public.
  • Have all methods return Task or Task<T>.
  • Not define any properties or indexers.
  • Not be generic (neither have generic parameters nor have methods with generic parameters).
  • Decorated with the HttpService attribute that contains its default base port.

Grains project

This project will contains grains and their interfaces. This includes the Service Grain and other grains. Orleans generates serialization and client code during build time and runtime, so two additional NuGets are required to support it.

  1. Create a new class library project named InventoryService.Grains.
  2. Add a reference to the Service Interface project.
  3. Add a new text file named paket.references with a the following:
Microsoft.Orleans.OrleansCodeGenerator
Microsoft.Orleans.OrleansCodeGenerator.Build
Gigya.Microdot.Interfaces
Gigya.ServiceContract
  1. Run paket.exe install (in the .paket folder).
  2. Add a C# class file with named ProductGrain.cs with the following content:
using Gigya.Common.Contracts.Exceptions;
using InventoryService.Interface;
using Orleans;
using System.Threading.Tasks;

namespace InventoryService.Grains
{
    public interface IProductGrain : IGrainWithGuidKey
    {
        Task<int> GetCurrentStock();
        Task ModifyStock(int quantity);
    }

    public class ProductGrain : Grain, IProductGrain
    {
        private int CurrentStock { get; set; }

        public Task<int> GetCurrentStock()
        {
            return Task.FromResult(CurrentStock);
        }

        public Task ModifyStock(int quantity)
        {
            var updatedStock = CurrentStock + quantity;

            if (updatedStock < 0)
                throw new OutOfStockException($"Not enough stock to complete the operation. Only {CurrentStock} items in stock.");

            if (updatedStock > 1000)
                throw new RequestException($"Cannot add stock - operation will cause the stock to exceed maximum of 1000 by {updatedStock - 1000}.");

            CurrentStock = updatedStock;

            if (updatedStock < 5)
            {
                // TODO: Send low stock warning -or- order more stock.
            }

            return Task.CompletedTask;
        }
    }
}
  1. Add a C# class file with named InventoryServiceGrain.cs with the following content:
using Gigya.Microdot.Interfaces.Logging;
using InventoryService.Interface;
using Orleans;
using Orleans.Concurrency;
using System;
using System.Threading.Tasks;

namespace InventoryService.Grains
{
    public interface IInventoryServiceGrain : IInventoryService, IGrainWithIntegerKey { }

    [Reentrant]
    [StatelessWorker]
    public class InventoryServiceGrain : Grain, IInventoryServiceGrain
    {
        private ILog Log { get; }

        public InventoryServiceGrain(ILog log)
        {
            Log = log;
        }

        public async Task RestockItems(Product product, int quantity)
        {
            if (quantity < 1)
                throw new ArgumentOutOfRangeException(nameof(quantity), "Restock quantity must be greater than 0.");

            var grain = GrainFactory.GetGrain<IProductGrain>(product.Id);
            await grain.ModifyStock(quantity);
            Log.Info(_ => _("Product successfully restocked", unencryptedTags: new { product.Name, product.Id, quantity }));
        }

        public async Task ShipItems(Product product, int quantity)
        {
            if (quantity < 1)
                throw new ArgumentOutOfRangeException(nameof(quantity), "Ship quantity must be greater than 0.");

            var grain = GrainFactory.GetGrain<IProductGrain>(product.Id);
            await grain.ModifyStock(-quantity);
            Log.Info(_ => _("Product successfully shipped", unencryptedTags: new { product.Name, product.Id, quantity }));

            // TODO: Send notification to customer that item has shipped
        }
    }
}

Main project

The main project will be the executable and contain your Microdot host and Dependency Injection configuration (Ninject Bindings).

  1. Create a new console application project named InventoryService.
  2. Add a reference to the Service Interface and Grains project.
  3. Add a new text file named paket.references with a the following:
Gigya.Microdot.Orleans.Ninject.Host
Gigya.Microdot.Logging.NLog;
  1. Run paket.exe install (in the .paket folder).
  2. Right click on the project in the Solution Explorer and click Properties. Select the Build tab on the left side and untick "Prefer 32-bit". Microdot doesn't support 32-bit processes at the stage.
  3. Open an administrator command prompt (WinKey+X, A, Alt+Y) and enter the following command, replacing YourUsernameHere with your Windows account name (including domain name if applicable):
    netsh http add urlacl url=http://+:10000/ user=YourUsernameHere
    This allows Microdot to listen to HTTP requests on port 6555 without administrator privileges. Alternatively, you can run Visual Studio as Administrator.
  4. To set up the correct GC mode according to Orleans guidelines and to configure NLog, modify the project's app.config file to contain the following:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <configSections>
    <section name="nlog" type="NLog.Config.ConfigSectionHandler, NLog"/>
  </configSections>
  
  <nlog throwExceptions="true" throwConfigExceptions="true" xmlns="http://www.nlog-project.org/schemas/NLog.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <targets>
      <target name="Console" xsi:type="ColoredConsole" layout="${time} ${pad:padding=-5:inner=${level:uppercase=true}}  ${message}"/>
      <target name="Null" xsi:type="Null"/>
    </targets>
    <rules>
      <logger name="Gigya.Microdot.Orleans.Hosting.Logging.OrleansLogConsumer" maxlevel="Info" writeTo="Null" final="true" />
      <logger name="*" minlevel="Info" writeTo="Console" />
    </rules>
  </nlog>
  
  <runtime>
    <gcServer enabled="true" />
    <gcConcurrent enabled="false" />
  </runtime>

  <startup>
    <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7" />
  </startup>
</configuration>
  1. Add an text file named loadPaths.json to the project with a content of one line: [ { "Pattern": ".\\*.config", "Priority": 1 } ]. Set its Copy to Output Directory property to Copy if newer. This file is required by the configuration system, and is used to specify paths to folders containing configuration files.

  2. Add a C# class file named InventoryServiceHost.cs. It will contain the host of your service. Add the following content:

using Gigya.Microdot.Orleans.Ninject.Host;
using Gigya.Microdot.Ninject;
using Gigya.Microdot.Logging.NLog;

namespace InventoryService
{
    public class InventoryServiceHost : MicrodotOrleansServiceHost
    {
        public override ILoggingModule GetLoggingModule() => new NLogModule();
    }
}
  1. Modify the Program.cs file to contain the following:
using System;

namespace InventoryService
{
    class Program
    {
        static void Main(string[] args)
        {
            Environment.SetEnvironmentVariable("GIGYA_CONFIG_ROOT", Environment.CurrentDirectory);
            new InventoryServiceHost().Run();
        }
    }
}
  1. Set the project as the startup project and then start debugging (F5). You should see a console window open and after a short pause it should display the line: Service initialized in interactive mode (command line). Press [Alt+S] to stop the service gracefully.

Client project

In order to see the service running, we have to call it somehow. We'll create a simple client that uses ServiceProxy to call the methods of the service.

  1. Create a new console application project named InventoryService.Client.
  2. Add a reference to the Service Interface project.
  3. Add a new text file named paket.references with a the following:
Gigya.Microdot.Interfaces
Gigya.Microdot.Ninject
Gigya.Microdot.Logging.NLog
  1. Run paket.exe install (in the .paket folder).
  2. This project also needs App.config and loadPaths.json (with "Copy if newer"), having the same content as in the main project.
  3. Because we are not running in a production environment with full service discovery facilities, we need to suppress any discovery attempt of our service. We will do this by specifying that our service should be reached locally. Add an XML file to the project named Discovery.config. Set it to "Copy if newer". Add the following content:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <Discovery>
    <Services>
      <InventoryService Source="Local" />
    </Services>
  </Discovery>
</configuration>
  1. Add a C# file to the project named FortuneCookieTrader.cs with the following content:
using Gigya.Microdot.Interfaces.Logging;
using InventoryService.Interface;
using System;
using System.Threading.Tasks;

namespace InventoryService.Client
{
    public class FortuneCookieTrader
    {
        private IInventoryService Inventory { get; }
        private ILog Log { get; }

        private Product Cookie { get; }

        public FortuneCookieTrader(IInventoryService inventory, ILog log)
        {
            Inventory = inventory;
            Log = log;

            var productId = Guid.NewGuid();
            var cookieNumber = BitConverter.ToUInt16(productId.ToByteArray(), 0);
            Cookie = new Product { Id = productId, Name = $"Fortune Cookie #{cookieNumber}" };
        }

        public async Task Start()
        {
            while (true)
            {
                try
                {
                    await Inventory.ShipItems(Cookie, 3);
                    Log.Info(_ => _("Shipped three fortune cookies.", unencryptedTags: new { Cookie.Name }));
                    await Task.Delay(1000);
                }
                catch (OutOfStockException)
                {
                    Log.Error("Out of stock! Restocking 10 items.");
                    await Inventory.RestockItems(Cookie, 10);
                    await Task.Delay(5000);
                }
            }
        }
    }
}
  1. Modify Program.cs to contain the following:
using Gigya.Microdot.Logging.NLog;
using Gigya.Microdot.Ninject;
using Gigya.Microdot.SharedLogic;
using Ninject;
using System;

namespace InventoryService.Client
{
    class Program
    {
        static void Main(string[] args)
        {
            Environment.SetEnvironmentVariable("GIGYA_CONFIG_ROOT", Environment.CurrentDirectory);
            CurrentApplicationInfo.Init("InventoryService.Client");

            var kernel = new StandardKernel();
            kernel.Load<MicrodotModule>();
            kernel.Load<NLogModule>();

            var trader = kernel.Get<FortuneCookieTrader>();
            trader.Start().Wait();
        }
    }
}
  1. While the InventoryService is up and running in the background (Ctrl+F5) you can run this project to see it interact with the service.



The finished result of this guide can be found at:

https://github.com/gigya/microdot-samples/tree/master/InventoryService