[译]用Spring Cloud治理微服务

原创
2016/07/19 10:31
阅读数 1.6K

        当我们从传统架构转向微服务架构,会面临一个重大的选择:用什么来管理服务之间的依赖关系?在单体系统中,组件之间通过简单的方法调用进行交互,而在微服务架构系统中,组件之间通过REST,Web Service或RPC进行跨越网络的交互。

        在一个单体系统中,我们基本可以避免服务组件之间因为依赖关系而产生的问题。每个组件可以根据需要直接创建它们所依赖的组件。不过通常我们不会这么做,因为组件之间的紧密耦合会让系统变得死板,不便于测试。相反,我们会把被依赖的组件可配置化,然后注入到需要它们的组件中去。依赖注入就是用来管理类或对象之间的依赖关系的。

        如果我们决定用微服务架构来实现一个系统,可以用管理单体系统的方式来管理微服务。我们可以用硬编码的方式把服务的地址写死,把各个服务紧紧地捆绑在一起。或者,我们可以把服务地址做成可配置的,在需要的时候才使用它。在这篇文章里,我会探讨以上两种方式在基于Spring Boot和Spring Cloud构建的微服务系统中是如何体现的。

        假设我们有一个叫repmax的微服务系统:

        repmax系统用于追踪用户的举重历史记录,还会为每一次举重选出排名前5的用户作为排行榜。logbook接收来自UI的运动数据,并保存起来。用户每记录一次举重记录,logbook都会把这一次举重的具体信息发送到leaderboard。

        从图上我们可以看出,logbook依赖leaderboard。根据最佳实践,我们把被依赖的loaderboard抽象成一个接口,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已经包含了发送一个POST请求到leaderboard所需要的逻辑代码,而把指定leaderboard具体地址的工作留给子类去做。把一个微服务关联到另一个微服务,最简单的做法是把被依赖方的地址硬编码到依赖方中。在单体系统中,就是要显式地实例化一个被依赖的组件。比如StaticWiredLeaderBoardApiClass:

public class StaticWiredLeaderBoardApi extends AbstractLeaderBoardApi {

    @Override
    protected String getLeaderBoardAddress() {
        return "http://localhost:8082";
    }
}

        硬编码服务地址虽然省时省心,但在实际应用中不建议这么做。服务会被部署到不同的环境中,部署方式可能会有所差异,硬编码服务地址会让部署变成一件令人头痛的事情,而且容易出错。我们可以把硬编码的服务地址从代码里移到配置文件中去。对于微服务架构的系统,也可以用类似的做法:把地址放到配置文件里,然后提供从配置文件读取地址的API。

        Spring Boot让配置参数的定义和注入变得简单明了。我们可以在application.properties文件里定义配置参数:

leaderboard.url=http://localhost:8082

        然后用@Value把参数注入到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支持外部配置的特性让我们既可以通过修改配置文件来改变参数的值,也可以在应用启动的时候通过指定环境变量来改变参数的值:

LEADERBOARD_URL=http://repmax.skipjaq.com/leaderboard java -jar repmax-logbook-1.0.0-RELEASE.jar

        现在,我们可以在不修改代码的情况下,为logbook指定任意一个leaderboard实例。如果我们的系统遵循12 factor原则,组件间的依赖关系信息都被放在了外部环境中,它们可以很容易得被映射到系统内部。

        像Cloud Foundry和Heroku这样的SaaS系统,它们向外部环境暴露了数据库和消息系统的依赖关系信息,允许我们更换成不同的数据库或消息系统。实际上,不管是关联两个服务,还是关联一个服务到它的存储系统,都没必要太在意两者之间的区别,我们只需要知道要连接的是两个分布式的系统。

 

更复杂的依赖关系

       对于简单的应用,依赖关系的外部配置化已经足以。但对于复杂的应用系统,我们不仅要考虑点对点的依赖关系,还要引入负载均衡机制。

        如果一个服务只依赖下游服务的一个实例,那么下游服务链上的任何一个故障都会给我们的最终用户带来灾难性的影响。而且,如果下游服务负载过重,我们的用户也会因为不断变慢的响应速度而蒙受损失。所以我们需要使用负载均衡。

        我们会让下游服务的一组实例来共同承担工作负荷,而不是只让一个实例做所有的事情。如果其中的一个实例出了故障或者满负荷工作,其它的实例会接着工作。实现这种机制的最简单的做法是在当前架构中引入负载均衡器。如果用AWS的Elastic负载均衡来部署repmax是这样的:

        logbook的每一个请求通过ELB被路由到leaderboard,而不是与leaderboard直接打交道。ELB把每一个请求路由给后面的leaderboard实例。有了ELB这个中间媒介,工作负荷可以由多个leaderboard实例共同承担,减轻了每一个leaderboard实例的压力。

        ELB的负载均衡是动态的,我们可以在运行时添加新的服务实例。所以当我们遇到流量高峰时,我们可以启动更多的leaderboard实例来应对。

        基于Spring Boot的应用使用acturator向外部暴露心跳检测端点,ELB用它来定期检测应用的心跳情况。ELB认为那些及时对心跳检测做出响应的应用是活着的,但如果经过几次检测都没有做出响应,会被从服务列表中移除。

        除了leaderboard以外,logbook和前端UI也可以通过使用负载均衡来提升横向扩展能力和故障恢复能力。

 

动态配置

        不管使用哪种负载均衡器,AWS ELB,Google Compute Load Balancing,还是基于HAProxy或Nginx的负载均衡器,我们都免不了要建立调用端到这些负载均衡器的联系。一种做法是给每一个负载均衡器起一个DNS名字,比如leaderboard.repmax.local。这个是可以被硬编码到应用中的,就像之前提到的那样。这种方式的灵活性取决于DNS本身所具备的灵活性。当然,硬编码名字意味着我们要在每一个运行服务的环境中配置一个DNS服务器。如果我们的应用要支持多种操作系统,配置额外的DNS服务器会给让开发变得复杂。更好的做法,是把负载均衡器相关的地址注入到应用中,就像前面那个例子做的那样。

        在AWS或GCP这样的云环境中,负载均衡器跟它们的地址都是可变的。一个负载均衡器在重启后会被分配一个新的地址。如果我们硬编码负载均衡器的地址,需要重新编译代码才能使用新的地址。而如果使用外部配置文件,我们只需要简单地修改配置然后重启即可。

        DNS是解决负载均衡器地址可变性问题比较便利的方案。每一个负载均衡器被赋予一个静态的DNS名字,然后这个名字被注入到调用端。当负载均衡器被重建,DNS名字被映射到新的均衡器地址上。如果你的环境里有DNS服务器,基于DNS的方案是个不错的选择。如果你不想运行一个额外的DNS服务器,但是又想保持动态可配性,那么可以使用Spring Cloud Config。

        Spring Cloud Config会启动一个叫Config Server的小型服务,并提供REST API访问统一的配置数据。默认情况下,配置数据保存在Git仓库里,基于Spring Boot的应用通过标准的PropertySource抽象层可以访问到这些数据。因为有了PropertySource,我们可以把本地的配置和Config Server上的配置无缝得组合在一起。在开发环境,我们使用本地的属性配置文件,到了生产环境,把这些配置覆盖掉。要在ConfigurableLeaderBoardApi使用Spring Cloud Config,我们要先初始化一个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应用所需要的默认配置信息。如果需要添加另外一份配置信息,比如development,我们只需要提交另外一个叫repmax-development.properties的文件。

       要启动Config Server,我们可以直接运行spring-cloud-config-server项目自带的Config Server,或者自己创建一个包含Config Server的Spring Boot项目:

@SpringBootApplication
@EnableConfigServer
public class RepmaxConfigServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(RepmaxConfigServerApplication.class, args);
    }
}

        @EnableConfigServer表示Config Server会随着Spring Boot项目启动。通过spring.cloud.config.server.git.uri把Config Server的地址指向Git仓库。本地测试的时候可以把下面这行加到application.properties里:

spring.cloud.config.server.git.uri=file://${user.home}/dev/repmax-config-repo

      这样,团队里的每一个开发人员都可以在他们自己的机器上启动一个Config Server,并连接到本地的Git仓库。等Config Server启动完毕,我们可以通过浏览器访问http://localhost:8888/repmax/default来进行验证那些配置信息是否正确。

        从图上我们可以看到,leaderboard.lb.url的值是http://localhost:8083. JSON串里的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上读取配置信息只需要做几步改变。第一步,把对spring-cloud-starter-config的依赖加到build.gradle里:

compile("org.springframework.cloud:spring-cloud-starter-config:1.1.1.BUILD-SNAPSHOT")

        接下来,我们再提供一些基本的配置,这些配置是Config Client所必需的。还记得吗,Config Server是从一个叫repmax.properties的文件加载配置信息的。我们需要告诉Config Client我们的应用名称是什么。于是配置在bootstrap.properties看起来是这个样子的:

spring.application.name=repmax

        Config Client默认情况下会认为Config Server的地址是http://localhost:8888。可以在客户端启动的时候指定环境变量SPRING_CLOUD_CONFIG_URI来改变这个地址。

        在客户端起来以后,我们可以通过访问http://localhost:8081/env 来检查Config Server的配置是否加载正确:

        在logbook中使用了Config Client以后,我们就可以把ConfigurableLeaderBoardApi改造一下,让它从Config Server上暴露出来的leaderboard.lb.url获取负载均衡器的地址。

 

动态刷新

        因为配置信息被保存在一个统一的地方,当配置发生变更时,我们可以很容易地让所有服务组件都接收到通知。但是要让配置变更生效,重启应用是免不了的。不过我们可以做得更好。Spring Boot提供了@ConfigurationProperties,允许我们把配置信息直接映射到JavaBean上。Spring Cloud Config更进一步,它为每一个客户端提供了/refresh功能。当/refresh被触发的时候,被@ConfigurationProperties注解的JavaBean会同时更新它们的属性。

        任何一个JavaBean都可以被@ConfigurationProperties注解,但最好还是只注解那些包含配置信息的JavaBean。所以,我们抽取了LeaderboardConfig作为leaderboard服务地址的持有者:

@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的值是我们要映射到JavaBean的配置信息的前缀。然后,每一个值会按照JavaBean的命名规则来映射。在这个例子里,JavaBean的url属性会被映射到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();
    }
}

        要刷新配置,只需发送一个HTTP POST请求到logbook服务的/refresh端点:

curl -X POST http://localhost:8081/refresh

 

服务发现

        用Spring Cloud Config和负载均衡器在logbook跟leaderboard之间架起了一座桥梁,我们的系统看起来很像样了。不过我们还可以做一些改进。如果我们把系统部署在AWS或GCP上,这些环境为我们提供了灵活的负载均衡器,我们可以好好利用它们。不过如果我们使用的是HAProxy或Nginx这样的负载均衡器,我们就要自己去处理服务注册和服务发现问题。leaderboard的每一个新实例都要被配置到均衡器上,一旦失效,又要从均衡器上移除。我们希望这些过程可以是动态的,服务实例可以自己注册到均衡器上,并且可以被客户端发现。

        使用负载均衡器还有一个潜在的问题:稳定性。所有的流量都通过均衡器来路由,于是均衡器本身的稳定性决定了整个系统的稳定性。均衡器的故障会引发系统的故障。我们还要考虑从客户端到均衡器,再从均衡器到服务端之间的交互开销。

        为了解决这些问题,Netflix推出了Eureka项目。Eureka是一个基于CS模式的支持服务注册和服务发现的系统。在服务实例启动时,它们会把自己注册到Eureka服务器上。客户端,比如logbook,会从Eureka服务器上获取可用的服务列表。接下来,客户端和服务端之间就可以直接进行端到端的交互。

         Eureka不依赖均衡器,从而让系统具有更高的稳定性。试想一下,如果leaderboard的均衡器挂掉,logbook就没办法跟leaderboard交互了。而如果使用Eureka,logbook知道所有leaderboard的实例,就算一个实例不可用,logbook可以找到下一个可用的实例。

        你也许会想,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也启动起来。默认情况下,出于高可用性考虑,Eureka会尝试连接到其它对等的服务器。但在单机模式下,这个特性最好还是关掉。在application.yml配置文件里是这样的:

server:
  port: 8761
eureka:
  instance:
    hostname: localhost
  client:
    registerWithEureka: false
    fetchRegistry: false

        我们遵循惯例,让Eureka服务器运行在8761端口上。通过访问http://localhost:8761可以看到Eureka的概览界面。因为我们还没有注册服务,所以看到的服务实例列表是空的:        要在Eureka上注册leaderboard 服务,需要用@EnableEurekaClient对应用程序的类进行注解。我们还要告诉客户端服务器地址,以及服务的注册名字:

spring.application.name=repmax-leaderboard
eureka.client.serviceUrl.defaultZone=http://localhost:8761/eureka

        当leaderboard服务启动时,Spring Boot会检测到@EnableEurekaClient,并启动Eureka Client,然后把leaderboard服务注册到Eureka服务器上。Eureka的概览界面可以显示新注册的服务:

        用相同的方式配置logbook服务,增加@EnableEurekaClient,然后配置Eureka URL。

        因为logbook启用了Eureka Client,Spring Cloud会对外暴露DiscoveryClientBean,我们可以用它来查找服务的实例。

@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可以获取服务的实例列表,列表里的每一个实例对应一个在Eureka服务器上注册过的leaderboard实例。为了方便,我们选取列表里的第一个实例来处理调用请求。

 

客户端负载均衡

        有了Eureka,服务组件之间可以相互发现对方,然后直接进行交互,避免了使用负载均衡器带来的额外开销和潜在的单点问题。不好的地方在于,它把负载均衡的复杂性带进我们的代码里。

        你应该注意到DiscoveryLeaderBoardApi.getLeaderBoardAddress方法直接选择第一个服务实例处理所有的调用请求,这样就没办法进行负载均衡。好在Netflix提供了另外一个可以处理客户端负载均衡的组件:Ribbon。

        在Spring Cloud和Eureka基础上使用Ribbon很简单,在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");
        }
    }
}

        Ribbon具备智能监测节点存活状态的能力,而且内置了负载均衡功能,选取使用哪个服务实例的工作就可以交给Ribbon去做 了。

展开阅读全文
加载中

作者的其它热门文章

打赏
2
3 收藏
分享
打赏
2 评论
3 收藏
2
分享
返回顶部
顶部