eShopOnContainers 知多少[9]:Ocelot gateways

引言

客户端与微服务的通信问题永远是一个绕不开的问题,对于小型微服务应用,客户端与微服务可以使用直连的方式进行通信,但对于对于大型的微服务应用我们将不得不面对以下问题:

  1. 如何降低客户端到后台的请求数量,并减少与多个微服务的无效交互?
  2. 如何处理微服务间的交叉问题,比如授权、数据转换和动态请求派发?
  3. 客户端如何与使用非互联网友好协议的服务进行交互?
  4. 如何打造移动端友好的服务?

而解决这一问题的方法之一就是借助API网关,其允许我们按需组合某些微服务以提供单一入口。

接下来,本文就来梳理一下eShopOnContainers是如何集成Ocelot网关来进行通信的。

使用自定义的API 网关服务

Hello Ocelot

关于Ocelot,张队在Github上贴心的整理了awesome-ocelot系列以便于我们学习。这里就简单介绍下Ocelot,不过多展开。
Ocelot是一个开源的轻量级的基于ASP.NET Core构建的快速且可扩展的API网关,核心功能包括路由、请求聚合、限速和负载均衡,集成了IdentityServer4以提供身份认证和授权,基于Consul提供了服务发现能力,借助Polly实现了服务熔断,能够很好的和k8s和Service Fabric集成。

Ocelot 集成

eShopOnContainers中的以下六个微服务都是通过网关API进行发布的。

引入网关层后,eShopOnContainers的整体架构如下图所示:
引入网关层后的整体架构设计

从代码结构来看,其基于业务边界(Marketing和Shopping)分别为Mobile和Web端建立多个网关项目,这样做利于隔离变化,降低耦合,且保证开发团队的独立自主性。所以我们在设计网关时也应注意到这一点,切忌设计大一统的单一API网关,以避免整个微服务架构体系的过度耦合。在网关设计中应当根据业务和领域去决定API网关的边界,尽量设计细粒度而非粗粒度的API网关。

eShopOnContainers中ApiGateways文件下是相关的网关项目。相关项目结构如下图所示。

ApiGateways 代码结构

从代码结构看,有四个configuration.json文件,该文件就是ocelot的配置文件,其中主要包含两个节点:

{
 "ReRoutes": [],
 "GlobalConfiguration": {}
}

那4个独立的配置文件是怎样设计成4个独立的API网关的呢?
在eShopOnContainers中,首先基于OcelotApiGw项目构建单个Ocelot API网关Docker容器镜像,然后在运行时,通过使用docker volume分别挂载不同路径下的configuration.json文件来启动不同类型的API-Gateway容器。示意图如下:
重用Ocelot Docker镜像启动多个网关容器服务

docker-compse.yml中相关配置如下:

// docker-compse.yml
mobileshoppingapigw:
 image: eshop/ocelotapigw:${TAG:-latest}
 build:
 context: .
 dockerfile: src/ApiGateways/ApiGw-Base/Dockerfile

// docker-compse.override.yml
mobileshoppingapigw:
 environment:
 - ASPNETCORE_ENVIRONMENT=Development
 - IdentityUrl=http://identity.api
 ports:
 - "5200:80"
 volumes:
 - ./src/ApiGateways/Mobile.Bff.Shopping/apigw:/app/configuration

通过这种方式将API网关分成多个API网关,不仅可以同时重复使用相同的Ocelot Docker镜像,而且开发团队可以专注于团队所属微服务的开发,并通过独立的Ocelot配置文件来管理自己的API网关。

而关于Ocelot的代码集成,主要就是指定配置文件以及注册Ocelot中间件。核心代码如下:

public void ConfigureServices(IServiceCollection services)
{
    //..
    services.AddOcelot (new ConfigurationBuilder ()
    .AddJsonFile (Path.Combine ("configuration", "configuration.json"))
    .Build ());
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
     //...
    app.UseOcelot().Wait();
}

请求聚合

在单体应用中时,进行页面展示时,可以一次性关联查询所需的对象并返回,但是对于微服务应用来说,某一个页面的展示可能需要涉及多个微服务的数据,那如何进行将多个微服务的数据进行聚合呢?首先,不可否认的是,Ocelot提供了请求聚合功能,但是就其灵活性而言,远不能满足我们的需求。因此,一般会选择自定义聚合器来完成灵活的聚合功能。在eShopOnContainers中就是通过独立ASP.NET Core Web API项目来提供明确的聚合服务。Mobile.Shopping.HttpAggregatorWeb.Shopping.HttpAggregator即是用于提供自定义的请求聚合服务。

使用聚合服务的架构

下面就以Web.Shopping.HttpAggregator项目为例来讲解自定义聚合的实现思路。
首先,该网关项目是基于ASP.NET Web API构建。其代码结构如下图所示:
Web.Shopping.HttpAggregator 自定义聚合服务代码结构

其核心思路是自定义网关服务借助HttpClient发起请求。我们来看一下BasketService的实现代码:

public class BasketService : IBasketService
{
    private readonly HttpClient _apiClient;
    private readonly ILogger<BasketService> _logger;
    private readonly UrlsConfig _urls;
    public BasketService(HttpClient httpClient,ILogger<BasketService> logger, IOptions<UrlsConfig> config)
    {
        _apiClient = httpClient;
        _logger = logger;
        _urls = config.Value;
    }
    public async Task<BasketData> GetById(string id)
    {
        var data = await _apiClient.GetStringAsync(_urls.Basket +  UrlsConfig.BasketOperations.GetItemById(id));
        var basket = !string.IsNullOrEmpty(data) ? JsonConvert.DeserializeObject<BasketData>(data) : null;
        return basket;
    }
}

代码中主要是通过构造函数注入HttpClient,然后方法中借助HttpClient实例发起相应请求。那HttpClient实例是如何注册的呢,我们来看下启动类里服务注册逻辑。

public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
    //register delegating handlers
    services.AddTransient<HttpClientAuthorizationDelegatingHandler>();
    services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();

    //register http services  
    services.AddHttpClient<IBasketService, BasketService>()
        .AddHttpMessageHandler<HttpClientAuthorizationDelegatingHandler>()
        .AddPolicyHandler(GetRetryPolicy())
        .AddPolicyHandler(GetCircuitBreakerPolicy());

    services.AddHttpClient<ICatalogService, CatalogService>()
        .AddPolicyHandler(GetRetryPolicy())
        .AddPolicyHandler(GetCircuitBreakerPolicy());

    services.AddHttpClient<IOrderApiClient, OrderApiClient>()
        .AddHttpMessageHandler<HttpClientAuthorizationDelegatingHandler>()
        .AddPolicyHandler(GetRetryPolicy())
        .AddPolicyHandler(GetCircuitBreakerPolicy());
    return services;
}

从代码中可以看到主要做了三件事:

  1. 注册HttpClientAuthorizationDelegatingHandler负责为HttpClient构造Authorization请求头
  2. 注册IHttpContextAccessor用于获取HttpContext
  3. 为三个网关服务分别注册独立的HttpClient,其中IBasketServieIOrderApiClient需要认证,所以注册了HttpClientAuthorizationDelegatingHandler用于构造Authorization请求头。另外,分别注册了Polly的请求重试和断路器策略。

HttpClientAuthorizationDelegatingHandler是如何构造Authorization请求头的呢?直接看代码实现:

public class HttpClientAuthorizationDelegatingHandler
     : DelegatingHandler
{
    private readonly IHttpContextAccessor _httpContextAccesor;
    public HttpClientAuthorizationDelegatingHandler(IHttpContextAccessor httpContextAccesor)
    {
        _httpContextAccesor = httpContextAccesor;
    }
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var authorizationHeader = _httpContextAccesor.HttpContext
            .Request.Headers["Authorization"];
        if (!string.IsNullOrEmpty(authorizationHeader))
        {
            request.Headers.Add("Authorization", new List<string>() { authorizationHeader });
        }
        var token = await GetToken();
        if (token != null)
        {
            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
        }
        return await base.SendAsync(request, cancellationToken);
    }
    async Task<string> GetToken()
    {
        const string ACCESS_TOKEN = "access_token";
        return await _httpContextAccesor.HttpContext
            .GetTokenAsync(ACCESS_TOKEN);
    }
}

代码实现也很简单:首先从 _httpContextAccesor.HttpContext.Request.Headers["Authorization"]中取,若没有则从_httpContextAccesor.HttpContext.GetTokenAsync("access_token")中取,拿到访问令牌后,添加到请求头request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);即可。

这里你肯定有个疑问就是:为什么不是到Identity microservices去取访问令牌,而是直接从_httpContextAccesor.HttpContext.GetTokenAsync("access_token")中取访问令牌?

Good Question,因为对于网关项目而言,其本身也是需要认证的,在访问网关暴露的需要认证的API时,其已经同Identity microservices协商并获取到令牌,并将令牌内置到HttpContext中了。所以,对于同一个请求上下文,我们仅需将网关项目申请到的令牌传递下去即可。

Ocelot网关中如何集成认证和授权

不管是独立的微服务还是网关,认证和授权问题都是要考虑的。Ocelot允许我们直接在网关内的进行身份验证,如下图所示:
网关内身份验证

因为认证授权作为微服务的交叉问题,所以将认证授权作为横切关注点设计为独立的微服务更符合关注点分离的思想。而Ocelot网关仅需简单的配置即可完成与外部认证授权服务的集成。

1. 配置认证选项
首先在configuration.json配置文件中为需要进行身份验证保护API的网关设置AuthenticationProviderKey。比如:

{
  "DownstreamPathTemplate": "/api/{version}/{everything}",
  "DownstreamScheme": "http",
  "DownstreamHostAndPorts": [
    {
      "Host": "basket.api",
      "Port": 80
    }
  ],
  "UpstreamPathTemplate": "/api/{version}/b/{everything}",
  "UpstreamHttpMethod": [],
  "AuthenticationOptions": {
    "AuthenticationProviderKey": "IdentityApiKey",
    "AllowedScopes": []
  }
}

2. 注册认证服务
当Ocelot运行时,它将根据Re-Routes节点中定义的AuthenticationOptions.AuthenticationProviderKey,去确认系统是否注册了相对应身份验证提供程序。如果没有,那么Ocelot将无法启动。如果有,则ReRoute将在执行时使用该提供程序。
OcelotApiGw的启动配置中,就注册了AuthenticationProviderKey:IdentityApiKey的认证服务。

public void ConfigureServices (IServiceCollection services) {
    var identityUrl = _cfg.GetValue<string> ("IdentityUrl");
    var authenticationProviderKey = "IdentityApiKey";
    //…
    services.AddAuthentication ()
        .AddJwtBearer (authenticationProviderKey, x => {
            x.Authority = identityUrl;
            x.RequireHttpsMetadata = false;
            x.TokenValidationParameters = new
            Microsoft.IdentityModel.Tokens.TokenValidationParameters () {
                ValidAudiences = new [] {
                "orders",
                "basket",
                "locations",
                "marketing",
                "mobileshoppingagg",
                "webshoppingagg"
                }
            };
        });
    //...
}

这里需要说明一点的是ValidAudiences用来指定可被允许访问的服务。其与各个微服务启动类中ConfigureServices() AddJwtBearer()指定的Audience相对应。比如:

// prevent from mapping "sub" claim to nameidentifier.
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear ();
var identityUrl = Configuration.GetValue<string> ("IdentityUrl");
services.AddAuthentication (options => {
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer (options => {
    options.Authority = identityUrl;
    options.RequireHttpsMetadata = false;
    options.Audience = "basket";
});

3. 按需配置申明进行鉴权
另外有一点不得不提的是,Ocelot支持在身份认证后进行基于声明的授权。仅需在ReRoute节点下配置RouteClaimsRequirement即可:

"RouteClaimsRequirement": {
 "UserType": "employee"
}

在该示例中,当调用授权中间件时,Ocelot将查找用户是否在令牌中是否存在UserType:employee的申明。如果不存在,则用户将不被授权,并响应403。

最后

经过以上的讲解,想必你对eShopOnContainers中如何借助API 网关模式解决客户端与微服务的通信问题有所了解,但其就是万金油吗?API 网关模式也有其缺点所在。

  1. 网关层与内部微服务间的高度耦合。
  2. 网关层可能出现单点故障。
  3. API网关可能导致性能瓶颈。
  4. API网关如果包含复杂的自定义逻辑和数据聚合,额外增加了团队的开发维护沟通成本。

虽然IT没有银弹,但eShopOnContainers中网关模式的应用案例至少指明了一种解决问题的思路。而至于在实战场景中的技术选型,适合的就是最好的。

posted @ 2019-03-05 14:13  「圣杰」  阅读(1859)  评论(7编辑  收藏