ASP.NET Core中Ocelot的使用:API网关的应用
在向微服务体系架构转型的过程中,我们都会毫不意外地遇到越来越多的现实问题,而这些问题却并不是因为功能性需求而引入的。比如,服务的注册与发现,是应用程序在云中部署、提供可伸缩支持的主要实现方案,在特定的微服务架构中,实践这样的云设计模式是利远远大于弊的。今我们需要讨论的API网关也是这样的一种微服务实现方案,它解决了客户端与服务端之间繁琐的通信问题。
在进一步讨论API网关在微服务架构中的应用前,先一起了解一下目前我手上的两个微服务:A服务提供了数学计算的API,B服务则提供了天气数据查询的API。两者看上去风马牛不相及,然而,B服务中的一个需求使得两者之间产生了密不可分的联系:B服务需要提供某些城市5年来平均气温的标准差,以便用户能够了解各个城市的气温变化情况。业务需求其实挺简单,从一个外部数据源将指定城市的平均气温数据读入,然后计算标准差即可,不过从实现上看,由于A服务已经提供了计算标准差的API,因此,B服务没有必要自己再写一套,只需要调用A服务提供的API即可,也就是A和B之间存在互相调用的情况。另一方面,对于用户来说,也存在需要同时调用A和B两组API的场景,因此,A和B的API也需要向外界曝露,这样一来应用程序的结构大致如下:
也就是上图中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日这段时间,沈阳的日平均气温变化量要远大于广州的日平均气温变化量。当然,这是合理的,因为南方一年四季的温差确实要比北方小。 在进行代码审核(Code Review)的过程中,你会发现,无论是B服务的实现还是前端页面的实现,我们都将所需调用的API地址通过const关键字写死在代码里,你肯定会指出这并不是一个好的做法,正确的做法应该是将这个API地址写在配置文件里,然后在代码中动态读取。OK,非常好的建议,不过,在云架构设计中,使用配置文件来指定所调服务的地址和端口,也不是一个很好的做法,这一点我们今后讨论。 OK,那么我们将API地址都写到配置文件中,然后在需要调用的时候将API的地址读入,我们可以根据《ASP.NET Core应用程序的参数配置及使用》一文中所描述的方法来实现这一步。不过,这还不是今天的重点。今天的重点是,你会发现,就前端页面而言,它需要为两个不同的服务指定API的URI,此时,你就会感觉到,这种做法是否真的合理:
- 在一个微服务架构的应用程序中,业务逻辑根据界定上下文分隔,并在不同的微服务中实现。所以,这样的应用程序通常会有几个、十几个甚至几十个微服务在运行、在调度、在伸缩。我们一个小小的演示案例就牵涉到了两个服务之间的互相调用,以及一个前端页面需要同时依赖两个服务的情况,更不用说在真实的应用中。比如,曾经有过报道,亚马逊商品信息页面后端就牵涉到几十个微服务,如果让前端页面对这几十个后端API进行逐个调用,就需要在前端应用中配置几十个API的地址,这样维护起来会非常麻烦而且容易出错
- 前端直接访问后端的API,会增加客户端与服务端之间的网络负载,比如:前端页面如果发出上百个API请求,那么就会有上百个来回于客户端和服务端的网络传输;但如果我们能够在服务端完成这些API的调用,并将结果组合起来一次发给客户端,那么网络传输就会变得更加高效
- 前端直接访问后端的API不利于微服务的治理和重构。比如:微服务容器重启之后,内部IP地址会发生变化,如果强依赖于IP,那么所有前端页面都需要更改。在今后维护后端代码时候,也会有可能对API的URI进行重构,也会导致URI维护出现困难和错误
- 前端直接访问后端的API,使得后端API不得不将访问接口曝露到公共域,而这样就加大了整个系统被攻击的可能性。比如,攻击A服务不成功,我可以尝试攻击B服务,一旦有一个服务受到攻击,就会对整个应用程序造成影响,带来安全隐患
- 与非功能性需求相关的技术实现,比如缓存、身份认证等等,都需要在每个微服务上进行实现,而绝大多数情况下,这些实现都是非常类似的,无需重复实现,如果在前端页面(客户端)与服务端之间有另外一层能够处理这部分逻辑,那么,后端微服务的实现就会得到简化
在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网关这一层,整体结构会是下面这个样子: 现在开始改造我们的应用程序,来看看如何在ASP.NET Core的应用程序中使用Ocelot实现上述架构。 首先,新建一个空的ASP.NET Core应用程序,这里强调“空的”,意味着我们不需要实现任何控制器(Controller),只需要借用ASP.NET Core的运行机制即可。创建空的ASP.NET Core应用程序是指,在下面的对话框中,选择Empty模板: 然后,添加Ocelot NuGet包的引用,可以通过Install-Package命令行,也可以在Visual Studio中右键点击刚刚新建的项目,选择Manage NuGet Packages选项,具体步骤就不说明了。添加完成后,就会在项目依赖项的NuGet节点下,看到Ocelot的引用: 接下来,在项目中添加一个配置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还是在配置文件中,后面的文章中我会逐渐改善这部分代码的。