Введение


Практически все, кто имел дело с микросервисами в .NET Core, наверняка знают книгу Кристиана Хорсдала “Микросервисы на платформе .NET”. Здесь прекрасно описаны подходы к построению приложения на основе микросервисов, подробно рассмотрены вопросы мониторинга, журналирования, управления доступом. Единственное, чего не хватает — это инструмента автоматизации взаимодействия между микросервисами.

При обычном подходе при разработке микросервиса параллельно разрабатывается web-клиент для него. И каждый раз, когда меняется web-интерфейс микросервиса, приходится затрачивать дополнительные усилия для соответствующих изменений web-клиента. Идея генерировать пару web-api/web-клиент с использованием OpenApi тоже достаточно трудоемка, хотелось бы чего-то более прозрачного для разработчика.

Итак, при альтернативном подходе в разработке нашего приложения хотелось бы:
Структуру микросервиса описывать интерфейсом с использованием атрибутов, описывающих тип метода, маршрут и способ передачи параметров, как это делается в MVC.

Функциональность микросервиса разрабатывается исключительно в классе,
реализующим этот интерфейс. Публикация конечных точек микросервиса должна быть автоматической, не требующей сложных настроек.

Web-клиент для микросервиса должен генерироваться автоматически на основе интерфейса и предоставляться через Dependency Injection.

Должен быть механизм организации редиректов на конечные точки микросервисов из главного приложения, взаимодействующего с пользовательским интерфейсом.

В соответствии с этим критериями разработан Nuget-пакет Shed.CoreKit.WebApi. В дополнение к нему создан вспомогательный пакет Shed.CoreKit.WebApi.Abstractions, содержащий атрибуты и классы, которые могут быть использованы при разработке общих проектов-сборок, где не требуется функциональность основного пакета.

Ниже мы рассмотрим использование возможностей этих пакетов при разработке приложения MicroCommerce, описанного в вышеупомянутой книге Кристиана Хорсдала.

Здесь и далее мы будем использовать следующую терминологию:

Микросервис — приложение (проект) ASP.NET Core, которое может запускаться консольно, под Internet Information Services (IIS) или в Docker-контейнере.
Интерфейс — сущность .NET, набор методов и свойств без реализации.
Конечная точка — путь к корню приложения микросервиса или реализации интерфейса. Примеры: localhost:5001, localhost:5000/products
Маршрут — путь к методу интерфейса от конечной точки. Может определяться по умолчанию так же как в MVC или устанавливаться при помощи атрибута.

Структура приложения MicroCommerce


  1. ProductCatalog — микросервис, предоставляющий сведения о продуктах.
  2. ShoppingCart — микросервис, предоставляющий сведения о покупках пользователя, а также возможность добавлять/удалять покупки. При изменении состояния корзины пользователя генерируются события для уведомления других микросервисов.
  3. ActivityLogger — микросервис, собирающий сведения о событиях других микросервисов. Предоставляет конечную точку для получения логов.
  4. WebUI — Пользовательский интерфейс приложения, должен быть реализован в виде Single Page Application.
  5. Interfaces — интерфейсы микросервисов и классы-модели.
  6. Middleware — общая функциональность для всех микросервисов

Разработка приложения MicroCommerce




Создаем пустое решение .Net Core. Добавляем в него проект WebUI как пустой ASP.NET Core WebApplication. Далее добавляем проекты микросервисов ProductCatalog, ShoppingCart, ActivityLog, также как пустые проекты ASP.NET Core WebApplication. В заключение добавляем две библиотеки классов — Interfaces и Middleware.

1. Interfaces — интерфейсы микросервисов и классы-модели


Подключаем к проекту Nuget-пакет Shed.CoreKit.WebApi.Abstractions.

Добавляем интерфейс IProductCatalog и модели для него:

//
// Interfaces/IProductCatalog.cs
//

using MicroCommerce.Models;
using Shed.CoreKit.WebApi;
using System;
using System.Collections.Generic;

namespace MicroCommerce
{
    public interface IProductCatalog
    {
        IEnumerable<Product> Get();

        [Route("get/{productId}")]
        public Product Get(Guid productId);
    }
}


//
// Interfaces/Models/Product.cs
//

using System;

namespace MicroCommerce.Models
{
    public class Product
    {
        public Guid Id { get; set; }

        public string Name { get; set; }

        public Product Clone()
        {
            return new Product
            {
                Id = Id,
                Name = Name
            };
        }
    }
}

Использование атрибута Route ничем не отличается от аналогичного в ASP.NET Core MVC, но нужно помнить, что этот атрибут должен быть из namespace Shed.CoreKit.WebApi, и никакого другого. То же самое касается атрибутов HttpGet, HttpPut, HttpPost, HttpPatch, HttpDelete, а также FromBody в случае их применения.

Правила применения атрибутов типа Http[Methodname] такие же, как в MVC, то есть если префикс имени метода интерфейса совпадает с именем требуемого Http-метода, то не нужно его дополнительно определять, иначе используем соответствующий атрибут.

Атрибут FromBody применяется к параметру метода, если этот параметр должен извлекаться из тела запроса. Замечу, что как и ASP.NET Core MVC, его нужно указывать всегда, никаких правил по умолчанию нет. И в параметрах метода может быть только один параметр с этим атрибутом.

Добавляем интерфейс IShoppingCart и модели для него

//
// Interfaces/IShoppingCart.cs
//

using MicroCommerce.Models;
using Shed.CoreKit.WebApi;
using System;
using System.Collections.Generic;

namespace MicroCommerce
{
    public interface IShoppingCart
    {
        Cart Get();

        [HttpPut, Route("addorder/{productId}/{qty}")]
        Cart AddOrder(Guid productId, int qty);

        Cart DeleteOrder(Guid orderId);

        [Route("getevents/{timestamp}")]
        IEnumerable<CartEvent> GetCartEvents(long timestamp);
    }
}

//
// Interfaces/IProductCatalog/Order.cs
//

using System;

namespace MicroCommerce.Models
{
    public class Order
    {
        public Guid Id { get; set; }

        public Product Product { get; set; }

        public int Quantity { get; set; }

        public Order Clone()
        {
            return new Order
            {
                Id = Id,
                Product = Product.Clone(),
                Quantity = Quantity

            };
        }
    }
}

//
// Interfaces/Models/Cart.cs
//

using System;

namespace MicroCommerce.Models
{
    public class Cart
    {
        public IEnumerable<Order> Orders { get; set; }
    }
}

//
// Interfaces/Models/CartEvent.cs
//

using System;

namespace MicroCommerce.Models
{
    public class CartEvent: EventBase
    {
        public CartEventTypeEnum Type { get; set; }
        public Order Order { get; set; }
    }
}

//
// Interfaces/Models/CartEventTypeEnum.cs
//

using System;

namespace MicroCommerce.Models
{
    public enum CartEventTypeEnum
    {
        OrderAdded,
        OrderChanged,
        OrderRemoved
    }
}

//
// Interfaces/Models/EventBase.cs
//

using System;

namespace MicroCommerce.Models
{
    public abstract class EventBase
    {
        private static long TimestampBase;

        static EventBase()
        {
            TimestampBase = new DateTime(2000, 1, 1).Ticks;
        }

        public long Timestamp { get; set; }
        
        public DateTime Time { get; set; }

        public EventBase()
        {
            Time = DateTime.Now;
            Timestamp = Time.Ticks - TimestampBase;
        }
    }
}

Пара слов о базовом типе событий EventBase. При публикации событий используем подход, описанный в книге, т.е. любое событие содержит метку времени создания — Timestamp, при опросе источника события слушатель передает последний полученный timestamp. К сожалению, тип long некорректно преобразуется в в тип Number javascript при больших значениях, поэтому мы используем некую хитрость — вычитаем timestamp базовой даты (Timestamp = Time.Ticks — TimestampBase). Конкретное значение базовой даты абсолютно неважно.

Добавляем интерфейс IActivityLogger и модели для него

//
// Interfaces/IActivityLogger.cs
//

using MicroCommerce.Models;
using System.Collections.Generic;

namespace MicroCommerce
{
    public interface IActivityLogger
    {
        IEnumerable<LogEvent> Get(long timestamp);
    }
}

//
// Interfaces/Models/LogEvent.cs
//

namespace MicroCommerce.Models
{
    public class LogEvent: EventBase
    {
        public string Description { get; set; }
    }
}

2. Микросервис ProductCatalog


Открываем Properties/launchSettings.json, привязываем проект к порту 5001.

{
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:60670",
      "sslPort": 0
    }
  },
  "profiles": {
    "MicroCommerce.ProductCatalog": {
      "commandName": "Project",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      },
      "applicationUrl": "http://localhost:5001"
    }
  }
}

Подключаем к проекту Nuget- пакет Shed.CoreKit.WebApi и ссылки на проекты Interfaces и Middleware. О Middleware будет более подробно рассказано ниже.

Добавляем реализацию интерфейса IProductCatalog:

//
// ProductCatalog/ProductCatalog.cs
//

using MicroCommerce.Models;
using System;
using System.Collections.Generic;
using System.Linq;

namespace MicroCommerce.ProductCatalog
{
    public class ProductCatalogImpl : IProductCatalog
    {
        private Product[] _products = new[]
        {
            new Product{ Id = new Guid("6BF3A1CE-1239-4528-8924-A56FF6527595"), Name = "T-shirt" },
            new Product{ Id = new Guid("6BF3A1CE-1239-4528-8924-A56FF6527596"), Name = "Hoodie" },
            new Product{ Id = new Guid("6BF3A1CE-1239-4528-8924-A56FF6527597"), Name = "Trousers" }
        };

        public IEnumerable<Product> Get()
        {
            return _products;
        }

        public Product Get(Guid productId)
        {
            return _products.FirstOrDefault(p => p.Id == productId);
        }
    }
}

Каталог продуктов храним в статическом поле, для упрощения примера. Конечно же, в реальном приложении нужно использовать какое-то другое хранилище, которое можно получить как зависимость через Dependency Injection.

Теперь эту реализацию нужно подключить как конечную точку. Если бы мы использовали традиционный подход, мы должны были бы использовать инфраструктуру MVC, то есть создать контроллер, передать ему нашу реализацию как зависимость, настроить роутинг и т.д. С использованием Nuget-пакета Shed.CoreKit.WebApi это делается гораздо проще. Достаточно зарегистрировать нашу реализацию в Dependency Injection (services.AddTransient<IProductCatalog, ProductCatalogImpl>()), затем объявляем ее как конечную точку (app.UseWebApiEndpoint()) при помощи метода-расширителя UseWebApiEndpoint из пакета Shed.CoreKit.WebApi. Это делается в Setup

//
// ProductCatalog/Setup.cs
//

using MicroCommerce.Middleware;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Shed.CoreKit.WebApi;

namespace MicroCommerce.ProductCatalog
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddCorrelationToken();
            services.AddCors();
            // регистрируем реализацию как зависимость в контейнере IoC
            services.AddTransient<IProductCatalog, ProductCatalogImpl>();
            services.AddLogging(builder => builder.AddConsole());
            services.AddRequestLogging();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseCorrelationToken();
            app.UseRequestLogging();
            app.UseCors(builder =>
            {
                builder
                    .AllowAnyOrigin()
                    .AllowAnyMethod()
                    .AllowAnyHeader();
            });

            // привязываем реализацию к конечной точке
            app.UseWebApiEndpoint<IProductCatalog>();
        }
    }
}

Это приводит к тому, что в микросервисе появляются методы:

http://localhost:5001/get
http://localhost:5001/get/<productid>

Метод UseWebApiEndpoint может принимать необязательный параметр root.

Если мы подключим конечную точку таким образом:
app.UseWebApiEndpoint(“products”)
то конечная точка микросервиса будет выглядеть вот так:

http://localhost:5001/products/get

Это может быть полезно, если у нас появится необходимость подключить к микросервису несколько интерфейсов.

Это все что нужно сделать. Можно запустить микросервис и протестировать его методы.

Остальной код в Setup настраивает и подключает дополнительные возможности.

Пара services.AddCors() / app.UseCors(...) разрешает использование кросс-доменных запросов в проекте. Это необходимо при редиректах запросов со стороны UI.

Пара services.AddCorrelationToken() / app.UseCorrelationToken() подключает использование токенов корреляции при журналировании запросов, как это описано в книге Кристиана Хорсдала. Мы дополнительно обсудим это позже.

И наконец, пара services.AddRequestLogging() / app.UseRequestLogging() подключает журналирование запросов из проекта Middleware. К этому тоже вернемся позже.

3. Микросервис ShoppingCart


Привязываем проект к порту 5002 аналогично ProductCatalog.

Подключаем к проекту Nuget- пакет Shed.CoreKit.WebApi и ссылки на проекты Interfaces и Middleware.

Добавляем реализацию интерфейса IShoppingCart.

//
// ShoppingCart/ShoppingCart.cs
//

using MicroCommerce.Models;
using System;
using System.Collections.Generic;
using System.Linq;

namespace MicroCommerce.ShoppingCart
{
    public class ShoppingCartImpl : IShoppingCart
    {
        private static List<Order> _orders = new List<Order>();
        private static List<CartEvent> _events = new List<CartEvent>();
        private IProductCatalog _catalog;

        public ShoppingCartImpl(IProductCatalog catalog)
        {
            _catalog = catalog;
        }

        public Cart AddOrder(Guid productId, int qty)
        {
            var order = _orders.FirstOrDefault(i => i.Product.Id == productId);
            if(order != null)
            {
                order.Quantity += qty;
                CreateEvent(CartEventTypeEnum.OrderChanged, order);
            }
            else
            {
                var product = _catalog.Get(productId);
                if (product != null)
                {
                    order = new Order
                    {
                        Id = Guid.NewGuid(),
                        Product = product,
                        Quantity = qty
                    };

                    _orders.Add(order);
                    CreateEvent(CartEventTypeEnum.OrderAdded, order);
                }
            }

            return Get();
        }

        public Cart DeleteOrder(Guid orderId)
        {
            var order = _orders.FirstOrDefault(i => i.Id == orderId);
            if(order != null)
            {
                _orders.Remove(order);
                CreateEvent(CartEventTypeEnum.OrderRemoved, order);
            }

            return Get();
        }

        public Cart Get()
        {
            return new Cart
            {
                Orders = _orders
            };
        }

        public IEnumerable<CartEvent> GetCartEvents(long timestamp)
        {
            return _events.Where(e => e.Timestamp > timestamp);
        }

        private void CreateEvent(CartEventTypeEnum type, Order order)
        {
            _events.Add(new CartEvent
            {
                Timestamp = DateTime.Now.Ticks,
                Time = DateTime.Now,
                Order = order.Clone(),
                Type = type
            });
        }
    }
}

Здесь, как и в ProductCatalog, используем статические поля как хранилища. Но этот микросервис еще использует вызовы к ProductCatalog для получения информации о продукте, поэтому ссылку на IProductCatalog передаем в конструктор как зависимость.

Теперь эту зависимость нужно определить в DI, и мы используем для этого метод-расширитель AddWebApiEndpoints из пакета Shed.CoreKit.WebApi. Этот метод регистрирует в DI фабрику-генератор WebApi-клиентов для интерфейса IProductCatalog.

При генерировании WebApi-клиента фабрика использует зависимость System.Net.Http.HttpClient. Если в приложении требуются какие-то специальные настройки для HttpClient (учетные данные, специальные заголовки/токены), это можно сделать при регистрации HttpClient в DI.

//
// ShoppingCart/Settings.cs
//

using MicroCommerce.Middleware;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Shed.CoreKit.WebApi;
using System.Net.Http;

namespace MicroCommerce.ShoppingCart
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddCorrelationToken();
            services.AddCors();
            services.AddTransient<IShoppingCart, ShoppingCartImpl>();
            services.AddTransient<HttpClient>();
            services.AddWebApiEndpoints(new WebApiEndpoint<IProductCatalog>(new System.Uri("http://localhost:5001")));
            services.AddLogging(builder => builder.AddConsole());
            services.AddRequestLogging();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseCorrelationToken();
            app.UseRequestLogging("getevents");
            app.UseCors(builder =>
            {
                builder
                    .AllowAnyOrigin()
                    .AllowAnyMethod()
                    .AllowAnyHeader();
            });

            app.UseWebApiEndpoint<IShoppingCart>();
        }
    }
}

Метод AddWebApiEndpoints может принимать произвольное количество параметров, поэтому возможно настроить все зависимости одним вызовом этого метода.

В остальном все настройки аналогичны ProductCatalog.

4. Микросервис ActivityLogger


Привязываем проект к порту 5003 аналогично ProductCatalog.

Подключаем к проекту Nuget- пакет Shed.CoreKit.WebApi и ссылки на проекты Interfaces и Middleware.

Добавляем реализацию интерфейса IActivityLogger.

//
// ActivityLogger/ActivityLogger.cs
//

using MicroCommerce;
using MicroCommerce.Models;
using System.Collections.Generic;
using System.Linq;

namespace ActivityLogger
{
    public class ActivityLoggerImpl : IActivityLogger
    {
        private IShoppingCart _shoppingCart;

        private static long timestamp;
        private static List<LogEvent> _log = new List<LogEvent>();

        public ActivityLoggerImpl(IShoppingCart shoppingCart)
        {
            _shoppingCart = shoppingCart;
        }

        public IEnumerable<LogEvent> Get(long timestamp)
        {
            return _log.Where(i => i.Timestamp > timestamp);
        }

        public void ReceiveEvents()
        {
            var cartEvents = _shoppingCart.GetCartEvents(timestamp);

            if(cartEvents.Count() > 0)
            {
                timestamp = cartEvents.Max(c => c.Timestamp);
                _log.AddRange(cartEvents.Select(e => new LogEvent
                {
                    Description = $"{GetEventDesc(e.Type)}: '{e.Order.Product.Name} ({e.Order.Quantity})'"
                }));
            }
        }

        private string GetEventDesc(CartEventTypeEnum type)
        {
            switch (type)
            {
                case CartEventTypeEnum.OrderAdded: return "order added";
                case CartEventTypeEnum.OrderChanged: return "order changed";
                case CartEventTypeEnum.OrderRemoved: return "order removed";
                default: return "unknown operation";
            }
        }
    }
}

Здесь также используется зависимость от другого микросервиса (IShoppingCart). Но одна из задач этого сервиса - слушать события других сервисов, поэтому добавляем дополнительный метод ReceiveEvents(), который будем вызывать из планировщика. Мы его добавим к проекту дополнительно.
//
// ActivityLogger/Scheduler.cs
//

using Microsoft.Extensions.Hosting;
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ActivityLogger
{
    public class Scheduler : BackgroundService
    {
        private IServiceProvider ServiceProvider;

        public Scheduler(IServiceProvider serviceProvider)
        {
            ServiceProvider = serviceProvider;
        }

        protected override Task ExecuteAsync(CancellationToken stoppingToken)
        {
            Timer timer = new Timer(new TimerCallback(PollEvents), stoppingToken, 2000, 2000);
            return Task.CompletedTask;
        }

        private void PollEvents(object state)
        {
            try
            {
                var logger = ServiceProvider.GetService(typeof(MicroCommerce.IActivityLogger)) as ActivityLoggerImpl;
                logger.ReceiveEvents();
            }
            catch
            {

            }
        }
    }
}

Настройки проекта аналогичны предыдущему пункту.

Дополнительно нужно только подключить добавленный ранее планировщик.

//
// ActivityLogger/Setup.cs
//

using System.Net.Http;
using MicroCommerce;
using MicroCommerce.Middleware;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Shed.CoreKit.WebApi;

namespace ActivityLogger
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddCorrelationToken();
            services.AddCors();
            services.AddTransient<IActivityLogger, ActivityLoggerImpl>();
            services.AddTransient<HttpClient>();
            services.AddWebApiEndpoints(new WebApiEndpoint<IShoppingCart>(new System.Uri("http://localhost:5002")));
            // регистрируем планировщик (запустится при старте приложения, больше ничего делать не нужно)
            services.AddHostedService<Scheduler>();
            services.AddLogging(builder => builder.AddConsole());
            services.AddRequestLogging();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseCorrelationToken();
            app.UseRequestLogging("get");
            app.UseCors(builder =>
            {
                builder
                    .AllowAnyOrigin()
                    .AllowAnyMethod()
                    .AllowAnyHeader();
            });

            app.UseWebApiEndpoint<IActivityLogger>();
        }
    }
}

5. WebUI - пользовательский интерфейс


Привязываем проект к порту 5000 аналогично ProductCatalog.

Подключаем к проекту Nuget- пакет Shed.CoreKit.WebApi. Cсылки на проекты Interfaces и Middleware нужно подключать только в том случае, если мы в этом проекте собираемся использовать вызовы к микросервисам.

Строго говоря, это обычный ASP.NET проект и в нем возможно использование MVC, т.е. для взаимодействия с UI мы можем создать контроллеры, которые используют наши интерфейсы микросервисов как зависимости. Но интереснее и практичнее оставить за этим проектом только предоставление пользовательского интерфейса, а все обращения со стороны UI перенаправлять непосредственно микросервисам. Для этого используется метод-расширитель UseWebApiRedirect из пакета Shed.CoreKit.WebApi:

//
// WebUI/Setup.cs
//

using MicroCommerce.Interfaces;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shed.CoreKit.WebApi;
using System.Net.Http;

namespace MicroCommerce.Web
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.Use(async (context, next) =>
            {
                //  when root calls, the start page will be returned
                if(string.IsNullOrEmpty(context.Request.Path.Value.Trim('/')))
                {
                    context.Request.Path = "/index.html";
                }

                await next();
            });
            app.UseStaticFiles();
            // редиректы на микросервисы
            app.UseWebApiRedirect("api/products", new WebApiEndpoint<IProductCatalog>(new System.Uri("http://localhost:5001")));
            app.UseWebApiRedirect("api/orders", new WebApiEndpoint<IShoppingCart>(new System.Uri("http://localhost:5002")));
            app.UseWebApiRedirect("api/logs", new WebApiEndpoint<IActivityLogger>(new System.Uri("http://localhost:5003")));
        }
    }
}

Все очень просто. Теперь если со стороны UI придет, например, запрос к ‘http://localhost:5000/api/products/get’, он будет автоматически перенаправлен на ‘http://localhost:5001/get’. Конечно же, для этого микросервисы должны разрешать кросс-доменные запросы, но мы разрешили это ранее (см. CORS в реализации микросервисов).

Теперь осталось только разработать пользовательский интерфейс, и лучше всего для этого подходит Single Page Application. Можно использовать Angular или React, но мы просто создадим маленькую страничку с использованием готовой темы bootstrap и фреймворка knockoutjs.

<!DOCTYPE html><!-- WebUI/wwwroot/index.html -->
<html>
<head>
    <meta charset="utf-8" />
    <title></title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootswatch/4.5.0/materia/bootstrap.min.css" />"
    <style type="text/css">
        body {
            background-color: #0094ff;
        }

        .panel {
            background-color: #FFFFFF;
            margin-top:20px;
            padding:10px;
            border-radius: 4px;
        }

        .table .desc {
            vertical-align: middle;
            font-weight:bold;
        }

        .table .actions {
            text-align:right;
            white-space:nowrap;
            width:40px;
        }
    </style>
    <script src="https://code.jquery.com/jquery-3.5.1.min.js"
            integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0="
            crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.5.1/knockout-latest.min.js"></script>
    <script src="../index.js"></script>
</head>
<body>
    <div class="container">
        <div class="row">
            <div class="col-12">
                <div class="panel panel-heading">
                    <div class="panel-heading">
                        <h1>MicroCommerce</h1>
                    </div>
                </div>
            </div>
            <div class="col-xs-12 col-md-6">
                <div class="panel panel-default">
                    <h2>All products</h2>
                    <table class="table table-bordered" data-bind="foreach:products">
                        <tr>
                            <td data-bind="text:name"></td>
                            <td class="actions">
                                <a class="btn btn-primary" data-bind="click:function(){$parent.addorder(id, 1);}">ADD</a>
                            </td>
                        </tr>
                    </table>
                </div>
            </div>
            <div class="col-xs-12 col-md-6">
                <div class="panel panel-default" data-bind="visible:shoppingCart()">
                    <h2>Shopping cart</h2>
                    <table class="table table-bordered" data-bind="foreach:shoppingCart().orders">
                        <tr>
                            <td data-bind="text:product.name"></td>
                            <td class="actions" data-bind="text:quantity"></td>
                            <td class="actions">
                                <a class="btn btn-primary" data-bind="click:function(){$parent.delorder(id);}">DELETE</a>
                            </td>
                        </tr>
                    </table>
                </div>
            </div>
            <div class="col-12">
                <div class="panel panel-default">
                    <h2>Operations history</h2>
                    <!-- ko foreach:logs -->
                    <div class="log-item">
                        <span data-bind="text:time"></span>
                        <span data-bind="text:description"></span>
                    </div>
                    <!-- /ko -->
                </div>
            </div>
        </div>
    </div>

    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js"></script>
    <script>
        var model = new IndexModel();
        ko.applyBindings(model);
    </script>
</body>
</html>

//
// WebUI/wwwroot/index.js
//

function request(url, method, data) {
    return $.ajax({
        cache: false,
        dataType: 'json',
        url: url,
        data: data ? JSON.stringify(data) : null,
        method: method,
        contentType: 'application/json'
    });
}

function IndexModel() {
    this.products = ko.observableArray([]);
    this.shoppingCart = ko.observableArray(null);
    this.logs = ko.observableArray([]);
    var _this = this;

    this.getproducts = function () {
        request('/api/products/get', 'GET')
            .done(function (products) {
                _this.products(products);
                console.log("get products: ", products);
            }).fail(function (err) {
                console.log("get products error: ", err);
            });
    };

    this.getcart = function () {
        request('/api/orders/get', 'GET')
            .done(function (cart) {
                _this.shoppingCart(cart);
                console.log("get cart: ", cart);
            }).fail(function (err) {
                console.log("get cart error: ", err);
            });
    };

    this.addorder = function (id, qty) {
        request(`/api/orders/addorder/${id}/${qty}`, 'PUT')
            .done(function (cart) {
                _this.shoppingCart(cart);
                console.log("add order: ", cart);
            }).fail(function (err) {
                console.log("add order error: ", err);
            });
    };

    this.delorder = function (id) {
        request(`/api/orders/deleteorder?orderId=${id}`, 'DELETE')
            .done(function (cart) {
                _this.shoppingCart(cart);
                console.log("del order: ", cart);
            }).fail(function (err) {
                console.log("del order error: ", err);
            });
    };

    this.timestamp = Number(0);
    this.updateLogsInProgress = false;
    this.updatelogs = function () {
        if (_this.updateLogsInProgress)
            return;

        _this.updateLogsInProgress = true;
        request(`/api/logs/get?timestamp=${_this.timestamp}`, 'GET')
            .done(function (logs) {
                if (!logs.length) {
                    return;
                }

                ko.utils.arrayForEach(logs, function (item) {
                    _this.logs.push(item);
                    _this.timestamp = Math.max(_this.timestamp, Number(item.timestamp));
                });
                console.log("update logs: ", logs, _this.timestamp);
            }).fail(function (err) {
                console.log("update logs error: ", err);
            }).always(function () { _this.updateLogsInProgress = false; });
    };

    this.getproducts();
    this.getcart();
    this.updatelogs();
    setInterval(() => _this.updatelogs(), 1000);
}

Я не буду подробно объяснять реализацию UI, т.к. это выходит за рамки темы статьи, скажу только, что в javascript-модели определены свойства и коллекции для привязки со стороны HTML-разметки, а также функции, реагирующие на нажатие кнопок для обращения к конечным точкам WebApi, которые незаметно для разработчика перенаправляются к соответствующим микросервисам. Как выглядит пользовательский интерфейс и как он работает мы рассмотрим позднее в разделе “Тестирование приложения”.

6. Несколько слов об общей функциональности


Мы не затронули в этой статье некоторые другие аспекты разработки приложения, такие как журналирование, мониторинг работоспособности, аутентификация и авторизация. Это все подробно рассмотрено в книге Кристиана Хорсдала и вполне применимо в рамках вышеописанного подхода. Вместе с тем эти аспекты слишком специфичны для для каждого конкретного приложения и не имеет смысла выносить их в Nuget-пакет, лучше просто создать отдельную сборку в рамках приложения. Мы такую сборку создали - это Middleware. Для примера просто добавим сюда функциональность для журналирования запросов, которую мы уже подключили при разработке микросервисов (см. пп. 2-4).

//
// Middleware/RequestLoggingExt.cs
//

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

namespace MicroCommerce.Middleware
{
    public static class RequestLoggingExt
    {
        private static RequestLoggingOptions Options = new RequestLoggingOptions();

        public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder builder, params string[] exclude)
        {
            Options.Exclude = exclude;

            return builder.UseMiddleware<RequestLoggingMiddleware>();
        }

        public static IServiceCollection AddRequestLogging(this IServiceCollection services)
        {
            return services.AddSingleton(Options);
        }
    }

    internal class RequestLoggingMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly ILogger _logger;
        private RequestLoggingOptions _options;

        public RequestLoggingMiddleware(RequestDelegate next, ILoggerFactory loggerFactory, RequestLoggingOptions options)
        {
            _next = next;
            _options = options;
            _logger = loggerFactory.CreateLogger("LoggingMiddleware");
        }

        public async Task InvokeAsync(HttpContext context)
        {
            if(_options.Exclude.Any(i => context.Request.Path.Value.Trim().ToLower().Contains(i)))
            {
                await _next.Invoke(context);
                return;
            }

            var request = context.Request;
            _logger.LogInformation($"Incoming request: {request.Method}, {request.Path}, [{HeadersToString(request.Headers)}]");
            await _next.Invoke(context);
            var response = context.Response;
            _logger.LogInformation($"Outgoing response: {response.StatusCode}, [{HeadersToString(response.Headers)}]");
        }

        private string HeadersToString(IHeaderDictionary headers)
        {
            var list = new List<string>();
            foreach(var key in headers.Keys)
            {
                list.Add($"'{key}':[{string.Join(';', headers[key])}]");
            }

            return string.Join(", ", list);
        }
    }

    internal class RequestLoggingOptions
    {
        public string[] Exclude = new string[] { };
    }
}

Пара методов AddRequestLogging() / UseRequestLogging(...) позволяет включить журналирование запросов в микросервисе. Метод UseRequestLogging кроме того может принимать произвольное количество путей-исключений. Мы воспользовались этим в ShoppingCart и в ActivityLogger чтобы исключить из журналирования опросы событий и избежать переполнения логов. Но повторюсь, журналирование, как и любая другай общая функциональность - это исключительно зона ответственности разработчиков и реализуется в рамках конкретного проекта.

Тестирование приложения


Запускаем решение, видим слева список продуктов для добавления в корзину, пустую корзину справа и историю операций снизу, тоже пока пустую.



В консолях микросервисов мы видим, что при старте UI уже запросил и получил некоторые данные. Например, для получения списка продуктов был отправлен запрос localhost:5000/api/products/get, который был перенаправлен на localhost:5001/get.





Когда мы нажимаем кнопку ADD, в корзину добавляется соответствующий продукт. Если продукт был уже ранее добавлен, просто увеличивается количество.



Микросервису ShoppingCart отправляется запрос localhost:5002/addorder/

Но поскольку ShoppingCart не хранит список продуктов, сведения о заказанном продукте он получает от ProductCatalog.



Обратите внимание, перед отправкой запроса к ProductCatalog был назначен токен корреляции. Это позволяет отследить цепочки связанных запросов в случае сбоев.

По завершении операции ShoppingCart публикует событие, которое отслеживает и регистрирует ActivityLogger. В свою очередь, UI периодически опрашивает этот микросервис и отображает полученные данные на панели истории операций. Конечно же, записи в истории появляются с некоторой задержкой, т.к. это параллельный, не зависящий от операции добавления продукта механизм.

Заключение


Nuget-пакет Shed.CoreKit.WebApi позволяет:

  • полностью сосредоточиться на разработке бизнес-логики приложения, не прилагая дополнительных усилий на вопросы взаимодействия микросервисов;
  • описывать структуру микросервиса интерфейсом .NET и использовать его как при разработке самого микросервиса, так и для генерации Web-клиента (Web-клиент для микросервиса генерируется фабричным методом после регистрации интерфейса в DI и предоставляется как зависимость);
  • регистрировать интерфейсы микросервисов как зависимости в Dependency Injection;
  • организовать перенаправление запросов со стороны Web UI к микросервисам без дополнительных усилий при разработке UI.


Microcommerce.zip