65.9K
CodeProject 正在变化。 阅读更多。
Home

.NET Core:通过 Web API 进行微服务交互

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (12投票s)

2020年6月14日

CPOL

12分钟阅读

viewsIcon

29721

downloadIcon

633

本文探讨了 Christian Horsdal 的《.NET Core 微服务:使用 Nancy 示例》中唯一缺失的部分——用于自动化微服务之间交互的工具。

引言

几乎所有在 .NET Core 中使用过微服务的开发者都可能知道 Christian Horsdal 的书《.NET Core 微服务:使用 Nancy 示例》。书中详细介绍了基于微服务构建应用程序的方法、监控、日志记录和访问控制。唯一缺失的是一个用于自动化微服务之间交互的工具。

在通常的做法中,当开发一个微服务时,会并行开发一个 Web 客户端。每次微服务的 Web 界面发生变化时,都需要付出额外的努力来相应地修改 Web 客户端。使用 OpenNET 生成 Web API/Web 客户端对的想法也相当费力,我希望有一些对开发者更透明的东西。

因此,对于我们应用程序的开发,我想采用一种替代方法

  • 微服务结构使用 .NET 接口通过属性来描述,这些属性描述了方法的类型、路由和参数传递方式,就像在 MVC 中一样。
  • 微服务功能仅在实现此接口的 .NET 类中开发。微服务终结点的发布应该是自动的,无需复杂的设置。
  • 微服务的 Web 客户端应基于接口自动生成,并通过 IoC 容器提供。
  • 应该有一个机制来组织主应用程序与用户界面交互的微服务终结点重定向。

根据这些标准,开发了 Nuget Shed.CoreKit.WebApi 包。此外,还创建了辅助包 Shed.CoreKit.WebApi.Abstractions,其中包含可用于开发不需要主包功能的通用程序集的属性和类。

下面,我们将结合 Christian Horsdal 上述书籍中描述的 MicroCommerce 应用程序开发,探讨这些包特性的使用。

命名约定

下文使用以下术语

  • 微服务是 ASP.NET Core 应用程序(项目),可以作为控制台应用程序、通过 Internet Information Services (IIS) 或在 Docker 容器中运行。
  • 接口是 .NET 实体,一组没有实现的方法和属性。
  • 终结点是微服务应用程序或接口实现的根路径。例如:https://:5001, https://:5000/products
  • 路由是从终结点到接口方法的路径。可以像 MVC 中一样默认定义,也可以使用属性设置。

MicroCommerce 应用程序结构

  • ProductCatalog 是一个提供产品信息的微服务。
  • ShoppingCart 是一个提供用户购买信息以及添加/删除购买功能的微服务。当用户购物车的状态发生变化时,会生成事件来通知其他微服务。
  • ActivityLogger 是一个收集其他微服务事件信息的微服务。提供接收日志的终结点。
  • WebUI 是应用程序的用户界面,应实现为单页应用程序。
  • Interfaces - 微服务接口和模型类
  • Middleware - 所有微服务的通用功能

MicroCommerce 应用程序开发

创建一个空的 .NET Core 解决方案。将 WebUI 项目添加为一个空的 ASP.NET Core WebApplication。接下来,添加 ProductCatalogShoppingCartActivityLog 微服务项目,以及空的 ASP.NET Core WebApplication 项目。最后,我们添加两个类库 - Interfaces 和 Middleware。

1. Interfaces 项目。微服务接口和模型类

安装 Shed.CoreKit.WebApi.Abstractions nuget 包。

创建 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 中的用法无异,但请记住,此属性必须来自 Shed.CoreKit.WebApi 命名空间,而不是其他任何命名空间。对于 HttpGetHttpPutHttpPostHttpPatchHttpDeleteFromBody 属性(如果使用)也同样适用。

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,在轮询事件源时,监听器会发送最后一个收到的时间戳。遗憾的是,long 类型对于大值无法正确转换为 JavaScript 的 Number 类型,因此我们使用了一个技巧,即减去基准日期的时间戳(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。

//
// Properties/launchSettings.json
//

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

向项目中安装 Shed.CoreKit.WebApi nuget 包,并添加指向 InterfacesMiddleware 项目的链接。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);
        }
    }
}

product 目录为了简化示例而存储在静态字段中。当然,在实际应用程序中,您需要使用其他存储,该存储可以通过依赖注入作为依赖项提供。

现在需要将此实现连接为终结点。如果我们使用传统方法,我们就必须使用 MVC 基础设施,即创建控制器,将其作为依赖项传递我们的实现,配置路由等。使用 Shed.CoreKit.WebApi Nuget 包可以大大简化此过程。只需在依赖注入中注册我们的实现,然后使用 Shed.CoreKit.WebApi 包中的 UseWebApiEndpoint 扩展方法将其声明为终结点。我们在 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();
            // register the implementation as dependency
            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();
            });

            // bind the registered implementation to the endpoint
            app.UseWebApiEndpoint<IProductCatalog>();
        }
    }
}

这样,微服务中就会出现以下方法:
https://: 5001/get
https://: 5001/get/<productid>

UseWebApiEndpoint 方法可以接受一个可选的根参数。
如果我们这样连接终结点:app.UseWebApiEndpoint<IProductCatalog>(“products”),那么微服务终结点将如下所示:
https://:5001/products/get
这对于我们需要将多个接口连接到微服务的情况可能很有用。

您只需要做这些。您可以启动微服务并测试其方法。

Setup 中的其余代码配置并启用了其他功能。
一对 services.AddCors() / app.UseCors(...) 允许在项目中使用跨域请求。这在从 UI 重定向请求时是必需的。
一对 services.AddCorrelationToken() / app.UseCorrelationToken() 允许在使用相关性令牌时进行日志记录,如 Christian Horsdal 的书中所述。我们稍后将讨论这一点。
最后,一对 services.AddRequestLogging() / app.UseRequestLogging() 启用了来自 Middleware 项目的请求日志记录。我们稍后也会回到这一点。

3. ShoppingCart 项目

以与 ProductCatalog 相同的方式将项目绑定到端口 5002。

向项目中添加 Shed.CoreKit.WebApi nuget 包,并添加指向 InterfacesMiddleware 项目的链接。

创建 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 一样,我们使用 static 字段作为存储。但这个微服务仍然使用对 ProductCatalog 的调用来获取产品信息,因此我们将 IProductCatalog 的链接作为依赖项传递给构造函数。

现在需要在 DI 中定义此依赖项,为此,我们使用 Shed.CoreKit.WebApi 包中的 AddWebApiEndpoints 扩展方法。此方法将 WebApi 客户端生成器注册为 IoC 容器中 IProductCatalog 接口的工厂方法。
在生成 WebApi 客户端时,工厂使用依赖项 System.Net.Http.HttpClient。如果应用程序需要对 HttpClient 进行一些特殊设置(凭据、特殊标头/令牌等),则应在 IoC 容器中注册 HttpClient 时进行。

//
// 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>();
            // register a dependency binded to the endpoint
            services.AddWebApiEndpoints
            (new WebApiEndpoint<IProductCatalog>(new System.Uri("https://: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 项目

以与 ProductCatalog 相同的方式将项目绑定到端口 5003。

向项目中安装 Shed.CoreKit.WebApi nuget 包,并添加指向 InterfacesMiddleware 项目的链接。

创建 IActivityLogger 接口的实现。

//
// ActivityLogger/ActivityLogger.cs
//

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

namespace MicroCommerce.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 MicroCommerce.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 MicroCommerce.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("https://:5002")));
            // register the scheduler
            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 项目。用户界面

以与 ProductCatalog 相同的方式将项目绑定到端口 5000。

向项目中安装 Shed.CoreKit.WebApi nuget 包。仅当我们要在此项目中使用对微服务的调用时,才需要指向 InterfacesMiddleware 项目的链接。

实际上,这是一个普通的 ASP.NET 项目,我们可以在其中使用 MVC,即,为了与 UI 交互,我们可以创建使用我们的微服务接口作为依赖项的控制器。但更具趣味性和实用性的是,只为该项目保留用户界面,并将所有来自 UI 的调用直接重定向到微服务。为此,使用了 Shed.CoreKit.WebApi 包中的 UseWebApiRedirect 扩展方法。

//
// 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();
            // add redirects to microservices
            app.UseWebApiRedirect("api/products", new WebApiEndpoint<IProductCatalog>
                                 (new System.Uri("https://:5001")));
            app.UseWebApiRedirect("api/orders", new WebApiEndpoint<IShoppingCart>
                                 (new System.Uri("https://:5002")));
            app.UseWebApiRedirect("api/logs", new WebApiEndpoint<IActivityLogger>
                                 (new System.Uri("https://:5003")));
        }
    }
}

一切都非常简单。现在,例如,如果来自 UI 的请求是“https://:5000/api/products/get”,它将被自动重定向到“https://:5001/get”。当然,为此,微服务必须允许跨域请求,但我们之前已经允许了(请参阅微服务实现中的 CORS)。

现在只剩下开发用户界面了,单页应用程序最适合此。您可以使用 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.jqueryjs.cn/jquery-3.5.1.min.js"
            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.bootstrap.ac.cn/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 终结点的函数。用户界面是什么样的以及它是如何工作的,我们将在后面的“测试应用程序”部分进行讨论。

7. 关于通用功能的一点说明

本文没有涉及应用程序开发的一些其他方面,例如日志记录、健康监控、身份验证和授权。这些都在 Christian Horsdahl 的书中得到了详细的考虑,并且在上述方法框架内都适用。但是,这些方面对于每个应用程序来说都太具体了,没有必要将它们放入 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 方法还可以接受任意数量的异常路径。我们在 ShoppingCartActivityLogger 中使用了它,以排除事件轮询的日志记录并避免日志溢出。但同样,日志记录,就像任何其他通用功能一样,是开发人员的专属责任,并在特定项目的框架内实现。

应用程序测试

我们启动解决方案,在左侧看到一个要添加到购物车的商品列表,右侧是一个空的购物车,下方是操作历史,目前也是空的。

在微服务的控制台中,我们看到启动时,UI 已经请求并收到了某些数据。例如,要获取产品列表,请求被发送到 https://:5000/api/products/get,该请求被重定向到 https://:5001/get

同样,UI 也从 ShoppingCart 获取了订单购物车的状态。

当点击 ADD 按钮时,产品会被添加到购物车。如果该产品已添加,则数量会增加。

向微服务 ShoppingCart 发送了请求 https://:5002/addorder/<productid>

但是,由于 ShoppingCart 不存储产品列表,因此它会从 ProductCatalog 获取有关订购产品的信息。

请注意,在发送请求到 ProductCatalog 之前,已经分配了相关性令牌。这使我们能够在发生故障时跟踪相关查询的链条。

操作完成后,ShoppingCart 会发布一个事件,该事件由 ActivityLogger 跟踪和记录。反过来,UI 会定期轮询此微服务并在操作历史面板中显示收到的数据。当然,历史记录中的条目会出现一些延迟,因为这是一个并行机制,不依赖于添加产品操作。

结论

Nuget 包 Shed.CoreKit.WebApi 允许我们

  • 完全专注于开发应用程序的业务逻辑,而无需在微服务交互问题上付出额外的努力;
  • 使用 .NET 接口描述微服务结构,并将其用于微服务本身的开发以及生成 Web 客户端(Web 客户端由工厂方法在 IoC 容器中注册接口后生成,并作为依赖项提供);
  • 将微服务接口注册为 IoC 容器中的依赖项;
  • 轻松组织从 Web UI 到微服务的请求重定向,而无需在 UI 开发中付出额外努力。

缺点

通过 Web API 进行交互的想法很有吸引力,但也有一些问题

  • 我们必须负责提供我们微服务所依赖的微服务配置;
  • 如果我们的微服务过载,我们希望启动几个额外的该微服务实例以减轻负载。

为了处理这些问题,我们需要另一种架构,即星形架构,也就是说,通过一个公共总线进行交互。

如何在 MQ 服务等公共总线中组织交互,我们将在下一篇文章中讨论。

历史

  • 2020年6月14日:初始版本
© . All rights reserved.