With .NET Core, Docker, and RabbitMQ
This is a sample project that gives you and introduction into to building distributed systems with NServiceBus. The project is based on Particular's Multiple Endpoints using .NET Core, Docker containers, and RabbitMQ.
The sample project is an e-commerce site where you can order some products. You will be building microservices for Sales, Billing and Shipping.
An accompanying slide deck that introduces some of the concepts of distributed systems and Service Oriented Architecure is available at https://www.slideshare.net/ChrisMorgan8/introduction-to-microservices-with-nservice-bus.
Each of the steps in this exercise have fully completed source code or you can start with the solution in the exercise
folder and code along.
- Windows 10
- Visual Studio 2017 or higher (community edition is fine)
- Docker for Windows
If you are installing Docker for Windows for this exercise the install will ask you if you want to use Windows container or Linux containers (default).
If you already have Docker for Windows installed you can change the type of containers used by doing the following:
- In your Windows Task Tray right click on the Docker icon.
- If there is a Switch to Linux containers... click that. If it says Switch to Linux container...* you are already using Linux containers and do not have to do anything further.
- In your Windows task tray right click on the Docker icon
- Click Settings
- Click Shared Drives
- Select the the drive you cloned this repository into.
This exercise will be using RabbitMQ which you can run in a Docker container using the supplied Docker Compose file.
- Open a command shell (command window, powershell, bash, terminus, etc.) and change to the root directory of where you cloned this repository to. There will be a
docker-compose.yaml
file in the directory. - Execute
docker-compose up -d
to start the container in a detached mode. This will download and run the containers configured in the docker-compose.yaml file.
If the docker-compose up command fails with an authorization error when pulling down the containers you may not be logged into Docker Hub. To login execute the command docker login
and enter your Docker Hub username and password.
You can now access the RabbitMQ Management console at http://localhost:15672 and login as retaildemo
with the password password
.
This step starts you out with a web site called store-web that simulates a shopping cart checkout and will starting the ordering process by sending a PlaceOrder
command to the Sales microservice.
- Open the retail-demo.sln solution located in the
src/exercise
folder.
In the solution at this point is an ASP.NET Core web site, a DockerCompose project, an Infrastructure project, and solution folders for the three services you will be creating. Each service folder contains a messages project.
Follow the steps below to run the web site and place on order. This is also where the code in the exercise
folder starts out and is the directory the rest of this guid will refer to.
- Set the docker-compose project as the startup project for the solution.
- Build the solution. The first build may take a bit since the aspnet core containers are being downloaded.
- Run the project in Visual Studio with F5 and access the web site at http://localhost:32773/.
- Click the proceed to checkout button to go to the checkout page. Here you will see an order id that has been generated that will be sent to the
PlaceOrder
command you will implement in step two. - Click Place your order to place an order.
At this point the CheckoutController.PlaceOrder()
action method is called which then redirects to a confirmation page but is not sending the PlaceOrder
command so lets add that now.
To make the store-web project an endpoint you need to install the NServiceBus
and NServiceBus.RabbitMQ
pacakges from NuGet and then configure and start an endpoint object during application startup.
- Open the
store-web/Startup.cs
file and review theAddEndpoint
method to see how an endpoint is configured and started.
You can learn more about endpoint configuration options here.
Since there are a few projects in this demo that are endpoints I have created a Infrastructure.EndpointConfigurationBuilder
class to help with this. Lets go ahead and change out the store-web configuration to use the builder.
-
Replace the
AddEndpoint
method with the following code to use the builder:private void AddEndpoint(IServiceCollection services) { Log.Info("****************** Store website endpoint starting ******************"); var connectionString = Environment.GetEnvironmentVariable("servicebus_connection_string"); var endpointConfiguration = new EndpointConfigurationBuilder("store-web", connectionString) .AsSendOnly() .Build(); // start the endpoint var endpointInstance = Endpoint.Start(endpointConfiguration).GetAwaiter().GetResult(); Log.Info("****************** Store website endpoint successfully started ******************"); // register endpoint instance with the IoC framework services.AddSingleton<IMessageSession>(endpointInstance); }
-
You can remove the
NServiceBus.RabbitMQ
NuGet package from the store-web project if you like since that will now come from theInfrastructure.EndpointConfigurationBuilder
project.
As of 5/10/2019 the Particular dotnet new template is using NSB version 7.1.4 and NServiceBus.Newtonsoft.Json version 2.1.0 and is compatible with the version of NServiceBus and NServiceBus.RabbitMQ version 5.0.2 used in the Infrastructure project. If the NServiceBus version your Particular dotnet new template uses is differnt than this you may have to upgrade/downgrade the Infrastructure project's NServiceBus version so they are compatible.
In this step you will add a Sales microservice that handles a PlaceOrder
command sent by the web site's checkout process.
Lets use the dotnet CLI and the Particular dotnet new Templates to create a container project that will be the endpoint host for the Sales service.
- From your favorite command shell execute
dotnet new -i "ParticularTemplates"
to install the latest version of the Particular dotnet new Templates. You can be in any directory for this. - Change to the
src/exercise
directory and executedotnet new nsbdockercontainer -n Sales.Endpoints
to create a Sales.Endpoints project. - In Visual Studio right click on the
Sales
solution folder and select Add Existing Project then select the new Sales.Endpoints.csproj project.
There is already a Sales.Messages
project in the Sales
solution folder. This is a netstandard class library project that will be used later on in this step.
Like you did in the store-web project lets swap out the endpoint configuration code with the builder.
In the Sales.Endpoints Hosts.cs
file:
- Change the
EndpointName
property value tosales
public string EndpointName => "sales";
- Replace the try block in the
Start
method with the following code:try { var connectionString = Environment.GetEnvironmentVariable("servicebus_connection_string"); var endpointConfiguration = new EndpointConfigurationBuilder(EndpointName, connectionString).Build(); // start the endpoint endpoint = await Endpoint.Start(endpointConfiguration); log.Info("****************** Sales endpoint successfully started ******************"); }
The solution is using Docker Compose to manage all the containers. The new Sales.Endpoints project you added is already configured to be a Docker container so all you have to do now is add it to the solution's docker-compose file.
-
Open the
src/exercise/docker-compose.yaml
file and add the following configuration in the services node at the same level as the store-web service configuration:sales: image: sales build: context: . dockerfile: Sales.Endpoints/Dockerfile environment: servicebus_connection_string: ${servicebus_connection_string}
The servicebus_connection_string
is an environment variable defined in the src/exercise/.env
file.
- Create a
Sales.Endpoints.PlaceOrderHandler
class that implementsNServiceBus.IHandleMessages<PlaceOrder>
. ThePlaceOrder
class is in theSales.Messages
project. - Add a
Handle
method.public class PlaceOrderHandler : IHandleMessages<PlaceOrder> { private static readonly ILog Log = LogManager.GetLogger<PlaceOrderHandler>(); public async Task Handle(PlaceOrder message, IMessageHandlerContext context) { // This is where you would load the existing order from the sales database and perform your business logic Log.Info($"******************** PlaceOrder for order id '{message.OrderId}' ********************"); await Task.CompletedTask; } }
Next you need to have the web site send the PlaceOrder
command to the Sales endpoint. Unlike Events, Commands are routed to a specific endpoint.
- Update the store-web endpoint's configuration in
Startup.cs
so it routes thePlaceOrder
command messages to the sales endpoint using the builder'sRoutToEndpoint
method.var endpointConfiguration = new EndpointConfigurationBuilder("store-web", connectionString) .AsSendOnly() .RouteToEndpoint(typeof(PlaceOrder), "sales") .Build();
- Update the
CheckoutController.PlaceOrder
action method to send aPlaceOrder
command when the place order button is clicked.public async Task<IActionResult> PlaceOrder(int orderId) { // ... var placeOrderCommand = new PlaceOrder { OrderId = orderId }; await _bus.Send(placeOrderCommand).ConfigureAwait(false); return View("Confirmation"); }
Now you can run the solution and when you place an order the Sales service will receive the message.
This step adds a new Billing microservice that processes payments for an order. The Billing service will subscribe to an OrderPlaced
event that is published by the Sales service.
- Open a command window to the
\src\step-one
directory and execute the command:dotnet new nsbdockercontainer -n Billing.Endpoints
- In Visual Studio add the created project to the Billing solution folder.
In the Hosts.cs
file:
- Change the
EndpointName
property value tobilling
public string EndpointName => "billing";
- Replace the try block in the
Start
method with the following code:try { var connectionString = Environment.GetEnvironmentVariable("servicebus_connection_string"); var endpointConfiguration = new EndpointConfigurationBuilder(EndpointName, connectionString).Build(); // start the endpoint endpoint = await Endpoint.Start(endpointConfiguration); log.Info("****************** Billing endpoint successfully started ******************"); }
Add a billing
service to the src/exercise/docker-compose.yaml
file.
- Open the
src/exercise/docker-compose.yaml
file and add the following configuration in the services node:billing: image: billing build: context: . dockerfile: Billing.Endpoints/Dockerfile environment: servicebus_connection_string: ${servicebus_connection_string}
-
Edit the
Sales.Endpoints.PlaceOrderHandler
handler to publish anOrderPlaced
event.public async Task Handle(PlaceOrder message, IMessageHandlerContext context) { // ... var orderPlacedEvent = new OrderPlaced { OrderId = message.OrderId }; await context.Publish(orderPlacedEvent); }
-
Create a
Billing.Endpoints.OrderPlacedHandler
class that implementsNServiceBus.IHandleMessages<OrderPlaced>
. TheOrderPlaced
class is in theSales.Messages
project. -
Add a
Handle
method.public class OrderPlacedHandler : IHandleMessages<OrderPlaced> { private static readonly ILog Log = LogManager.GetLogger<OrderPlacedHandler>(); public async Task Handle(OrderPlaced message, IMessageHandlerContext context) { Log.Info($"******************** OrderPlaced for order id '{message.OrderId}' ********************"); // Load the payment method and amount data from the billing database // Use Payment Gateway to charge or put hold on the credit card await Task.CompletedTask; } }
The Billing service is now handling the OrderPlaced
event and will charge the customer's credit card.
In this step you are going to create a Shipping microservice that will also handle the OrderPlaced
event. This demonstrates having multiple endpoints (Billing and Shipping) handling a single event.
- Change to the
src/exercise
directory and execute the commanddotnet new nsbdockercontainer -n Shipping.Endpoints
to create the new Shipping.Endpoints project. - In Visual Studio add the created project to the Billing solution folder.
In the Hosts.cs
file:
- Change the
EndpointName
property value toshipping
public string EndpointName => "shipping";
- Replace the try block in the
Start
method with the following code:try { var connectionString = Environment.GetEnvironmentVariable("servicebus_connection_string"); var endpointConfiguration = new EndpointConfigurationBuilder(EndpointName, connectionString).Build(); // start the endpoint endpoint = await Endpoint.Start(endpointConfiguration); log.Info("****************** Shipping endpoint successfully started ******************"); }
Add a shipping
service to the src/exercise/docker-compose.yaml
file.
- Open the
src/exercise/docker-compose.yaml
file and add the following configuration in the services node:shipping: image: shipping build: context: . dockerfile: Shipping.Endpoints/Dockerfile environment: servicebus_connection_string: ${servicebus_connection_string}
-
Create a
Shipping.Endpoints.OrderPlacedHandler
class that implementsNServiceBus.IHandleMessages<OrderPlaced>
. TheOrderPlaced
class is in theSales.Messages
project. -
Add a
Handle
method.public class OrderPlacedHandler : IHandleMessages<OrderPlaced> { private static readonly ILog Log = LogManager.GetLogger<OrderPlacedHandler>(); public async Task Handle(OrderPlaced message, IMessageHandlerContext context) { Log.Info($"******************** OrderPlaced for order id '{message.OrderId}' ********************"); // Should this be shipped yet? // Load the warehouse data for the products, notify fulfillment agency, etc. // Call some third party shipping API like FedEx to schedule a pickup await Task.CompletedTask; } }
This step adds publishing of an OrderBilled
event by the Billing service which will be handled by the Shipping service.
In the Billing.Endpoints.OrderPlacedHandler
class update the Handle
method so it publishes an OrderBilled
event after the fake payment processing call. The OrderBilled
event has already been created in the Billing.Messages project.
```cs
public async Task Handle(OrderPlaced message, IMessageHandlerContext context)
{
// ...
Thread.Sleep(4000); // simulate a long running call
var orderBilled = new OrderBilled
{
OrderId = message.OrderId
};
await context.Publish(orderBilled);
}
```
Update Shipping to handle the OrderBilled
event.
- In the Shipping.Endpoints project add a class named
OrderBilledHandler
.public class OrderBilledHandler : IHandleMessages<OrderBilled> { private static readonly ILog Log = LogManager.GetLogger<OrderBilledHandler>(); public Task Handle(OrderBilled message, IMessageHandlerContext context) { Log.Info($"******************* Received OrderBilled, OrderId = {message.OrderId} ******************"); // Should this be shipped yet? return Task.CompletedTask; } }
The Shipping service has a problem! It shouldn't ship the order until it has received both the OrderPlaced
and OrderBilled
events but in an eventually consistent system like this the OrderBilled
event could arrive before OrderPlaced
. The Shipping service needs to track what events have arrived using a saga.
This step is based on the NServiceBus sagas: Getting started tutorial.
In this step we are replacing the OrderPlacedHandler
and OrderBilledHandler
handlers in the Shipping service with a saga.
Move the two handlers into a single handler called ShippingPolicy
.
- Create a
ShippingPolicy
class in the Shipping.Endpoints project and configure it to handle theOrderPlaced
andOrderBilled
events.public class ShippingPolicy : IHandleMessages<OrderPlaced>, IHandleMessages<OrderBilled> { private static readonly ILog Log = LogManager.GetLogger<ShippingPolicy>(); public Task Handle(OrderPlaced message, IMessageHandlerContext context) { Log.Info($"******************* Received OrderPlaced, OrderId = {message.OrderId} ******************"); return Task.CompletedTask; } public Task Handle(OrderBilled message, IMessageHandlerContext context) { Log.Info($"******************* Received OrderBilled, OrderId = {message.OrderId} ******************"); return Task.CompletedTask; } }
- Delete the existing two handler classes in the Shipping.Endpoints folder.
Sagas persist data using a persister that is registered with the endpoint.
-
Edit the
Shipping.Endpoints.Host.Start
method to use theWithPersistence
method of theInfrastructure.EndpointConfigurationBuilder
. This will register the LearningPersister which simulates saga persistence.var endpointConfiguration = new EndpointConfigurationBuilder(EndpointName, connectionString) .WithPersistence() .Build();
-
Create a
ShippingPolicyData
class that has properties to track whether or not theOrderPlaced
andOrderBilled
events have been received. Since this data is only used by the saga it can be an internal class toShippingPolicy
.public class ShippingPolicy : Saga<ShippingPolicyData>, IAmStartedByMessages<OrderPlaced>, IAmStartedByMessages<OrderBilled> { // ... public class ShippingPolicyData : ContainSagaData { public string OrderId { get; set; } public bool IsOrderPlaced { get; set; } public bool IsOrderBilled { get; set; } } }
-
Make
ShippingPolicy
a saga that usesShippingPolicyData
by having it inherit from fromSaga<ShippingPolicy.ShippingPolicyData>
and tpublic class ShippingPolicy : Saga<ShippingPolicyData>, IHandleMessages<OrderPlaced>, IHandleMessages<OrderBilled>
-
Implement the abstract
ConfigureHowToFindSaga
member and tell the saga how messages are correlated to a saga instance.protected override void ConfigureHowToFindSaga(SagaPropertyMapper<ShippingPolicyData> mapper) { mapper.ConfigureMapping<OrderPlaced>(message => message.OrderId) .ToSaga(sagaData => sagaData.OrderId); mapper.ConfigureMapping<OrderBilled>(message => message.OrderId) .ToSaga(sagaData => sagaData.OrderId); }
-
Update both
Handle
methods so they set the corresponding flag on theShippingPolicyData
class using the saga'aData
property.public Task Handle(OrderPlaced message, IMessageHandlerContext context) { Log.Info($"******************* Received OrderPlaced, OrderId = {message.OrderId} ******************"); Data.IsOrderPlaced = true; return Task.CompletedTask; } public Task Handle(OrderBilled message, IMessageHandlerContext context) { Log.Info($"******************* Received OrderBilled, OrderId = {message.OrderId} ******************"); Data.IsOrderBilled = true; return Task.CompletedTask; }
-
Configure the saga so it is started by both the
OrderPlaced
orOrderBilled
events by using theIAmStartedByMessages<T>
interface instead ofIHandleMessages<T>
.public class ShippingPolicy : Saga<ShippingPolicyData>, IAmStartedByMessages<OrderPlaced>, IAmStartedByMessages<OrderBilled>
-
Add a
ProcessOrder
method to theShippingPolicy
class that will send aShipOrder
command and mark the saga as complete if the order has been placed and billed.private async Task ProcessOrder(IMessageHandlerContext context) { if (Data.IsOrderPlaced && Data.IsOrderBilled) { await context.SendLocal(new ShipOrder { OrderId = Data.OrderId }); MarkAsComplete(); } }
-
Update both
Handle
methods to call the newProcessOrder
method like this.public Task Handle(OrderPlaced message, IMessageHandlerContext context) { Log.Info($"******************* Received OrderPlaced, OrderId = {message.OrderId} ******************"); Data.IsOrderPlaced = true; return ProcessOrder(context); }
-
Add a
ShipOrderHandler
class to theShipping.Endpoints
project that will handle theShipOrder
command and publish anOrderShipped
event.public class ShipOrderHandler : IHandleMessages<ShipOrder> { private static readonly ILog Log = LogManager.GetLogger<ShipOrderHandler>(); public async Task Handle(ShipOrder message, IMessageHandlerContext context) { Log.Info($"******************* Received ShipOrder, OrderId = {message.OrderId} ******************"); await Task.CompletedTask; } }
"Other than interacting with its own internal state, a saga should not access a database, call out to web services, or access other resources - neither directly nor indirectly by having such dependencies injected into it."
The shipping carrier you use is not always able to get shipments out on time. If they don't ship the order within 20 seconds you need to have a secondary carrier fullfil the shipment to keep your customers happy.
You can do this with a Saga Timeout which is essentialy a way to have an event be scheduled for some time in the future.
This step adds a timeout to the ShippingPolicy
saga called OrderShippingPickupTimeExceeded
.
- In
ShipOrderHandler
publish aOrderShipped
eventpublic class ShipOrderHandler : IHandleMessages<ShipOrder> { private static readonly ILog Log = LogManager.GetLogger<ShipOrderHandler>(); public async Task Handle(ShipOrder message, IMessageHandlerContext context) { Log.Info($"******************* Received ShipOrder, OrderId = {message.OrderId} ******************"); var orderShipped = new OrderShipped { OrderId = message.OrderId }; await context.Publish(orderShipped); } }
- Update
ShippingPolicy
saga to be handle theOrderShipped
eventpublic class ShippingPolicy : Saga<ShippingPolicyData>, IAmStartedByMessages<OrderPlaced>, IAmStartedByMessages<OrderBilled>, IHandleMessages<OrderShipped>
- Implement the Handle method and set an
IsShipped
flag on the aga data object.public Task Handle(OrderShipped message, IMessageHandlerContext context) { Log.Info($"******************* Received OrderShipped, OrderId = {message.OrderId} ******************"); Data.IsShipped = true; return ProcessOrder(context); }
- Update the
ProcessOrder
method to checkIsShipped
private async Task ProcessOrder(IMessageHandlerContext context) { if (Data.IsOrderPlaced && Data.IsOrderBilled && Data.IsShipped) { await context.SendLocal(new ShipOrder { OrderId = Data.OrderId }); MarkAsComplete(); } }
- Update the
ConfigureHowToFindSaga
method so the saga knows how to map to theOrderShipped
event.protected override void ConfigureHowToFindSaga(SagaPropertyMapper<ShippingPolicyData> mapper) { // ... mapper.ConfigureMapping<OrderShipped>(message => message.OrderId) .ToSaga(sagaData => sagaData.OrderId); }
- Update the saga to handle the timeout
public class ShippingPolicy : Saga<ShippingPolicyData>, IAmStartedByMessages<OrderPlaced>, IAmStartedByMessages<OrderBilled>, IHandleMessages<OrderShipped>, IHandleTimeouts<OrderShippingPickupTimeExceeded>
- Implement the method to handle the timeout
- In the
ProcessOrder
method request aOrderShippingLate
timeout after theShipOrder
command is sent and remove theMarkAsComplete()
call.private async Task ProcessOrder(IMessageHandlerContext context) { if (Data.IsOrderPlaced && Data.IsOrderBilled && Data.IsShipped) { await context.SendLocal(new ShipOrder { OrderId = Data.OrderId }); await RequestTimeout<OrderShippingPickupTimeExceeded>(context, TimeSpan.FromSeconds(20)); } }
- Add a
Timeout
method that will handle theOrderShippingPickupTimeExceeded
event.public async Task Timeout(OrderShippingPickupTimeExceeded state, IMessageHandlerContext context) { Log.Info($"******************* Received OrderShipped, OrderId = {Data.OrderId} ******************"); // Have secondary carrier to ship the order MarkAsComplete(); await Task.CompletedTask; }
- Add a
OrderShippingPickupTimeExceeded
as an inner class ofShippingPolicy
public class OrderShippingPickupTimeExceeded {}
- Update
ShipOrderHandler
to have a delay or throw an exception so theOrderShippingPickupTimeExceeded
event is handled beforeOrderShipped
is.public async Task Handle(ShipOrder message, IMessageHandlerContext context) { Log.Info($"******************* Received ShipOrder, OrderId = {message.OrderId} ******************"); Thread.Sleep(25000); // cause OrderShippingPickupTimeExceeded to happen before OrderShipped var orderShipped = new OrderShipped { OrderId = message.OrderId }; await context.Publish(orderShipped); }
- In the Shipping
Host.cs
disable delayed and immediate retries to make it easier to demo