《基于.NET Core构建微服务》系列文章(更新至第6篇,最新第7篇,已发布主页候选区)

原文:Building Microservices On .NET Core – Part 1 The Plan
时间:2019年1月14日
作者:Wojciech Suwała, Head Architect, ASC LAB

从一开始我就喜欢.NET技术。实际上,我在2004年左右就离开了过度XML化的J2EE开发。多年来,我在Altkom Software&Consulting的团队为保险银行业建立并维护了越来越复杂的业务解决方案。当Java处于停滞状态时,.NET平台发展很快。已创建并广泛采用了几个开源库,例如NHibernateCastle Projectlog4net。使用ASP.NET MVC可以使事情变得更好。但是在2014年左右,随着Microsoft开始对.NET Core进行大量投资,情况发生了变化。这个想法似乎很棒——在所有主要平台上都运行.NET。但是花了很多年才实现,而第一个发行版却令人失望。如此众多的API缺失导致我们的许多收藏的库未被移植。 Entity Framework的第一个版本缺少关键功能。 .NET的未来似乎令人怀疑。与此同时,Java 8出现并给语言带来了新鲜感,同时出现了基于微服务的架构方法。加上Spring Boot,这说服了我回到Java领域。

但是,.NET的最新发行版——尤其是.NET Core 2.x和.NET标准计划使我确信.NET Core已重新投入业务。因此,我和队友决定探索在.NET Core上构建基于微服务的解决方案的可能性和挑战。

自2015年以来,我们就在微服务领域拥有丰富的经验,我们以这种架构方法开发和部署系统。我们希望找到可能的选项来处理与微服务相关的典型任务,例如服务发现,同步和异步服务通信,数据访问各种数据源(关系和NoSQL),日志记录,弹性,错误处理,JWT和API网关构建的安全性。

计划

在本系列文章中,我们将逐步介绍构建基于微服务的解决方案所需的典型任务。

这些任务包括:

  • 使用以下方法设计微服务的内部架构
    • CQRS
    • 事件源(Event Sourcing)
  • 访问数据源
    • Entity Framework Core与PostgreSQL
    • NHibernate
    • Marten与PostgreSQL
    • NEST与ElasticSearch
  • 微服务之间的通信
    • 直接HTTP REST同步调用
    • RabbitMQ异步调用
  • 具有Ocelot的API网关
  • 使用Eureka进行服务发现
  • 弹性与Polly
  • 使用Serilog进行日志记录
  • 使用JWT保护服务
  • 使用Hangfire运行后台作业
  • 使用ELK进行日志聚合
  • 指标矩阵(Metrics)和监控
  • 具有Azure DevOps的CI / CD
  • 将服务部署到
    • Azure
    • Kubernetes

发布文章列表

Part 2: Shaping microservice internal architecture with CQRS and MediatR
Part 3: Service Discovery with Eureka
Part 4: Building API Gateways With Ocelot
Part 5: Marten An Ideal Repository For Your Domain Aggregates
Part 6: Real time server client communication with SignalR and RabbitMQ

在该系列的新文章发表后,该列表将进行更新。

商业案例

我们将为保险代理人建立非常简化的系统以销售各种保险产品。

保险代理人必须登录,系统会向他们显示他们可以销售的产品清单。 代理商将能够查看产品并找到适合其客户的产品。 然后他们可以创建合同,系统将根据提供的参数计算价格。

最后,代理商将能够通过将报价转换为保单并打印PDF证书来确认销售。

门户网站还将使他们能够搜索和查看报价和政策。

门户网站还将具有一些基本的社交网络功能,例如与代理聊天。

解决方案架构

您可以在下面找到我们系统的架构图。

系统架构图

该系统包括:

  • 前端 – 一个VueJS单页应用程序,它使保险代理能够为其客户选择合适的产品,计算价格,创建报价并通过将报价转换为保单来结束销售过程。该应用程序还提供策略和报价的搜索和查看功能。前端通过API网关与后端服务进行对话。
  • Agent Portal API网关 – 是一种特殊的微服务,其主要目的是向客户端应用程序隐藏底层后台服务结构的复杂性。通常,我们为每个客户端应用程序创建专用的API网关。如果将来我们在系统中添加Xamarin移动应用,则需要为其构建专用的API网关。 API网关还提供安全屏障,并且不允许将未经身份验证的请求传递到后端服务。 API网关的另一种流行用法是来自多个服务的内容聚合。
  • 身份验证服务 – 负责用户身份验证的服务。我们的安全系统将基于JWT令牌。用户正确识别自己的身份后,身份验证服务将颁发令牌,该令牌将进一步用于检查用户权限和可用产品。
  • 聊天服务 – 使用SignalR使座席能够相互聊天的服务。
  • 定价服务 – 负责根据其参数化计算给定保险产品价格的服务。
  • 政策服务 – 我们系统中的主要服务。它负责创建报价和策略。它通过REST HTTP调用使用定价服务来计算价格。创建策略后,它将异步事件发送到事件总线(RabbitMQ),以便其他服务可以做出反应。
  • 策略搜索服务 – 此服务的唯一目的是公开搜索功能。该服务订阅与策略生命周期相关的事件,并在ElasticSearch中为给定策略的索引建立索引,以提供高级搜索功能。
  • 付款服务 – 该服务负责管理与政策相关的财务运作。它订阅与策略相关的事件,在创建策略时创建策略帐户,注册预期付款。它还具有非常简化的后台作业,该作业可以解析带有收款的文件并将其分配给适当的保单帐户。
  • 产品服务 – 这是产品目录。它提供有关每种保险产品及其参数的基本信息,可以在为客户创建报价时对其进行自定义。
  • 文件服务 – 该服务使用JS报告生成pdf证书。

如您所见,业务能力分解了服务。我们还有一项技术服务–文件服务。当技术服务需要可扩展性/弹性并且我们希望减少更新/修复或许可成本的时间时,引入技术服务就很有意义。

每个微服务都以DDD术语围绕有界上下文构建。您还可以观察到他们合作实现了主要业务目标——向最终客户出售保险产品。

解决方案结构

以下是我们在Rider IDE中打开的解决方案的一部分。如您所见,我们为每个微服务创建了三个项目:一个用于API定义,一个用于实现和一个用于测试。您可以在GitHub上找到我们项目的源代码。请注意,这是一个正在进行的工作,代码将随着我们系列的进行而更改。

解决方案结构

API定义项目包含类和接口,这些类和接口以其可以处理的命令,其可以回答的查询,所发出的事件以及所公开的数据传输对象类的形式描述对外公开的服务功能。我们可以将它们视为端口和适配器体系结构中的端口定义。

实现项目包含命令处理程序,查询处理程序和通知处理程序,它们一起提供服务功能。大多数业务逻辑进入域模型部分。与外界对话的适配器被实现为控制器(用于处理传入的HTTP请求),侦听器(用于通过队列传递的事件)和REST客户端(用于处理传出的HTTP请求)。

测试项目包含单元测试和集成测试。

总结

我们认为.NET Core是构建微服务的绝佳平台,在本系列文章中我们将对此进行证明。周围有一个丰富的开源工具和库生态系统。 C#语言本身就是一个很好的工具。

该平台和库都是开源的,并且以很高的速度交付了新功能。

.NET Core 2.x在性能和内存使用方面的增强也使其更具吸引力。

在接下来的文章中,我们将介绍与微服务实现和部署相关的常见任务的发现和解决方案。


原文:Building Microservices On .NET Core – Part 2 Shaping microservice internal architecture with CQRS and MediatR
时间:2019年1月21日
作者:Ewelina Polska-Brzostowska, Lead .NET Developer

在有关在.NET Core中构建微服务的系列文章的第一篇文章中,我们将重点介绍典型微服务的内部体系结构。 根据微服务类型,可以考虑许多选项。 系统中的某些服务将是典型的CRUD,因此无需讨论其设计(除非从性能和可伸缩性角度来看它们至关重要)。

在本文中,我们将设计非平凡微服务的内部体系结构,该体系结构既负责管理其数据状态,又将其公开给外部世界。 基本上,我们的微服务将负责其数据的创建和各种修改,还将公开API,该API将允许其他服务和应用程序查询此数据。

完整解决方案的源代码可以在我们的Github上找到。

CQRS概览

想象一下ProductService类。它实现了我们可以对产品执行的所有操作。在我的示例中,它是保险产品,但是在此情况下,这并不重要。代码的每个更改都需要调查其工作方式以及可能产生的副作用。这会导致代码增长,变得难以处理并且启动很费时间。

在许多应用程序中,有许多巨型类实现逻辑并包含可以使用给定类型的对象完成的所有操作。如何重构它们以拆分代码并共享功能?简化一下,我们可以区分两个主要的数据操作。操作可以更改或读取数据。因此,自然的方法是通过此类将它们分开。可以将更改数据的操作(Commands)与仅读取数据的操作(queries)区分开。在大多数系统中,读取和写入之间的差异至关重要。进行读取时,您没有进行任何验证或业务逻辑。但是您经常使用缓存。读和写操作的模型也(或必须是)大不相同。

CQRS – 命令查询责任隔离是一种模式,需要将执行查询逻辑的代码和模型与执行命令的代码和模型分开。

回到我们的示例–根据上述规则共享的ProductService现在变为:

  • 返回IEnumerable<ProductDto>FindAllProductsQuery(也可以实现为另一个模型–包含ProductDto集合的FindAllProductsResult
  • 返回ProductDtoFindProductByCodeQuery
  • 使用输入ProductDraftDtoCreateProductDraftHandler并将产品添加到我们的系统中。

上面的查询共享了一个模型,但是如果需要在结果中使用不同的数据,则应将模型分开(通常是这样)。

因此,我们现在有两个部分:命令或查询类以及结果类。

如何连接它们?
如何知道每个查询/命令的输入/输出是什么类型?
现在该介绍中介者了。在这种情况下,调解员要做的工作是将这些片段捆绑到单个请求中。

.NET Core 2.x和MediatR

我认为我们现在可以看到一些代码。 我们使用MediatR库来帮助我们在ProductService中实现CQRS模式MediatR是某种“内存总线”,它是应用程序不同部分之间通信的接口。

我们可以使用Package Manager Console将MediatR添加到项目中,键入:

Install-Package MediatR

接下来,仅通过添加代码services.AddMediatR();将其注册到DI容器中。 在Startup类的ConfigureServices方法中。

要使用MediatR创建查询消息,我们需要添加实现IRequest接口的类,并指定查询类期望的响应类型:

public class FindProductByCodeQuery : IRequest<ProductDto>
{
    public string ProductCode { get; set; }
}

如何定义输入模型? 它是控制器动作的参数:

// GET api/products/{code}
[HttpGet("{code}")]
public async Task<ActionResult> GetByCode([FromRoute]string code)
{
     var result = await mediator.Send(new FindProductByCodeQuery{ ProductCode = code });
     return new JsonResult(result);
}

现在,我们可以使用MediatR发送消息。 控制器非常苗条。 这里没有逻辑。 它的唯一职责是发送客户端JSON响应。 为了准备响应,我们发送中介消息-调用IMediator对象的Send方法(从DI容器注入-参见下文)。 我们发送具有属性ProductCode设置的FindProductByCodeQuery对象。

private readonly IMediator mediator;

public ProductsController(IMediator mediator)
{
    this.mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
}

在这里,我们需要定义我们的CQRS解决方案的另一部分。 它可以处理请求。 将回答给定类型的每个消息的类。

另外,MediatR使它更容易实现:

public class FindProductByCodeHandler : IRequestHandler<FindProductByCodeQuery, ProductDto>
{
    private readonly IProductRepository productRepository;

    public FindProductByCodeHandler(IProductRepository productRepository)
    {
        this.productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository));
    }
}

如我们所见,处理程序使用输入和输出类型的定义实现IRequestHandler接口:

public interface IRequestHandler<in TRequest, TResponse> where TRequest : IRequest<TResponse>
{
Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken);
}

在我们的示例中,FindProductByCodeHandler定义向我们(和中介者)提供了“知道”如何响应FindProductByCodeQuery消息并返回ProductDto对象的信息。 现在我们需要定义如何处理消息。 该接口定义了我们应该实现的Handle方法。 我们将转到IProductRepository并检索请求的对象:

public async Task<ProductDto> Handle(FindProductByCodeQuery request, CancellationToken cancellationToken)
{
    var result = await productRepository.FindOne(request.ProductCode);

    return result != null ? new ProductDto
    {
        Code = result.Code,
        Name = result.Name,
        Description = result.Description,
        Image = result.Image,
        MaxNumberOfInsured = result.MaxNumberOfInsured,
        Questions = result.Questions != null ? ProductMapper.ToQuestionDtoList(result.Questions) : null,
        Covers = result.Covers != null ? ProductMapper.ToCoverDtoList(result.Covers) : null
    } : null;
}

到结果类型的映射也在处理程序类中执行。 如果需要,我们可以使用例如 AutoMapper或实现一些自定义映射器。 我们还可以在此处添加缓存以及准备响应所需的任何其他逻辑。

用xUnit测试

现在,我们具有准备按ProductCode产品的功能。 让我们测试一下。 我们使用xUnit测试我们的.NET Core 2.x应用程序。

使用MediatRCQRS进行测试非常简单。 我们在ProductsControllerTest中创建了一个简短方法,该方法使用总线来测试控制器:

[Fact]
public async Task GetAll_ReturnsJsonResult_WithListOfProducts()
{
    var client = factory.CreateClient();

    var response = await client.DoGetAsync<List>("/api/Products");
    
    True(response.Count > 1);
}

我们还应该测试我们的处理程序,这是FindProductsHandlersTest中的测试之一:

[Fact]
public async Task FindProductByCodeHandler_ReturnsOneProduct()
{
    var findProductByCodeHandler = new FindProductByCodeHandler(productRepository.Object);
    
    var result = await findProductByCodeHandler.Handle(new Api.Queries.FindProductByCodeQuery { ProductCode = TestProductFactory.Travel().Code}, new System.Threading.CancellationToken());

    Assert.NotNull(result);            
}

productRepository是IProductRepository的模拟,它是通过以下方式定义的:

private Mock productRepository;        

private List products = new List
{
    TestProductFactory.Travel(),
    TestProductFactory.House()
};

public FindProductsHandlersTest()
{
    productRepository = new Mock();
               
    
    productRepository.Setup(x => x.FindAll()).Returns(Task.FromResult(products));
    productRepository.Setup(x => x.FindOne(It.Is(s => products.Select(p => p.Code).Contains(s)))).Returns(Task.FromResult(products.First()));
    productRepository.Setup(x => x.FindOne(It.Is(s => !products.Select(p => p.Code).Contains(s)))).Returns(Task.FromResult(null));
}

命令的实现绝对相同。这里没有地方显示示例,但是转到GitHub上的完整源代码(这里是命令示例),您可以在其中查看所有代码,项目组织等。

总结

我强烈建议尝试使用MediatR库。它易于设置,因此使我们可以快速入门,并发现库,尤其是CQRS模式为我们提供了什么。我希望我的文字表明它可以使所有事物分离,每个类都有自己的责任,输入和输出模型很合适,控制器也尽可能整洁。

如果我们创建不同的,单独的请求和处理程序而不是一个大接口,那么我们可以更改服务功能的任何部分而没有副作用。我们可以轻松更改处理程序的行为(逻辑!),直到它仍返回正确类型的对象为止-不会对控制器产生影响。

我们可以通过添加新的请求处理程序对来创建新功能。或者删除其他删除它们。如果我们是长期开发的系统中的新手,那么我们只需要研究其中的一小部分,仅在需要维护的地方进行。

CQRS也可以在微服务体系结构中实现–查询命令和/或命令处理程序可以作为单独的微服务实现。我们也可以实现命令队列。可以使用不同的模型进行读写,而微服务可以使用不同的数据模型。可以通过运行不同数量的命令或查询类型的处理程序来扩展操作。

当然,CQRS并不能解决所有问题-我认为对数千个事件的定义不能使我们的系统易于维护。如果它无法应对您的开发挑战,请不要使用它。


原文:Building Microservices On .NET Core – Part 3 Service Discovery with Eureka
作者:

  • Wojciech Suwała, Head Architect, ASC LAB
  • Robert Witkowski, Senior Software Engineer, ASC LAB

时间:2019年2月20日

这是我们系列中有关在.NET Core上构建微服务的第三篇文章。 在第一篇文章中,我们介绍了该系列并准备了计划:业务案例和解决方案体系结构。 在第二篇文章中,我们描述了如何使用CQRS模式和MediatR库来构建一个微服务的内部架构。

在本文中,我们将专注于服务发现,这是基于微服务的体系结构的基本概念之一。

完整解决方案的源代码可以在我们的GitHub上找到。

什么是服务发现,我需要吗?

如果您迟早(而不是早些时候)构建基于微服务的解决方案,则会遇到一种服务需要与另一服务进行通信的情况。为了做到这一点,呼叫者必须知道目标微服务在其运行的网络中的确切位置。
您必须以某种方式提供目标微服务侦听请求的IP地址和端口。您可以使用配置文件或环境变量来完成此操作,但是这种方法有一些缺点和局限性。

首先,您必须为所有环境维护并正确部署配置文件:本地开发,测试,预生产和生产。添加新服务或将现有服务移至其他节点时忘记更新任何这些配置,将导致在运行时发现错误。
其次,更重要的问题是,它只能在静态环境中使用,这意味着您无法动态添加/删除节点,因此您将无法动态扩展系统。自主扩展和部署给定微服务的能力是基于微服务的体系结构的主要优势之一,我们不希望失去这一能力。

因此,我们需要介绍服务发现。服务发现是一种允许服务找到彼此的网络位置的机制。此模式有许多可能的实现,但是在本文中,我们将重点介绍由Service Registry组件和Service Registry Client组成的实现。
服务注册表是一个中心组件,用于维护当前正在运行的微服务实例及其相应网络位置的列表。您的微服务使用Service Registry Client来:在注册表中注册自身,以查询注册表以获取与之通信的给定微服务的地址。

有许多现有的服务注册表实现。不幸的是,我不知道任何本机.NET解决方案。在Java世界中,我们在Altkom Software&Consulting的许多项目中使用了两种解决方案:Netflix EurekaHashiCorp Consul

就本文而言,我们将使用Eureka,因为它具有由Pivotal(Spring框架的创建者)– Steeltoe开发和维护的.NET Core很好的客户端库。

使用Eureka设置服务注册表

启动Eureka Server的最简单方法是克隆GitHub存储库,并使用附加到该项目的Maven包装器运行。这使您可以运行Maven项目,而无需在路径上安装Maven和安装Maven。

我们将存储库克隆到了eureka文件夹,因此在我们的例子中就足够了:

cd eureka
mvnw spring-boot:run

Eureka Server配置存储在文件application.yml中。

server:
  port: 8761

eureka:
  client:
    registerWithEureka: false
    fetchRegistry: false
  server:
    waitTimeInMsWhenSyncEmpty: 0

如果要检查Eureka是否正确启动,请转至localhost:8761。您应该显示以下内容:

356bc566e6d0faed751baede4a257177.png

在Eureka中注册微服务/注册Eureka客户

我们使用了Steeltoe的Eureka客户端实现来注册并从Eureka Server获取服务。 在本节中,我们重点介绍在服务注册表中的注册。 我们以其中一个微服务– PricingService的示例完成所有操作。

第一步是添加所需的NuGet软件包。

dotnet add package Steeltoe.Discovery.ClientCore --version 2.1.1

第二步是在Startup.cs类中添加发现客户端。 在这里,我们必须添加两行,第一行到ConfigureServices方法,第二行到Configure方法。

using Steeltoe.Discovery.Client;

namespace PricingService
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDiscoveryClient(Configuration);
            [...]
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
           [...]
            app.UseDiscoveryClient();
        }
    }
}

最后一步,最重要的一步是在appsettings.json中设置配置。

"spring" : {
    "application" : {
      "name" : "PricingService"
    }
  },
  "eureka" : {
    "client" : {
      "shouldRegisterWithEureka" : true,
      "serviceUrl" : "http://localhost:8761/eureka",
      "ValidateCertificates":  false
    },
    "instance" : {
      "appName" : "PricingService",
      "hostName" : "localhost",
      "port" : "5040”
    }
  }

配置包含以下元素:

  • spring.application.name:包含我们服务的名称
  • eureka.client包含
    • shouldRegisterWithEureka:告诉我们服务是否应该在Eureka中进行注册,如果我们只想调用其他服务,可以将其设置为false,如果我们希望其他服务能够调用我们的服务,则必须将其设置为true,
    • serviceUrl:尤里卡服务的地址
    • instance:告诉我们如何在尤里卡(Eureka)注册我们的服务,我们指定:
      • appName,使用此名称,其他服务将能够查询我们的服务地址
      • hostName,我们的服务正在运行的主机名
      • port,我们的服务正在使用的端口

起初,我不得不指定我的服务正在使用的主机名和端口,而不是Eureka客户端能够在运行时动态发现此信息,这让我有些失望。但这确实是一个非常有用的功能。

在Docker容器中运行服务时,我们不希望它们注册容器实例本地的端口和地址,而是在Docker网络上可见的地址和端口进行注册。

有关配置选项的更多信息,请参见Steeltoe文档

现在,我们可以使用命令行来运行我们的微服务。从根存储库文件夹:

dotnet run --project ./PricingService

并打开Eureka以查看您的服务是否可见。 打开浏览器,然后转到localhost:8761

b9796d8185e6adc8d2ba7cf23c89ca3a.png

如果一切正常,则应在“当前在Eureka中注册的实例”部分中列出您的服务实例。

您可以创建运行状况检查终结点,以便Eureka可以检查您的服务是否已启动并正在运行。 这样可以通过Eureka仪表板几乎实时监视可用服务及其状态。

如果实现此类终结点,则可以使用healthCheckUrl配置属性指定它。

使用服务发现调用另一个微服务

我们的第一步是以与PricingService相同的方式添加Eureka客户端配置。

经过前面的步骤,我们准备从另一个服务中调用我们的服务。 我们想从PolicyService调用PricingService,因为在策略创建步骤之一中,我们需要一些PricingService中有关价格的数据。

我们将结合强制Steeltoe发现客户端,RestEasePollyPricingService创建一个不错的,声明性的和灵活的客户端。

using Microsoft.Extensions.Configuration;
using Polly;
using PricingService.Api.Commands;
using RestEase;
using System;
using System.Net.Http;
using System.Threading.Tasks;
using Steeltoe.Common.Discovery;

namespace PolicyService.RestClients
{
    public interface IPricingClient
    {
        [Post]
        Task CalculatePrice([Body] CalculatePriceCommand cmd);
    }

    public class PricingClient : IPricingClient
    {
        private readonly IPricingClient client;

        private static Policy retryPolicy = Policy
            .Handle()
            .WaitAndRetryAsync(retryCount: 3, sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(3));

        public PricingClient(IConfiguration configuration, IDiscoveryClient discoveryClient)
        {
            var handler = new DiscoveryHttpClientHandler(discoveryClient);
            var httpClient = new HttpClient(handler, false)
            {
                BaseAddress = new Uri(configuration.GetValue("PricingServiceUri"))
            };
            client = RestClient.For(httpClient);
        }

        public Task CalculatePrice([Body] CalculatePriceCommand cmd)
        {
            return retryPolicy.ExecuteAsync(async () => await client.CalculatePrice(cmd));
        }
    }
}

我们声明了一个接口,该接口表示PricingService公开的操作。

我们创建了此接口的实现,该接口在内部使用服务发现客户端,该客户端将从Eureka的服务注册表中获取PricingService的地址,并与Polly和RestEase结合使用。

此示例中最重要的行是处理程序和HTTP客户端的创建:

var handler = new DiscoveryHttpClientHandler(discoveryClient);
var httpClient = new HttpClient(handler, false)
{
    BaseAddress = new Uri(configuration.GetValue("PricingServiceUri"))
};

我们使用在appsettings.json中配置的PricingService的URL,如下所示:

"PricingServiceUri" : "http://PricingService/api/pricing"

如您所见,配置中的地址指向服务名称,而不是实际的网络地址。

最后一步是在IoC容器中注册我们的客户。 为此,我们创建一个名为RestClientsInstaller的类。

 public static class RestClientsInstaller
    {
        public static IServiceCollection AddPricingRestClient(this IServiceCollection services)
        {
            services.AddSingleton(typeof(IPricingClient), typeof(PricingClient));
...
            return services;
        }
    }

我们在Startup.cs中使用此类

public void ConfigureServices(IServiceCollection services)
        {
            services.AddDiscoveryClient(Configuration);
            services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
            services.AddMediatR();
            services.AddPricingRestClient();
            services.AddNHibernate(Configuration.GetConnectionString("DefaultConnection"));
            services.AddRabbit();
        }

现在,您可以注入IPricingClient并使用它来调用PricingService

总结

服务发现是基于微服务的体系结构的基础之一。 没有它,正确配置和部署微服务需要大量的手动和容易出错的工作。

使用Eureka和Steeltoe设置服务发现非常简单,并使您能够动态添加和删除服务实例。 同时,它也无需对需要与之通信的服务的地址进行硬编码。
https://github.com/asc-lab/dotnetcore-microservices-poc中查看完整的解决方案源代码。

当然,该解决方案只是实现服务发现机制的可能方式之一。 您还可以尝试使用Kubernetes,nginx或Consul的替代方法。


原文:Building Microservices On .NET Core – Part 4 Building API Gateways With Ocelot
作者:Wojciech Suwała, Head Architect, ASC LAB
时间:2019年3月13日

这是我们系列中有关在.NET Core上构建微服务的第四篇文章。 在第一篇文章中,我们介绍了该系列并准备了计划:业务案例和解决方案体系结构。 在第二篇文章中,我们描述了如何使用CQRS模式和MediatR库构建一个微服务的内部架构。 在第三篇文章中,我们描述了服务发现在基于微服务的体系结构中的重要性,并介绍了Eureka的实际实现。

在本文中,我们将重点介绍基于微服务的体系结构的另一个基本概念-API网关

完整解决方案的源代码可以在我们的GitHub上找到。

什么是API网关

基于微服务的方法的优点之一是,您可以由较小的服务组成大型系统,每个服务负责一项业务功能。这种方法在应用于电子商务,保险或金融等大型复杂领域时,会产生由数个到数十个微服务组成的解决方案。考虑到这种情况是动态的,当工作负载增加时将启动新的服务实例,添加新服务,将某些服务拆分为多个服务,您可以想象如果想直接从中访问每个服务会很困难。您的客户端应用程序。

API网关模式试图通过在客户端应用程序和后端服务之间添加单点交互来解决从客户端应用程序访问单个服务的问题。 API网关用作门户,可将底层系统的复杂性隐藏在其客户端中。

API网关是在您的后端服务之前运行的另一种微服务,仅公开给定客户端所需的操作。

API网关不仅仅可以将请求从客户端应用程序路由到适当的后端服务中。但是,您应注意不要引入可能导致过分夸大的API网关问题的业务和流程逻辑

除了路由API外,网关通常负责安全性。我们通常不允许未经身份验证和未经授权的呼叫通过网关,因此网关负责检查是否存在必需的安全令牌,它们是否有效以及是否包含必需的声明。

接下来是处理CORS。必须准备好从运行与API-Gateway起源不同的单页应用程序的Web浏览器访问API网关。

API网关通常负责请求和响应的转换,例如添加标头,更改请求格式以在客户端和服务器使用的数据表示之间进行转换。

最后但并非最不重要的一点是,API网关可用于更改通信协议。例如,您可以在API网关上将服务公开为HTTP REST,例如,这些调用由API网关转换为gRPC。

在我们的IT公司中,通常的做法是为每种类型的客户端应用程序构建单独的API网关。例如,如果我们有基于微服务的保险系统,我们将构建:保险代理门户的单独网关,后台应用程序的单独网关,银行保险集成的单独网关,最终客户移动应用程序的单独网关。

使用Ocelot构建API网关

在Java领域中有许多用于构建API网关的解决方案,但是当我在.NET空间中寻找解决方案时,除了从头开始构建自己的解决方案之外,唯一可行的解决方案是Ocelot。 这是一个非常有趣且功能强大的项目,甚至在Microsoft官方示例中也使用过。

让我们使用Ocelot为我们的示例保险销售门户实施API Gateway。

入门

我们从空的ASP.NET Core Web应用程序开始。 我们需要的只是Program.csappsettings.json文件。

我们首先使用nuget将Ocelot添加到我们的项目中。

Install-Package Ocelot

在我们的项目中,我们还使用Ocelot服务发现和缓存功能,因此我们需要再添加两个NuGet包:Ocelot.Provider.EurekaOcelot.Cache.CacheManager。 最后,我们的解决方案应如下图所示。

在下一步中,我们需要添加ocelot.json文件,该文件将托管我们的Ocelot网关配置。

现在,我们可以修改Program.cs以正确引导所有必需的服务,包括Ocelot。

public class Program
{
   public static void Main(string[] args)
   {
       BuildWebHost(args).Run();
   }

   public static IWebHost BuildWebHost(string[] args)
   {  
       return WebHost.CreateDefaultBuilder(args)
           .UseUrls("http://localhost:8099")
           .ConfigureAppConfiguration((hostingContext, config) =>
           {
               config
                   .SetBasePath(hostingContext.HostingEnvironment.ContentRootPath)
                   .AddJsonFile("appsettings.json", true, true)
                   .AddJsonFile($"appsettings.{hostingContext.HostingEnvironment.EnvironmentName}.json", true,
                       true)
                   .AddJsonFile("ocelot.json", false, false)
                   .AddEnvironmentVariables();
           })
           .ConfigureServices(s =>
           {
               s.AddOcelot().AddEureka().AddCacheManager(x => x.WithDictionaryHandle());
           })
           .Configure(a =>
           {
              a.UseOcelot().Wait();
           })
           .Build();
   }
}

这里最重要的部分是:添加ocelot.json配置文件,添加具有Eureka和Cache Manager支持的Ocelot服务。

如果您还记得本系列的前一部分,我们将Eureka用作服务注册表和发现机制。 在这里,我们想利用它,并告诉Ocelot从Eureka解析下游服务URL,而不是对其进行硬编码。
我们还在Ocelot中使用缓存支持来介绍如何配置api网关以缓存同样缓慢变化的数据。

为了使所有这些正常工作,我们现在必须正确填充配置文件。

让我们从添加Eureka配置的appsettings.json开始。

{
  "spring": {
    "application": { "name": "Agent-Portal-Api-Gateway" }
  },
  "eureka": {
    "client": {
      "serviceUrl": "http://localhost:8761/eureka/",
      "shouldRegisterWithEureka": false,
      "validateCertificates": false
    }
  }
}

现在是时候研究ocelot.json了-我们的API网关的中央配置部分。 ocelot.json由两个主要部分组成:ReRoutesGlobalConfiguration
ReRoutes定义路由–将API网关公开的端点映射到后端服务。 作为此映射安全性的一部分,还可以定义缓存和转换。

GlobalConfiguration为整个API网关定义全局设置。

让我们从GlobalConfiguration开始:

"GlobalConfiguration": {
    "RequestIdKey": "OcRequestId",
    "AdministrationPath": "/administration",
    "UseServiceDiscovery" : true,
    "ServiceDiscoveryProvider": { "Type": "Eureka", "Host" : "localhost", "Port" : "8761"}
  }

这里的关键是:启用服务发现并指向正确的Eureka实例。

现在我们可以定义路由了。 让我们定义第一个路由,该路由将/products/{code}的API网关请求作为HTTP GET映射到下游服务ProductService,该服务以HTTP GET [serviceHost:port]/api/Products/{code}公开产品数据。

"ReRoutes": [
    {
      "DownstreamPathTemplate": "/api/Products/{everything}",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/Products/{everything}",
      "ServiceName": "ProductService",
      "UpstreamHttpMethod": [ "Get" ]
    }
]

DownstreamPathTemplate指定后端服务URL,UpstreamPathTemplate指定API网关公开的URL,"下游和上游模式"指定架构,ServiceName指定在Eureka中注册下游服务的名称。

我们来看另一个例子。 这次,我们将配置商品创建服务,该服务由PolicyService公开为HTTP POST [serviceHost:port]/api/Offer

{
      "DownstreamPathTemplate": "/api/Offer",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/Offers",
      "ServiceName": "PolicyService",
      "UpstreamHttpMethod": [ "Post" ]
 }

Ocelot的高级功能CORS

这本身与Ocelot无关,但是通常需要在API网关层支持跨源请求。 我们需要修改Program.cs。"在ConfigureServices()中,我们需要添加":

s.AddCors();

在Configure()方法中,我们需要添加:

a.UseCors(b => b
          .AllowAnyOrigin()
          .AllowAnyMethod()
          .AllowAnyHeader()
          .AllowCredentials()
);

安全

接下来,我们将基于JWT令牌的安全性添加到我们的api网关。 这样,未经身份验证的请求将不会通过我们的API网关传递。

BuildWebHost方法中,我们需要添加用于JWT验证的密钥。 在实际应用中,您应该将此密钥存储在安全的秘密存储区中,但出于演示目的,我们只创建一个变量。

var key = Encoding.ASCII.GetBytes("THIS_IS_A_RANDOM_SECRET_2e7a1e80-16ee-4e52-b5c6-5e8892453459");

现在我们需要在ConfigureService()中设置安全性:

s.AddAuthentication(x =>
{
     x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
     x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;    
})
.AddJwtBearer("ApiSecurity", x =>
{
      x.RequireHttpsMetadata = false;
      x.SaveToken = true;
      x.TokenValidationParameters = new TokenValidationParameters
      {
           ValidateIssuerSigningKey = true,
           IssuerSigningKey = new SymmetricSecurityKey(key),
           ValidateIssuer = false,
           ValidateAudience = false
       };
});

使用此设置,我们现在可以返回到ocelot.json并定义我们的路由的安全性要求。

在我们的案例中,我们要求对用户进行身份验证,并且令牌包含具有值SALESMAN的声明userType

让我们看看如何配置它:

{
      "DownstreamPathTemplate": "/api/Products",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/Products",
      "ServiceName": "ProductService",
      "UpstreamHttpMethod": [ "Get" ],
      "FileCacheOptions": { "TtlSeconds": 15 },
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "ApiSecurity",
        "AllowedScopes": []
      },
      "RouteClaimsRequirement": {
        "userType" : "SALESMAN"
      }
    }

我们添加了AuthenticationOptions部分,以将Program.cs中定义的身份验证机制与Ocelot链接在一起,然后在RouteClaimsRequirement中指定了该声明,该声明必须提供值才能将请求传递到后端服务。

服务发现

我们已经介绍了使用Eureka进行服务发现的方法。您不必使用服务发现,也可以使用硬编码的URL将上游请求映射到后端服务,但这将消除基于微服务的体系结构的许多优点,并使部署和操作变得非常复杂,因为您必须保持后端同步具有ocelot配置的微服务URL。

除Eureka之外,Ocelot还支持其他服务发现机制:Consul和Kubernetes。

您可以在Ocelot服务发现文档中阅读有关此主题的更多信息。

负载均衡

Ocelot提供了内置的负载均衡器,可以针对每个路由进行配置。它有四种可用的类型:最少连接,轮询,cookie粘性会话,第一种可用服务。

您可以在Ocelot文档中了解更多信息。

缓存

Ocelot提供了开箱即用的简单缓存实现。一旦包含Ocelot.Cache.CacheManager程序包并激活它

s.AddOcelot()
    .AddCacheManager(x => { x.WithDictionaryHandle(); })

您可以为每个路由配置缓存。 例如,将缓存添加到使用给定产品代码获取产品定义的路由:

{
      "DownstreamPathTemplate": "/api/Products/{everything}",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/Products/{everything}",
      "ServiceName": "ProductService",
      "UpstreamHttpMethod": [ "Get" ],
      "FileCacheOptions": { "TtlSeconds": 15 },
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "ApiSecurity",
        "AllowedScopes": []
      },
      "RouteClaimsRequirement": {
        "userType" : "SALESMAN"
      }
    }

此配置告诉Ocelot将给定请求的结果缓存15秒。

Ocelot还使您能够插入自己的缓存打开可能性,以使用更强大的选项(例如Redis或memcache)来扩展简单缓存。

您可以在Ocelot缓存文档中阅读有关它的更多信息。

限速

Ocelot支持速率限制。 此功能可帮助您防止下游服务超载。 像往常一样,您可以配置每个路由的速率限制。 为了启用速率限制,您需要在路由中添加以下JSON:

"RateLimitOptions": {
    "ClientWhitelist": [],
    "EnableRateLimiting": true,
    "Period": "1s",
    "PeriodTimespan": 1,
    "Limit": 1
}

ClientWhiteList允许您指定不应该限制哪些客户端,EnableRateLimiting启用速率限制,Period配置要应用限制的时间段(可以以秒,分钟,小时或天数指定),Limit配置在给定Period内允许的请求数。如果在给定期间内客户端超过了Limit中指定的请求数,则他们必须等待PeriodTimespan才能将另一个请求传递给下游服务。

转型

Ocelot允许我们配置标题和声明转换。您可以将标头添加到请求和响应。除静态值外,您还可以使用占位符:{RemoteIpAddress}客户端IP地址,{BaseUrl} ocelot基本URL,{DownstreamBaseUrl}下游服务基本URL和{TraceId} Butterfly跟踪ID(如果您使用Butterfly分布式跟踪)。您还可以查找和替换标题值。

Ocelot还允许您访问声明并将其转换为标题,查询字符串参数或其他声明。当您需要将有关授权用户的信息传递给后端服务时,这非常有用。与往常一样,您可以按路线指定这些转换。
在下面的示例中,您可以看到如何提取子声明并将其放入CustomerId标头中。

"AddHeadersToRequest": {
    "CustomerId": "Claims[sub] > value[1] > |"
}

您可以在Ocelot标头转换文档和Ocelot声明转换文档中阅读有关此主题的更多信息。

总结

Ocelot为我们提供了功能丰富的api网关实现,几乎不需要编码。 您必须执行的大多数工作都与正确定义暴露的api网关端点和后端服务URL之间的路由有关。 您可以轻松添加身份验证和授权支持以及缓存。

除了本文中描述的功能之外,Ocelot还支持请求聚合,日志记录,Web套接字,使用Butterfly项目进行的分布式跟踪以及委托处理程序。

您可以在以下位置查看完整的解决方案源代码:https://github.com/asc-lab/dotnetcore-microservices-poc


原文:Building Microservices On .NET Core – Part 5 Marten An Ideal Repository For Your Domain Aggregates
作者:Wojciech Suwała, Head Architect, ASC LAB
时间:2019年4月11日

这是我们系列中有关在.NET Core上构建微服务的第五篇文章。 在第一篇文章中,我们介绍了该系列并准备了计划:业务案例和解决方案体系结构。 在第二篇文章中,我们描述了如何使用CQRS模式和MediatR库来构建一个微服务的内部架构。 在第三篇文章中,我们描述了服务发现在基于微服务的体系结构中的重要性,并介绍了Eureka的实际实现。 在第四部分中,我们介绍了如何使用Ocelot为微服务构建API网关。

在本文中,我们将退一步,讨论数据访问以及如何有效地持久存储数据。

完整解决方案的源代码可以在我们的GitHub上找到。

持久性是一个已解决的问题,不是吗?

当.NET Framework的第一个版本于2002年左右发布时,我们有两个用于数据访问的主要API:数据集和数据读取器。数据集,在数据库中表的内存表示形式中,另一方面,数据读取器答应让您快速读取数据,但必须手动将其推入对象中。许多开发人员发现了反射的力量,几乎每个人都开发了自己的ORM。过去,我的团队很少评估这样的框架,但是对于我们来说,这些框架似乎都不是合适的解决方案,因为我们正在为保险业开发复杂的应用程序。因此,我们决定在每次插入,更新和搜索时都使用DataReaders和手工编码的SQL。几年后,我们建立了今天称为微型ORM的产品。即使使用我们自己开发的简单工具,我们也可以消除大约70%的数据访问代码。然后是NHibernate时代。作为具有Java经验的开发人员,我很嫉妒Java同事拥有如此强大的库,当NHibernate的早期版本可用时,我很想尝试一下。当我们开始在生产中使用NHibernate时,我认为它是2.0版。多年来,NHibernate一直是我们的首选,并在许多项目中为我们提供了帮助。这是令人难以置信的灵活且功能丰富的库。但是微软决定实施自己的专有解决方案–实体框架。随着它的大力推广,许多.NET商店决定改用EF和NHibernate,因此社区开始萎缩。

然后,Microsoft引入了.NET Core,一切都变了。他们没有移植现有的实体框架,而是决定从头开始开发它的新版本。结果,.NET Core的第一个版本实际上没有用于数据访问的企业级解决方案。当前,我们几乎已经拥有.NET Core 3,而EF Core仍然缺少您希望成熟的ORM提供的许多功能。 NHibernate最终登陆.NET Core,但我认为它不会因为周围的社区小得多而重新流行起来。与今天的ORM相比,NHibernate也具有很大的侵入性,例如,它迫使您将所有属性虚拟化,因此可以由ORM代理。

.NET Core的到来和微服务的日益普及完全改变了.NET体系结构的格局。您现在可以在Linux上进行开发和部署。在.NET开发人员中,MS SQL以外的数据库的使用正变得越来越流行。

微服务还增加了多语言持久性流行度。开发人员意识到他们可以将不同的数据存储用于不同种类的服务。有文档数据库,图形数据库,事件存储和其他类型的数据库相关解决方案。

如您所见,有很多选项可供选择,在本文中,我想谈一谈使用关系数据库作为文档数据库,以充分利用两者的优势。在Marten的帮助下,您可以实现这一目标。

什么是Marten?

Marten是一个客户端库,允许.NET开发人员将Postgresql用作文档数据库和事件存储。它由杰里米·米勒(Jeremy Miller)于2015年10月左右某个时候启动,以替代RavenDB数据库,但不仅限于此。

如果您曾经使用过像MongoDB或RavenDB这样的文档数据库,您就会知道它为您带来了出色的开发人员体验,尤其是易用性和开发速度,但是还存在一些与性能和数据一致性相关的问题。

使用Marten,您可以轻松地将关系数据库用作文档之一,并具有完全的ACID合规性和广泛的LINQ支持。

从我的角度来看,对于这种方法,有一个特定的用例似乎是理想的。如果您正在练习域驱动的设计并将域模型划分为小的聚合,则可以将聚合视为文档。如果您采用这种方法,并与Marten之类的库结合使用,则持久化,加载和查找聚合几乎不需要任何代码。由于符合ACID,因此您可以在同一事务中修改和保存许多聚合,而这在许多文档数据库中是不可能的。使用关系数据库还可以简化基础架构管理,因为您仍然可以依靠熟悉的工具进行备份和监视。

更重要的是,您的域模型不受ORM功能的限制。

Vaughn Vernon的文章“理想的域驱动设计聚合存储”中描述了将聚合作为JSON保存在关系数据库中的想法,从中我从中获得了这篇文章标题的灵感。

使用Marten

将Marten添加到项目

与往常一样,我们首先使用NuGet将Marten依赖项添加到我们的项目中。

Install-Package Marten

接下来需要做的是将连接字符串添加到PostgreSQL数据库中的appsettings.json

{
    "ConnectionStrings": {
        "PgConnection": "User ID=lab_user;Password=*****;Database=lab_netmicro_payments;Host=localhost;Port=5432"
    }}

我们还需要安装PostgreSQL 9.5+数据库服务器。

设置Marten

现在我们可以建立Marten。 我们将看一下取自PaymentService的示例代码。
在我们的解决方案中,我们决定将域逻辑与持久性细节分开,为此我们引入了两个接口。

public interface IPolicyAccountRepository
{
    void Add(PolicyAccount policyAccount);

    Task FindByNumber(string accountNumber);
}

第一个接口代表PolicyAccount聚合的存储库。 在这里,我们使用仓库模式,如Eric Evans的DDD Blue Book中所述。 我们的存储库提供了用于存储数据的接口,因此我们可以将其用作简单的收集类。 请注意,我们不创建通用存储库。 如果我们正在进行域驱动的设计,则存储库应该是域语言的一部分,并且应该仅公开域代码所需的操作。

第二个界面表示工作单元模式(Unit Of Work Pattern)–一种服务,该服务跟踪加载的对象并让我们保留更改。

public interface IDataStore : IDisposable
    {
        IPolicyAccountRepository PolicyAccounts { get; }

        Task CommitChanges();
    }

数据存储接口使我们能够访问存储库,因此我们可以从存储库中添加和检索策略帐户对象,并允许我们将更改提交到持久性存储。

让我们看看如何使用Marten来实现这些接口。但是在开始之前,我们必须了解Marten中的两个基本概念:DocumentStoreDocumentSession

第一个代表我们文档存储的配置。它保留配置数据,例如连接字符串,序列化设置,模式定制,映射信息。

DocumentSession代表我们的工作单元。它负责打开和管理数据库连接,针对数据库执行SQL语句,加载文档,跟踪加载的文档以及最后将更改保存回数据库。首先,创建一个DocumentStore实例,然后可以要求它创建一个DocumentSession实例,最后可以使用DocumentSession在数据库中创建,加载,修改和存储文档。

DocumentSession有三种实现:

  • 轻量级会话 – 不跟踪更改的会话,
  • 标准会话 – 具有身份映射跟踪的会话,但没有更改跟踪
  • 脏跟踪会话 – 一种带有身份映射和脏跟踪的会话。

脏跟踪是通过比较最初从数据库加载的JSON和从聚合生成的JSON来实现的,因此您必须了解性能和内存成本。在我们的代码中,我们将使用轻量级文档会话。

您可以从Marten的官方文档中了解更多信息。

现在我们知道了基础知识。我们可以创建MartenInstaller类,将在Startup类中使用该类来初始化和连接所有必需的片段。

public static class MartenInstaller
    {
        public static void AddMarten(this IServiceCollection services, string cnnString)
        {
            services.AddSingleton(CreateDocumentStore(cnnString));

            services.AddScoped<Domain.IDataStore, MartenDataStore>();
        }

        private static IDocumentStore CreateDocumentStore(string cn)
        {
            return DocumentStore.For(_ =>
            {
                _.Connection(cn);
                _.DatabaseSchemaName = "payment_service";
                _.Serializer(CustomizeJsonSerializer());
                _.Schema.For().Duplicate(t => t.PolicyNumber,pgType: "varchar(50)", configure: idx => idx.IsUnique = true);
            });
        }

        private static JsonNetSerializer CustomizeJsonSerializer()
        {
            var serializer = new JsonNetSerializer();

            serializer.Customize(_ =>
            {
                _.ContractResolver = new ProtectedSettersContractResolver();
        _.PreserveReferencesHandling = PreserveReferencesHandling.Objects;
            });

            return serializer;
        }
    }

此处的关键方法是CreateDocumentStore。 它创建一个文档存储实例并对其进行配置。

DocumentStore.For(_ =>
            {
                _.Connection(cn); (1)
                _.DatabaseSchemaName = "payment_service"; (2)
                _.Serializer(CustomizeJsonSerializer()); (3)
                _.Schema.For().Duplicate(t => t.PolicyNumber,pgType: "varchar(50)", configure: idx => idx.IsUnique = true); (4)
            });

在这里,我们:

  1. 提供到Postgresql数据库的连接字符串。
  2. 自定义架构名称(如果不这样做,则将在公共架构中创建用于存储文档的表)。
  3. 自定义JsonSerializer,以便它可以序列化受保护的属性(这很重要,我们正在尝试根据DDD规则设计聚合,我们不想使用公共设置器公开内部状态)并处理对象之间的循环引用。
  4. 我们为保单号添加“重复”字段。 在这里,我们告诉Marten不仅将策略号存储为序列化JSON文档中聚合的一部分,而且还要创建单独的列和唯一索引以加快搜索速度。 这样做是因为我们想快速找到给定保单号的帐户。

有关架构和映射自定义的更多详细信息将在本文后面提供。

让我们看一下IDataStore接口的实现:

public class MartenDataStore : IDataStore
{
    private readonly IDocumentSession session;

    public MartenDataStore(IDocumentStore documentStore)
    {
        session = documentStore.LightweightSession();
        PolicyAccounts = new MartenPolicyAccountRepository(session);
    }

    public IPolicyAccountRepository PolicyAccounts { get; }

    public async Task CommitChanges()
    {
        await session.SaveChangesAsync();
    }

    ...
}

在构造函数中,我们打开文档会话。 当我们将丢弃类的实例时,我们将关闭它(此处省略IDisposable实现,但是您可以在GitHub上检查完整的代码)。 CommitChanges方法使用DocumentSession类的SaveChangesAsync方法。 在这里,我们使用Marten的异步API,但是如果您愿意,也可以使用同步版本。

IPolicyAccountRepository的实现非常简单。

public class MartenPolicyAccountRepository : IPolicyAccountRepository
{
    private readonly IDocumentSession documentSession;

    public MartenPolicyAccountRepository(IDocumentSession documentSession)
    {
        this.documentSession = documentSession;
    }

    public void Add(PolicyAccount policyAccount)
    {
        this.documentSession.Insert(policyAccount);
    }

    public async Task FindByNumber(string accountNumber)
    {
        return await this.documentSession
            .Query()
            .FirstOrDefaultAsync(p => p.PolicyNumber == accountNumber);
    }
}

我们接受对构造函数中打开文档会话的引用。 Add方法使用DocumentSessionInsert方法将文档注册为新的工作单元。在DataStore上调用CommitChanges时,文档将保存在数据库中。 CommitChanges在基础文档会话上调用SaveChanges

FindByNumber更加有趣,因为它表明您可以使用LINQ来构造对数据库中存储的文档的查询。在我们的例子中,这是一个非常简单的查询,它查找具有给定编号的策略帐户。我们将在本文中进一步详细描述Marten LINQ功能。

自定义架构和映射

Marten将为要保存到数据库的聚合的每种.NET类型创建一个表。 Marten还将为每个表生成一个"upsert"数据库函数。

默认情况下,表是在公共架构中创建的,并以"mt_doc_"前缀和您的类名的串联命名。
在我们的例子中,我们有PolicyAccount类,因此Marten创建了mt_doc_policyaccount表。

您可以使用各种自定义选项。

您可以指定要使用的数据库架构。在我们的案例中,我们希望将所有表创建为"payment_service"模式的一部分。

var store = DocumentStore.For(_ =>
{
    _.DatabaseSchemaName = "payment_service";
}

You can also specify schema for each table.

_.Storage.MappingFor(typeof(BillingPeriod)).DatabaseSchemaName = "billing";

默认情况下,Marten创建一个带有ID列的表,数据列序列为json,并添加一些元数据列:上次修改日期,.NET类型名称,版本(用于乐观并发)和软删除标记列。

Marten要求您的类具有将被映射到主键的属性。 默认情况下,Marten将查找名为:id,Id或ID的属性。 您可以通过使用[Identity]属性注释类的一个属性来更改它,或者在文档存储初始化代码中自定义映射。

var store = DocumentStore.For(_ =>
{
    _.Schema.For.Identity(x => x.MyId);
}

您还可以自定义ID生成策略。 例如,您可以选择使用CombGuid(顺序guid)。

_.Schema.For().IdStrategy(new CombGuidIdGeneration());

如果要提高查询性能,可以命令Marten创建索引和重复字段。
您的第一个选择是使用计算索引。 在下面的示例中,我们在所有者的名字和姓氏上创建了索引,因此按这两个字段进行搜索应该更快。

_.Schema.For().Index(x => x.Owner.FirstName);
_.Schema.For().Index(x => x.Owner.LastName);

请注意,计算索引不适用于DateTime和DateTimeOffset字段。

第二种选择是引入所谓的重复字段。 我们使用这种方法通过相应的保单号优化查找帐户。

_.Schema.For().Duplicate(t => t.PolicyNumber,pgType: "varchar(50)", configure: idx => idx.IsUnique = true);

在这里,我们告诉Marten为具有唯一索引的varchar(50)类型的策略号添加附加字段。 这样,Marten不仅将策略号保存为JSON数据的一部分,而且还将其保存到具有唯一索引的单独的列中,因此对它的搜索应该超级快。

您可以为给定类型启用乐观并发。

_.Schema.For().UseOptimisticConcurrency(true);

还有许多其他选项,例如全文本索引,外键可让我们链接两个聚合(gin / gist索引)。

保存聚合

借助IDataStoreIPolicyAccountRepository,可以轻松存储PolicyAccount聚合。
这是创建新帐户并将其保存在数据库中的示例代码。

public async Task Handle(PolicyCreated notification, CancellationToken cancellationToken)
{
            var policy = new PolicyAccount(notification.PolicyNumber, policyAccountNumberGenerator.Generate());

            using (dataStore)
            {
                dataStore.PolicyAccounts.Add(policy);
                await dataStore.CommitChanges();
            }
}

如您所见,保存和加载域对象不需要任何形式(代码或属性)或配置的映射。

您的类必须满足的唯一要求是:您的类必须可序列化为JSON(可以对JSON Serializer进行tweek配置,以使您的类正确地进行序列化/反序列化),您的类必须公开标识符字段或属性。标识符属性将用作主键的值源。字段/属性名称必须是ID或ID或ID,但是您可以使用[Identity]属性覆盖此规则,也可以在代码中自定义映射。以下数据类型可用作标识符:字符串GuidCombGuid顺序GUID),intlong或自定义类。对于intlong而言,Marten使用HiLo生成器。 Marten确保在IDocumentSession.Store期间设置了标识符。

Marten还支持乐观并发。可以根据每种文档类型激活此功能。为了为您的类启用乐观并发,您可以将[UseOptimisticConcurrency]属性添加到您的类或自定义架构配置。

加载聚合

加载聚合也是微不足道的。

public async Task Handle(GetAccountBalanceQuery request, CancellationToken cancellationToken)
{
    var policyAccount = await dataStore.PolicyAccounts.FindByNumber(request.PolicyNumber);

    if (policyAccount == null)
    {
        throw new PolicyAccountNotFound(request.PolicyNumber);
    }

    return new GetAccountBalanceQueryResult
    {
        Balance = new PolicyAccountBalanceDto
        {
            PolicyNumber = policyAccount.PolicyNumber,
            PolicyAccountNumber = policyAccount.PolicyAccountNumber,
            Balance = policyAccount.BalanceAt(DateTimeOffset.Now)
        }
    };
} 

查询方式

Marten提供广泛的LINQ支持。 示例简单查询,查找具有给定编号的策略的策略帐户:

session.Query<PolicyAccount>().Where(p => p.PolicyNumber == "12121212")

结合了多个条件和逻辑运算符的查询示例:

session.Query<PolicyAccount>().Where(p => p.PolicyNumber == "12121212" && p.PolicyAccountNumber!="32323232323")

搜索您的汇总的子集合并查找条目金额等于200的帐户的示例:

var accounts  = session.Query()
                    .Where(p => p.Entries.Any(_ => _.Amount == 200.0M)).ToList()

请注意,搜索子集合的支持仅限于检查子集合的成员是否相等(但是在以后的Marten版本中可能会改变)。

您还可以在对象层次结构中进行深入搜索。 例如,如果我们将帐户所有者数据存储在保单帐户中,则可以像这样搜索给定人员的保单帐户:

var accounts  = session.Query()
                    .Where(p => p.Owner.Name.LastName == “Jones” && p.Owner.Name.FirstName == “Tim”)).ToList()

您可以使用StartsWithEndsWithContains搜索字符串字段。

session.Query<PolicyAccount>().Where(p => p.PolicyNumber.EndsWith("009898"))

您可以根据聚合属性计算CountMinMaxAverageSum

session.Query().Max(p => p.PolicyAccountNumber)

您可以订购结果,并使用Take / Skip进行分页。

session.Query().Skip(10).Take(10).OrderBy(p => p.PolicyAccountNumber)

还有一个方便的快捷方式ToPagedList结合了跳过和采用。

如果您无法弄清楚为什么查询没有达到预期效果,Marten可以为您提供预览LINQ查询的功能。

var query = session.Query()
                    .Where(p => p.PolicyNumber == "1223");

                var cmd = query.ToCommand(FetchType.FetchMany);
                var sql = cmd.CommandText;

下面的代码片段将LINQ查询转换为ADO.NET命令,以便您可以检查查询文本和参数值。

可以在此处找到受支持的运算符的完整列表。

除了LINQ,您还可以使用SQL查询文档。

var user =
    session.Query("select data from payment_service.mt_doc_policyaccount where data ->> 'PolicyAccountNumber' = 1221212")
           .Single();

您可以选择从数据库检索原始JSON。

var json = session.Json.FindById<PolicyAccount>(id);

编译查询

也有称为编译查询的高级功能。 LINQ在构造查询时非常酷并且有用,但是它具有一定的性能和内存使用开销。

如果您的查询很复杂并且经常执行,则可以利用编译查询。 使用编译的查询,您可以避免在每次查询执行时解析LINQ表达式树的开销。

编译查询是实现ICompiledQuery<TDoc,TResult>接口的类。
示例查询类,用于搜索具有给定编号的策略帐户。

public class FindAccountByNumberQuery : ICompiledQuery<PolicyAccount, PolicyAccount>
{
    public string AccountNumber { get; set; }

    public Expression<Func<IQueryable, PolicyAccount>> QueryIs()
    {
        return q => q.FirstOrDefault(p => p.PolicyAccountNumber == AccountNumber);
    }
}

此处的关键方法是QueryIs。 此方法返回定义查询的表达式。

该类可以这样使用:

var account = session.Query(new FindAccountByNumberQuery {AccountNumber = "11121212"});

您可以在此处阅读有关编译查询的更多信息。

修补数据

Marten修补API可用于更新数据库中的现有文档。在某些情况下,这比将整个文档加载到内存,对其进行序列化,更改,反序列化然后保存回数据库更为有效。

修复数据错误和处理类结构中的更改时,修补程序API也非常有用。

我们的设计不会永远保持不变。随着时间的流逝,我们将向类中添加新属性,更改对集合的简单引用,或者相反。某些属性可能会被提取并重构为新类,某些属性可能会被丢弃。
使用关系数据库中的表时,我们有一组众所周知的SQL DDL命令,例如ALTER TABLE ADD / DROP COLUMN。

使用JSON文档时,我们必须以某种方式处理所有更改,以便在更改相应的类时仍可以加载和查询现有文档。

让我们尝试修改PolicyAccount类并迁移数据库中的现有数据,使其保持一致。
我们从PolicyAccount开始,必须具有代表帐户所有者姓氏和名字的属性。

public class PolicyAccount
{
    public Guid Id { get; protected set; }
    public string PolicyAccountNumber { get; protected set; }
    public string PolicyNumber { get; protected set; }
    public string OwnerFirstName { get; protected set; }
    public string OwnerName { get; protected set; }
    public ICollection Entries { get; protected set; }
    …

在数据库中,我们的数据如下所示:

{
    "Id": "51d43842-896d-4d92-b1b9-b4c6512d3cf7", 
    "$id": "2", 
    "Entries": [], 
    "OwnerName": "Jones", 
    "PolicyNumber": "POLICY_1", 
    "OwnerFirstName": "Tim", 
    "PolicyAccountNumber": "231232132131"
}

我们可以看到OwnerName不是最佳名称,我们想将其重命名为OwnerLastName

在C#上,这非常容易,因为大多数IDE都提供了开箱即用的重命名重构功能。 进行操作,然后使用Patch API修复数据库中的数据

public void RenameProperty()
{
    using (var session = SessionProvider.OpenSession())
    {
        session
            .Patch(x => x.OwnerLastName == null)
            .Rename("OwnerName", x => x.OwnerLastName);

        session.SaveChanges();
    }

}

如果运行此方法,数据库中的数据现在将如下所示:

{
    "Id": "51d43842-896d-4d92-b1b9-b4c6512d3cf7", 
    "$id": "2", 
    "Entries": [], 
    "PolicyNumber": "POLICY_1", 
    "OwnerLastName": "Jones", 
    "OwnerFirstName": "Tim", 
    "PolicyAccountNumber": "231232132131"
}

让我们尝试一些更复杂的事情。 我们决定将OwnerFirstNameOwnerLastName提取到一个类中。 现在,我们的C#代码如下所示:

public class PolicyAccount
{
        public Guid Id { get; protected set; }
        public string PolicyAccountNumber { get; protected set; }
        public string PolicyNumber { get; protected set; }
        public string OwnerFirstName { get; protected set; }
        public string OwnerLastName { get; protected set; }
        public Owner Owner { get; protected set; }
        public ICollection Entries { get; protected set; }
}

我们添加了一个具有FirstNameLastName属性的新类。 现在,我们将使用Patch API修复数据库中的数据。

public void AddANewProperty()
{
    using (var session = SessionProvider.OpenSession())
    {
         session
            .Patch(x=>x.Owner.LastName==null)
            .Duplicate(x => x.OwnerLastName, w => w.Owner.LastName);

        session
            .Patch(x=>x.Owner.FirstName==null)
            .Duplicate(x => x.OwnerFirstName, w => w.Owner.FirstName);

        session.SaveChanges();
    }
}

以及我们数据库中的数据:

{
    "Id": "51d43842-896d-4d92-b1b9-b4c6512d3cf7", 
    "$id": "2", 
    "Owner": {
        "LastName": "Jones", 
        "FirstName": "Tim"
    }, 
    "Entries": [], 
    "PolicyNumber": "POLICY_1", 
    "OwnerLastName": "Jones", 
    "OwnerFirstName": "Tim", 
    "PolicyAccountNumber": "231232132131"
}

现在是时候清理了。 我们必须从C#代码和数据库中的数据中删除未使用的OwnerFirstNameOwnerLastName属性。

public void RemoveProperty()
{
    using (var session = SessionProvider.OpenSession())
    {
        session
            .Patch(x=>x.Owner!=null)
            .Delete("OwnerLastName");

        session
            .Patch(x=>x.Owner!=null)
            .Delete("OwnerFirstName");

        session.SaveChanges();
    }
}

数据库中的数据现在看起来像这样。 OwnerFirstNameOwnerLastName不见了。

{
    "Id": "51d43842-896d-4d92-b1b9-b4c6512d3cf7", 
    "$id": "2", 
    "Owner": {
        "LastName": "Jones", 
        "FirstName": "Tim"
     }, 
    "Entries": [], 
    "PolicyNumber": "POLICY_1", 
    "PolicyAccountNumber": "231232132131"
}

补丁程序API提供了更多开箱即用的操作。你可以在这里读更多关于它的内容。
修补程序API要求您安装PostgreSQL PLV8引擎

除了Marten的Patching API外,您始终可以使用PostgreSQL的全部功能,该功能可为您提供一组可与JSON类型一起使用的功能,并将其与使用JavaScript作为PLV8引擎提供的数据库功能/过程的语言相结合。实际上,Patch API生成的功能是用JavaScript编写的,并使用PLV8引擎在数据库中执行。

Marten利弊

优点

  • 两全其美:关系数据库的ACID和SQL支持以及文档数据库的易于使用和开发。
  • ACID支持使您可以在一个事务中保存来自许多不同表的许多文档,而大多数文档数据库都不支持。
  • 使用文档使您可以保存和加载文档,而不必定义使用关系数据库时在对象模型和数据库模型之间定义的映射。这样可以加快开发速度,尤其是在开发的早期阶段,您不必担心方案更改。
  • 对LINQ查询的广泛支持为EF和NHibernate用户提供了熟悉的体验。
  • 能够用作文档存储和事件存储。
  • 能够为您的单元/集成测试快速设置/拆卸数据。
  • 批量操作支持。
  • 用于修补现有文档的API。
  • 不能或无法执行LINQ查询时可以使用SQL。
  • 在集成测试中轻松使用真实的数据库。建立一个数据库,用初始数据填充它,然后清理它是非常简单和快速的。
  • 多租户支持。
  • 编译和批处理查询支持。
  • DocumentSession能够参与TransactionScope托管的事务。

缺点

  • 仅适用于PostgreSQL。
  • 使用Patch API进行数据迁移需要更多的工作和学习新知识。
  • 在子集合中搜索的支持有限。
  • 不太适合报表和临时查询。

总结

在为微服务设计数据访问策略时,有多个选项可供选择。除实体框架或手工SQL之外,还有其他选项。

Marten是一个成熟的库,具有许多有用的功能和良好的LINQ支持。如果您以PostgreSQL数据库为目标,并使用域驱动的设计方法将您的域模型划分为小的聚合,那么Marten值得一试。

它在设计和探索阶段或构建原型时也可能是非常有用的工具,使您可以快速演化域模型并能够持久化和查询数据。

您可以在以下位置查看完整的解决方案源代码:https://github.com/asc-lab/dotnetcore-microservices-poc


原文:Building Microservices On .NET Core – Part 6 Real time server client communication with SignalR and RabbitMQ
作者:Wojciech Suwała, Head Architect, ASC LAB
时间:2019年12月8日

这是我们系列中有关在.NET Core上构建微服务的第六篇文章。在第一篇文章中,我们介绍了该系列并准备了计划:业务案例和解决方案体系结构。在第二篇文章中,我们描述了如何使用CQRS模式和MediatR库构建一个微服务的内部架构。在第三篇文章中,我们描述了服务发现在基于微服务的体系结构中的重要性,并介绍了Eureka的实际实现。在第四部分中,我们介绍了如何使用Ocelot为微服务构建API网关。第五部分致力于利用Marten库实现数据访问。

在本文中,我们将向您展示如何结合使用SignalR和RabbitMQ来建立服务器与客户端之间的实时通信。我们将通过聊天服务扩展我们的保险销售门户。通过此聊天,保险代理人可以相互交流。我们还将使用此聊天服务向用户发送有关某些业务事件的信息,例如新产品的上市,成功销售或保险产品或关税变更。

完整解决方案的源代码可以在我们的GitHub上找到。

我们要创建什么?

在我们现有的基于微服务的保险销售系统中,我们将添加聊天服务

此聊天服务将具有两个功能:

  • 它将允许保险代理商使用我们的应用程序相互交谈,
  • 它会允许我们的系统在发生重要的商业事件时发送通知,例如新产品或关税引入,成功销售,佣金计算或收款等事件。

d2684f13f2ac07d8e738492ea47e6e15.png

我们从常规的.NET Web API应用程序开始,但是这次我们将使用2.2版,这将带来严重的后果。 和往常一样,我们添加MediatR。 在开始聊天实施之前,我们需要保护我们的服务和设置CORS。

为了保护对我们新服务的访问,我们将使用基于JWT令牌的方法。
我们需要通过将代码添加到ConfigureConfigureServices方法中来在Startup类中进行设置。 以下是显示关键配置部分的代码段:

var appSettings = appSettingsSection.Get();
var key = Encoding.ASCII.GetBytes(appSettings.Secret);

services
        .AddAuthentication(x =>
        {
            x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
            x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;    
        })
        .AddJwtBearer(x =>
        {
            x.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(key),
                ValidateIssuer = false,
                ValidateAudience = false,
                ValidateActor = false
            };
            x.Events = new JwtBearerEvents
            {
                OnMessageReceived = context =>
                {
                    var accessToken = context.Request.Query["access_token"];

                    var path = context.HttpContext.Request.Path;
                    if (!string.IsNullOrEmpty(accessToken) &&
                        (path.StartsWithSegments("/agentsChat")))
                    {
                        context.Token = accessToken;
                    }
                    return Task.CompletedTask;    
                }
            };
        });

这是典型的JWT设置,其秘密存储在配置文件中。 唯一不寻常的是OnMessageReceived,这是SignalR正常工作所必需的(您可以在此处找到更多信息)。

接下来是CORS设置。 使用.NET Core 2.2,我们在这里有了重大突破。 我们不再允许将公共API(带有AllowAnyOrigin的API)与AllowCredentials结合使用。

因此,我们需要在配置中指定所有允许的客户端。 使用配置中列出的客户端:

"AppSettings": {
    "AllowedChatOrigins" : ["http://localhost:8080"]
}

现在我们可以配置CORS:

services.AddCors(opt => opt.AddPolicy("CorsPolicy",
        builder =>
        {
                Builder
                        .AllowAnyHeader()
                        .AllowAnyMethod()
                        .AllowCredentials()
                        .WithOrigins(appSettingsSection.Get().AllowedChatOrigins);
        }
));

使用SignalR构建聊天服务

什么是SignalR?

SignalR是一个库,允许.NET开发人员向Web应用程序添加实时通信。 它具有从连接的客户端接收消息并从服务器向客户端发送通知的能力。

除了服务器端实现之外,您还可以找到适用于大多数流行平台的客户端实现:JavaScript,Java,.NET和.NET Core。

添加到项目

现在,我们可以将SignalR添加到我们的项目中。 第一步是将其添加到Startup类的ConfigureServices方法中:

services.AddSignalR();

接下来,我们需要添加一个集线器,它是服务器和客户端之间通信的中心点。 这很简单。 您只需要添加一个扩展Hub的类。

[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public class AgentChatHub : Hub

我们还添加了Authorize属性,并指定SignalR应该使用哪种身份验证架构。

我们还需要提供一项服务,该服务将根据当前用户主体为SignalR提供“用户名”。 为此,我们需要实现IUserIdProvider并注册该实现。

public class NameUserIdProvider : IUserIdProvider
{
        public string GetUserId(HubConnectionContext connection)
        {
                return connection.User?.Identity?.Name;
        }
}

//Startup.cs ConfigureServices
services.AddSingleton<IUserIdProvider, NameUserIdProvider>();

最后一步是为我们的中心添加URL映射。 我们需要将其添加到Startup类的Configure方法中:

app.UseSignalR(routes =>
{
        routes.MapHub("/agentsChat");
});

现在,提供有效JWT令牌并在AllowedChatOrigins配置设置中列出的客户端可以从外部访问我们的中心。

Hub

SignalR集线器允许客户端将消息发送到服务器,反之亦然。 集线器维护已连接客户端的列表,并允许服务器端代码在客户端上“执行”方法。

我们现在将方法SendMessage添加到我们的AgentChatHub中。 当代理之一想要向所有当前连接的用户发送消息时,客户端SPA应用程序将调用此方法。

public async Task SendMessage(string message)
{
        var avatar = Context.User?.Claims?.FirstOrDefault(c=>c.Type=="avatar")?.Value;
            
        await Clients.All.SendAsync("ReceiveMessage", Context.User?.Identity?.Name, avatar, message);
}

此方法从经过身份验证的用户数据中提取一些信息,并将所有客户端称为" ReceiveMessage",并传递用户名,头像和消息。

集线器还会对用户连接或断开连接等事件做出反应。 您可以在下面找到简单的方法,将有关此类事件的信息广播给所有用户。

public override async Task OnConnectedAsync()
{
        await Clients.Others.SendAsync("ReceiveNotification", $"{Context.User?.Identity?.Name} join chat");
}

public override async Task OnDisconnectedAsync(Exception exception)
{
        await Clients.Others.SendAsync("ReceiveNotification", $"{Context.User?.Identity?.Name} left chat");
}

用VueJS实现客户端

要使用VueJS中的SignalR,我们需要使用npm安装适当的软件包。

"dependencies": {
        "@aspnet/signalr": "^1.1.2",
        …
}

现在我们可以添加发送消息的工具并处理来自服务器的新消息的到达。 大多数有趣的代码位于Chat.vue组件中。

我们需要连接到集线器。 这在创建的处理程序中发生:

this.hubConnection
        .start()
        .then(()=>console.info("connected to hub"))
        .catch(err => console.error(err));

我们还需要为服务器发送的事件注册侦听器

this.hubConnection.on("ReceiveMessage",(usr, avatar,msg) => {
        this.appendMsgToChat(usr,avatar,msg);
});

this.hubConnection.on("ReceiveNotification", msg => {
        this.appendAlertToChat(msg);
});

唯一缺少的部分是一种将消息发送到我们的中心的方法:

send() {       
        this.hubConnection.invoke("SendMessage", this.message);
        this.message = '';
}

这是正在运行的应用程序的屏幕截图。

f44184183df707255763bef06e3fffc4.png

使用RabbitMQ实现发布订阅

RabbitMQ是轻量级的,易于使用和操作的开源消息代理。在我们的Software House中,我们在许多项目中至少使用了6年。它可以用于与发布者-订阅者模式异步集成系统组件,也可以用作RPC机制。

为了将RabbitMQ与.NET Core一起使用,我们建议使用RawRabbit库。
在开始实施发布订阅解决方案之前,我们必须安装RabbitMQ。可以在官方文档页面上找到大多数流行操作系统的安装说明。

您已经安装并运行RabbitMQ,我们就可以开始实施我们的解决方案。

发布事件

我们将添加将域事件发布到PolicyService的功能。当有人购买或终止保单时,我们将向RabbitMQ发送消息,以便对此类事件感兴趣的其他微服务可以做出反应。例如,PaymentService可以为刚售出的保单创建一个帐户,并在保单终止时关闭该帐户。

在这篇文章中,我们将看到ChatService如何订阅即将出售的新策略事件,以及如何使用SignalR通知当前登录的用户这种情况。

要使用RawRabbit实现事件发布,我们需要添加以下NuGet包。

<PackageReference Include="RawRabbit.DependencyInjection.ServiceCollection" Version="2.0.0-rc5" />
<PackageReference Include="RawRabbit.Operations.Tools" Version="2.0.0-rc5" />

现在我们可以配置库了。 我们将配置封装在RawRabbitInstaller类中。

services.AddRawRabbit(new RawRabbitOptions
{
        ClientConfiguration = new RawRabbit.Configuration.RawRabbitConfiguration
        {
            Username = "guest",
            Password = "guest",
            VirtualHost = "/",
            Port = 5672,
            Hostnames = new List {"localhost"},
            RequestTimeout = TimeSpan.FromSeconds(10),
            PublishConfirmTimeout = TimeSpan.FromSeconds(1),
            RecoveryInterval = TimeSpan.FromSeconds(1),
            PersistentDeliveryMode = true,
            AutoCloseConnection = true,
            AutomaticRecovery = true,
            TopologyRecovery = true,
            Exchange = new RawRabbit.Configuration.GeneralExchangeConfiguration
            {
                Durable = true,
                AutoDelete = false,
                Type = RawRabbit.Configuration.Exchange.ExchangeType.Topic
            },
            Queue = new RawRabbit.Configuration.GeneralQueueConfiguration
            {
                Durable = true,
                AutoDelete = false,
                Exclusive = false
            }
        }
});

在这里,我们配置代理地址,用于连接的用户凭据,交换和将默认设置排队。 对于生产解决方案,我们应将大多数这些参数移至系统配置。

有了这个,我们可以注册发布者的实现,该实现将在我们的代码库中使用。

services.AddSingleton<IEventPublisher,RabbitEventPublisher>();

我们当然必须在ConfigureServices方法的Startup类中添加服务:

services.AddRabbit();

这是发布者代码:

public class RabbitEventPublisher : IEventPublisher
{
        private readonly IBusClient busClient;

        public RabbitEventPublisher(IBusClient busClient)
        {
                this.busClient = busClient;
        }

        public Task PublishMessage(T msg)
        {
                return busClient.BasicPublishAsync(msg, cfg => {
                        cfg.OnExchange("lab-dotnet-micro").WithRoutingKey(typeof(T).Name.ToLower());
                });
        }
}

它接受RawRabbit库的一部分的busClient,并使用它发送消息以交换名为lab-dotnet-micro的消息,路由键等于消息类的名称,而内容等于消息类型的实例。

例如,我们有PolicyCreated类,该类代表正在出售的新政策的事件。 如果我们执行Publisher.PublishMessage(new PolicyCreated {…}),则消息将被路由到lab-dotnet-micro交换,该消息的路由键为PolicyCreated,内容为JSON序列化的PolicyCreated类。

现在,我们可以修改CreatePolicyHandler以在售出新策略时发送事件。

我们需要添加对IEventPublisher的依赖关系,并使用它来发送事件。

public async Task Handle(CreatePolicyCommand request, CancellationToken cancellationToken)
{
        using (var uow = uowProvider.Create())
        {
                var offer = await uow.Offers.WithNumber(request.OfferNumber);
                var customer =  ….
                var policy = offer.Buy(customer);

                uow.Policies.Add(policy);
                await uow.CommitChanges();

                await eventPublisher.PublishMessage(PolicyCreated(policy));

                return new CreatePolicyResult
                {
                PolicyNumber = policy.Number
                };    
        }
}

private PolicyCreated PolicyCreated(Policy policy)
{
        return new PolicyCreated
        {
                PolicyNumber = policy.Number,
                …
        };
}

侦听事件并向用户发送SignalR通知

现在,当发布部件准备就绪时,我们可以在ChatService中实现PolicyCreated事件的侦听器。

首先,我们需要在ChatService.csproj中的以下软件包上添加NuGet依赖项。

<PackageReference Include="RawRabbit.DependencyInjection.ServiceCollection" Version="2.0.0-rc5" />
<PackageReference Include="RawRabbit.Operations.Subscribe" Version="2.0.0-rc5" />

现在,我们可以配置与RabbitMQ的连接并配置侦听器。连接配置与发布者部分相同。您可以在ChatService中的RawRabbitInstaller 找到它。

在详细了解如何实现对消息的订阅之前,我们需要先讨论一下解决方案中的体系结构原理。到目前为止,我们将命令和查询处理程序用作封装域逻辑并将其公开给外部世界的工具。这些命令和查询使用Web API控制器公开。

我们的架构基于该领域的MediatR。为了保持一致性,我们还将使用MediatR处理消息。 MediatR具有INotificationINotificationHandler接口的定义。第一个用于标记事件或消息类,第二个用于实现给定通知类型(事件/消息类型)的处理程序。

如您可能已经看到的,PolicyCreated类标记有INotification接口。

在下面,您可以看到PolicyCreated通知的处理程序

public class PolicyCreatedHandler : INotificationHandler
{
        private readonly IHubContext chatHubContext;

        public PolicyCreatedHandler(IHubContext chatHubContext)
        {
                this.chatHubContext = chatHubContext;
        }

        public async Task Handle(PolicyCreated notification, CancellationToken cancellationToken)
        {
                await chatHubContext.Clients.All.SendAsync("ReceiveNotification",$"{notification.AgentLogin} just sold policy for {notification.ProductCode}!!!");
        }
}

在这里,您可以看到我们使用AgentChatHub的注入实例向所有连接的用户发送一条消息,即具有给定登录名的代理已出售给定类型的策略。 这样,我们将SignalR与来自其他微服务的消息结合在一起。 但是我们仍然没有看到如何将MediatR通知处理程序与RabbitMQ消息粘合在一起。

让我们分析RabbitEventListenerRabbitListenersInstaller类。 让我们检查RabbitEventListener ListenTo方法的作用

public void ListenTo(List eventsToSubscribe)
{
        foreach (var evtType in eventsToSubscribe)
        {
                This.GetType()
                        .GetMethod("Subscribe", 
                                System.Reflection.BindingFlags.NonPublic|
                                System.Reflection.BindingFlags.Instance)
                        .MakeGenericMethod(evtType)
                        .Invoke(this, new object[] { });
        }
}

此方法采用一种类型的列表,这些类型代表了我们想听的消息。 我们遍历此列表,对于每种类型,我们都调用Subscribe<T>方法。 这是完成实际工作的地方。

private void Subscribe() where T : INotification
{
        this.busClient.SubscribeAsync(
        async (msg) =>
        {
                using (var scope = serviceProvider.CreateScope())
                {
                        var internalBus = scope.ServiceProvider.GetRequiredService();
                        await internalBus.Publish(msg);
                }
        },
        cfg => cfg.UseSubscribeConfiguration(
                c => c
                .OnDeclaredExchange(e => e
                        .WithName("lab-dotnet-micro")
                        .WithType(RawRabbit.Configuration.Exchange.ExchangeType.Topic)
                        .WithArgument("key", typeof(T).Name.ToLower()))
                        .FromDeclaredQueue(q => q.WithName("lab-chat-service-" + typeof(T).Name)))
        );
}

在这里,我们连接到交换lab-dotnet-micro并声明一个队列,该队列将使用路由键等于事件类型名称的消息来收集消息。 队列名称是lab-chat-service加事件类型名称。

当带有这种密钥的消息到达交换机时,它将被放入我们的队列中。 当我们的ChatService启动并运行时,它将收到此消息,并且将执行粗体代码。

这段代码解决了IMediator的问题,并使用它路由到适当的INotificationHandler注册。

请记住,我们将MediatR集成与ASP.NET Core依赖项注入结合使用,因此我们无需执行任何其他步骤(除了在Startup类中调用services.AddMediatR()之外)即可注册PolicyCreatedHandler

因此,我们只需要注册要处理的消息类型,RabbitEventListener就会启动队列并开始订阅。

为此,我们将以下代码添加到Startup类的Configure方法中:

app.UseRabbitListeners(new List<Type> {typeof(PolicyCreated)});

这样,如果您要处理几种类型的消息,例如PolicyTerminatedProductActivated,我们要做的就是:

  • 为给定类型创建INotificationHandler
  • 将其添加到传递给UserRabbitListeners的类型列表中

总结

如您所见,在SignalR和RawRabbit的帮助下,我们可以实现相当复杂的功能。 将MadiatR与前面提到的组件结合在一起,可以使我们的业务逻辑代码摆脱基础设施的困扰,并有助于使我们保持整个解决方案的体系结构清洁。

我们还为我们的解决方案增加了巨大的商业价值。 现在,我们的保险代理人可以彼此交谈,并且他们还可以获取有关各种业务事件的实时通知。

您可以在https://github.com/asc-lab/dotnetcore-microservices-poc中查看完整的解决方案源代码。

posted @ 2022-03-31 09:16  桁椽  阅读(151)  评论(0编辑  收藏  举报