ASP.NET Core中Ocelot的使用:API网关的应用

By | 2018年10月29日

在向微服务体系架构转型的过程中,我们都会毫不意外地遇到越来越多的现实问题,而这些问题却并不是因为功能性需求而引入的。比如,服务的注册与发现,是应用程序在云中部署、提供可伸缩支持的主要实现方案,在特定的微服务架构中,实践这样的云设计模式是利远远大于弊的。今我们需要讨论的API网关也是这样的一种微服务实现方案,它解决了客户端与服务端之间繁琐的通信问题。

在进一步讨论API网关在微服务架构中的应用前,先一起了解一下目前我手上的两个微服务:A服务提供了数学计算的API,B服务则提供了天气数据查询的API。两者看上去风马牛不相及,然而,B服务中的一个需求使得两者之间产生了密不可分的联系:B服务需要提供某些城市5年来平均气温的标准差,以便用户能够了解各个城市的气温变化情况。业务需求其实挺简单,从一个外部数据源将指定城市的平均气温数据读入,然后计算标准差即可,不过从实现上看,由于A服务已经提供了计算标准差的API,因此,B服务没有必要自己再写一套,只需要调用A服务提供的API即可,也就是A和B之间存在互相调用的情况。另一方面,对于用户来说,也存在需要同时调用A和B两组API的场景,因此,A和B的API也需要向外界曝露,这样一来应用程序的结构大致如下:

image

也就是上图中B服务的/weather/stddev API将会用到A服务中的/calc/stddev这个API。OK,目前我们的应用程序结构还是比较简单,无非也就是调用几个API,既然如此,我们先用ASP.NET Core Web API把这个应用程序实现出来。

使用ASP.NET Core实现整个应用程序

先看看A服务的实现,它比较简单,在ASP.NET Core的项目上,新建一个控制器(Controller),然后曝露RESTful API向外界提供计算服务即可。控制器代码如下:

[Route("api/[controller]")]
[ApiController]
public class CalcController : ControllerBase
{
    [HttpGet("add/{x}/{y}")]
    public int Add(int x, int y) => x + y;

    [HttpGet("sub/{x}/{y}")]
    public int Sub(int x, int y) => x - y;

    [HttpPost("stddev")]
    public double StdDev([FromBody]float[] numbers) 
        => Math.Sqrt(numbers.Select(x => Math.Pow(x - numbers.Average(), 2)).Sum() / (numbers.Length - 1));
}

A服务其它部分的代码就不多说明了,都是标准做法,没有什么特别。再看看B服务,其实除了通过外部数据源获取指定城市5年来每天的平均气温数据,以及调用A服务计算标准差之外,也没有什么特别之处,主要代码如下:

[Route("api/[controller]")]
[ApiController]
public class WeatherController : ControllerBase
{
    const string StdDevCalculationApiURI = "http://localhost:49814/api/calc/stddev";

    static readonly Dictionary<string, List<Tuple<DateTime, float>>> weatherData;

    static WeatherController()
    {
        weatherData = JsonConvert.DeserializeObject<Dictionary<string, List<Tuple<DateTime, float>>>>(System.IO.File.ReadAllText("weather_data.json"));
    }

    [HttpGet("stddev/{city}")]
    public async Task<IActionResult> StdDevForCity(string city)
    {
        if (!weatherData.ContainsKey(city.ToUpper()))
        {
            return BadRequest($"城市'{city}'的气象数据不存在。");
        }

        using (var client = new HttpClient())
        {
            var response = await client.PostAsJsonAsync(StdDevCalculationApiURI, weatherData[city.ToUpper()].Select(x => x.Item2).ToArray());
            response.EnsureSuccessStatusCode();
            return Ok(Convert.ToDouble(await response.Content.ReadAsStringAsync()));
        }
    }
}

为了便于演示,我已经预先将气温数据序列化成一个JSON文件,所以在上面的代码中,WeatherController在初次被引用时,会将气温数据从JSON文件中反序列化出来,并读入到weatherData字典中。在StdDevForCity的方法中可以看到,此API通过HttpClient向A服务发起了计算标准差的请求,并将平均气温数据以Post Body的形式代入到发出的请求中。

接下来,我们的前端页面需要使用A服务进行一些纯粹的数学计算,并且使用B服务为用户提供一些天气数据的统计信息。为了简化描述,这个前端应用我也采用ASP.NET Core来实现。使用Visual Studio创建了一个ASP.NET Core MVC的应用程序,然后,添加一个API的页面,在HomeController中,使用如下代码为API页面提供数据模型:

public class HomeController : Controller
{
    const string CalculationServiceUri = "http://localhost:49814";
    const string WeatherServiceUri = "http://localhost:50379";

    public async Task<IActionResult> API()
    {
        using (var client = new HttpClient())
        {
            // 调用计算服务,计算两个整数的和与差
            const int x = 124, y = 134;
            var sumResponse = await client.GetAsync($"{CalculationServiceUri}/api/calc/add/{x}/{y}");
            sumResponse.EnsureSuccessStatusCode();
            var sumResult = await sumResponse.Content.ReadAsStringAsync();
            ViewData["sum"] = $"x + y = {sumResult}";

            var subResponse = await client.GetAsync($"{CalculationServiceUri}/api/calc/sub/{x}/{y}");
            subResponse.EnsureSuccessStatusCode();
            var subResult = await subResponse.Content.ReadAsStringAsync();
            ViewData["sub"] = $"x - y = {subResult}";

            // 调用天气服务,计算大连和广州的平均气温标准差
            var stddevShenyangResponse = await client.GetAsync($"{WeatherServiceUri}/api/weather/stddev/shenyang");
            stddevShenyangResponse.EnsureSuccessStatusCode();
            var stddevShenyangResult = await stddevShenyangResponse.Content.ReadAsStringAsync();
            ViewData["stddev_sy"] = $"沈阳:{stddevShenyangResult}";

            var stddevGuangzhouResponse = await client.GetAsync($"{WeatherServiceUri}/api/weather/stddev/guangzhou");
            stddevGuangzhouResponse.EnsureSuccessStatusCode();
            var stddevGuangzhouResult = await stddevGuangzhouResponse.Content.ReadAsStringAsync();
            ViewData["stddev_gz"] = $"广州:{stddevGuangzhouResult}";
        }
        return View();
    }
}

然后,使用下面的视图(View)将数据模型显示给最终用户:

@{
    ViewData["Title"] = "API";
}
<h2>API</h2>
<h3>计算服务调用测试</h3>
<p>
   @ViewData["sum"]
</p>
<p>
    @ViewData["sub"]
</p>
<h3>天气服务调用测试</h3>
<h4>
    2013年1月1日至2018年10月26日各城市平均气温标准差为:
</h4>
<ul>
    <li>@ViewData["stddev_sy"]</li>
    <li>@ViewData["stddev_gz"]</li>
</ul>

代码都很简单,将A服务、B服务和这个前端应用程序都运行起来之后,就可以通过ASP.NET Core MVC主页的API菜单,打开API的测试页面。可以看到,2013年1月1日至2018年10月26日这段时间,沈阳的日平均气温变化量要远大于广州的日平均气温变化量。当然,这是合理的,因为南方一年四季的温差确实要比北方小。

image

在进行代码审核(Code Review)的过程中,你会发现,无论是B服务的实现还是前端页面的实现,我们都将所需调用的API地址通过const关键字写死在代码里,你肯定会指出这并不是一个好的做法,正确的做法应该是将这个API地址写在配置文件里,然后在代码中动态读取。OK,非常好的建议,不过,在云架构设计中,使用配置文件来指定所调服务的地址和端口,也不是一个很好的做法,这一点我们今后讨论。

OK,那么我们将API地址都写到配置文件中,然后在需要调用的时候将API的地址读入,我们可以根据《ASP.NET Core应用程序的参数配置及使用》一文中所描述的方法来实现这一步。不过,这还不是今天的重点。今天的重点是,你会发现,就前端页面而言,它需要为两个不同的服务指定API的URI,此时,你就会感觉到,这种做法是否真的合理:

  1. 在一个微服务架构的应用程序中,业务逻辑根据界定上下文分隔,并在不同的微服务中实现。所以,这样的应用程序通常会有几个、十几个甚至几十个微服务在运行、在调度、在伸缩。我们一个小小的演示案例就牵涉到了两个服务之间的互相调用,以及一个前端页面需要同时依赖两个服务的情况,更不用说在真实的应用中。比如,曾经有过报道,亚马逊商品信息页面后端就牵涉到几十个微服务,如果让前端页面对这几十个后端API进行逐个调用,就需要在前端应用中配置几十个API的地址,这样维护起来会非常麻烦而且容易出错
  2. 前端直接访问后端的API,会增加客户端与服务端之间的网络负载,比如:前端页面如果发出上百个API请求,那么就会有上百个来回于客户端和服务端的网络传输;但如果我们能够在服务端完成这些API的调用,并将结果组合起来一次发给客户端,那么网络传输就会变得更加高效
  3. 前端直接访问后端的API不利于微服务的治理和重构。比如:微服务容器重启之后,内部IP地址会发生变化,如果强依赖于IP,那么所有前端页面都需要更改。在今后维护后端代码时候,也会有可能对API的URI进行重构,也会导致URI维护出现困难和错误
  4. 前端直接访问后端的API,使得后端API不得不将访问接口曝露到公共域,而这样就加大了整个系统被攻击的可能性。比如,攻击A服务不成功,我可以尝试攻击B服务,一旦有一个服务受到攻击,就会对整个应用程序造成影响,带来安全隐患
  5. 与非功能性需求相关的技术实现,比如缓存、身份认证等等,都需要在每个微服务上进行实现,而绝大多数情况下,这些实现都是非常类似的,无需重复实现,如果在前端页面(客户端)与服务端之间有另外一层能够处理这部分逻辑,那么,后端微服务的实现就会得到简化

接下来,我们就使用ASP.NET Core下的API网关:Ocelot来解决这些问题。

在ASP.NET Core应用程序中使用Ocelot API网关

API网关是这样一种机制,它能够向服务端外界提供访问内部服务的统一接口,并且提供一定的负载均衡、安全认证、缓存等应用特性。在微服务实践领域,API网关可以有很多种实现,比如可以使用Nginx,这也是我在《ASP.NET Core应用程序容器化、持续集成与Kubernetes集群部署(一)》一文中介绍的案例tasklist所使用的一种方式。在这里,我还是主要介绍ASP.NET Core下API网关的一种实现:Ocelot,其实,网上介绍Ocelot的文章已经有很多了,在这里,我只想由浅入深地将微服务实践中API网关的应用进行一个系统性的探讨,在接下来的文章中,我还会结合Ocelot对微服务的注册、发现以及容器部署进行更深入的实践。另外值得一提的是,国内.NET开源先锋,张善友队长也是Ocelot项目的贡献者之一。

在使用Ocelot之前,先了解一下,上述的应用程序架构会产生哪些变化。最直接的结果就是,在API用户与A、B两个服务之间,会多出API网关这一层,整体结构会是下面这个样子:

image

现在开始改造我们的应用程序,来看看如何在ASP.NET Core的应用程序中使用Ocelot实现上述架构。

首先,新建一个空的ASP.NET Core应用程序,这里强调“空的”,意味着我们不需要实现任何控制器(Controller),只需要借用ASP.NET Core的运行机制即可。创建空的ASP.NET Core应用程序是指,在下面的对话框中,选择Empty模板:

image

然后,添加Ocelot NuGet包的引用,可以通过Install-Package命令行,也可以在Visual Studio中右键点击刚刚新建的项目,选择Manage NuGet Packages选项,具体步骤就不说明了。添加完成后,就会在项目依赖项的NuGet节点下,看到Ocelot的引用:

image

接下来,在项目中添加一个配置Ocelot的JSON文件,我们就用ocelot.configuration.json吧,内容如下:

{
  "ReRoutes": [
    {
      "DownstreamPathTemplate": "/api/calc/add/{x}/{y}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 49814
        }
      ],
      "UpstreamPathTemplate": "/add/{x}/{y}",
      "UpstreamHttpMethod": [ "Get" ]
    },
    {
      "DownstreamPathTemplate": "/api/calc/sub/{x}/{y}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 49814
        }
      ],
      "UpstreamPathTemplate": "/sub/{x}/{y}",
      "UpstreamHttpMethod": [ "Get" ]
    },
    {
      "DownstreamPathTemplate": "/api/calc/stddev",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 49814
        }
      ],
      "UpstreamPathTemplate": "/stddev",
      "UpstreamHttpMethod": [ "Post" ]
    },
    {
      "DownstreamPathTemplate": "/api/weather/stddev/{city}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 50379
        }
      ],
      "UpstreamPathTemplate": "/weather-stddev/{city}",
      "UpstreamHttpMethod": [ "Get" ]
    }
  ],
  "GlobalConfiguration": {
    "RequestIdKey": "OcRequestId",
    "AdministrationPath": "/administration"
  }
}

在ReRoutes部分定义了URL重定向的规则,比如,当Ocelot服务接收到/sub/{x}/{y}的Get请求时,它就会把请求转发到http://localhost:49814/api/calc/sub/{x}/{y}上。目前Ocelot的配置还是相对简单的,仅定义了一些重定向的规则,也没有使用Ocelot的一些高级功能。不过,这些设定对于我们实现上面的软件架构已经绰绰有余了。网上有很多文章介绍Ocelot的配置文件,官网也有详细说明,这里就不多解释了。

然后,修改Program.cs文件,修改WebHostBuilder的构建过程:

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

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((configBuilder) =>
        {
            configBuilder.AddJsonFile("ocelot.configuration.json");
        })
        .ConfigureServices((buildContext, services) =>
        {
            services.AddOcelot();
        })
        .UseStartup<Startup>()
        .Configure(app =>
        {
            app.UseOcelot().Wait();
        });
}

注意上面代码的高亮部分,基本上就是将配置文件载入,然后加入Ocelot的Middleware即可。OK,就这么简单,我们的API网关就完成了!

接下来的事情就显而易见了,将前面所介绍的前端MVC的代码,从分别向A、B两个服务发出请求,改为仅向API网关发出请求即可。例如,可以将MVC中HomeController的代码改造如下:

public class HomeController : Controller
{
    const string ServiceUri = "http://localhost:59495";

    public async Task<IActionResult> API()
    {
        using (var client = new HttpClient())
        {
            // 调用计算服务,计算两个整数的和与差
            const int x = 124, y = 134;
            var sumResponse = await client.GetAsync($"{ServiceUri}/add/{x}/{y}");
            sumResponse.EnsureSuccessStatusCode();
            var sumResult = await sumResponse.Content.ReadAsStringAsync();
            ViewData["sum"] = $"x + y = {sumResult}";

            var subResponse = await client.GetAsync($"{ServiceUri}/sub/{x}/{y}");
            subResponse.EnsureSuccessStatusCode();
            var subResult = await subResponse.Content.ReadAsStringAsync();
            ViewData["sub"] = $"x - y = {subResult}";

            // 调用天气服务,计算大连和广州的平均气温标准差
            var stddevShenyangResponse = await client.GetAsync($"{ServiceUri}/weather-stddev/shenyang");
            stddevShenyangResponse.EnsureSuccessStatusCode();
            var stddevShenyangResult = await stddevShenyangResponse.Content.ReadAsStringAsync();
            ViewData["stddev_sy"] = $"沈阳:{stddevShenyangResult}";

            var stddevGuangzhouResponse = await client.GetAsync($"{ServiceUri}/weather-stddev/guangzhou");
            stddevGuangzhouResponse.EnsureSuccessStatusCode();
            var stddevGuangzhouResult = await stddevGuangzhouResponse.Content.ReadAsStringAsync();
            ViewData["stddev_gz"] = $"广州:{stddevGuangzhouResult}";
        }
        return View();
    }
}

怎么,现在改为将API网关的URL地址hard code在代码里了?暂时还是先别纠结URL是hard code还是在配置文件中,后面的文章中我会逐渐改善这部分代码的。

测试一下

现在,我们把A服务、B服务、API网关全部运行起来,在浏览器中输入http://localhost:59495/add/2/3,可以看到,结果被正确输出:

image

查看日志输出,发现我们的服务请求已经被成功重定向:

image

再次刷新前端页面,结果没变:

image

总结

本文首先介绍了一下微服务实践的挑战以及API网关的必要性,然后介绍了如何在ASP.NET Core应用程序中使用Ocelot实现API网关,并提供了一套完整的案例。虽然网上也有很多介绍Ocelot的文章,但本文随后的内容会从微服务架构的设计与实现的角度,将API网关的内容进一步深化,比如本文之前提到的URL hard code的问题,在后续的文章讨论中都会慢慢地得到解决。

源码下载

【请点击此处下载本文相关的源代码】

也可以访问我的Github页面下载本文案例代码,地址:https://github.com/daxnet/ocelot-sample/releases/tag/chapter_1

(总访问量:1,830;当日访问量:1)

2 thoughts on “ASP.NET Core中Ocelot的使用:API网关的应用

  1. Pingback: ASP.NET Core中Ocelot的使用:基于服务发现的负载均衡 – sunnycoding

  2. Pingback: Angular SPA基于Ocelot API网关与IdentityServer4的身份认证与授权(二) – sunnycoding

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据