-
Notifications
You must be signed in to change notification settings - Fork 230
Building Your First Microdot Service
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.
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.
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).
- 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 containspaket.exe
. - Create an empty
paket.dependencies
file in the root of your solution. The is the solution-wide equivalent ofpackages.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. - 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).
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.
- Create a new class library project named
InventoryService.Interface
. - Add a new text file named
paket.references
with a single line:Gigya.ServiceContract
. This instructs Paket to add theGigya.ServiceContract
NuGet package to this project. - 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. - Run
paket.exe install
(in the.paket
folder) to download and install theGigya.ServiceContract
in the project. - 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; }
}
}
- 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.
Your service interface must:
- Be public.
- Have all methods return
Task
orTask<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.
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.
- Create a new class library project named
InventoryService.Grains
. - Add a reference to the Service Interface project.
- Add a new text file named
paket.references
with a the following:
Microsoft.Orleans.OrleansCodeGenerator
Microsoft.Orleans.OrleansCodeGenerator.Build
Gigya.Microdot.Interfaces
Gigya.ServiceContract
- Run
paket.exe install
(in the.paket
folder). - 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;
}
}
}
- 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
}
}
}
The main project will be the executable and contain your Microdot host and Dependency Injection configuration (Ninject Bindings).
- Create a new console application project named
InventoryService
. - Add a reference to the Service Interface and Grains project.
- Add a new text file named
paket.references
with a the following:
Gigya.Microdot.Orleans.Ninject.Host
Gigya.Microdot.Logging.NLog;
- Run
paket.exe install
(in the.paket
folder). - 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.
- 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. - 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>
-
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. -
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();
}
}
- 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();
}
}
}
- 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.
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.
- Create a new console application project named
InventoryService.Client
. - Add a reference to the Service Interface project.
- Add a new text file named
paket.references
with a the following:
Gigya.Microdot.Interfaces
Gigya.Microdot.Ninject
Gigya.Microdot.Logging.NLog
- Run
paket.exe install
(in the.paket
folder). - This project also needs
App.config
andloadPaths.json
(with "Copy if newer"), having the same content as in the main project. - 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>
- 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);
}
}
}
}
}
- 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();
}
}
}
- 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