ASP.NET Core中Ocelot的使用:基于Spring Cloud Netflix Eureka的动态路由
在《ASP.NET Core中Ocelot的使用:API网关的应用》一文中,我介绍了如何在ASP.NET Core中使用Ocelot实现API网关。在介绍的过程中,引出了一个问题,就是服务之间相互调用的代码能不能别写死(hard code)在代码里,至少也得通过配置文件来指定吧?说的没错,很好的建议,只不过我也提到过,写在配置文件中也不是一个很好的实践。设想一下,假如我们有几十个微服务,各个微服务之间都有可能会有相互调用的情况,那么我们就需要维护大量的与微服务的地址、端口相关的配置信息,这样非常容易出错,如果是通过微服务的环境变量进行设置,那就更糟糕了,一大堆的环境变量设置,会使得应用程序的部署变得非常麻烦而且容易出错。另一个原因是,在云端部署的微服务,本身就具有伸缩的能力,服务的地址和端口很有可能是不固定的,所以,将这些信息保存在配置文件或者环境变量中是不合理的。因此,本文会介绍Spring Cloud Netflix Eureka服务的使用,通过服务注册与发现(Register and Discovery)机制,配合Ocelot的动态路由来解决这个问题。
这是一件有趣的事情:我们打算实现微服务,于是,将我们的业务拆分成两个微服务,然后发现微服务之间有相互调用的关系,客户端也需要同时使用多个微服务来展示处理结果,因此,就需要在各个服务以及前端应用中重复地配置所需调用的微服务的API地址。为了解决这个问题,我们使用Ocelot引入API网关,由API网关负责统一转发API调用请求。然而,使用API网关,就需要将各服务的地址写入API网关的配置中,而上面已经提到,在云中,应用程序运行的地址和端口号都有可能是动态的,于是,我们又需要解决这个hard code的问题。这样一层一层下来,你会发现,其实我们在逐步实践云架构的过程中,已经遇到了各总各样的问题,而解决这些问题,都可以参考某种模式,或者使用某个框架,慢慢地,那些你从来都不认识的概念、模式、框架,都会变得熟悉起来,不知不觉中,你会学到很多很多。
在动手写代码之前,先看看我们应该如何调整整个案例的架构设计。
调整架构
首先,我们会引入一个服务注册与发现的微服务:Spring Cloud Netflix Eureka(简称Eureka),它是Spring Cloud套件的一个组成部分,当然,它是Java的,不过这也不影响我们的主体业务微服务采用.NET Core来完成。在我们的架构中引入Eureka,那么当某个微服务启动时,它会根据所配置的Eureka服务地址,将自己的地址和端口都注册到Eureka服务中,不仅如此,它还可以通过Eureka的客户端组件(Eureka Client),根据另一个微服务的名称来获取其访问地址和端口信息,然后发起API调用请求。因此整个过程中,A服务并不需要知道B服务的具体地址和端口,只需要知道B服务的名称即可,而服务的名称在今后还可以通过配置中心来获得。于是,我们的架构就会变成下面这个样子:
整个API的调用过程如下:
- A服务、B服务以及我们的Ocelot API网关在启动的时候,会将自己注册到Spring Cloud Eureka中
- API用户首先向Eureka发出请求,查询Ocelot API网关的访问地址和端口,Eureka会将API网关的信息反馈给API用户
- API用户通过得到的API网关的地址,访问Ocelot API网关,请求微服务API调用
- Ocelot API网关会根据Eureka中的服务注册信息,发起动态路由请求
- 请求被转发到所调用的服务(上图中为A服务),然后服务调用结果会由API网关返回
从上面的结构可以看到,对于API用户而言,它只需要知道服务注册与发现微服务Eureka的访问方式,就可以完成整套的API调用周期,甚至不需要知道API网关的存在。API网关仍然保证了跨多个微服务的客户端请求能够在一次网络传输中完成处理,而不是从客户端的角度来重复查询服务注册机制,而获得多个服务的访问方式。
思考:为什么是把API网关注册到Eureka,然后让API用户去访问Eureka来获得API网关地址,而不是让API用户直接访问网关,而把Eureka隐藏起来?其实我觉得两种实现方式差别不是特别大,无论是API网关,还是Eureka服务,都可以做负载均衡和认证授权,就看在具体项目中是如何运用了。因此,很多在线的文档都把API网关与服务注册/发现画在一起,作为一个整体进行讨论。而在《Eclipse Microservices and Service Discovery》一文中,则是采用的上面我介绍的这个架构。相比于直接访问API网关来调用后台服务而言,上面这个架构还更进一步,相对也稍许复杂一些,那么我们就按这个复杂的结构来吧。
OK,接下来就让我们一步一步地实现这个架构吧。
在解决方案中引入服务注册与发现机制
实现Spring Cloud Netflix Eureka服务端
要实现上面所讨论的架构,就需要有一个Spring Cloud Eureka的服务。我选择使用Eclipse加上Spring Cloud的开发套件来开发这个服务,很多朋友感觉使用IntelliJ IDEA比较顺手,也是可以的,选一款合适自己的开发工具把问题解决就行。我下载了最新版的Eclipse,安装了Spring Tool Suite 4,这会使我的开发变得容易一些。在Eclipse中新建一个Spring Starter Project,在New Spring Starter Project Dependencies对话框中,选择Eureka Server:
项目创建过程的其它步骤在这里就不多描述了。然后,修改应用程序的入口类(这里是OcelotSampleDiscoveryServiceApplication),加上EnableEurekaServer的Annotation:
package cn.sunnycoding.ocelotsample; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer; @EnableEurekaServer @SpringBootApplication public class OcelotSampleDiscoveryServiceApplication { public static void main(String[] args) { SpringApplication.run(OcelotSampleDiscoveryServiceApplication.class, args); } }
然后,在application.properties文件中,指定服务的侦听端口以及Eureka服务的相关配置:
server.port=8761 eureka.client.register-with-eureka=false eureka.client.fetch-registry=false
OK,一个Eureka服务就完成了,接下来,就是修改我们的计算服务(A服务)以及天气服务(B服务),使得它们在启动的时候,能够自动注册到Eureka中。
微服务的自动注册:基于Spring Cloud Netflix Eureka的实现
我们的A、B两个服务都是基于ASP.NET Core实现的,那么ASP.NET Core如何访问由Java实现的Eureka服务呢?答案就是Pivotal的Steeltoe。它是一个基于.NET的开源项目,它能帮助.NET开发人员在云端实现标准化的微服务体系结构,其主要组件包括:服务发现、配置服务器、熔断器、云端连接器以及云端安全服务等。有关Steeltoe的具体内容这里也不多介绍了,大家可以上官网了解。
现在,在Visual Studio中打开解决方案文件,在OcelotSample.CalcService项目上添加对Steeltoe.Discovery.Client NuGet包的引用,然后,修改appsettings.json文件,加入有关spring和eureka的两个配置节:
"spring": { "application": { "name": "calc" } }, "eureka": { "client": { "serviceUrl": "http://localhost:8761/eureka", "shouldFetchRegistry": false }, "instance": { "port": 49814, "preferIpAddress": true } }
上面的spring.application.name指定了当前服务的名称,这里就是“calc”,接下来当需要通过Eureka查询计算服务的访问地址时,就可以直接通过这个名称来进行查询。eureka.client.serviceUrl指定了Eureka服务的访问地址,shouldFetchRegistry表示在服务启动的时候,不需要将Eureka中已注册的所有服务的信息下载到本地,因为我们目前的场景是要将当前服务注册到Eureka中。instance.port表示当前服务所侦听的端口,这个值应该要与ASPNETCORE_URLS环境变量中指定的端口保持一致。有关Steeltoe所能支持的其它Eureka客户端的设置,可以参考Eureka Client Settings一文。
接下来就是修改Startup.cs,加入如下代码:
public void ConfigureServices(IServiceCollection services) { services.AddDiscoveryClient(Configuration); services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); } public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseMvc(); app.UseDiscoveryClient(); }
OK,微服务的自动注册就搞定了,就是这么简单!Steeltoe的DiscoveryClient中间件会确保在服务启动时,将服务的访问地址注册到所配置的Eureka服务中。既然服务已经能够注册到Eureka,那么所注册上去的地址和端口就有可能是动态的,所以,Ocelot的API网关也就不能把下游(Downstream)服务的Host和Port写死(hard code)在配置文件中,而应该是根据Eureka中所注册的微服务的名称实现动态路由。下面我们一起看一下,在Ocelot API网关部分,代码应该如何调整。
Ocelot API网关的调整:实现动态路由
API网关部分,首先是我们已经不能将下游服务的访问地址写死了,而应该由API网关自动到Eureka服务上去定位待访问服务的地址,然后再将访问请求转发过去。所以,Ocelot API网关也需要连接Eureka并从中获取服务的注册信息,下面配置信息中的shouldFetchRegistry被设置为true,其它部分与上面介绍的并无太大差别:
"spring": { "application": { "name": "api-gateway" } }, "eureka": { "client": { "serviceUrl": "http://localhost:8761/eureka/", "shouldRegisterWithEureka": true, "shouldFetchRegistry": true }, "instance": { "port": 59495, "preferIpAddress": true } }
接下来就是修改Ocelot的配置文件,将之前使用的静态路由配置ReRoutes节点下的内容全部删除,而在GlobalConfiguration中,指定需要使用Eureka作为service discovery provider,然后将Eureka的信息配置进去。完整的Ocelot配置如下:
{ "ReRoutes": [ ], "GlobalConfiguration": { "RequestIdKey": "OcRequestId", "AdministrationPath": "/administration", "ServiceDiscoveryProvider": { "Host": "localhost", "Port": 8761, "Type": "Eureka", "Token": null, "ConfigurationKey": null }, "DownstreamScheme": "http" } }
我还是觉得ServiceDiscoveryProvider中的Host和Port并没有起到作用,由于Steeltoe的引入,API网关会使用Spring Cloud Eureka Client的配置信息来得知Eureka服务的访问地址,不管怎样,无论是本地调试,还是打包到docker镜像,上面的配置都是可以工作的,我们还是遵循Ocelot的官方文档介绍。
下一步就是让Ocelot启用Eureka的服务发现机制,此时需要引入Ocelot.Provider.Eureka NuGet包,并在程序启动并配置服务的时候,加入对Eureka的支持:
public static IWebHostBuilder CreateWebHostBuilder(string[] args) => WebHost.CreateDefaultBuilder(args) .ConfigureAppConfiguration((configBuilder) => { configBuilder.AddJsonFile("ocelot.configuration.dynamic.json"); }) .ConfigureServices((buildContext, services) => { services.AddOcelot().AddEureka(); }) .UseStartup<Startup>() .Configure(app => { app.UseOcelot().Wait(); });
注意:Ocelot的正式版本12.0.1中有一个bug,它会导致使用动态路由的Ocelot API网关无法正常启动。详细信息请参考:https://github.com/ThreeMammals/Ocelot/issues/655。事实上这个bug已经修复并且并入了develop分支了,但截至目前为止,Ocelot还没有发布一个稳定版本,所以,我们需要将Ocelot的NuGet包引用从12.0.1升级到12.1.0-aphia0011,相信不久以后这部分修复代码就会在正式版中发布。
然后,就和上面的Calc服务一样,在ConfigureServices和Configure两个方法中,注册Eureka Discovery Client的依赖,并启用Discovery Client的中间件,因为我们也需要将API网关注册到Eureka服务中,详细代码就不再重复贴出了。
那么,对于实现了动态路由的Ocelot API网关而言,调用方应该如何指定API地址呢?假设Calc服务本身实现的API是http://localhost:1234/api/calc/add,那么,它的主机名和端口号就会被注册到Eureka中,而API网关会将所接收到的API请求的URL地址的第一部分,作为服务名称,到Eureka服务中查询该下游服务所对应的访问地址,然后再把URL中剩下的部分与所查询到的API地址拼接起来,就成为了真正的后台服务地址。比如在这个例子中,假设Calc在Eureka中的注册名称为Calc,那么,客户端应该向Ocelot API网关的http://localhost:2345/calc/api/calc/add发送请求,此处localhost:2345就是Ocelot API网关的访问机器名和端口号。
OK,API网关的调整就搞定了。就是这么简单!接下来要做的,就是在服务的调用部分,也就是我们的WebFront前端代码中,将hard code的API访问路径,改为通过Eureka服务来获得API网关的访问地址,然后由API网关负责转发请求并得到结果。
前端代码的修改
前端代码需要进行如下的修改:
- 在配置中加入eureka client的配置,注意:无需指定shouldRegisterWithEureka,因为客户端不需要向Eureka进行注册,而应该把shouldFetchRegistry设置为true
- 在ConfigureServices和Configure两个方法中,注册Eureka Discovery Client的依赖,并启用Discovery Client的中间件
- 修改API Action的实现,从使用hard code的URL,改为首先通过Discovery Client连接Eureka获取Ocelot API网关的地址,然后再向API网关发出请求
上面所述的前两项修改,仅适用于我目前的这个案例,也就是使用ASP.NET Core MVC实现的前端代码;而API Action的实现,也有可以考量的地方,如果是直接通过API网关连接,那么就没必要去查询Eureka服务了。还是根据具体需要来决定吧。API Action修改完后,代码就变成了下面这样:
public async Task<IActionResult> API() { var apiGatewayInstances = discoClient.GetInstances("api-gateway"); var apiGatewayUri = apiGatewayInstances.First().Uri; using (var client = new HttpClient()) { // 调用计算服务,计算两个整数的和与差 const int x = 124, y = 134; var sumResponse = await client.GetAsync($"{apiGatewayUri}calc/api/calc/add/{x}/{y}"); sumResponse.EnsureSuccessStatusCode(); var sumResult = await sumResponse.Content.ReadAsStringAsync(); ViewData["sum"] = $"x + y = {sumResult}"; var subResponse = await client.GetAsync($"{apiGatewayUri}calc/api/calc/sub/{x}/{y}"); subResponse.EnsureSuccessStatusCode(); var subResult = await subResponse.Content.ReadAsStringAsync(); ViewData["sub"] = $"x - y = {subResult}"; // 调用天气服务,计算大连和广州的平均气温标准差 var stddevShenyangResponse = await client.GetAsync($"{apiGatewayUri}weather/api/weather/stddev/shenyang"); stddevShenyangResponse.EnsureSuccessStatusCode(); var stddevShenyangResult = await stddevShenyangResponse.Content.ReadAsStringAsync(); ViewData["stddev_sy"] = $"沈阳:{stddevShenyangResult}"; var stddevGuangzhouResponse = await client.GetAsync($"{apiGatewayUri}weather/api/weather/stddev/guangzhou"); stddevGuangzhouResponse.EnsureSuccessStatusCode(); var stddevGuangzhouResult = await stddevGuangzhouResponse.Content.ReadAsStringAsync(); ViewData["stddev_gz"] = $"广州:{stddevGuangzhouResult}"; } return View(); }
请注意高亮的部分,与前文中描述的案例代码有什么差异。可以看到,这里再也没有出现类似于localhost:1234这样的服务器名和端口号了,而是指定的所需访问的服务的名称。你可能会说,这里不还是hard code了一个值么?也是。但是这个值我们可以在后面,通过使用配置服务(ConfigServer)来指定。与本地配置文件相比,配置服务的最大优势是,配置可以动态使用,并且可以追溯配置版本,这里就不多讨论了。
解决方案容器化
读到这里,估计也有点晕了,配置和代码修改虽然不多,但都比较分散,而且测试一下还需要启动4个服务,外加一个前端的MVC项目,比较麻烦,所以,我们将整个解决方案容器化。容器化有几个好处,首先就是便于程序的运行,只需要一条命令,就可以将程序运行起来;其次,对于持续集成和DevOps也有很多帮助。由于文章篇幅有限,我也就不将所有的Dockerfile和docker-compose文件贴出了,这里说几个重点。以计算服务(CalcService)的配置部分为例:
ocelot-sample-calc: image: ocelot-sample-calc build: context: sln dockerfile: OcelotSample.CalcService/Dockerfile command: sh -c './wait-for -t 100 ocelot-sample-disco:8761 -- dotnet ./OcelotSample.CalcService.dll' depends_on: - ocelot-sample-disco links: - ocelot-sample-disco environment: - ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_URLS=http://*:49814 - spring__application__name=calc - eureka__client__serviceUrl=http://ocelot-sample-disco:8761/eureka - eureka__client__shouldFetchRegistry=false - eureka__instance__port=49814 - eureka__instance__preferIpAddress=true restart: on-failure container_name: ocelot-sample-calc
第一就是,前文中我们在appsettings.json中配置的Spring应用程序名称的配置,以及Eureka客户端的配置,我们都将它们写在Docker的环境变量中,还有我们的几个微服务所侦听的端口,也都写在环境变量中。值得注意的是,类似于Spring应用程序名称的环境变量名,我们没有使用spring:application:name这样的形式,而是使用下划线代替冒号,这样做兼容性更好,类似于Kubernetes这样的容器编排系统也能够兼容这样的配置设定。
第二就是,即使我们在depends_on部分指定了该服务需要依赖于ocelot-sample-disco服务,但是,docker-compose仅仅保证disco服务的创建会优先于当前服务,而不保证在当前服务已经运行并侦听端口的时候,disco服务也已经启动完成。这就造成了当前服务在启动的时候,由于disco服务还没有完全启动,而在将自己注册到disco服务的时候发生错误。这本也没有什么问题,因为Steeltoe Discovery Client会在一段时间之后重试,但是一个奇怪的问题是,Ocelot API网关却在一次注册失败时,没有进行重试,所以整个应用程序也无法正常运行。个人怀疑是Ocelot.Providers.Eureka中的一个问题,但暂时也还没有仔细去研究。所以,目前只是找了一个workaround,通过docker-compose的command指令来调用wait-for脚本,以确保disco服务已经完成启动。有关wait-for的详细信息,可以参考它的源代码:https://github.com/circleci/wait-for。注意:这里仅仅是为了演示需要,所以选择了这个方案,在真实环境中,还是应该寻求正确的做法,通过熔断机制来解决服务互访问时出现的问题。
现在来看看我们的案例运行的效果。启动Docker for Windows(如果是在Linux下,也可以直接使用Docker),首先进入docker-compose.yml文件所在目录,执行下面的命令,以在容器中编译整个解决方案:
docker-compose build
然后执行:
docker-compose up
稍等片刻之后,就可以看到服务都已经正常启动:
再次打开我们的前端页面,发现程序可以正常运行。不过效果跟上文结尾时的效果一样。
总结
本文主要介绍了如何基于Spring Cloud Netflix Eureka的服务注册/发现机制,实现Ocelot API网关的动态路由。这部分内容是微服务和云计算架构模式中的重要内容,本文也只是介绍了一下基本用法和实现方式,更多的最佳实践还需要在真实项目中进行总结归纳。在接下来的文章中,我会研究一下基于Ocelot的负载均衡,以及微服务的伸缩性相关的内容。
源代码的使用
请访问https://github.com/daxnet/ocelot-sample/releases/tag/chapter_2下载本文相关案例的源代码。