主要结论
- Spring Cloud 为微服务系统中相互依赖的服务提供了丰富的连接选项。
- Spring Cloud Config 为配置数据提供了通过 Git 管理的版本控制机制,并能在无需重启动的情况下对此类数据进行动态刷新。
- 通过将 Spring Cloud 与 Netflix Eureka 以及 Ribbon 组件配合使用,应用程序服务将能用更为动态的方式相互发现,并能通过专用负载平衡器代理将负载平衡决策推送至客户端服务。
- 系统的边缘位置依然有诸如 AWS ELB 等负载平衡解决方案的一席之地,这里的传入流量还无法控制。
- 针对中间层微服务之间的通信,Ribbon 提供了一种更为可靠和高性能的解决方案,该方案不依赖特定的云供应商。
简介
随着转向基于微服务的体系结构,我们开始面临一项重要决策:如何将不同服务连接在一起?单层系统(Monolithic system)中的不同组件可以通过简单的方法调用进行通信,但微服务系统中的不同组件很有可能需要借助 REST、Web 服务,或某种类似 RPC 的机制实现网络通信。
在单层系统中,可以完全避免服的连接方面遇到的问题,让每个组件根据需求创建自己的依存项。但实际上我们很少会这样做。组件和依存项之间的这种紧密耦合会使得系统过于僵硬,会对测试工作产生不利影响。此时我们会选择让组件的依存关系外化(Externalise),并在创建组件时直接注入这样的关系,依存关系的注入主要可用于类和对象的连接。
假设打算通过一系列微服务实现一个应用程序,可以使用与单层系统类似的连接选项。依存项的地址可硬编码到程序中,借此将服务紧密连接在一起。或者也可以将所依赖的服务地址外化,并在部署或运行的时候提供这些服务。本文将介绍在微服务应用程序的构建过程中,如何通过 Spring Boot 和 Spring Cloud 实现这些选项。
我们假设了下图所示的一个名为repmax
的简单微服务系统:
Repmax 系统
Repmax 应用程序可以记录追踪用户的举重成绩,并用每次举重前五名选手的成绩生成排行榜。其中logbook
服务负责通过 UI 收集每次练习的数据并存储每位用户的完整历史信息。当用户在练习完毕录入成绩后,logbook
会将此次举重的详细信息发送至leaderboard
服务。
从图中可以看到,logbook
服务需要依赖leaderboard
服务。从最佳实践的角度考虑,我们将这个依存项抽象为LeaderBoardApi
接口:
public interface LeaderBoardApi { void recordLift(Lift lift); }
由于这是个 Spring 应用程序,需要使用RestTemplate
处理 logbook 和 leaderboard 服务之间通信的细节:
abstract class AbstractLeaderBoardApi implements LeaderBoardApi { private final RestTemplate restTemplate; public AbstractLeaderBoardApi() { RestTemplate restTemplate = new RestTemplate(); restTemplate.getMessageConverters().add(new FormHttpMessageConverter()); this.restTemplate = restTemplate; } @Override public final void recordLift(Lifter lifter, Lift lift) { URI url = URI.create(String.format("%s/lifts", getLeaderBoardAddress())); MultiValueMap<String, String> params = new LinkedMultiValueMap<>(); params.set("exerciseName", lift.getDescription()); params.set("lifterName", lifter.getFullName()); params.set("reps", Integer.toString(lift.getReps())); params.set("weight", Double.toString(lift.getWeight())); this.restTemplate.postForLocation(url, params); } protected abstract String getLeaderBoardAddress(); }
AbstractLeaderBoardApi
类可以捕获针对leaderboard
服务创建POST
请求的全部逻辑,并可通过子类指定leaderboard
服务的准确地址。
将多个微服务相互连接最简单的方法可能就是将每个服务需要的依存项地址硬编码到程序中。这相当于在单层系统的世界中通过硬编码的方式实现依赖项的具现化(Instantiation)。这一点可以在StaticWiredLeaderBoardApi
类中轻松实现:
public class StaticWiredLeaderBoardApi extends AbstractLeaderBoardApi { @Override protected String getLeaderBoardAddress() { return "http://localhost:8082"; } }
硬编码方式指定的服务地址使得我们能够快速上手,但在现实环境中这样做有些不太实际。服务的每个不同部署需要自定义编译,这一做法很快会变得充满痛苦并且容易出错。
如果要部署的是单层系统,并且希望对应用程序进行重构以消除硬编码的地址,首先需要将地址信息外化至配置文件。微服务应用程序也可以使用相似的方法:将地址信息推送至配置文件,并让所实现的 API 从配置中读取地址。
Spring Boot 使得配置参数的定义和注入工作变得更简单。只要将地址参数加入application.properties
文件即可:
leaderboard.url=http://localhost:8082
随后可以使用@Value
标注(Annotation)将这个参数注入ConfigurableLeaderBoardApi
:
public class ConfigurableLeaderBoardApi extends AbstractLeaderBoardApi { private final String leaderBoardAddress; @Autowired public ConfigurableLeaderBoardApi(@Value("${leaderboard.url}") String leaderBoardAddress) { this.leaderBoardAddress = leaderBoardAddress; } @Override protected String getLeaderBoardAddress() { return this.leaderBoardAddress; } }
Spring Boot 对 Externalized Configuration 的支持使得我们不仅可以通过修改配置文件更改leaderboard.url
的值,而且可以在启动应用程序时指定环境变量:
LEADERBOARD_URL=http://repmax.skipjaq.com/leaderboard java -jar repmax-logbook-1.0.0-RELEASE.jar
随后即可在不更改代码的情况下将logbook
服务实例指向任何一个leaderboard
服务实例。如果系统符合 12 factor 原则,环境中很可能已经包含了连接信息,因此可通过简单的工作将其直接映射至应用程序。
诸如 Cloud Foundry 和 Heroku 等平台即服务(PaaS)系统会将数据库和消息系统等托管服务的连接信息暴露到环境中,这样即可用完全相同的方式连接这些依赖项。实际上,将两个服务连接在一起,以及将一个服务与相应的数据存储连接在一起,这两种做法并没有什么本质差异,都只是将两个分布式系统连接在一起。
超越点对点连接
对于比较简单的应用程序,为依存项的地址使用外部配置就已足够。然而对于任何规模的应用程序,我们需要的可能不仅仅是简单的点对点连接,可能还希望实现某种形式的负载平衡。
如果每个服务都直接依赖某一下游服务实例,下游出现的任何故障都可能造成最终用户遇到严重问题。同理,如果下游服务超载,用户将会面临响应时间延长的问题。此时我们需要的是负载平衡。
与其直接依赖一个下游实例,我们更希望通过一组下游服务实例分摊负载。如果这些实例中有一个故障或超载,其他实例可以接手处理任务。为这种体系结构实现负载平衡的最简单方法是使用负载平衡代理。下图展示了在 Amazon Web Services 部署中使用 Elastic Load Balancing 实现这种方式的具体做法:
为排行榜应用 ELB
这种情况下无需让logbook
服务直接与leaderboard
服务通信,而是可以使用 ELB 对每个请求进行路由。ELB 会将每个请求路由至某一后端leaderboard
服务。通过让 ELB 充当中介,可将负载分摊到多个 leaderboard 实例,这有助于减少每个实例的负担。
ELB 的负载平衡是动态的,运行过程中可以给后端添加新的实例,因此如果传入流量激增,即可启动更多leaderboard
实例加以应对。
Spring Boot 应用程序可使用 actuator 暴露供 ELB 定期监控的/health
端点。能够响应此类运行状况检查操作的实例会保留在 ELB 的活跃集(Active set)中,如果多次检查均未响应,相应的实例会从服务中移除。
在我们的系统中,leaderboard
服务不是唯一能通过负载平衡获益的服务。logbook
服务以及前端 UI 均能借助负载平衡机制实现更好的可扩展性和弹性。
动态重配置
无论使用 AWS ELB 、 Google Compute Load Balancing ,或者使用 HAProxy 或 NGINX 自行搭建负载平衡代理,都需要将服务与负载平衡器相互连接。
此时一种方法是为每个负载平衡器提供一个「众所周知」的 DNS 名称,例如leaderboard.repmax.local
,并使用上文提到的静态连接方式将其硬编码至应用程序中。由于 DNS 系统本身的灵活性,这种方法已经可以做到相当灵活。然而使用硬编码的名称意味着要在运行服务的每个环境中配置一台 DNS 服务器。在开发过程中,由于需要为多种多样的操作系统提供支持,提供定制化 DNS 的操作就显得尤为麻烦。此时更好的做法是使用类似上文leaderboard.url
的例子那样,将负载平衡器的地址自然地注入服务。
在 AWS 和 GCP 等云环境中,负载平衡器(及其地址)会频繁变动。当负载平衡器被删除并重建后,通常会使用一个新的地址。如果将负载平衡器的地址硬编码到程序中,为了应对地址的变化,必须在每次改变后重新编译代码。但通过使用外化的配置,只需更改配置文件并重启动服务即可。
为了应对负载平衡器地址不断变化这一本质,DNS 是一种很方便的做法。每个负载平衡器都可分配一个固定的 DNS 名称,并将这个名称注入所调用的服务。在重建负载平衡器时,其 DNS 名称可重映射至负载平衡器的新地址。如果准备在环境中运行 DNS 服务器,就很适合使用这种基于 DNS 的方法。如果不想运行 DNS,但依然希望对负载平衡器进行动态重配置,此时可以考虑使用 Spring Cloud Config 。
Spring Cloud Config 会运行一个名为 Config Server 的小巧服务,并通过 REST API 提供可集中访问的配置数据。默认情况下配置数据存储在一个 Git 仓库中,并可通过标准的PropertySource
抽象暴露给 Spring Boot 服务。使用PropertySource
可将本地属性文件中包含的配置与 Config Server 中存储的配置无缝结合在一起。对于本地开发,可以使用来自本地属性文件的配置,并只在将应用程序部署在现实环境时才覆盖这些配置信息。
为使用 Spring Cloud Config 取代ConfigurableLeaderBoardApi
,首先可以用所需配置初始化一个 Git 代码库:
mkdir -p ~/dev/repmax-config-repo cd ~/dev/repmax-config-repo git init echo 'leaderboard.lb.url=http://some.lb.address' >> repmax.properties git add repmax.properties git commit -m 'LB config for the leaderboard service'
repmax.properties
文件中包含repmax
应用程序default
配置文件的设置。如果希望将配置加入其他配置文件,例如加入development
,此时只需要提交另一个名为repmax-development.properties
的文件即可。
若要运行 Config Server,可以运行spring-cloud-config-server
项目提供的默认 Config Server,或自行创建一个简单的 Spring Boot 项目并承载下列 Config Server:
@SpringBootApplication @EnableConfigServer public class RepmaxConfigServerApplication { public static void main(String[] args) { SpringApplication.run(RepmaxConfigServerApplication.class, args); } }
其中@EnableConfigServer
标注可用于通过小巧的 Spring Boot 应用程序启动 Config Server。随后可以用spring.cloud.config.server.git.uri
属性将 Config Server 指向 Git 代码库。对于本地测试工作,可将其加入 Config Server 应用程序的application.properties
文件:
spring.cloud.config.server.git.uri=file://${user.home}/dev/repmax-config-repo
通过这种方式,团队中的每位开发者都可以在自己的计算机上启动 Config Server,并通过本地 Git 代码库进行测试。若要验证repmax
应用程序的属性是否已通过 Config Server 暴露,可在 Config Server 运行后使用浏览器访问http://localhost:8888/repmax/default
:
在 Config Server 中浏览配置信息
从图中可以看到,leaderboard.lb.url
属性已通过repmax.properties
文件暴露,其值为http://localhost:8083
。JSONT 载荷的version
属性显示了加载配置时所用的 Git 版本。
在生产环境中,可以充分借助PropertySource
抽象将 Git 代码库的名称以环境变量的方式提供:
SPRING_CLOUD_CONFIG_SERVER_GIT_URI=https://gitlab.com/rdh/repmax-config-repo java -jar repmax-config-server-1.0.0-RELEASE.jar
## Spring Cloud Config Client
修改 logbook 服务使其从新增的 Config Server 中读取配置,这一过程只需要几个简单的步骤。首先在build.gradle
文件中为 spring-cloud-starter-config`增加一个依存项;
compile("org.springframework.cloud:spring-cloud-starter-config:1.1.1.BUILD-SNAPSHOT")
随后提供 Config Client 所需的基本自举配置。考虑到 Config Server 会通过一个名为repmax.properties
的文件暴露配置,此时要向 Config Client 提供应用程序的名称。此类自举配置位于logbook
服务的bootstrap.properties
文件中:
spring.application.name=repmax
默认情况下,Config Client 会通过http://localhost:8888
查找 Config Server。若要修改这个地址,可在启动客户端应用程序时指定SPRING_CLOUD_CONFIG_URI
环境。
一旦客户端,即本例中的logbook
启动后,即可访问http://localhost:8081/env
以确认来自 Config Server 的配置是否正确加载:
确认 Config Client 可以访问 Config Server
将logbook
服务配置为使用 Config Client 后,可修改ConfigurableLeaderBoardApi
以从 Config Server 暴露的leaderboard.lb.url
属性中获取负载平衡器的地址。
启用动态刷新
通过将配置信息集中存储在一个位置,可以轻松更改repmax
配置,使其能够被所有服务直接使用。然而为了应用这些配置依然需要重启动服务。实际上可以通过更好的方式实现。可以借助 Spring Boot 提供的@ConfigurationProperties
标注将配置直接映射给 JavaBeans。Spring Cloud Config 更进一步为每个客户端服务暴露了一个/refresh
端点。带有@ConfigurationProperties
标注的 Bean 可在通过/refresh
端点触发刷新后更新自己的属性。
任何 Bean 均可添加@ConfigurationProperties
标注,但是有必要对刷新操作进行限制,只应用于包含配置数据的 Bean。为此可以用一个专门用于保存leaderboard
地址的LeaderboardConfig
Bean:
@ConfigurationProperties("leaderboard.lb") public class LeaderboardConfig { private volatile String url; public String getUrl() { return this.url; } public void setUrl(String url) { this.url = url; } }
@ConfigurationProperties
标注的值实际上是希望映射至 Bean 的配置值的前缀。随后每个值可使用标准的 JavaBean 命名规则进行映射。这种情况下,url
Bean 属性可映射至配置中的leaderboard.lb.url
。
随后要修改ConfigurableLeaderBoardApi
以接受LeaderboardConfig
实例,而非原始的leaderboard
地址:
public class ConfigurableLeaderBoardApi extends AbstractLeaderBoardApi { private final LeaderboardConfig config; @Autowired public ConfigurableLeaderBoardApi(LeaderboardConfig config) { this.config = config; } @Override protected String getLeaderBoardAddress() { return this.config.getLeaderboardAddress(); } }
为了触发配置刷新操作,可向logbook
服务的/refresh
端点发送一个 HTTP POST
请求:
curl -X POST http://localhost:8081/refresh
## 有关服务发现
通过使用 Spring Cloud Config,并在logbook
和leaderboard
服务之间使用负载平衡代理,应用程序已经基本完成了。然而还需要进行一定的完善。
如果在 AWS 或 GCP 中部署,可以充分利用这些环境中提供的高弹性负载平衡器,但如果使用诸如 HAProxy 或 NGINX 之类的市售负载平衡代理产品,此时必须自行处理服务的发现和注册工作。leaderboard
的每个新增实例,以及每个因为故障要从代理中移除的实例,都必须在代理中进行配置。我们真正需要的是动态发现技术,每个服务实例都需要能自行注册以供发现和使用。
使用负载平衡代理的情况下还存在另一个潜在问题:可靠性。由于所有流量需要通过代理进行路由,因此整个系统的可靠性都受制于代理本身的可靠性。代理停机同时会导致整个系统停机。此外还需要考虑客户端和代理之间,以及代理和服务器之间通信所产生的开销。
为解决这些问题 Netflix 开发了 Eureka。Eureka 是一种用于提供服务注册和发现能力的客户端 - 服务器系统。服务实例启动后,可将自己与 Eureka 服务器进行注册。诸如logbook
等客户端服务可以联系 Eureka 服务器以获取可用服务列表。客户端和服务器之间采用了点对点的通信方式。
Eureka 使得我们不再需要代理,这样可以改善整个系统的可靠性。如果leaderboard
代理故障,logbook
服务将完全无法联系leaderboard
服务。通过使用 Eureka,logbook
可以知道所有可用leaderboard
实例,就算一个实例故障,logbook
也只需要联系下一个leaderboard
实例并重试。
那么在整个系统体系结构中,Eureka 服务器本身是否会成为一个故障点?抛开为 Eureka 服务器创建 _ 集群 _ 这种做法不谈,每个 Eureka 客户端都可以在本地缓存服务的运行状态。只要在 Eureka 服务器上运行了服务监视器,例如systemd
,就可以顺利应对偶尔出现的崩溃等问题。
与 Config Server 类似,Eureka 服务器也可以作为一个小巧的 Spring Boot 应用程序来运行:
@SpringBootApplication @EnableEurekaServer public class RepmaxEurekaServerApplication { public static void main(String[] args) { SpringApplication.run(RepmaxEurekaServerApplication.class, args); } }
在应用程序启动时,@EnableEurekaServer
标注会通知 Spring Boot 启动 Eureka。出于高可用目的,默认情况下服务器会尝试联系其他服务器。在独立安装的情况下可以考虑在application.yml
中关闭该功能:
server: port: 8761 eureka: instance: hostname: localhost client: registerWithEureka: false fetchRegistry: false
请注意,按照惯例可在8761
端口运行 Eureka 服务器。访问http://localhost:8761
可以查看 Eureka 仪表板。由于目前尚未注册任何服务,可用实例列表中什么也没显示:
空白的 Eureka 仪表板
若要将leaderboard
服务注册至 Eureka,可为该应用程序类添加一个@EnableEurekaClient
标注。随后通过application.properties
告诉 Eureka 客户端在哪里可以找到服务器,以及应用程序在服务器上注册时所用的名称:
spring.application.name=repmax-leaderboard eureka.client.serviceUrl.defaultZone=http://localhost:8761/eureka
leaderboard
服务启动时,Spring Boot 会检测到@EnableEurekaClient
标注并启动 Eureka 客户端,随后该客户端会将leaderboard
服务注册至 Eureka 服务器。Eureka 仪表板将会显示出新注册的服务:
服务注册后 Eureka 仪表板显示的内容
logbook
服务可以通过与leaderboard
服务相同的方式配置为 Eureka 客户端,需要添加@EnableEurekaClient
标注并配置 Eureka 服务 URL。
通过在logbook
服务中启用 Eureka 客户端,Spring Cloud 会暴露一个用于查询服务实例的DiscoveryClient
Bean:
@Component public class DiscoveryLeaderBoardApi extends AbstractLeaderBoardApi { public DiscoveryLeaderBoardApi(DiscoveryClient discoveryClient) { this.discoveryClient = discoveryClient; } private final DiscoveryClient discoveryClient; @Override protected String getLeaderBoardAddress() { List<ServiceInstance> instances = this.discoveryClient.getInstances("repmax-leaderboard"); if(instances != null && !instances.isEmpty()) { ServiceInstance serviceInstance = instances.get(0); return String.format("http://%s:%d", serviceInstance.getHost(), serviceInstance.getPort()); } throw new IllegalStateException("Unable to locate a leaderboard service"); } }
调用DiscoveryClient.getInstances
可获得ServiceInstances
列表,列表中每一项均对应了一个注册到 Eureka 服务器的leaderboard
服务。从简化的角度考虑,可以从列表中选择第一项服务用于远程调用。
客户端的负载平衡
Eureka 就位后,不同服务将能以动态的方式相互发现,并能直接相互通信,借此可避免负载平衡器代理所产生的开销以及可能的故障点。当然这里也需要进行权衡,因为我们将有关负载平衡的复杂性转嫁到了代码中。
在这里可以看到,DiscoveryLeaderBoardApi.getLeaderBoardAddress
方法在每次远程调用过程中,会直接选择找到的第一个ServiceInstance
。借助这种方法可以方便地将负载分散到所有可用实例。此外本例中还可以通过 Netflix Cloud 的另一个组件处理客户端的负载平衡: Ribbon 。
将 Ribbon 与 Spring Cloud 以及现有的 Eureka 环境配合使用的方法很简单。只需要在logbook
服务中添加针对spring-cloud-starter-ribbon
的依赖关系,并改为使用LoadBalancerClient
取代DiscoveryClient
即可:
public class RibbonLeaderBoardApi extends AbstractLeaderBoardApi { private final LoadBalancerClient loadBalancerClient; @Autowired public RibbonLeaderBoardApi(LoadBalancerClient loadBalancerClient) { this.loadBalancerClient = loadBalancerClient; } @Override protected String getLeaderBoardAddress() { ServiceInstance serviceInstance = this.loadBalancerClient.choose("repmax-leaderboard"); if (serviceInstance != null) { return String.format("http://%s:%d", serviceInstance.getHost(), serviceInstance.getPort()); } else { throw new IllegalStateException("Unable to locate a leaderboard service"); } } }
至此选择ServiceInstance
的任务将由 Ribbon 负责,该功能可以智能地监控端点运行状况,并通过内建机制实现负载平衡。
总结
本文介绍了各种将微服务连接在一起的方法。其中最简单的方法可能就是将服务所需的每个依存项的地址硬编码到程序中。这种方法可以帮助我们快速上手,但在现实环境中实用性很低。
对于现实世界中最基本的应用程序,通过外部配置使用application.properties
文件指定依存项地址这种做法已经足够了。诸如 Cloud Foundry 和 Heroku 等平台即服务(PaaS)系统通过暴露连接信息,使得我们能够用完全相同的方式连接这些依赖项。
然而更大规模的应用程序不仅需要简单的点对点连接,还需要使用某种形式的负载平衡。Spring Cloud Config 与负载平衡代理的紧密结合是一种解决方案,但如果使用诸如 HAProxy 或 NGINX 等市售的负载平衡代理,就只能自行处理服务的发现和注册过程,代理也有可能成为所有流量的一个故障点。通过使用 Netflix 的 Eureka 和 Ribbon 组件,应用程序中的服务将能以动态的方式互相查找,并能将有关负载平衡的决策从专门的负载平衡器代理交由客户端服务来处理。
由于无法控制中间层微服务之间通信产生的传入流量,诸如 AWS ELB 等负载平衡解决方案在系统边缘可能依然占有一席之地,Ribbon 提供了一种不依赖具体的云供应商,可靠性和性能更为出色的解决方案。
关于作者
Rob Harrop是 Skipjaq 公司 CTO,该公司致力于通过机器学习技术解决绩效管理方面遇到的问题。在加入 Skipjaq 前,Rob 以 SpringSource 共同创始人的身份广为人知,这家软件公司开发了大获成功的 Spring 框架。在 SpringSource 任职期间,他是 Spring 框架的核心贡献者,并领导了 dm Server(现名为 Eclipse Virgo)的开发团队。在加入 SpringSource 前,(当时仅 19 岁的)Rob 是英国曼彻斯特顾问公司 Cake Solutions 的共同创始人兼 CTO。作为广受敬重的作者、演讲人和讲师,Rob 经常撰写和探讨有关大规模系统、云体系结构,以及功能编程(Functional programming)的话题。他出版的著作包括极受欢迎的 Spring 框架参考书《Pro Spring》。
评论