乘风破浪,遇见最佳跨平台跨终端框架.Net Core/.Net生态 - 浅析ASP.NET Core网关和BFF,使用Ocelot/Envoy/YARP打造专用网关

什么是BFF

用于前端的后端模式(Backend For Frontend,BFF),它负责认证授权、负责服务聚合,目标是为前端提供服务。

image

前世今生

在最早期的时候,是网关的概念先提出来。

BFF是我们在前后端分离的架构出来之后,我们会发现为前端提供单纯的API这样子的网关,所以用于前端的后端模式(Backend For Frontend,BFF)的概念就出来了

在微服务架构里面,BFF和网关之间的区别实际不大,它们之间的职责可以是重叠的,可以是聚合在一起,网关承载BFF的责任也是可以的。

本质来讲,BFF的模式,它是网关职责的一种进化。

直连模式

image

在没有网关的情况下,移动客户端、单页Web应用程序、传统Web应用都可以直连微服务,这种架构类似早期面向服务架构,微服务本身就代表早期的各种各样的、拆分得不那么细的服务。

共享网关模式

image

共享网关的模式,所有的服务都放置在网关之后,客户端也好、单页应用程序、传统Web应用都通过网关去调用微服务,这种模式的好处是统一的入口,然后所有应用程序都通过单一的网关来做,比较适合网关非常强大,并且所有的接口都是在网关上面一个个注册的,在云服务平台上面,可以用云服务的API网关服务来去实现这种模式,这个架构就要求网关的可用性要非常之高,对于客户端来讲,我们暴露的是无差别的API服务。

共享网关+聚合服务模式

image

随着我们业务的发展,实际上整个应用程序、单个微服务并不能满足我们的诉求,很有可能我们的需求需要跨服务之间去获取数据来组装数据的,这个时候就会出现聚合服务,聚合服务可作为一个单独的服务,在微服务的体系下面,通过网关来去访问它。聚合服务来去聚合不同服务的数据,然后响应给我们的应用客户端。

聚合服务也可以实现在我们的网关里,我们的网关来实现数据的聚合,这样子网关就类似于我们的BFF,它不但承担了网关的能力,还专门为客户端提供了聚合数据的能力。

专用网关模式

image

作者介绍

创建独立的后端服务,由特定的前端应用或接口来消费。当你想避免为多个接口定制一个后端时,这种模式很有用。这种模式最早是由Sam Newman描述的。

随着网络的出现和成功,提供用户界面的事实方式已经从厚重的客户端应用程序转移到了通过网络提供的界面,这一趋势也使得基于SAAS的解决方案普遍得到了发展。通过网络提供用户界面的好处是巨大的——主要是发布新功能的成本大大降低,因为客户端的安装成本(在大多数情况下)被完全消除了

但这个简单的世界并没有持续多久,因为不久之后就进入了移动时代。现在我们遇到了一个问题。我们有服务器端的功能,我们希望通过我们的桌面Web UI和一个或多个移动UI来展示。对于一个最初以桌面网络用户界面为基础开发的系统,我们在适应这些新类型的用户界面时经常会遇到问题,因为我们已经在桌面网络用户界面和我们的支持服务之间建立了紧密的联系

我在REA和SoundCloud看到的解决这个问题的方法是,不要有一个通用的API后端,而是每个用户体验都有一个后端——或者像(前SoundClouder)Phil Calçado所说的那样,一个前端后端(BFF)。从概念上讲,你应该把面向用户的应用程序看成是两个组件——一个生活在你周围的客户端应用程序,和一个在你周围的服务器端组件(BFF)。

BFF与特定的用户体验紧密相连,并且通常由与用户界面相同的团队维护,从而更容易根据用户界面的要求定义和调整API,同时也简化了客户端和服务器组件的发布过程。

BFF紧密地集中在一个单一的用户界面上,而且只是那个用户界面。这使它能够集中精力,因此也会更小。

对于一个只提供网页用户界面的应用程序来说,我认为只有当你在服务器端有大量的聚合需求时,BFF才有意义。否则,我认为其他的UI组合技术也可以很好地工作,而不需要额外的服务器端组件(我希望很快会讨论这些问题)。

不过,当你需要为移动UI或第三方提供特定的功能时,我会强烈考虑从一开始就为每一方使用一个BFFs。如果部署额外服务的成本很高,我可能会重新考虑,但BFF所带来的分离关注,在大多数情况下是一个相当有说服力的提议。如果构建用户界面的人和下游服务之间有明显的分离,我甚至会更倾向于使用BFF,原因如上所述。

使用Ocelot打造网关

https://github.com/TaylorShi/HelloGateway

什么是Ocelot

https://github.com/ThreeMammals/Ocelot

image

Ocelot是一个.NET API网关。这个项目是针对使用.NET运行微服务/面向服务架构的人,他们需要一个统一的入口点进入他们的系统。然而,它将与任何讲HTTP的东西一起工作,并在ASP.NET Core支持的任何平台上运行。

我特别希望能与IdentityServer参考和不记名令牌轻松集成。

在我目前的工作场所,我们一直无法找到这种方式,而不需要编写我们自己的Javascript中间程序来处理IdentityServer参考令牌。我们宁愿使用已经存在的IdentityServer代码来做这件事。

Ocelot是一堆按特定顺序排列的中间件。

Ocelot操纵HttpRequest对象进入其配置所指定的状态,直到它到达一个请求生成器中间件,在那里它创建一个HttpRequestMessage对象,用来向下游服务发出请求。提出请求的中间件是Ocelot管道中的最后一件事。它不会调用下一个中间件。当请求回到Ocelot管道时,下游服务的响应被检索出来。有一个中间件将HttpResponseMessage映射到HttpResponse对象上,并将其返回给客户端。基本上就是这样,还有一些其他的功能!

功能介绍

  • 路由选择
  • 请求聚合
  • 使用Consul和Eureka的服务发现
  • 服务结构
  • Kubernetes
  • 呼叫中心
  • 认证
  • 授权
  • 速率限制
  • 缓存
  • 重试策略/QoS
  • 负载平衡
  • 日志/追踪/关联
  • 头信息/方法/查询字符串/索赔转换
  • 自定义中间件/委托处理程序
  • 配置/管理REST API
  • 与平台/云无关

Ocelot示例项目

以下体系结构关系图显示了如何在eShopOnContainers中通过Ocelot实现API网关

image

使用API网关的eShopOnContainers体系结构

该图显示了如何使用“用于Windows的Docker”或“用于Mac的Docker”将整个应用程序部署到单个Docker主机或开发电脑。然而,虽然部署到业务流程协调程序中的方法与之相似,但图中的容器都可在业务流程协调程序中横向扩展。

此外,应从业务流程协调程序卸载基础结构资产(如数据库、缓存和消息代理),并将其部署到基础结构的高可用系统,如AzureSQL数据库、AzureCosmosDB、AzureRedis、Azure服务总线或本地HA群集解决方案。

此外,在关系图中还可以看到,在开发和部署微服务以及自己的相关API网关时,拥有多个API网关后,多个开发团队可进行自治(在本例中为“营销”功能和“购物”功能)。

如果有单一API网关,这意味着多个开发团队可更新单个点,由此可将所有微服务与应用程序的某一个部分相结合。

更进一步来说,在设计时,有时也可将精细API网关限于单个业务微服务,具体取决于所选体系结构。使用由业务或域指示的API网关边界将有助于更好地设计。

例如,由于精细API网关的概念类似于UI复合服务,因此API网关层中的精细粒度特别适用于基于微服务的高级复合UI应用程序。

对于许多大中型应用程序,关键在于使用定制构建的API网关产品,这通常是一种不错的方法,但不能作为单一聚合器或唯一的中央定制API网关,除非该API网关允许多个开发团队在多个独立配置区域创建自主微服务。

打造网关步骤

我们可以基于Ocelot来构造网关。

  • 添加包Ocelot
  • 添加配置文件ocelot.json
  • 添加配置读取代码
  • 注册Ocelot服务
  • 注册Ocelot中间件

Ocelot包和支持情况

依赖包

https://www.nuget.org/packages/Ocelot

dotnet add package Ocelot

目前最新版本只支持.Net 7,如果需要在更低版本框架使用它,可参考下面这个表格

版本 支持情况
18.0.0 >= .Net 7
16.0.2 >= .Net 5
16.0.1 >= .Net Core 3.1

启用Ocelot打造网关

我们准备了三个项目网关项目Tesla.Mobile.Gateway(端口:5003,5002)、聚合项目Tesla.Mobile.ApiAggregator(端口:5005,5004)、订单项目Tesla.Ordering.Api(端口:5001,5000)

image

三个项目我们都添加同样的TestController作为测试使用

[Route("api/[controller]/[action]")]
[ApiController]
public class TestController : ControllerBase
{
    [HttpGet]
    public IActionResult Abc()
    {
        return Content("Tesla.Mobile.Gateway");
    }

    [HttpGet]
    public IActionResult ShowRequestUri()
    {
        return Content(Request.GetDisplayUrl());
    }

    [HttpGet]
    public IActionResult ShowHeaders()
    {
        var sb = new StringBuilder();
        Request.Headers.ToList().ForEach(item =>
        {
            sb.AppendLine($"{item.Key}:{item.Value}");
        });

        return Content(sb.ToString());
    }
}

这个Abc方法不同项目返回不同的项目名称,以作区分。

这里我们将项目Tesla.Mobile.Gateway作为网关使用,在它这里安装Ocelot包,从ASP.NET Core 3.1的角度,用这个版本会正常一点。

dotnet add package Ocelot --version 14.1.3

image

我们需要去Startup.csConfigureServices中启用Ocelot服务

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddOcelot(Configuration);
}

这里将Configuration传进去即可,它会自动读取配置。

然后还需要在Configure中启用Ocelot的中间件。

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

    app.UseHttpsRedirection();

    app.UseRouting();

    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });

    app.UseOcelot().Wait();
}

注意这里的顺序,UseOcelot()它是放在最后的,这样可以保留这个项目本身的API支持。

接下来,我们需要在网关项目appsetting.json配置Ocelot相关的配置节点ReRoutes

{
    "ReRoutes": [
        {
            "DownstreamPathTemplate": "/api/{everything}",
            "DownstreamScheme": "https",
            "DownstreamHostAndPorts": [
                {
                    "Host": "localhost",
                    // Tesla.Mobile.ApiAggregator
                    "Port": 5005
                }
            ],
            "UpstreamPathTemplate": "/mobileAgg/api/{everything}",
            "UpstreamHttpMethod": []
        },
        {
            "DownstreamPathTemplate": "/api/{everything}",
            "DownstreamScheme": "https",
            "DownstreamHostAndPorts": [
                {
                    "Host": "localhost",
                    // Tesla.Ordering.Api
                    "Port": 5001
                }
            ],
            "UpstreamPathTemplate": "/mobile/api/{everything}",
            "UpstreamHttpMethod": []
        }
    ]
}

这个配置节点内容是一个数组,我们可以配置多个路由配置,其中Upstream代表上游请求流量,Downstream代表下游响应流量。

以第一组为例,它是针对Tesla.Mobile.ApiAggregator服务的,进来的路由模板是/mobileAgg/api/{everything},这个流量会匹配到下游的localhost:5005,并且这里我们指定了协议是https,如果改成http的话,那我们端口也要跟着切换。

依此类推,第二种便是针对Tesla.Ordering.Api服务的,请求地址是/mobile/api/{everything},会匹配下游的localhost:5001,且协议为https

这里之所以采用https并且使用它的端口,是因为在创建项目的时候默认配置了启用Https,并且我们开启了HTTPS中间件:UseHttpsRedirection

我们回到解决方案,在解决方案上直接右键,有个设置启动项目的选项。

image

这里,我们切换到多个启动项目,然后把上诉三个项目都设置为启动操作,这样我们就可以一次性启动三个项目了。

image

这时候点击启动即可,三个项目都顺利启动了。

image

image

我们来试试Ocelot配置的网关效果

image

image

image

一切符合预期!

Ocelot配置参数

Ocelot基本上都是靠参数来配置的。

//无Consul配置,简单配置,包含两大配置块,转发路由和全局配置
{
  // 转发路由,数组中的每个元素都是某个服务的一组路由转发规则
  "ReRoutes": [
    {
      // 下游(服务提供方)服务路由模板
      "DownstreamPathTemplate": "/api/{path}",
      // Uri方案,http、https
      "DownstreamScheme": "https",
      // 服务地址和端口,如果是集群就设置多个
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 6001
        },
        {
          "Host": "localhost",
          "Port": 6002
        }
      ],
      // 允许的HTTP请求方法,可以写多个
      "UpstreamHttpMethod": [ "Get", "Post" ],
      // 上游(客户端,服务消费方)请求路由模板
      "UpstreamPathTemplate": "/OcelotNotes/{path}",
      // 负载均衡,只有上面匹配了集群,才有效
      /*
       负载均衡算法,总共三种类型。
        LeastConnection:最小链接,将请求发往最空闲的那个服务器
        RoundRobin:轮询,轮流发送
        NoLoadBalance:无负载均衡,总是发往第一个请求或者是服务发现
        */
      "LoadBalancerOptions": {
        "Type": "RoundRobin" // 轮询
      }
    },
    {
      // 下游(服务提供方)服务路由模板
      "DownstreamPathTemplate": "/api/{path}",
      // Uri方案,http、https
      "DownstreamScheme": "http",
      // 服务地址和端口,如果是集群就设置多个
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 7001
        },
        {
          "Host": "localhost",
          "Port": 7002
        }
      ],
      // 允许的HTTP请求方法,可以写多个
      "UpstreamHttpMethod": [ "Get", "Post" ],
      // 上游(客户端,服务消费方)请求路由模板
      "UpstreamPathTemplate": "/MyServiceB/{path}",
      // 负载均衡,只有上面匹配了集群,才有效
      "LoadBalancerOptions": {
        "Type": "RoundRobin" // 轮询
      }
    }
  ],
  // 全局配置,此节点的配置会覆盖Routes,可以在这里设置一些通用的配置
  "GlobalConfiguration": {
    "ReRouteIsCaseSensitive": false// 路由是否区分大小写
  }
}

结合Consul

{
   //全局配置
  "GlobalConfiguration": {
    //"BaseUrl": "http://127.0.0.1:6299", //网关对外地址
    "RequestIdKey": "OcRequestId",
    "ReRouteIsCaseSensitive": true, //是否区分路由字母大小写
    "ServiceDiscoveryProvider": { //服务发现提供者,配置Consul地址
      "Host": "localhost", //Consul主机名称
      "Port": 8501, //Consul端口号
      "Type": "Consul" //必须指定Consul服务发现类型
    }
}

熔断、超时

.AddPolly()
{
    "QoSOptions": { //超时、熔断
        "ExceptionsAllowedBeforeBreaking": 3, //允许多少个异常请求
        "DurationOfBreak": 10000, // 熔断的时间,单位为ms
        "TimeoutValue": 10 //超时时间,单位为ms, 默认90秒
    }
}

限流

{
    "RateLimitOptions": {
        "ClientWhitelist": [ "SCscHeroTokenAPI ],
        //白名单功能,在Request的Header增加名为ClientId的Key,输入白名单中的元素的Value即可访问(区分大小写)。
        "EnableRateLimiting": true,//限流是否开启
        "Period": "5m", //1s, 5m, 1h, 1d
        "PeriodTimespan": 30, //多少秒之后客户端可以重试
        "Limit": 5 //统计时间段内允许的最大请求数量
      } 
}

全局限流

{
    //限流相关配置
    "RateLimitOptions": {
        "ClientIdHeader": "ClientId",//Request请求头的Key
        "QuotaExceededMessage": "RateLimit SCscHero", //限流响应的自定义提示
        "RateLimitCounterPrefix": "ocelot",//待研究,看翻译应该是限流计数器前缀
        "DisableRateLimitHeaders": false,
        "HttpStatusCode": 666
        }
}

通过聚合服务来聚合数据

这次我们通过聚合服务Tesla.Mobile.ApiAggregator来实现订单和支付数据的聚合查询。

它的数据来源是Tesla.Ordering.ApiTesla.Pay.Api这两个微服务,它们和聚合服务之间使用Grpc方案通信。

首先我们更新下proto文件。

order.proto

syntax = "proto3";

option csharp_namespace = "GrpcServices";

// 订单服务
service OrderGrpc
{
	// 创建订单
	rpc CreateOrder(CreateOrderCommand) returns (CreateOrderResult);

	// 搜索订单
	rpc SearchOrder(SearchOrderRequest) returns (SearchOrderResponse);
}

// 创建订单命令
message CreateOrderCommand
{
	string buyerId = 1;
	int32 productId = 2;
	double unitPrice = 3;
	double discount = 4;
	int32 units = 5;
}

// 创建订单结果
message CreateOrderResult
{
	int32 orderId = 1;
}

// 搜索订单命令
message SearchOrderRequest
{
	string UserName = 1;
}

// 搜索订单结果
message SearchOrderResponse
{
	repeated string Orders = 1;
}

这里增加了一个SearchOrder接口。

pay.proto

syntax = "proto3";

option csharp_namespace = "GrpcServices";

// 支付服务
service PayGrpc
{
	// 查询支付状态
	rpc QueryPayStatus(QueryPayStatusRequest) returns (QueryPayStatusResponse);
}

// 查询支付状态
message QueryPayStatusRequest
{
	string orderId = 1;
}

// 搜索订单结果
message QueryPayStatusResponse
{
	bool isPayed = 1;
}

这里有一个QueryPayStatus接口。

然后我们将其添加到这两个微服务中,并且聚合服务也要添加。

dotnet grpc add-file ..\HelloGrpcProto\Protos\order.proto
dotnet grpc add-file ..\HelloGrpcProto\Protos\pay.proto

Tesla.Ordering.Api项目中添加Grpc相关服务和终结点支持。

public class OrderService : OrderGrpc.OrderGrpcBase
{
    readonly IMediator _mediator;
    public OrderService(IMediator mediator)
    {
        this._mediator = mediator;
    }

    public override async Task<SearchOrderResponse> SearchOrder(SearchOrderRequest request, ServerCallContext context)
    {
        var result = await _mediator.Send(new SearchOrderCommand(request.UserName));
        var response = new SearchOrderResponse();
        response.Orders.AddRange(result);
        return await Task.FromResult(response);
    }
}
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddGrpc(grpcServiceOptions =>
    {
        // 生产环境需要将内部错误信息输出关闭掉
        grpcServiceOptions.EnableDetailedErrors = false;
    });
    services.AddMediatR(typeof(Program).Assembly);
}

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseHttpsRedirection();

    app.UseRouting();

    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
        endpoints.MapGrpcService<OrderService>();
    });
}

Tesla.Pay.Api项目中添加Grpc相关服务和终结点支持。

public class PayService : PayGrpc.PayGrpcBase
{
    readonly IMediator _mediator;
    public PayService(IMediator mediator)
    {
        _mediator = mediator;
    }

    public override async Task<QueryPayStatusResponse> QueryPayStatus(QueryPayStatusRequest request, ServerCallContext context)
    {
        var result = await _mediator.Send(new QueryPayStatusCommand(request.OrderId));
        var response = new QueryPayStatusResponse();
        response.IsPayed = result;
        return await Task.FromResult(response);
    }
}
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddGrpc(grpcServiceOptions =>
    {
        // 生产环境需要将内部错误信息输出关闭掉
        grpcServiceOptions.EnableDetailedErrors = false;
    });
    services.AddMediatR(typeof(Program).Assembly);
}

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseHttpsRedirection();

    app.UseRouting();

    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
        endpoints.MapGrpcService<PayService>();
    });
}

最后配置下两个服务的端口,错开下就行。

回到聚合服务Tesla.Mobile.ApiAggregator,我们新增一个OrderController来承载我们的聚合接口。

[Route("api/[controller]/[action]")]
[ApiController]
public class OrderController : ControllerBase
{
    readonly OrderGrpcClient _orderGrpcClient;
    readonly PayGrpcClient _payGrpcClient;

    public OrderController(OrderGrpcClient orderGrpcClient, PayGrpcClient payGrpcClient)
    {
        this._orderGrpcClient = orderGrpcClient;
        this._payGrpcClient = payGrpcClient;
    }

    [HttpGet]
    public async Task<IActionResult> Search([FromQuery] SearchOrderRequest searchOrderRequest)
    {
        var infos = new List<SearchOrderInfo>();
        var orderResponse = await _orderGrpcClient.SearchOrderAsync(searchOrderRequest);
        foreach (var o in orderResponse?.Orders?.ToList())
        {
            var payResponse = await _payGrpcClient.QueryPayStatusAsync(new QueryPayStatusRequest() { OrderId = o });
            var orderInfo = new SearchOrderInfo
            {
                OrderId = o,
                IsPayed = payResponse?.IsPayed ?? false
            };
            infos.Add(orderInfo);
        }

        return Ok(infos);
    }
}

这里依赖了Grpc引入的两个Client,我们通过OrderGrpcClientSearchOrderAsync获取订单Id,通过PayGrpcClientQueryPayStatusAsync获取订单的支付情况。

然后将两个数据聚合成SearchOrderInfo数组进行输出。

public class SearchOrderInfo
{
    public string OrderId { get; set; }

    public bool IsPayed { get; set; }
}

除此之外,我们需要在聚合项目中将两个Client注册进去。

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddGrpcClient<OrderGrpc.OrderGrpcClient>(grpcClientFactoryOptions =>
    {
        grpcClientFactoryOptions.Address = new Uri("https://localhost:5001");
    }).ConfigurePrimaryHttpMessageHandler(serviceProvider =>
    {
        var handler = new SocketsHttpHandler();
        // 允许无效或自签名证书
        handler.SslOptions.RemoteCertificateValidationCallback = (a, b, c, d) => true;
        return handler;
    });

    services.AddGrpcClient<PayGrpc.PayGrpcClient>(grpcClientFactoryOptions =>
    {
        grpcClientFactoryOptions.Address = new Uri("https://localhost:5006");
    }).ConfigurePrimaryHttpMessageHandler(serviceProvider =>
    {
        var handler = new SocketsHttpHandler();
        // 允许无效或自签名证书
        handler.SslOptions.RemoteCertificateValidationCallback = (a, b, c, d) => true;
        return handler;
    });
}

好了,把几个项目一起跑起来运行试试

image

非常完美。

最佳实践总结

在微服务的架构里面,通常不建议服务之间直接相互调用,也不建议微服务之间共享数据存储,微服务之间是通过EventBus来传递集成事件,然后来传递数据的。它们之间不应该有直接的调用。

当我们需要微服务之间的数据的聚合来满足前端的需求时,我们就可以搭建聚合服务,或者在网关上直接实现聚合服务。

架构上建议为不同客户端来设计不同的网关,因为不同的客户端可能我们的身份认证的方式时不同的,比如说移动客户端,我们可能需要采用Token的方式,对于传统的Web还是建议使用Session的方式。

专用网关模式的另外一个好处是故障隔离,为不同的客户端来设计不同的网关,可以避免某一个网关出现问题时影响其他的端。

启用Ocelot配置界面

依赖包

https://www.nuget.org/packages/Ocelot.ConfigEditor

dotnet add package Ocelot.ConfigEditor

image

安装之后,会多一些静态文件。

image

启用它

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    services.AddOcelot(Configuration);
    services.AddOcelotConfigEditor();
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseOcelotConfigEditor();
    app.UseOcelot().Wait();
}

但是最终失败,因为我没解决MVC启用的问题,网上没找到如何进一步正确配置它的文章。

启用Ocelot的Swagger聚合

https://github.com/Burgyn/MMLib.SwaggerForOcelot

image

SwaggerForOcelot结合了两个了不起的项目Swashbuckle.AspNetCoreOcelot。允许你直接通过Ocelot项目查看和使用下游服务的swagger文档。

直接通过http://ocelotprojecturl:port/swagger ,为配置在Ocelot.json中的下游服务提供文档。此外,地址被修改为与配置中的UpstreamPathTemplate匹配。

image

依赖包

https://www.nuget.org/packages/MMLib.SwaggerForOcelot

dotnet add package MMLib.SwaggerForOcelot

使用Envoy打造网关

什么是Envoy

https://www.envoyproxy.io

image

Envoy是专为大型现代SOA(面向服务架构)架构设计的L7代理和通信总线,体积小,性能高,它通过一款单一的软件满足了我们的众多需求,而不需要我们去搭配一些工具混合使用。

Envoy对WebSocket协议的内置支持,这是eShopOnContainers中实现的新gRPC服务间通信所必需的。

image

Envoy特点

SideCar模式

image

Envoy是一个独立进程,设计为伴随每个应用程序服务运行。所有的Envoy形成一个透明的通信网格,每个应用程序发送消息到本地主机或从本地主机接收消息,不需要知道网络拓扑。

L3/L4/L7架构

image

传统的网络代理,要么在HTTP层工作,要么在TCP层工作。Envoy支持同时在3/4层和7层操作,以此应对这两种方法各自都有其实际限制的现实。

动态更新

image

与Nginx等代理的热加载不同,Envoy可以通过API来实现其控制平面,控制平面可以集中服务发现,并通过API接口动态下发规则更新数据平面的配置,不需要重启数据平面的代理。

Envoy几大优势

image

  • 非侵入式架构: Envoy基于Sidecar模式,是一个独立进程,对应用透明。
  • 基于C++开发实现:拥有强大的定制化能力和优异的性能。
  • L3/L4/L7架构: 传统的网络代理,要么在HTTP层工作,要么在TCP层工作。而Envoy同时支持3/4层和7层代理。
  • 顶级HTTP/2支持: 它将HTTP/2视为一等公民,并且可以在HTTP/2和HTTP/1.1之间相互转换(双向),建议使用HTTP/2。
  • gRPC支持: Envoy完美支持HTTP/2,也可以很方便地支持gRPC(gRPC使用HTTP/2作为底层多路复用传输协议)。
  • 服务发现和动态配置: 与Nginx等代理的热加载不同,Envoy可以通过API接口动态更新配置,无需重启代理。
  • 特殊协议支持: Envoy支持对特殊协议在L7进行嗅探和统计,包括:MongoDB、DynamoDB等。
  • 可观测性: Envoy内置stats模块,可以集成诸如prometheus/statsd等监控方案。还可以集成分布式追踪系统,对请求进行追踪。

相对Ocelot而言,可以支持L3/L4/L7多层代理,支持gRPC,支持监控。

Envoy架构图

image

Docker创建Envoy实例

https://hub.docker.com/r/envoyproxy/envoy

docker run -d --name envoy --restart unless-stopped -p 10000:10000 envoyproxy/envoy:v1.24.0

image

使用YARP打造反向代理

什么是YARP

https://microsoft.github.io/reverse-proxy/

image

YARP被设计成一个提供核心代理功能的库,然后你可以通过添加或替换模块来进行定制。YARP目前是以NuGet包和代码片段的形式提供。我们计划在未来提供一个项目模板和预构建的exe。

YARP是在.NET Core基础架构之上实现的,可以在Windows、Linux或MacOS上使用。开发可以通过SDK和你最喜欢的编辑器、Microsoft Visual Studio或Visual Studio Code完成。

YARP由来

我们发现微软的一些内部团队要么正在为他们的服务建立一个反向代理,要么一直在询问建立一个反向代理的API和技术,所以我们决定让他们一起致力于一个共同的解决方案,这个项目。这些项目中的每一个都在做一些稍微偏僻的事情,这意味着现有的代理不能很好地为他们服务,而且定制这些代理的成本很高,还要考虑持续的维护。

许多现有的代理是为支持HTTP/1.1而建立的,但随着工作负载的变化,包括gRPC流量,他们需要HTTP/2的支持,这需要一个明显更复杂的实现。通过使用YARP,项目可以定制路由和处理行为,而不需要实现http协议。

使用YARP

YARP是使用ASP.NET和.NET(.NET Core 3.1、.NET 5和.NET 6)的基础设施建立在.NET上。YARP的关键区别在于,它被设计成可以通过.NET代码轻松定制和调整,以满足每个部署场景的具体需求。

最终,我们希望YARP能以库、项目模板和单文件exe的形式出现,为建立一个强大的、高性能的代理服务器提供多种选择。它的管道和模块的设计是为了让你可以根据你的需要定制功能。例如,虽然YARP支持配置文件,但我们预计许多用户将希望根据他们自己的配置管理系统以编程方式管理配置。YARP提供了一个配置API,以便在程序中实现这种定制。

YARP的设计是以可定制性为主要方案,而不是要求你打破脚本或从源头重建库。

依赖包

https://www.nuget.org/packages/Yarp.ReverseProxy

dotnet add package Yarp.ReverseProxy

image

Startup.csConfigureServices中注入Yarp相关服务。

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    // 添加ReverseProxy服务并加载配置
    services.AddReverseProxy().LoadFromConfig(Configuration.GetSection("ReverseProxy"));
}

然后在Configure的路由终结点启用ReverseProxy

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

    app.UseHttpsRedirection();

    app.UseRouting();

    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
        endpoints.MapReverseProxy();
    });
}

appsettings.json中添加相关配置

{
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft": "Warning",
            "Microsoft.Hosting.Lifetime": "Information"
        }
    },
    "AllowedHosts": "*",
    "ReverseProxy": {
        "Routes": {
            "OrderingApi": {
                "ClusterId": "OrderingCluster",
                "Match": {
                    "Path": "{**catch-all}"
                }
            }
        },
        "Clusters": {
            "OrderingCluster": {
                "Destinations": {
                    "destination1": {
                        "Address": "https://localhost:5001"
                    }
                }
            }
        }
    }
}

配置中,需要注意的是Routes中节点的ClusterId必须是和下面Clusters节点中名称对应上的,否则会提示找不到。

同时启动Tesla.Yarp.GatewayTesla.Ordering.Api,运行测试

image

这里确实是从Tesla.Yarp.Gateway的端口成功反向代理到了Tesla.Ordering.Api的接口。

身份认证

身份认证方案

ASP.NET Core提供了两种内置身份认证方案

  • Cookie
  • JWT Bearer

什么是JWT

https://jwt.io

  • 全称JSON Web Tokens
  • 支持签名的数据结构

JSON Web Token(JWT)是目前最流行的跨域认证解决方案,是一种基于Token的认证授权机制。从JWT的全称可以看出,JWT本身也是Token,一种规范化之后的JSON结构的Token。

image

JWT自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储Session信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力

可以看出,JWT更符合设计RESTful API时的「Stateless(无状态)」原则。

并且,使用JWT认证可以有效避免CSRF攻击,因为JWT一般是存在在LocalStorage中,使用JWT进行身份验证的过程中是不会涉及到Cookie的

JWT数据结构

典型JWT数据结构如下图

image

  • 头部(Header),令牌类型、加密类型等信息
  • 负载(Payload),令牌内容,预定义部分字段信息,支持自定义
  • 签名(Signature),根据Header、Payload和私有密钥计算出来的签名,主要是用来验证JWT前面的有效性,避免黑客攻击和伪造

启用JWT Bearer身份认证

https://www.nuget.org/packages/Microsoft.AspNetCore.Authentication.JwtBearer

  • Microsoft.AspNetCore.Authentication.JwtBearer

配置身份认证

  • Ocelot网关配置身份认证
  • 微服务配置认证与授权

使用JWT给网关添加身份认证

依赖包

https://www.nuget.org/packages/Microsoft.AspNetCore.Authentication.JwtBearer

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 3.1.30

image

先在配置文件appsettings.json中添加一个JWT加签的密钥节点SecurityKey

{
    "SecurityKey": "xxxxxxxxxxxxxxxxx"
}

然后在Startup.cs的服务配置方法ConfigureServices中从配置中读取密钥并且将它注入到服务中。

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    // 从配置中读取安全密钥并加入到容器中
    var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["SecurityKey"]));
    services.AddSingleton(securityKey);

这里看下SymmetricSecurityKey定义,它的入参是一个字节数组,所以这里我们通过Encoding.UTF8.GetBytes将配置的密钥字符串转为字节数组。

namespace Microsoft.IdentityModel.Tokens
{
    public class SymmetricSecurityKey : SecurityKey
    {
        public SymmetricSecurityKey(byte[] key)
        {
            if (key == null)
            {
                throw LogHelper.LogArgumentNullException("key");
            }

            if (key.Length == 0)
            {
                throw LogHelper.LogExceptionMessage(new ArgumentException(LogHelper.FormatInvariant("IDX10703: Cannot create a '{0}', key length is zero.", typeof(SymmetricSecurityKey))));
            }

            _key = key.CloneByteArray();
            _keySize = _key.Length * 8;
        }
    }
}

接下来,我们添加身份认证服务,并且指定默认的身份认证方式为Cookies

// 添加身份验证,指定默认方案为Cookies
services.AddAuthentication(defaultScheme: CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
    {

    })
    .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            // 验证签发者
            ValidateIssuer = true,
            // 验证客户端
            ValidateAudience = true,
            // 验证失效时间
            ValidateLifetime = true,
            // 失效时间的偏离时间,这里设置30秒,意味着在失效的30秒内它还是可以使用的
            ClockSkew = TimeSpan.FromSeconds(30),
            // 是否验证SigningKey
            ValidateIssuerSigningKey = true,
            // 有效的客户端
            ValidAudience = "localhost",
            // 有效的签发者
            ValidIssuer = "localhost",
            // 签发者密钥
            IssuerSigningKey = securityKey
        };
    });

这里通过AddAuthentication注入身份认证相关服务,并且有个默认认证方式的入参。

namespace Microsoft.Extensions.DependencyInjection
{
    public static class AuthenticationServiceCollectionExtensions
    {
        public static AuthenticationBuilder AddAuthentication(this IServiceCollection services);
        public static AuthenticationBuilder AddAuthentication(this IServiceCollection services, Action<AuthenticationOptions> configureOptions);
        public static AuthenticationBuilder AddAuthentication(this IServiceCollection services, string defaultScheme);
    }
}

接下里,这里通过AddCookieAddJwtBearer的方法分别添加Cookie和JWT两种认证方式。

namespace Microsoft.Extensions.DependencyInjection
{
    public static class CookieExtensions
    {
        public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder);
        public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder, Action<CookieAuthenticationOptions> configureOptions);
        public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder, string authenticationScheme);
        public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder, string authenticationScheme, Action<CookieAuthenticationOptions> configureOptions);
        public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<CookieAuthenticationOptions> configureOptions);
    }
}
namespace Microsoft.Extensions.DependencyInjection
{
    public static class JwtBearerExtensions
    {
        public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder)
        {
            return builder.AddJwtBearer("Bearer", delegate
            {
            });
        }

        public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder, Action<JwtBearerOptions> configureOptions)
        {
            return builder.AddJwtBearer("Bearer", configureOptions);
        }

        public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder, string authenticationScheme, Action<JwtBearerOptions> configureOptions)
        {
            return builder.AddJwtBearer(authenticationScheme, null, configureOptions);
        }

        public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<JwtBearerOptions> configureOptions)
        {
            builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<JwtBearerOptions>, JwtBearerPostConfigureOptions>());
            return builder.AddScheme<JwtBearerOptions, JwtBearerHandler>(authenticationScheme, displayName, configureOptions);
        }
    }
}

可以看出,这两个方法都存在一个委托配置来设置对应认证方式的参数,其中对于JWT而言,可设置一系列Token验证参数

.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        // 验证签发者
        ValidateIssuer = true,
        // 验证客户端
        ValidateAudience = true,
        // 验证失效时间
        ValidateLifetime = true,
        // 失效时间的偏离时间,这里设置30秒,意味着在失效的30秒内它还是可以使用的
        ClockSkew = TimeSpan.FromSeconds(30),
        // 是否验证SigningKey
        ValidateIssuerSigningKey = true,
        // 有效的客户端
        ValidAudience = "localhost",
        // 有效的签发者
        ValidIssuer = "localhost",
        // 签发者密钥
        IssuerSigningKey = securityKey
    };
});
public TokenValidationParameters()
{
    RequireExpirationTime = true;
    RequireSignedTokens = true;
    RequireAudience = true;
    SaveSigninToken = false;
    ValidateActor = false;
    ValidateAudience = true;
    ValidateIssuer = true;
    ValidateIssuerSigningKey = false;
    ValidateLifetime = true;
    ValidateTokenReplay = false;
}

为了能够启用身份验证,我们需要在Startup.csConfigure方法中启用身份认证中间件UseAuthentication、启用认证UseAuthorization

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // 启用身份验证中间件
    app.UseAuthentication();

    // 必须在UseEndpoints之前
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });

这里需要特别注意下,这两个中间件的启用先后顺序,UseAuthentication在前,UseAuthorization在后,同时他们两个必须在UseEndpoints之间。

验证JWT身份验证

我们首先建立一个AccountController来用于生成Cookie和JWT Token

[Route("api/[controller]/[action]")]
[ApiController]
public class AccountController : ControllerBase
{
    /// <summary>
    /// 登录
    /// </summary>
    /// <returns></returns>
    [HttpGet]
    public async Task<string> Login()
    {
        return await Task.FromResult("请先登录");
    }

    /// <summary>
    /// 使用Cookie进行登录
    /// </summary>
    /// <param name="userName"></param>
    /// <returns></returns>
    [HttpGet]
    public async Task<IActionResult> CookieLogin(string userName)
    {
        var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
        identity.AddClaim(new Claim("Name", userName));
        await this.HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity));
        return Content("login");
    }

    /// <summary>
    /// 使用JWT进行登录
    /// </summary>
    /// <param name="securityKey"></param>
    /// <param name="userName"></param>
    /// <returns></returns>
    [HttpGet]
    public async Task<IActionResult> JwtLogin([FromServices]SymmetricSecurityKey securityKey, string userName)
    {
        List<Claim> claims = new List<Claim>();
        claims.Add(new Claim("Name", userName));

        // 加密算法选择HmacSha256
        var creds = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
        // 构造JWT的Token
        var token = new JwtSecurityToken(
            issuer: "localhost",
            audience: "localhost",
            claims: claims,
            expires: DateTime.Now.AddMinutes(30),
            signingCredentials: creds
            );
        // 生成JWT Token的字符串
        var t = new JwtSecurityTokenHandler().WriteToken(token);
        return Content(t);
    }
}

JwtLogin中先指定HmacSha256作为签名算法,然后新建了一个JwtSecurityToken对象,最后通过JwtSecurityTokenHandlerWriteToken将这个对象生成对应的JWT Token值。

我们来运行一下,请求:https://localhost:5003/api/account/jwtlogin?username=1

image

得到一串JWT Token字符串。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJOYW1lIjoiMSIsImV4cCI6MTY2NjY3ODc2NSwiaXNzIjoibG9jYWxob3N0IiwiYXVkIjoibG9jYWxob3N0In0.xAhdFr7BESoFvbx0f00PcGnzb2GjHd9IV6QFENKAUDk

我们前往官方的https://jwt.io/#debugger-io 去解开看看

image

还可以输入密钥,它会做一个签名验证,如果验证成功,那么左下角会提示Signature Verified

另外我们也调用下生成Cookie的接口

image

其次,我们新建一个BankAccountController来验证身份授权

[Route("api/[controller]/[action]")]
[ApiController]
public class BankAccountController : ControllerBase
{
    /// <summary>
    /// 使用默认的身份验证方案
    /// </summary>
    /// <returns></returns>
    [Authorize]
    public IActionResult Default()
    {
        return Content("Bank Account");
    }

    /// <summary>
    /// 使用Cookie身份验证方案
    /// </summary>
    /// <returns></returns>
    [Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)]
    public IActionResult Cookie()
    {
        return Content(User.FindFirst("Name").Value);
    }

    /// <summary>
    /// 使用JWT身份验证方案
    /// </summary>
    /// <returns></returns>
    [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
    public IActionResult Jwt()
    {
        return Content(User.FindFirst("Name").Value);
    }

    /// <summary>
    /// 同时使用Cookie和JWT身份验证方案
    /// </summary>
    /// <returns></returns>
    [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme + "," + CookieAuthenticationDefaults.AuthenticationScheme)]
    public IActionResult Hybird()
    {
        return Content(User.FindFirst("Name").Value);
    }
}

通过在Action上添加Authorize标识来添加身份认证,可以通过AuthenticationSchemes指定身份认证的方案,可以单个,也可以多个,用逗号隔开即可。

启动后,我们打开Postman调试下。

首先,我们看到使用默认身份验证方案的是通过了验证的,因为前面我们默认了Cookie方式,并且又获取过Cookie了。

image

然后直接使用Cookies方式验证也是欧克的,并且它成功输出了name的值。

image

然后直接使用JWT方式会失败,因为我们并没有携带有效的Token值

image

然后使用混合模式的会成功,因为只要Cookie和JWT任意方案验证通过即可。

image

如果想要JWT验证通过,我们需要在Header中加上有效的JWT Token

这里JWT Token的格式是:Bearer + 空格 + Token,在Header中的Key是Authorization

image

这样就可以验证通过了。

使用建议

一部分接口,我们可以使用JWT的方案,尤其是针对移动客户端的场景,针对浏览器的场景,我们可以让它支持Cookie的身份认证。

在聚合服务配置同样的身份验证

我们再来调用下试试,其中使用JWT的方案是好的

image

但是Cookie的方案出问题了,直接404了

image

JWT注意事项

使用JWT注意事项

  • Payload信息不宜过大
  • Payload不宜存储敏感信息

在没有密钥的情况下,实际上是可以看到JWT内部存储的信息的,它本质来讲,JWT的Header和Payload都是把Json信息进行了Base64这样的数据转换,它本质上不是一个加密动作,JWT本身不是一个信息加密的Token,它是一个信息签名的Token,所以在使用JWT时,不应该在JWT的Payload的存储用户的密码或者说一些敏感信息,因为这些都是明文传输的

另外就是,记得在网关和聚合服务之间共享JWT的密钥,这样JWT在所有站点才是互通的。

参考

posted @ 2022-10-23 11:16  TaylorShi  阅读(1836)  评论(1编辑  收藏  举报