11

携程架构部开源的配置中心Apollo深度解读

 3 years ago
source link: https://my.oschina.net/u/3748584/blog/4871718
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

原文首发地址:不问不要紧,一问要人命,绝对的面试加分项--配置中心Apollo深度解读

为什么要使用配置中心

随着程序功能的日益复杂,程序的配置日益增多:各种功能的开关、参数的配置、服务器的地址……

对程序配置的期望值也越来越高:配置修改后实时生效,灰度发布,分环境、分集群管理配置,完善的权限、审核机制……

在这样的大环境下,传统的通过配置文件、数据库等方式已经越来越无法满足开发人员对配置管理的需求。

想必大家都深有体会,我们做的项目都伴随着各种配置文件,而且总是本地配置一套配置文件,测试服配置一套,正式服配置一套,有时候一不小心就改错了,挨骂是小事,扣绩效那可就闹大了。

而且每当项目发布的时候,配置文件也会被打包进去,也就是配置文件会跟着项目一起发布。然后每次出现问题需要我们修改配置文件的时候,我们总是得先在本地修改,然后重新发布才能让新的配置生效。

当请求压力越来越大,你的项目也会从 1 个节点变成多个节点,这个时候如果配置需要发生变化,对应的修改操作也是相同的,只需要在项目中修改一次即可,但对于发布操作工作量就比之前大了很多,因为要发布多个节点。

修改这些配置,增加的发布的工作量降低了整体的工作效率,为了能够提升工作效率,配置中心应运而生了,我们可以将配置统一存放在配置中心来进行管理。

总体而言在没有引入配置中心之前,我们都会面临以下问题:

  1. 配置散乱格式不标准,有的用 properties 格式,有的用 xml 格式,还有的存 DB,团队倾向自造轮子,做法五花八门。

  2. 主要采用本地静态配置,配置修改麻烦,配置修改一般需要经过一个较长的测试发布周期。在分布式微服务环境下,当服务实例很多时,修改配置费时费力。

  3. 易引发生产事故,这个是我亲身经历,之前在一家互联网公司,有团队在发布的时候将测试环境的配置带到生产上,引发百万级资损事故。

  4. 配置缺乏安全审计和版本控制功能,谁改的配置?改了什么?什么时候改的?无从追溯,出了问题也无法及时回滚。

  5. 增加了运维小哥哥的工作量,极大的损害了运维小哥哥和开发小哥哥的基情。

到底什么是配置中心

配置中心就是把项目中各种个样的配置、参数、开关,全部都放到一个集中的地方进行统一管理,并提供一套标准的接口。当各个服务需要获取配置的时候,就来配置中心的接口拉取。当配置中心中的各种参数有更新的时候,也能通知到各个服务实时的过来同步最新的信息,使之动态更新。

Apollo 简介

Apollo(阿波罗)是携程框架部门研发的开源配置管理中心,能够集中化管理应用不同环境、不同集群的配置,配置修改后能够实时推送到应用端,并且具备规范的权限、流程治理等特性。

Apollo支持4个维度管理Key-Value格式的配置(下面会详细说明):

1、application (应用)
2、environment (环境)

3、cluster (集群)
4、namespace (命名空间)

什么是配置

既然Apollo定位于配置中心,那么在这里有必要先简单介绍一下什么是配置。

按照我们的理解,配置有以下几个属性:

  • 配置是独立于程序的只读变量

    • 配置首先是独立于程序的,同一份程序在不同的配置下会有不同的行为。

    • 其次,配置对于程序是只读的,程序通过读取配置来改变自己的行为,但是程序不应该去改变配置。

    • 常见的配置有:DB Connection Str、Thread Pool Size、Buffer Size、Request Timeout、Feature Switch、Server Urls等。

  • 配置伴随应用的整个生命周期

    • 配置贯穿于应用的整个生命周期,应用在启动时通过读取配置来初始化,在运行时根据配置调整行为。

  • 配置可以有多种加载方式

    • 配置也有很多种加载方式,常见的有程序内部hard code,配置文件,环境变量,启动参数,基于数据库等

  • 配置需要治理

    • 还有一类比较特殊的配置 - 框架类组件配置,比如CAT客户端的配置。

    • 虽然这类框架类组件是由其他团队开发、维护,但是运行时是在业务实际应用内的,所以本质上可以认为框架类组件也是应用的一部分。

    • 这类组件对应的配置也需要有比较完善的管理方式。

    • 同一份程序在不同的环境(开发,测试,生产)、不同的集群(如不同的数据中心)经常需要有不同的配置,所以需要有完善的环境、集群配置管理

    • 由于配置能改变程序的行为,不正确的配置甚至能引起灾难,所以对配置的修改必须有比较完善的权限控制

    • 不同环境、集群配置管理

    • 框架类组件配置管理

为什么使用ApolloApollo有哪些特征

正是基于配置的特殊性,所以Apollo从设计之初就立志于成为一个有治理能力的配置发布平台,目前提供了以下的特性:

  • 统一管理不同环境、不同集群的配置

    • Apollo提供了一个统一界面集中式管理不同环境(environment)、不同集群(cluster)、不同命名空间(namespace)的配置。

    • 同一份代码部署在不同的集群,可以有不同的配置,比如zookeeper的地址等

    • 通过命名空间(namespace)可以很方便地支持多个不同应用共享同一份配置,同时还允许应用对共享的配置进行覆盖

  • 配置修改实时生效(热发布)

    • 用户在Apollo修改完配置并发布后,客户端能实时(1秒)接收到最新的配置,并通知到应用程序

  • 版本发布管理

    • 所有的配置发布都有版本概念,从而可以方便地支持配置的回滚

    • 支持配置的灰度发布,比如点了发布后,只对部分应用实例生效,等观察一段时间没问题后再推给所有应用实例

  • 权限管理、发布审核、操作审计

    • 应用和配置的管理都有完善的权限管理机制,对配置的管理还分为了编辑和发布两个环节,从而减少人为的错误。

    • 所有的操作都有审计日志,可以方便地追踪问题

  • 客户端配置信息监控

    • 可以在界面上方便地看到配置在被哪些实例使用

  • 提供Java和.Net原生客户端

    • 提供了Java和.Net的原生客户端,方便应用集成

    • 支持Spring Placeholder, Annotation和Spring Boot的ConfigurationProperties,方便应用使用(需要Spring 3.1.1+)

    • 同时提供了Http接口,非Java和.Net应用也可以方便地使用

  • 提供开放平台API

    • Apollo自身提供了比较完善的统一配置管理界面,支持多环境、多数据中心配置管理、权限、流程治理等特性。不过Apollo出于通用性考虑,不会对配置的修改做过多限制,只要符合基本的格式就能保存,不会针对不同的配置值进行针对性的校验,如数据库用户名、密码,Redis服务地址等

    • 对于这类应用配置,Apollo支持应用方通过开放平台API在Apollo进行配置的修改和发布,并且具备完善的授权和权限控制

    • 配置中心作为基础服务,可用性要求非常高,这就要求Apollo对外部依赖尽可能地少

    • 目前唯一的外部依赖是MySQL,所以部署非常简单,只要安装好Java和MySQL就可以让Apollo跑起来

    • Apollo还提供了打包脚本,一键就可以生成所有需要的安装包,并且支持自定义运行时参数

Apollo整体架构

首先我们来看看Applo的基本工作流程如下图所示

1.用户在配置中心对配置进行修改并发布
2.配置中心通知Apollo客户端有配置更新
3.Apollo客户端从配置中心拉取最新的配置、更新本地配置并通知到应用

接下来我们来看看Apollo的整体架构图

上图简要描述了Apollo的总体设计,我们可以从下往上看:

  • Config Service提供配置的读取、推送等功能,服务对象是Apollo客户端

  • Admin Service提供配置的修改、发布等功能,服务对象是Apollo Portal(管理界面)

  • Config Service和Admin Service都是多实例、无状态部署,所以需要将自己注册到Eureka中并保持心跳

  • 在Eureka之上我们架了一层Meta Server用于封装Eureka的服务发现接口

  • Client通过域名访问Meta Server获取Config Service服务列表(IP+Port),而后直接通过IP+Port访问服务,同时在Client侧会做load balance、错误重试

  • Portal通过域名访问Meta Server获取Admin Service服务列表(IP+Port),而后直接通过IP+Port访问服务,同时在Portal侧会做load balance、错误重试

  • 为了简化部署,我们实际上会把Config Service、Eureka和Meta Server三个逻辑角色部署在同一个JVM进程中

Why Eureka

为什么我们采用Eureka作为服务注册中心,而不是使用传统的zk、etcd呢?我大致总结了一下,有以下几方面的原因:

  • 它提供了完整的Service Registry和Service Discovery实现
    首先是提供了完整的实现,并且也经受住了Netflix自己的生产环境考验,相对使用起来会比较省心。
    和Spring Cloud无缝集成

  • 的项目本身就使用了Spring Cloud和Spring Boot,同时Spring Cloud还有一套非常完善的开源代码来整合Eureka,所以使用起来非常方便。

  • 另外,Eureka还支持在我们应用自身的容器中启动,也就是说我们的应用启动完之后,既充当了Eureka的角色,同时也是服务的提供者。这样就极大的提高了服务的可用性。

  • 这一点是我们选择Eureka而不是zk、etcd等的主要原因,为了提高配置中心的可用性和降低部署复杂度,我们需要尽可能地减少外部依赖。
    Open Source

  • 最后一点是开源,由于代码是开源的,所以非常便于我们了解它的实现原理和排查问题。

各模块概要介绍

Config Service

  • 提供配置获取接口

  • 提供配置更新推送接口(基于Http long polling)

  • 服务端使用Spring DeferredResult实现异步化,从而大大增加长连接数量

  • 目前使用的tomcat embed默认配置是最多10000个连接(可以调整),使用了4C8G的虚拟机实测- 可以支撑10000个连接,所以满足需求(一个应用实例只会发起一个长连接)。接口服务对象为Apollo客户端

Admin Service

  • 提供配置管理接口

  • 提供配置修改、发布等接口

  • 接口服务对象为Portal

Meta Server

  • Portal通过域名访问Meta Server获取Admin Service服务列表(IP+Port)

  • Client通过域名访问Meta Server获取Config Service服务列表(IP+Port)

  • Meta Server从Eureka获取Config Service和Admin Service的服务信息,相当于是一个Eureka Client
    增设一个Meta Server的角色主要是为了封装服务发现的细节,对Portal和Client而言,永远通过一个

  • Http接口获取Admin Service和Config Service的服务信息,而不需要关心背后实际的服务注册和发现组件

  • Meta Server只是一个逻辑角色,在部署时和Config Service是在一个JVM进程中的,所以IP、端口和Config Service一致

Eureka

  • 基于Eureka和Spring Cloud Netflix提供服务注册和发现

  • Config Service和Admin Service会向Eureka注册服务,并保持心跳

  • 为了简单起见,目前Eureka在部署时和Config Service是在一个JVM进程中的(通过Spring Cloud Netflix)

Portal

  • 提供Web界面供用户管理配置

  • 通过Meta Server获取Admin Service服务列表(IP+Port),通过IP+Port访问服务

  • 在Portal侧做load balance、错误重试

Client

  • Apollo提供的客户端程序,为应用提供配置获取、实时更新等功能

  • 通过Meta Server获取Config Service服务列表(IP+Port),通过IP+Port访问服务

  • 在Client侧做load balance、错误重试

Apollo核心概念介绍

1、application

1、Apollo 客户端在运行时需要知道当前应用是谁,从而可以根据不同的应用来获取对应应用的配置。

2、每个应用都需要有唯一的身份标识,可以在代码中配置 app.id 参数来标识当前应用,Apollo 会根据此指来辨别当前应用。

2、environment

在实际开发中,我们的应用经常要部署在不同的环境中,一般情况下分为 开发、测试、生产 等等不同环境,不同环境中的配置也是不同的,在 Apollo 中默认提供了

四种环境:

FAT:功能测试环境

UAT:集成测试环境

DEV:开发环境

PRO:生产环境

在程序中如果想指定使用哪个环境,可以配置变量 env 的值为对应环境名称即可。

3、cluster

1、一个应用下不同实例的分组,比如典型的可以按照数据中心分,把上海机房的应用实例分为一个集群,把北京机房的应用实例分为另一个集群。

2、对不同的集群,同一个配置可以有不一样的值,比如说上面所指的两个北京、上海两个机房设置两个集群,都有 mysql 配置参数,其中参数中配置的地址是不一样的。

4、namespace

一个应用中不同配置的分组,可以简单地把 namespace 类比为不同的配置文件,不同类型的配置存放在不同的文件中,如数据库配置文件,RPC 配置文件等。

熟悉 SpringBoot 的都知道,SpringBoot 项目都有一个默认配置文件 application.yml,如果还想用多个配置,可以创建多个配置文件来存放不同的配置信息,通过

指定 spring.profiles.active 参数指定应用不同的配置文件。这里的 namespace 概念与其类似,将不同的配置放到不同的配置 namespace 中。

Namespace 分为两种权限,分别为:

public(公共的):public权限的 Namespace,能被任何应用获取。
private(私有的):只能被所属的应用获取到。一个应用尝试获取其它应用 private 的 Namespace,Apollo 会报 “404” 异常。

Apollo实时发布配置

1. 配置发布后的实时推送设计

配置中心最重要的一个特性就是实时推送,正因为有这个特性,我们才可以依赖配置中心做很多事情。如图所示。

up-24d569956208b8fe3f0b5bc1c5b86449168.png

图 1 简要描述了配置发布的大致过程。

  • 用户在 Portal 中进行配置的编辑和发布。

  • Portal 会调用 Admin Service 提供的接口进行发布操作。

  • Admin Service 收到请求后,发送 ReleaseMessage 给各个 Config Service,通知 Config Service 配置发生变化。

  • Config Service 收到 ReleaseMessage 后,通知对应的客户端,基于 Http 长连接实现。

2. 发送 ReleaseMessage 的实现方式

ReleaseMessage 消息是通过 Mysql 实现了一个简单的消息队列。之所以没有采用消息中间件,是为了让 Apollo 在部署的时候尽量简单,尽可能减少外部依赖,如图所示。

上图简要描述了发送 ReleaseMessage 的大致过程:

  • Admin Service 在配置发布后会往 ReleaseMessage 表插入一条消息记录。

  • Config Service 会启动一个线程定时扫描 ReleaseMessage 表,来查看是否有新的消息记录。

  • Config Service 发现有新的消息记录,就会通知到所有的消息监听器。

  • 消息监听器得到配置发布的信息后,就会通知对应的客户端。

3. Config Service 通知客户端的实现方式

通知采用基于 Http 长连接实现,主要分为下面几个步骤:

  • 客户端会发起一个 Http 请求到 Config Service 的 notifications/v2 接口。

  • notifications/v2 接口通过 Spring DeferredResult 把请求挂起,不会立即返回。

  • 如果在 60s 内没有该客户端关心的配置发布,那么会返回 Http 状态码 304 给客户端。

  • 如果发现配置有修改,则会调用 DeferredResult 的 setResult 方法,传入有配置变化的 namespace 信息,同时该请求会立即返回。

  • 客户端从返回的结果中获取到配置变化的 namespace 后,会立即请求 Config Service 获取该 namespace 的最新配置。

4. 源码解析实时推送设计

Apollo 推送涉及的代码比较多,本教程就不做详细分析了,笔者把推送这里的代码稍微简化了下,给大家进行讲解,这样理解起来会更容易。

当然,这些代码比较简单,很多细节就不做考虑了,只是为了能够让大家明白 Apollo 推送的核心原理。

发送 ReleaseMessage 的逻辑我们就写一个简单的接口,用队列存储,测试的时候就调用这个接口模拟配置有更新,发送 ReleaseMessage 消息。具体代码如下所示。

@RestControllerpublic class NotificationControllerV2 implements ReleaseMessageListener {// 模拟配置更新, 向其中插入数据表示有更新public static Queue<String> queue = new LinkedBlockingDeque<>();  @GetMapping("/addMsg")  public String addMsg() {    queue.add("xxx");    return "success";  }}

消息发送之后,根据前面讲过的 Config Service 会启动一个线程定时扫描 ReleaseMessage 表,查看是否有新的消息记录,然后取通知客户端,在这里我们也会启动一个线程去扫描,具体代码如下所示。

@Componentpublic class ReleaseMessageScanner implements InitializingBean {  @Autowired  private NotificationControllerV2 configController;  @Override  public void afterPropertiesSet() throws Exception {    // 定时任务从数据库扫描有没有新的配置发布    new Thread(() -> {      for (;;) {        String result = NotificationControllerV2.queue.poll();        if (result != null) {        ReleaseMessage message = new ReleaseMessage();        message.setMessage(result);        configController.handleMessage(message);      }    }  }).start();  ;  }}

循环读取 NotificationControllerV2 中的队列,如果有消息的话就构造一个 Release-Message 的对象,然后调用 NotificationControllerV2 中的 handleMessage() 方法进行消息的处理。

ReleaseMessage 就一个字段,模拟消息内容,具体代码如下所示。

public class ReleaseMessage {  private String message;
  public void setMessage(String message) {    this.message = message;  }
  public String getMessage() {    return message;  }}

接下来,我们来看 handleMessage 做了哪些工作。

NotificationControllerV2 实现了 ReleaseMessageListener 接口,ReleaseMessageListener 中定义了 handleMessage() 方法,具体代码如下所示。

public interface ReleaseMessageListener {    void handleMessage(ReleaseMessage message);}

handleMessage 就是当配置发生变化的时候,发送通知的消息监听器。消息监听器在得到配置发布的信息后,会通知对应的客户端,具体代码如下所示。

@RestControllerpublic class NotificationControllerV2 implements ReleaseMessageListener {
  private final Multimap<String, DeferredResultWrapper> deferredResults = Multimaps.synchronizedSetMultimap(HashMultimap.create());

  @Override  public void handleMessage(ReleaseMessage message) {    System.err.println("handleMessage:" + message);    List<DeferredResultWrapper> results = Lists.newArrayList(deferredResults.get("xxxx"));    for (DeferredResultWrapper deferredResultWrapper : results) {      List<ApolloConfigNotification> list = new ArrayList<>();      list.add(new ApolloConfigNotification("application", 1));      deferredResultWrapper.setResult(list);    }  }}

Apollo 的实时推送是基于 Spring DeferredResult 实现的,在 handleMessage() 方法中可以看到是通过 deferredResults 获取 DeferredResult,deferredResults 就是第一行的 Multimap,Key 其实就是消息内容,Value 就是 DeferredResult 的业务包装类 DeferredResultWrapper,我们来看下 DeferredResultWrapper 的代码,代码如下所示。

public class DeferredResultWrapper {  private static final long TIMEOUT = 60 * 1000;// 60 seconds
  private static final ResponseEntity<List<ApolloConfigNotification>> NOT_MODIFIED_RESPONSE_LIST = new ResponseEntity<>(HttpStatus.NOT_MODIFIED);
  private DeferredResult<ResponseEntity<List<ApolloConfigNotification>>> result;
  public DeferredResultWrapper() {    result = new DeferredResult<>(TIMEOUT, NOT_MODIFIED_RESPONSE_LIST);  }
  public void onTimeout(Runnable timeoutCallback) {    result.onTimeout(timeoutCallback);  }

  public void onCompletion(Runnable completionCallback) {    result.onCompletion(completionCallback);  }
  public void setResult(ApolloConfigNotification notification) {    setResult(Lists.newArrayList(notification));  }
  public void setResult(List<ApolloConfigNotification> notifications) {    result.setResult(new ResponseEntity<>(notifications, HttpStatus.OK));  }
  public DeferredResult<ResponseEntity<List<ApolloConfigNotification>>> getResult() {    return result;  }}

通过 setResult() 方法设置返回结果给客户端,以上就是当配置发生变化,然后通过消息监听器通知客户端的原理,那么客户端是在什么时候接入的呢?具体代码如下。

@RestControllerpublic class NotificationControllerV2 implements ReleaseMessageListener {
// 模拟配置更新, 向其中插入数据表示有更新  public static Queue<String> queue = new LinkedBlockingDeque<>();  private final Multimap<String, DeferredResultWrapper> deferredResults = Multimaps.synchronizedSetMultimap(HashMultimap.create());

  @GetMapping("/getConfig")  public DeferredResult<ResponseEntity<List<ApolloConfigNotification>>> getConfig() {    DeferredResultWrapper deferredResultWrapper = new DeferredResultWrapper();    List<ApolloConfigNotification> newNotifications = getApolloConfigNotifications();    if (!CollectionUtils.isEmpty(newNotifications)) {      deferredResultWrapper.setResult(newNotifications);    } else {      deferredResultWrapper.onTimeout(() -> {    System.err.println("onTimeout");    });
  deferredResultWrapper.onCompletion(() -> {    System.err.println("onCompletion");  });  deferredResults.put("xxxx", deferredResultWrapper);  }  return deferredResultWrapper.getResult();  }
  private List<ApolloConfigNotification> getApolloConfigNotifications() {    List<ApolloConfigNotification> list = new ArrayList<>();    String result = queue.poll();    if (result != null) {    list.add(new ApolloConfigNotification("application", 1));  }    return list;  }}

NotificationControllerV2 中提供了一个 /getConfig 的接口,客户端在启动的时候会调用这个接口,这个时候会执行 getApolloConfigNotifications() 方法去获取有没有配置的变更信息,如果有的话证明配置修改过,直接就通过 deferredResultWrapper.setResult(newNotifications) 返回结果给客户端,客户端收到结果后重新拉取配置的信息覆盖本地的配置。

如果 getApolloConfigNotifications() 方法没有返回配置修改的信息,则证明配置没有发生修改,那就将 DeferredResultWrapper 对象添加到 deferredResults 中,等待后续配置发生变化时消息监听器进行通知。

同时这个请求就会挂起,不会立即返回,挂起是通过 DeferredResultWrapper 中的下面这部分代码实现的,具体代码如下所示。

private static final long TIMEOUT = 60 * 1000; // 60 seconds
private static final ResponseEntity<List<ApolloConfigNotification>> NOT_MODIFIED_RESPONSE_LIST           = new ResponseEntity<>(HttpStatus.NOT_MODIFIED);
private DeferredResult<ResponseEntity<List<ApolloConfigNotification>>> result;
public DeferredResultWrapper() {  result = new DeferredResult<>(TIMEOUT, NOT_MODIFIED_RESPONSE_LIST);}

在创建 DeferredResult 对象的时候指定了超时的时间和超时后返回的响应码,如果 60s 内没有消息监听器进行通知,那么这个请求就会超时,超时后客户端收到的响应码就是 304。

整个 Config Service 的流程就走完了,接下来我们来看一下客户端是怎么实现的,我们简单地写一个测试类模拟客户端注册,具体代码如下所示。

public class ClientTest {    public static void main(String[] args) {        reg();    }    private static void reg() {        System.err.println("注册");        String result = request("http://localhost:8081/getConfig");        if (result != null) {        // 配置有更新, 重新拉取配置        // ......        }        // 重新注册        reg();    }    private static String request(String url) {        HttpURLConnection connection = null;        BufferedReader reader = null;        try {            URL getUrl = new URL(url);            connection = (HttpURLConnection) getUrl.openConnection();            connection.setReadTimeout(90000);            connection.setConnectTimeout(3000);            connection.setRequestMethod("GET");            connection.setRequestProperty("Accept-Charset", "utf-8");            connection.setRequestProperty("Content-Type", "application/json");            connection.setRequestProperty("Charset", "UTF-8");            System.out.println(connection.getResponseCode());            if (200 == connection.getResponseCode()) {                reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), "UTF-8"));                StringBuilder result = new StringBuilder();                String line = null;                while ((line = reader.readLine()) != null) {                    result.append(line);                }                System.out.println("结果 " + result);                return result.toString();            }        } catch (IOException e) {            e.printStackTrace();        } finally {            if (connection != null) {                connection.disconnect();            }        }        return null;    }}

首先启动 /getConfig 接口所在的服务,然后启动客户端,然后客户端就会发起注册请求,如果有修改直接获取到结果,则进行配置的更新操作。如果无修改,请求会挂起,这里客户端设置的读取超时时间是 90s,大于服务端的 60s 超时时间。
每次收到结果后,无论是有修改还是无修改,都必须重新进行注册,通过这样的方式就可以达到配置实时推送的效果。
我们可以调用之前写的 /addMsg 接口来模拟配置发生变化,调用之后客户端就能马上得到返回结果。

Apollo客户端设计

上图简要描述了Apollo客户端的实现原理:

  1. 客户端和服务端保持了一个长连接,从而能第一时间获得配置更新的推送。(通过Http Long Polling实现)

  2. 客户端还会定时从Apollo配置中心服务端拉取应用的最新配置。

    • 这是一个fallback机制,为了防止推送机制失效导致配置不更新

    • 客户端定时拉取会上报本地版本,所以一般情况下,对于定时拉取的操作,服务端都会返回304 - Not Modified

    • 定时频率默认为每5分钟拉取一次,客户端也可以通过在运行时指定System Property: apollo.refreshInterval来覆盖,单位为分钟。

  3. 客户端从Apollo配置中心服务端获取到应用的最新配置后,会保存在内存中

  4. 客户端会把从服务端获取到的配置在本地文件系统缓存一份(在遇到服务不可用,或网络不通的时候,依然能从本地恢复配置)

  5. 应用程序可以从Apollo客户端获取最新的配置、订阅配置更新通知

Apollo客户端用法

Apollo支持API方式和Spring整合方式,该怎么选择用哪一种方式?

  • API方式灵活,功能完备,配置值实时更新(热发布),支持所有Java环境。

  • Spring方式接入简单,结合Spring有N种酷炫的玩法,如

    • 代码中直接使用,如:@Value("${someKeyFromApollo:someDefaultValue}")

    • 配置文件中使用替换placeholder,如:spring.datasource.url: ${someKeyFromApollo:someDefaultValue}

    • 直接托管spring的配置,如在apollo中直接配置spring.datasource.url=jdbc:mysql://localhost:3306/somedb?characterEncoding=utf8

    • Placeholder方式:

    • Spring boot的@ConfigurationProperties方式

    • 从v0.10.0开始的版本支持placeholder在运行时自动更新,具体参见PR #972。(v0.10.0之前的版本在配置变化后不会重新注入,需要重启才会更新,如果需要配置值实时更新,可以参考后续3.2.2 Spring Placeholder的使用的说明)

  • Spring方式也可以结合API方式使用,如注入Apollo的Config对象,就可以照常通过API方式获取配置了:

    @ApolloConfig
    private Config config; //inject config for namespace application
  • 更多有意思的实际使用场景和示例代码,请参考apollo-use-cases

1、API使用方式

API方式是最简单、高效使用Apollo配置的方式,不依赖Spring框架即可使用。

获取默认namespace的配置(application)

Config config = ConfigService.getAppConfig(); //config instance is singleton for each namespace and is never nullString someKey = "someKeyFromDefaultNamespace";String someDefaultValue = "someDefaultValueForTheKey";String value = config.getProperty(someKey, someDefaultValue);

通过上述的config.getProperty可以获取到someKey对应的实时最新的配置值。

另外,配置值从内存中获取,所以不需要应用自己做缓存。

监听配置变化事件

监听配置变化事件只在应用真的关心配置变化,需要在配置变化时得到通知时使用,比如:数据库连接串变化后需要重建连接等。

如果只是希望每次都取到最新的配置的话,只需要按照上面的例子,调用config.getProperty即可。

Config config = ConfigService.getAppConfig(); //config instance is singleton for each namespace and is never nullconfig.addChangeListener(new ConfigChangeListener() {    @Override    public void onChange(ConfigChangeEvent changeEvent) {        System.out.println("Changes for namespace " + changeEvent.getNamespace());        for (String key : changeEvent.changedKeys()) {            ConfigChange change = changeEvent.getChange(key);            System.out.println(String.format("Found change - key: %s, oldValue: %s, newValue: %s, changeType: %s", change.getPropertyName(), change.getOldValue(), change.getNewValue(), change.getChangeType()));        }    }});

获取公共Namespace的配置

String somePublicNamespace = "CAT";Config config = ConfigService.getConfig(somePublicNamespace); //config instance is singleton for each namespace and is never nullString someKey = "someKeyFromPublicNamespace";String someDefaultValue = "someDefaultValueForTheKey";String value = config.getProperty(someKey, someDefaultValue);

获取非properties格式namespace的配置

1.yaml/yml格式的namespace

apollo-client 1.3.0版本开始对yaml/yml做了更好的支持,使用起来和properties格式一致。

Config config = ConfigService.getConfig("application.yml");String someKey = "someKeyFromYmlNamespace";String someDefaultValue = "someDefaultValueForTheKey";String value = config.getProperty(someKey, someDefaultValue);

2.非yaml/yml格式的namespace

获取时需要使用ConfigService.getConfigFile接口并指定Format,如ConfigFileFormat.XML

String someNamespace = "test";ConfigFile configFile = ConfigService.getConfigFile("test", ConfigFileFormat.XML);String content = configFile.getContent();

Spring整合方式

配置

Apollo也支持和Spring整合(Spring 3.1.1+),只需要做一些简单的配置就可以了。

Apollo目前既支持比较传统的基于XML的配置,也支持目前比较流行的基于Java(推荐)的配置。

如果是Spring Boot环境,建议参照3.2.1.3 Spring Boot集成方式(推荐)配置。

需要注意的是,如果之前有使用org.springframework.beans.factory.config.PropertyPlaceholderConfigurer的,请替换成org.springframework.context.support.PropertySourcesPlaceholderConfigurer。Spring 3.1以后就不建议使用PropertyPlaceholderConfigurer了,要改用PropertySourcesPlaceholderConfigurer。

如果之前有使用<context:property-placeholder>,请注意xml中引入的spring-context.xsd版本需要是3.1以上(一般只要没有指定版本会自动升级的),建议使用不带版本号的形式引入,如:http://www.springframework.org/schema/context/spring-context.xsd

注1:yaml/yml格式的namespace从1.3.0版本开始支持和Spring整合,注入时需要填写带后缀的完整名字,比如application.yml

注2:非properties、非yaml/yml格式(如xml,json等)的namespace暂不支持和Spring整合。

基于XML的配置

注:需要把apollo相关的xml namespace加到配置文件头上,不然会报xml语法错误。

1.注入默认namespace的配置到Spring中

<?xml version="1.0" encoding="UTF-8"?><beans xmlns="http://www.springframework.org/schema/beans"       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"       xmlns:apollo="http://www.ctrip.com/schema/apollo"       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd       http://www.ctrip.com/schema/apollo http://www.ctrip.com/schema/apollo.xsd">    <!-- 这个是最简单的配置形式,一般应用用这种形式就可以了,用来指示Apollo注入application namespace的配置到Spring环境中 -->    <apollo:config/>    <bean class="com.ctrip.framework.apollo.spring.TestXmlBean">        <property name="timeout" value="${timeout:100}"/>        <property name="batch" value="${batch:200}"/>    </bean></beans>

2.注入多个namespace的配置到Spring中

<?xml version="1.0" encoding="UTF-8"?><beans xmlns="http://www.springframework.org/schema/beans"       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"       xmlns:apollo="http://www.ctrip.com/schema/apollo"       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd       http://www.ctrip.com/schema/apollo http://www.ctrip.com/schema/apollo.xsd">    <!-- 这个是最简单的配置形式,一般应用用这种形式就可以了,用来指示Apollo注入application namespace的配置到Spring环境中 -->    <apollo:config/>    <!-- 这个是稍微复杂一些的配置形式,指示Apollo注入FX.apollo和application.yml namespace的配置到Spring环境中 -->    <apollo:config namespaces="FX.apollo,application.yml"/>    <bean class="com.ctrip.framework.apollo.spring.TestXmlBean">        <property name="timeout" value="${timeout:100}"/>        <property name="batch" value="${batch:200}"/>    </bean></beans>

3.注入多个namespace,并且指定顺序

Spring的配置是有顺序的,如果多个property source都有同一个key,那么最终是顺序在前的配置生效。

apollo:config如果不指定order,那么默认是最低优先级。

<?xml version="1.0" encoding="UTF-8"?><beans xmlns="http://www.springframework.org/schema/beans"       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"       xmlns:apollo="http://www.ctrip.com/schema/apollo"       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd       http://www.ctrip.com/schema/apollo http://www.ctrip.com/schema/apollo.xsd">    <apollo:config order="2"/>    <!-- 这个是最复杂的配置形式,指示Apollo注入FX.apollo和application.yml namespace的配置到Spring环境中,并且顺序在application前面 -->    <apollo:config namespaces="FX.apollo,application.yml" order="1"/>    <bean class="com.ctrip.framework.apollo.spring.TestXmlBean">        <property name="timeout" value="${timeout:100}"/>        <property name="batch" value="${batch:200}"/>    </bean></beans>

基于Java的配置(推荐)

相对于基于XML的配置,基于Java的配置是目前比较流行的方式。

注意@EnableApolloConfig要和@Configuration一起使用,不然不会生效。

1.注入默认namespace的配置到Spring中

//这个是最简单的配置形式,一般应用用这种形式就可以了,用来指示Apollo注入application namespace的配置到Spring环境中@Configuration@EnableApolloConfigpublic class AppConfig {  @Bean  public TestJavaConfigBean javaConfigBean() {    return new TestJavaConfigBean();  }}

2.注入多个namespace的配置到Spring中

@Configuration@EnableApolloConfigpublic class SomeAppConfig {  @Bean  public TestJavaConfigBean javaConfigBean() {    return new TestJavaConfigBean();  }}
//这个是稍微复杂一些的配置形式,指示Apollo注入FX.apollo和application.yml namespace的配置到Spring环境中@Configuration@EnableApolloConfig({"FX.apollo", "application.yml"})public class AnotherAppConfig {}

3.注入多个namespace,并且指定顺序

//这个是最复杂的配置形式,指示Apollo注入FX.apollo和application.yml namespace的配置到Spring环境中,并且顺序在application前面@Configuration@EnableApolloConfig(order = 2)public class SomeAppConfig {  @Bean  public TestJavaConfigBean javaConfigBean() {    return new TestJavaConfigBean();  }}@Configuration@EnableApolloConfig(value = {"FX.apollo", "application.yml"}, order = 1)public class AnotherAppConfig {}

Spring Boot集成方式(推荐)

Spring Boot除了支持上述两种集成方式以外,还支持通过application.properties/bootstrap.properties来配置,该方式能使配置在更早的阶段注入,比如使用@ConditionalOnProperty的场景或者是有一些spring-boot-starter在启动阶段就需要读取配置做一些事情(如dubbo-spring-boot-project),所以对于Spring Boot环境建议通过以下方式来接入Apollo(需要0.10.0及以上版本)。

使用方式很简单,只需要在application.properties/bootstrap.properties中按照如下样例配置即可。

注入默认application namespace的配置示例

  #will inject 'application' namespace in bootstrap phase  apollo.bootstrap.enabled = true

注入非默认application namespace或多个namespace的配置示例

  apollo.bootstrap.enabled = true  # will inject 'application', 'FX.apollo' and 'application.yml' namespaces in bootstrap phase  apollo.bootstrap.namespaces = application,FX.apollo,application.yml

将Apollo配置加载提到初始化日志系统之前(1.2.0+)

从1.2.0版本开始,如果希望把日志相关的配置(如logging.level.root=infologback-spring.xml中的参数)也放在Apollo管理,那么可以额外配置apollo.bootstrap.eagerLoad.enabled=true来使Apollo的加载顺序放到日志系统加载之前,不过这会导致Apollo的启动过程无法通过日志的方式输出(因为执行Apollo加载的时候,日志系统压根没有准备好呢!所以在Apollo代码中使用Slf4j的日志输出便没有任何内容),更多信息可以参考PR 1614。参考配置示例如下:

  # will inject 'application' namespace in bootstrap phase  apollo.bootstrap.enabled = true  # put apollo initialization before logging system initialization  apollo.bootstrap.eagerLoad.enabled=true

Spring Placeholder的使用

Spring应用通常会使用Placeholder来注入配置,使用的格式形如someKey:someDefaultValue,如{someKey:someDefaultValue},如someKey:someDefaultValue,如{timeout:100}。冒号前面的是key,冒号后面的是默认值。

建议在实际使用时尽量给出默认值,以免由于key没有定义导致运行时错误。

从v0.10.0开始的版本支持placeholder在运行时自动更新,具体参见PR #972。

如果需要关闭placeholder在运行时自动更新功能,可以通过以下两种方式关闭:

1. 通过设置System Property apollo.autoUpdateInjectedSpringProperties,如启动时传入-Dapollo.autoUpdateInjectedSpringProperties=false

2.通过设置META-INF/app.properties中的apollo.autoUpdateInjectedSpringProperties属性,如​​​​​​​

app.id=SampleAppapollo.autoUpdateInjectedSpringProperties=false

 XML使用方式

假设我有一个TestXmlBean,它有两个配置项需要注入:​​​​​​​

public class TestXmlBean {  private int timeout;  private int batch;
  public void setTimeout(int timeout) {    this.timeout = timeout;  }
  public void setBatch(int batch) {    this.batch = batch;  }
  public int getTimeout() {    return timeout;  }
  public int getBatch() {    return batch;  }}

那么,我在XML中会使用如下方式来定义(假设应用默认的application namespace中有timeout和batch的配置项):

<?xml version="1.0" encoding="UTF-8"?><beans xmlns="http://www.springframework.org/schema/beans"       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"       xmlns:apollo="http://www.ctrip.com/schema/apollo"       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd       http://www.ctrip.com/schema/apollo http://www.ctrip.com/schema/apollo.xsd">    <apollo:config/>    <bean class="com.ctrip.framework.apollo.spring.TestXmlBean">        <property name="timeout" value="${timeout:100}"/>        <property name="batch" value="${batch:200}"/>    </bean></beans>

 Java Config使用方式

假设我有一个TestJavaConfigBean,通过Java Config的方式还可以使用@Value的方式注入:​​​​​​​

public class TestJavaConfigBean {  @Value("${timeout:100}")  private int timeout;  private int batch;
  @Value("${batch:200}")  public void setBatch(int batch) {    this.batch = batch;  }
  public int getTimeout() {    return timeout;  }
  public int getBatch() {    return batch;  }}

在Configuration类中按照下面的方式使用(假设应用默认的application namespace中有timeoutbatch的配置项):​​​​​​​

@Configuration@EnableApolloConfigpublic class AppConfig {  @Bean  public TestJavaConfigBean javaConfigBean() {    return new TestJavaConfigBean();  }}

ConfigurationProperties使用方式

Spring Boot提供了@ConfigurationProperties把配置注入到bean对象中。

Apollo也支持这种方式,下面的例子会把redis.cache.expireSecondsredis.cache.commandTimeout分别注入到SampleRedisConfig的expireSecondscommandTimeout字段中。​​​​​​​

@ConfigurationProperties(prefix = "redis.cache")public class SampleRedisConfig {  private int expireSeconds;  private int commandTimeout;
  public void setExpireSeconds(int expireSeconds) {    this.expireSeconds = expireSeconds;  }
  public void setCommandTimeout(int commandTimeout) {    this.commandTimeout = commandTimeout;  }}

在Configuration类中按照下面的方式使用(假设应用默认的application namespace中有redis.cache.expireSecondsredis.cache.commandTimeout的配置项):​​​​​​​

@Configuration@EnableApolloConfigpublic class AppConfig {  @Bean  public SampleRedisConfig sampleRedisConfig() {    return new SampleRedisConfig();  }}

需要注意的是,@ConfigurationProperties如果需要在Apollo配置变化时自动更新注入的值,需要配合使用EnvironmentChangeEvent或RefreshScope。相关代码实现,可以参考apollo-use-cases项目中的ZuulPropertiesRefresher.java和apollo-demo项目中的SampleRedisConfig.java以及SpringBootApolloRefreshConfig.java

Spring Annotation支持

Apollo同时还增加了几个新的Annotation来简化在Spring环境中的使用。

  1. @ApolloConfig

    • 用来自动注入Config对象

  2. @ApolloConfigChangeListener

    • 用来自动注册ConfigChangeListener

  3. @ApolloJsonValue

    • 用来把配置的json字符串自动注入为对象

使用样例如下:

public class TestApolloAnnotationBean {  @ApolloConfig  private Config config; //inject config for namespace application  @ApolloConfig("application")  private Config anotherConfig; //inject config for namespace application  @ApolloConfig("FX.apollo")  private Config yetAnotherConfig; //inject config for namespace FX.apollo  @ApolloConfig("application.yml")  private Config ymlConfig; //inject config for namespace application.yml
  /**   * ApolloJsonValue annotated on fields example, the default value is specified as empty list - []   * <br />   * jsonBeanProperty=[{"someString":"hello","someInt":100},{"someString":"world!","someInt":200}]   */  @ApolloJsonValue("${jsonBeanProperty:[]}")  private List<JsonBean> anotherJsonBeans;
  @Value("${batch:100}")  private int batch;
  //config change listener for namespace application  @ApolloConfigChangeListener  private void someOnChange(ConfigChangeEvent changeEvent) {    //update injected value of batch if it is changed in Apollo    if (changeEvent.isChanged("batch")) {      batch = config.getIntProperty("batch", 100);    }  }
  //config change listener for namespace application  @ApolloConfigChangeListener("application")  private void anotherOnChange(ConfigChangeEvent changeEvent) {    //do something  }
  //config change listener for namespaces application, FX.apollo and application.yml  @ApolloConfigChangeListener({"application", "FX.apollo", "application.yml"})  private void yetAnotherOnChange(ConfigChangeEvent changeEvent) {    //do something  }
  //example of getting config from Apollo directly  //this will always return the latest value of timeout  public int getTimeout() {    return config.getIntProperty("timeout", 200);  }
  //example of getting config from injected value  //the program needs to update the injected value when batch is changed in Apollo using @ApolloConfigChangeListener shown above  public int getBatch() {    return this.batch;  }
  private static class JsonBean{    private String someString;    private int someInt;  }}

在Configuration类中按照下面的方式使用:

​​​​​​​

@Configuration@EnableApolloConfigpublic class AppConfig {  @Bean  public TestApolloAnnotationBean testApolloAnnotationBean() {    return new TestApolloAnnotationBean();  }}

 已有配置迁移

很多情况下,应用可能已经有不少配置了,比如Spring Boot的应用,就会有bootstrap.properties/yml, application.properties/yml等配置。

在应用接入Apollo之后,这些配置是可以非常方便的迁移到Apollo的,具体步骤如下:

  1. 在Apollo为应用新建项目

  2. 在应用中配置好META-INF/app.properties

  3. 建议把原先配置先转为properties格式,然后通过Apollo提供的文本编辑模式全部粘帖到应用的application namespace,发布配置

    • 如果原来格式是yml,可以使用YamlPropertiesFactoryBean.getObject转成properties格式

  4. 如果原来是yml,想继续使用yml来编辑配置,那么可以创建私有的application.yml namespace,把原来的配置全部粘贴进去,发布配置

    • 需要apollo-client是1.3.0及以上版本

  5. 把原先的配置文件如bootstrap.properties/yml, application.properties/yml从项目中删除

    • 如果需要保留本地配置文件,需要注意部分配置如server.port必须确保本地文件已经删除该配置项

​​​​​​​

spring.application.name = reservation-serviceserver.port = 8080
logging.level = ERROR
eureka.client.serviceUrl.defaultZone = http://127.0.0.1:8761/eureka/eureka.client.healthcheck.enabled = trueeureka.client.registerWithEureka = trueeureka.client.fetchRegistry = trueeureka.client.eurekaServiceUrlPollIntervalSeconds = 60
eureka.instance.preferIpAddress = true

Apollo的高可用性设计

​​​​​​​
高可用是分布式系统架构设计中必须考虑的因素之一,它通常是指通过设计减少系统不能提供服务的时间。

Apollo 在高可用设计上下了很大的功夫,下面我们来简单的分析下:

1)某台Config Service 下线

无影响,Config Service 可用部署多个节点。

2)所有 Config Service 下线

所有 Config Service 下线会影响客户端的使用,无法读取最新的配置。可采用读取本地缓存的配置文件来过渡。

3)某台 Admin Service 下线

无影响,Admin Service 可用部署多个节点。

4)所有 Admin Service 下线

Admin Service 是服务于 Portal,所有 Admin Service 下线之后只会影响 Portal 的操作,不会影响客户端,客户端是依赖 Config Service。

5)某台 Portal 下线

Portal 可用部署多台,通过 Nginx 做负载,某台下线之后不影响使用。

6)全部 Portal 下线

对客户端读取配置是没有影响的,只是不能通过 Portal 去查看,修改配置。

7)数据库宕机

当配置的数据库宕机之后,对客户端是没有影响的,但是会导致 Portal 中无法更新配置。当客户端重启,这个时候如果需要重新拉取配置,就会有影响,可采取开启配置缓存的选项来避免数据库宕机带来的影响。

本文根据现实开发中的种种复杂情况,引入配置中心,现在市面上的配置中心很多,本文着重介绍的是携程开源的Apollo。

本文从对Apollo的简介到为什么使用Apollo和Apollo的特征介绍。然后对Apollo的整体架构和Apollo的核心概念进行了详细介绍。并且重点介绍了Apollo的客户端设计和用法。

最后对Apollo的高可用设计进行了详细说明。

本文算是比较全面的介绍了Apollo相关的知识,也是我根据高频面试题常问的几个方面对Apollo进行解读。

总之,走过路过不要错过,本文很长建议先马再看。

Spring Cloud 微服务精彩系列


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK