Apollo架构设计

2020/04/05 Apollo

Apollo架构设计

基础模型

如下即是Apollo的基础模型:

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

image

架构模块

image

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

  • (1)Config Service 服务于Client(项目中的Apollo客户端)对配置的操作,提供配置的查询接口。 提供配置更新推送接口(基于Http long polling)。
  • (2)Admin Service 服务于后台Portal(Web管理端),提供配置管理接口。
  • (3)Meta Server Meta Server是对Eureka的一个封装,提供了Http接口获取Admin Service和Config Service的服务信息。 部署时和Config Service是在一个JVM进程中的,所以IP、端口和Config Service一致。
  • (4)Eureka 用于提供服务注册和发现。 Config Service和Admin Service会向Eureka注册服务。 为了简化部署流程,Eureka在部署时和Config Service是在一个JVM进程中,也就是说Config Service同时包含了Eureka和Meta Server。
  • (5)Portal 后台Web界面管理配置。 通过Meta Server获取Admin Service服务列表(IP+Port)进行配置的管理,在客户端内做负载均衡。
  • (6)Client Apollo提供的客户端,用于项目中对配置的获取、更新。 通过Meta Server获取Config Service服务列表(IP+Port)进行配置的管理,在客户端内做负载均衡。

Apollo架构设计流程

其中,Apollo架构设计流程可分为如下几类。

  • (1)Portal管理配置流程 Portal连接了PortalDB,通过域名访问Meta Server获取Admin Service服务列表,直接对Admin Service发起接口调用,Admin Service会对ConfigDB进行数据操作。
  • (2)客户端获取配置流程 Client通过域名访问Meta Server获取Config Service服务列表,直接对Config Service发起接口调用,Config Service会对ConfigDB进行数据操作。
  • (3)Meta Server获取服务列表流程 Meta Server会去Eureka中获取对应服务的实例信息,Eureka中的实例信息是Admin Service和Config Service自动注册到Eureka中并保持心跳。

Apollo服务端设计

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

在配置中心中,一个重要的功能就是配置发布后实时推送到客户端。下面我们简要看一下这块是怎么设计实现的

  • 用户在Portal操作发布配置
  • Portal调用Admin Service的接口操作发布
  • Admin Service发布配置后,发送ReleaseMessage给各Config Service
  • Config Service收到ReleaseMessage后通知对应的客户端

发送ReleaseMessage的实现方式

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

  • Admin Service在配置发布后会往ReleaseMessage表插入一条消息记录。
  • Config Service会启动一个线程定时扫描ReleaseMessage表,来查看是否有新的消息记录。
  • Config Service发现有新的消息记录,就会通知到所有的消息监听器。
  • 消息监听器得到配置发布的信息后,就会通知对应的客户端。

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

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

  • 客户端会发起一个Http请求到Config Service的notifications/v2接口。
  • notifications/v2接口通过Spring DeferredResult把请求挂起,不会立即返回。
  • 如果在60s内没有该客户端关心的配置发布,那么会返回Http状态码304给客户端。
  • 如果发现配置有修改,则会调用DeferredResult的setResult方法,传入有配置变化的namespace信息,同时该请求会立即返回。
  • 客户端从返回的结果中获取到配置变化的namespace后,会立即请求Config Service获取该namespace的最新配置。

源码解析实时推送设计

Apollo推送涉及的代码比较多,本书就不做详细分析了,笔者把推送这里的代码稍微简化了下,给大家进行讲解,这样理解起来会更容易。当然,这些代码比较简单,很多细节就不做考虑了,只是为了能够让大家明白Apollo推送的核心原理。 发送ReleaseMessage的逻辑我们就写一个简单的接口,用队列存储,测试的时候就调用这个接口模拟配置有更新,发送ReleaseMessage消息。 配置变化消息发送

@RestController
public class NotificationControllerV2 implements ReleaseMessageListener {

    // 模拟配置更新,向其中插入数据表示有更新
    public static Queue<String> queue = new LinkedBlockingDeque<>();

    @GetMapping("/addMsg")
    public String addMsg() {
        queue.add("xxx");
        return "success";
    }

}

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

@Component
public 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就是当配置发生变化的时候,发送通知的消息监听器。消息监听器在得到配置发布的信息后,会通知对应的客户端。


@RestController
public 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()方法设置返回结果给客户端,以上就是当配置发生变化,然后通过消息监听器通知客户端的原理,那么客户端是在什么时候接入的呢?。

@RestController
public 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客户端的实现原理。

  • 客户端和服务端保持了一个长连接,编译配置的实时更新推送。
  • 定时拉取配置是客户端本地的一个定时任务,默认为每5分钟拉取一次,也可以通过在运行时指定System Property:apollo.refreshInterval来覆盖,单位是分钟,推送+定时拉取=双保险。
  • 客户端从Apollo配置中心服务端获取到应用的最新配置后,会保存在内存中。
  • 客户端会把从服务端获取到的配置在本地文件系统缓存一份,当服务或者网络不可用时,可以使用本地的配置,也就是我们的本地开发模式env=Local。

和Spring集成的原理

Apollo除了支持API方式获取配置,也支持和Spring/Spring Boot集成,集成后可以直接通过@Value获取配置,我们来分析下集成的原理。 Spring从3.1版本开始增加了ConfigurableEnvironment和PropertySource:

  • ConfigurableEnvironment实现了Environment接口,并且包含了多个Property-Source。
  • PropertySource可以理解为很多个Key-Value的属性配置,在运行时的结构形如图10-12所示。 需要注意的是,PropertySource之间是有优先级顺序的,如果有一个Key在多个property source中都存在,那么位于前面的property source优先。 集成的原理就是在应用启动阶段,Apollo从远端获取配置,然后组装成PropertySource并插入到第一个即可。

启动时初始化配置到Spring

客户端集成Spring的代码分析,我们也采取简化的方式进行讲解。 首先我们来分析,在项目启动的时候从Apollo拉取配置,是怎么集成到Spring中的。创建一个PropertySourcesProcessor类,用于初始化配置到Spring PropertySource中。 配置初始化逻辑

@Component
public class PropertySourcesProcessor implements BeanFactoryPostProcessor, EnvironmentAware {

    String APOLLO_PROPERTY_SOURCE_NAME = "ApolloPropertySources";

    private ConfigurableEnvironment environment;

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        // 启动时初始化配置到Spring PropertySource
        Config config = new Config();
        ConfigPropertySource configPropertySource = new ConfigPropertySource("ap-plication", config);
        
        CompositePropertySource composite = new CompositePropertySource(APOLLO_PROPERTY_SOURCE_NAME);
        composite.addPropertySource(configPropertySource);

        environment.getPropertySources().addFirst(composite);
    }

    @Override
    public void setEnvironment(Environment environment) {
        this.environment = (ConfigurableEnvironment) environment;
    }

}

实现EnvironmentAware接口是为了获取Environment对象。实现BeanFactory-Post-Processor接口,我们可以在容器实例化bean之前读取bean的信息并修改它。 Config在Apollo中是一个接口,定义了很多读取配置的方法,比如getProperty:getIntProperty等。通过子类去实现这些方法,在这里我们就简化下,直接定义成一个类,提供两个必要的方法。 配置获取类

public class Config {

    public String getProperty(String key, String defaultValue) {
        if (key.equals("cxytiandiName")) {
            return "猿天地";
        }
        return null;
    }

    public Set<String> getPropertyNames() {
        Set<String> names = new HashSet<>();
        names.add("cxytiandiName");
        return names;
    }

}

Config就是配置类,配置拉取之后会存储在类中,所有配置的读取都必须经过它,我们在这里就平格定义需要读取的key为cxytiandiName。 然后需要将Config封装成PropertySource才能插入到Spring Environment中。 定义一个ConfigPropertySource用于将Config封装成PropertySource,ConfigProperty-Source继承了EnumerablePropertySource,EnumerablePropertySource继承了PropertySource。 配置类转换成PropertySource

public class ConfigPropertySource extends EnumerablePropertySource<Config> {

    private static final String[] EMPTY_ARRAY = new String[0];

    ConfigPropertySource(String name, Config source) {
        super(name, source);
    }

    @Override
    public String[] getPropertyNames() {
        Set<String> propertyNames = this.source.getPropertyNames();
        if (propertyNames.isEmpty()) {
            return EMPTY_ARRAY;
        }
        return propertyNames.toArray(new String[propertyNames.size()]);
    }

    @Override
    public Object getProperty(String name) {
        return this.source.getProperty(name, null);
    }
}

需要做的操作还是重写getPropertyNames和getProperty这两个方法。当调用这两个方法时,返回的就是Config中的内容。 最后将ConfigPropertySource添加到CompositePropertySource中,并且加入到Confi-gu-rable-Environment即可。 配置测试代码

@RestController
public class ConfigController {

    @Value("${cxytiandiName:yinjihuan}")
    private String name;
    @GetMapping("/get")
    private String cxytiandiUrl;
    @GetMapping("/get")
    public String get() {
        return name + cxytiandiUrl;
    }
}

在配置文件中增加对应的配置:

cxytiandiName=xxx
cxytiandiUrl=http://cxytiandi.com

在没有增加上面讲的代码之前,访问/get接口返回的是xxxhttp://cxytiandi.com。加上上面讲解的代码之后,返回的内容就变成了猿天地http://cxytiandi.com。这是因为我们在Config中对应cxytiandiName这个key的返回值是猿天地,也间接证明了在启动的时候可以通过这种方式来覆盖本地的值。这就是Apollo与Spring集成的原理。

运行中修改配置如何刷新

在这一节中,我们来讲解下在项目运行过程中,配置发生修改之后推送给了客户端,那么这个值如何去更新Spring当中的值呢? 原理就是把这些配置都存储起来,当配置发生变化的时候进行修改就可以。Apollo中定义了一个SpringValueProcessor类,用来处理Spring中值的修改。下面只贴出一部分代码,完整源码大家可以去GitHub上查看。

@Component
public class SpringValueProcessor implements BeanPostProcessor, BeanFactoryAware {

    private PlaceholderHelper placeholderHelper = new PlaceholderHelper() ;

    private BeanFactory beanFactory;

    public SpringValueRegistry springValueRegistry = new SpringValueRegistry();

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        Class clazz = bean.getClass();
        for (Field field : findAllField(clazz)) {
            processField(bean, beanName, field);
        }
        return bean;
    }

    private void processField(Object bean, String beanName, Field field) {
        // register @Value on field
        Value value = field.getAnnotation(Value.class);
        if (value == null) {
            return;
        }
        Set<String> keys = placeholderHelper.extractPlaceholderKeys(value.value());
        if (keys.isEmpty()) {
            return;
        }

        for (String key : keys) {
            SpringValue springValue = new SpringValue(key, value.value(), bean, beanName, field, false);
            springValueRegistry.register(beanFactory, key, springValue);
        }
    }
}

通过实现BeanPostProcessor来处理每个bean中的值,然后将这个配置信息封装成一个SpringValue存储到springValueRegistry中。

public class SpringValue {

    private MethodParameter methodParameter;
    private Field field;
    private Object bean;
    private String beanName;
    private String key;
    private String placeholder;
    private Class<?> targetType;
    private Type genericType;
    private boolean isJson;
}

SpringValueRegistry就是利用Map来存储。


public class SpringValueRegistry {
private final Map<BeanFactory, Multimap<String, SpringValue>> registry = Maps.newConcurrentMap();
private final Object LOCK = new Object();

    public void register(BeanFactory beanFactory, String key, SpringValue springValue) {
        if (!registry.containsKey(beanFactory)) {
            synchronized (LOCK) {
                if (!registry.containsKey(beanFactory)) {
                    registry.put(beanFactory, LinkedListMultimap.<String, SpringValue>create());
                }
            }
        }
        registry.get(beanFactory).put(key, springValue);
    }

    public Collection<SpringValue> get(BeanFactory beanFactory, String key) {
        Multimap<String, SpringValue> beanFactorySpringValues = registry.get(beanFactory);
        if (beanFactorySpringValues == null) {
            return null;
        }
        return beanFactorySpringValues.get(key);
    }
}

写个接口用于模拟配置修改

@RestController
public class ConfigController {

    @Autowired
    private SpringValueProcessor springValueProcessor;
    @Autowired
    private ConfigurableBeanFactory beanFactory;

    @GetMapping("/update")
    public String update(String value) {
        Collection<SpringValue> targetValues = springValueProcessor.springValueRegistry.get(beanFactory,
                "cxytiandiName");
        for (SpringValue val : targetValues) {
            try {
                val.update(value);
            } catch (IllegalAccessException | InvocationTargetException e) {
                e.printStackTrace();
            }
        }
        return name;
    }

}

当我们调用/update接口后,在前面的/get接口可以看到猿天地的值改成了你传入的那个值,这就是动态修改。

Apollo高可用设计

高可用是分布式系统架构设计中必须考虑的因素之一,它通常是指通过设计减少系统不能提供服务的时间。 Apollo在高可用设计上下了很大的功夫,下面我们来简单的分析下:

  • 某台Config Service下线 无影响,Config Service可用部署多个节点。
  • 所有Config Service下线 所有Config Service下线会影响客户端的使用,无法读取最新的配置。可采用读取本地缓存的配置文件来过渡。
  • 某台Admin Service下线 无影响,Admin Service可用部署多个节点。
  • 所有Admin Service下线 Admin Service是服务于Portal,所有Admin Service下线之后只会影响Portal的操作,不会影响客户端,客户端是依赖Config Service。
  • 某台Portal下线 Portal可用部署多台,通过Nginx做负载,某台下线之后不影响使用。
  • 全部Portal下线 对客户端读取配置是没有影响的,只是不能通过Portal去查看,修改配置。
  • 数据库宕机 当配置的数据库宕机之后,对客户端是没有影响的,但是会导致Portal中无法更新配置。当客户端重启,这个时候如果需要重新拉取配置,就会有影响,可采取开启配置缓存的选项来避免数据库宕机带来的影响。 通过上面的分析,我们可以看出Apollo在可用性这块做得确实不错,各种场景会发生的问题都有备用方案,基本上不会有太大问题,大家放心大胆地使用吧。

Search

    微信好友

    博士的沙漏

    Table of Contents