使用Spring Cloud连接不同服务

http://www.infoq.com/cn/articles/spring-cloud-service-wiring

主要结论

  • 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 FoundryHeroku等平台即服务(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 ELBGoogle 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,并在logbookleaderboard服务之间使用负载平衡代理,应用程序已经基本完成了。然而还需要进行一定的完善。

如果在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》。

posted @ 2018-04-02 17:23  Bigben  阅读(1274)  评论(0编辑  收藏  举报