随着转向基于微服务的体系结构,我们开始面临一项重要决策:如何将不同服务连接在一起?单层系统(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
修改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》。