基於Apache Zookeeper手寫實現動態配置中心(純程式碼實踐)

跟著Mic學架構發表於2021-10-26

相信大家都知道,每個專案中會有一些配置資訊放在一個獨立的properties檔案中,比如application.properties。這個檔案中會放一些常量的配置,比如資料庫連線資訊、執行緒池大小、限流引數。

在傳統的開發模式下,這種方式很方便,一方面能夠對配置進行統一管理,另一方面,我們在維護的時候很方便。

但是隨著業務的發展以及架構的升級,在微服務架構中,服務的數量以及每個服務涉及到的配置會越來越多,並且對於配置管理的需求越來越高,比如要求實時性、獨立性。

另外,在微服務架構下,會涉及到不同的環境下的配置管理、灰度釋出、動態限流、動態降級等需求,包括對於配置內容的安全與許可權,所以傳統的配置維護方式很難達到需求。

因此,就產生了分散式配置中心。

  • 傳統的配置方式不方便維護
  • 配置內容的安全和訪問許可權,在傳統的配置方式中很難實現
  • 更新配置內容時,需要重啟

配置中心的工作流程

image-20200709192446173

圖11-1

Spring Boot的外部化配置

在本次課程中,我們會Zookeeper整合到Spring Boot的外部化配置中,讓使用者無感知的使用配置中心上的資料作為資料來源,所以我們需要先了解Spring Boot中的外部化配置。

Spring Boot的外部化配置是基於Environment來實現的,它表示Spring Boot應用執行時的環境資訊,先來看基本使用

Environment的使用

  • 在spring boot應用中,修改aplication.properties配置

    key=value
    
  • 建立一個Controller進行測試

    @RestController
    public class EnvironementController {
    
        @Autowired
        Environment environment;
    
        @GetMapping("/env")
        public String env(){
            return environment.getProperty("key");
        }
    }
    

@Value註解使用

在properties檔案中定義的屬性,除了可以通過environment的getProperty方法獲取之外,spring還提供了@Value註解,

@RestController
public class EnvironementController {

    @Value("${env}")
    private String env;

    @GetMapping("/env")
    public String env(){
        return env;
    }
}

spring容器在載入一個bean時,當發現這個Bean中有@Value註解時,那麼它可以從Environment中將屬性值進行注入,如果Environment中沒有這個屬性,則會報錯。

Environment設計猜想

Spring Boot的外部化配置,不僅僅只是appliation.properties,包括命令列引數、系統屬性、作業系統環境變數等,都可以作為Environment的資料來源。

  • @Value("${java.version}") 獲取System.getProperties , 獲取系統屬性
  • 配置command的jvm引數, -Denvtest=command ,然後通過@Value("${envtest}")

image-20210818164156459

圖11-2
  • 第一部分是屬性定義,這個屬性定義可以來自於很多地方,比如application.properties、或者系統環境變數等。
  • 然後根據約定的方式去指定路徑或者指定範圍去載入這些配置,儲存到記憶體中。
  • 最後,我們可以根據指定的key從快取中去查詢這個值。

擴充套件Environment

我們可以自己擴充套件Environment中的資料來源,程式碼如下;

其中,EnvironmentPostProcessor:它可以在spring上下文構建之前可以設定一些系統配置。

CusEnvironmentPostProcessor

public class CusEnvironmentPostProcessor implements EnvironmentPostProcessor {
    private final Properties properties=new Properties();
    private String propertiesFile="custom.properties";

    @Override
    public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
        Resource resource=new ClassPathResource(propertiesFile);
        environment.getPropertySources().addLast(loadProperties(resource));
    }

    private PropertySource<?> loadProperties(Resource resource){
        if(!resource.exists()){
            throw new IllegalArgumentException("file:{"+resource+"} not exist");
        }
        try {
            properties.load(resource.getInputStream());
            return new PropertiesPropertySource(resource.getFilename(),properties);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

custom.properties

在classpath目錄下建立custom.properties檔案

name=mic
age=18

spring.factories

在META-INF目錄下建立spring.factories檔案,因為EnvironmentPostProcessor的擴充套件實現是基於SPI機制完成的。

org.springframework.boot.env.EnvironmentPostProcessor=\
  com.example.springbootzookeeper.CusEnvironmentPostProcessor

TestController

建立測試類,演示自定義配置載入的功能。

@RestController
public class TestController {

    @Value("${name}")
    public String val;

    @GetMapping("/")
    public String say(){
        return val;
    }
}

總結

通過上面的例子我們發現,在Environment中,我們可以通過指定PropertySources來增加Environment外部化配置資訊,使得在Spring Boot執行期間自由訪問到這些配置。

那麼我們要實現動態配置中心,無非就是要在啟動的時候,從遠端伺服器上獲取到資料儲存到PropertySource中,並且新增到Environment。

下面我們就開始來實現這個過程。

Zookeeper實現配置中心

在本小節中,主要基於Spring的Environment擴充套件實現自己的動態配置中心,程式碼結構如圖11-3所示。

image-20210805232800966

圖11-3

自定義配置中心的相關說明

在本次案例中,我們並沒有完全使用EnvironmentPostProcessor這個擴充套件點,而是基於SpringFactoriesLoader自定義了一個擴充套件點,主要目的是讓大家知道EnvironmentPostProcessor擴充套件點的工作原理,以及我們以後自己也可以定義擴充套件點。

程式碼實現

以下是所有程式碼的實現過程,按照下面這個步驟去開發即可完成動態配置中心。

ZookeeperApplicationContextInitializer

ApplicationContextInitializer擴充套件,它是在ConfigurableApplicationContext通過呼叫refresh函式來初始化Spring容器之前會進行回撥的一個擴充套件方法,我們可以在這個擴充套件中實現Environment的擴充套件。

所以這個類的主要作用就是在ApplicationContext完成refresh之前,擴充套件Environment,增加外部化配置注入。

public class ZookeeperApplicationContextInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext>{
    //PropertySourceLocator介面支援擴充套件自定義配置載入到spring Environment中。
    private final List<PropertySourceLocator> propertySourceLocators;

    public ZookeeperApplicationContextInitializer(){
        //基於SPI機制載入所有的外部化屬性擴充套件點
        ClassLoader classLoader=ClassUtils.getDefaultClassLoader();
        //這部分的程式碼是SPI機制
        propertySourceLocators=new ArrayList<>(SpringFactoriesLoader.loadFactories(PropertySourceLocator.class,classLoader));
    }
    @Override
    public void initialize(ConfigurableApplicationContext applicationContext) {
        //獲取執行的環境上下文
        ConfigurableEnvironment environment=applicationContext.getEnvironment();
        //MutablePropertySources它包含了一個CopyOnWriteArrayList集合,用來包含多個PropertySource。
        MutablePropertySources mutablePropertySources = environment.getPropertySources();
        for (PropertySourceLocator locator : this.propertySourceLocators) {
            //回撥所有實現PropertySourceLocator介面例項的locate方法,收集所有擴充套件屬性配置儲存到Environment中
            Collection<PropertySource<?>> source = locator.locateCollection(environment,applicationContext);
            if (source == null || source.size() == 0) {
                continue;
            }
            //把PropertySource屬性源新增到environment中。
            for (PropertySource<?> p : source) {
                //addFirst或者Last決定了配置的優先順序
                mutablePropertySources.addFirst(p);
            }
        }
    }
}

建立classpath:/META-INF/spring.factories

org.springframework.context.ApplicationContextInitializer=\
  com.gupaoedu.example.zookeepercuratordemo.config.ZookeeperApplicationContextInitializer

PropertySourceLocator

PropertySourceLocator介面支援擴充套件自定義配置載入到spring Environment中。

public interface PropertySourceLocator {

    PropertySource<?> locate(Environment environment,ConfigurableApplicationContext applicationContext);
	//Environment表示環境變數資訊
    //applicationContext表示應用上下文
    default Collection<PropertySource<?>> locateCollection(Environment environment, ConfigurableApplicationContext applicationContext) {
        return locateCollection(this, environment,applicationContext);
    }

    static Collection<PropertySource<?>> locateCollection(PropertySourceLocator locator,
                                                          Environment environment,ConfigurableApplicationContext applicationContext) {
        PropertySource<?> propertySource = locator.locate(environment,applicationContext);
        if (propertySource == null) {
            return Collections.emptyList();
        }
        return Arrays.asList(propertySource);
    }
}

ZookeeperPropertySourceLocator

ZookeeperPropertySourceLocator用來實現基於Zookeeper屬性配置的擴充套件點,它會訪問zookeeper獲取遠端伺服器資料。

public class ZookeeperPropertySourceLocator implements PropertySourceLocator{

    private final CuratorFramework curatorFramework;

    private final String DATA_NODE="/data";  //僅僅為了演示,所以寫死目標資料節點

    public ZookeeperPropertySourceLocator() {
        curatorFramework= CuratorFrameworkFactory.builder()
                .connectString("192.168.221.128:2181")
                .sessionTimeoutMs(20000).connectionTimeoutMs(20000)
                .retryPolicy(new ExponentialBackoffRetry(1000,3))
                .namespace("config").build();
        curatorFramework.start();
    }

    @Override
    public PropertySource<?> locate(Environment environment, ConfigurableApplicationContext applicationContext) {
        System.out.println("開始載入遠端配置到Environment中");
        CompositePropertySource composite = new CompositePropertySource("configService");
        try {
            Map<String,Object> dataMap=getRemoteEnvironment();
            //基於Map結構的屬性源
            MapPropertySource mapPropertySource=new MapPropertySource("configService",dataMap);
            composite.addPropertySource(mapPropertySource);
            addListener(environment,applicationContext);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return composite;
    }

    private Map<String,Object> getRemoteEnvironment() throws Exception {
        String data=new String(curatorFramework.getData().forPath(DATA_NODE));
        //暫時支援json格式
        ObjectMapper objectMapper=new ObjectMapper();
        Map<String,Object> map=objectMapper.readValue(data, Map.class);
        return map;
    }
    //新增節點變更事件
    private void addListener(Environment environment, ConfigurableApplicationContext applicationContext){
        NodeDataCuratorCacheListener curatorCacheListener=new NodeDataCuratorCacheListener(environment,applicationContext);
        CuratorCache curatorCache=CuratorCache.build(curatorFramework,DATA_NODE,CuratorCache.Options.SINGLE_NODE_CACHE);
        CuratorCacheListener listener=CuratorCacheListener
                .builder()
                .forChanges(curatorCacheListener).build();
        curatorCache.listenable().addListener(listener);
        curatorCache.start();
    }
}

配置擴充套件點: classpath:/META-INF/spring.factories

com.gupaoedu.example.zookeepercuratordemo.config.PropertySourceLocator=\
  com.gupaoedu.example.zookeepercuratordemo.config.ZookeeperPropertySourceLocator

配置動態變更邏輯

NodeDataCuratorCacheListener

NodeDataCuratorCacheListener用來實現持久化訂閱機制,當目標節點資料發生變更時,需要收到變更並且應用。

public class NodeDataCuratorCacheListener implements CuratorCacheListenerBuilder.ChangeListener {
    private Environment environment;
    private ConfigurableApplicationContext applicationContext;
    public NodeDataCuratorCacheListener(Environment environment, ConfigurableApplicationContext applicationContext) {
        this.environment = environment;
        this.applicationContext=applicationContext;
    }
    @Override
    public void event(ChildData oldNode, ChildData node) {
        System.out.println("資料發生變更");
        String resultData=new String(node.getData());
        ObjectMapper objectMapper=new ObjectMapper();
        try {
            Map<String,Object> map=objectMapper.readValue(resultData, Map.class);
            ConfigurableEnvironment cfe=(ConfigurableEnvironment)environment;
            MapPropertySource mapPropertySource=new MapPropertySource("configService",map);
            cfe.getPropertySources().replace("configService",mapPropertySource);
            //釋出事件,用來更新@Value註解對應的值(事件機制可以分兩步演示)
            applicationContext.publishEvent(new EnvironmentChangeEvent(this));
            System.out.println("資料更新完成");
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
    }
}

EnvironmentChangeEvent

定義一個環境變數變更事件。

public class EnvironmentChangeEvent extends ApplicationEvent {
    
    public EnvironmentChangeEvent(Object source) {
        super(source);
    }
}

ConfigurationPropertiesRebinder

ConfigurationPropertiesRebinder接收事件,並重新繫結@Value註解的資料,使得資料能夠動態改變

@Component
public class ConfigurationPropertiesRebinder implements ApplicationListener<EnvironmentChangeEvent> {
    private ConfigurationPropertiesBeans beans;
    private Environment environment;
    public ConfigurationPropertiesRebinder(ConfigurationPropertiesBeans beans,Environment environment) {
        this.beans = beans;
        this.environment=environment;
    }

    @Override
    public void onApplicationEvent(EnvironmentChangeEvent event) {
        rebind();
    }
    public void rebind(){
        this.beans.getFieldMapper().forEach((k,v)->{
            v.forEach(f->f.resetValue(environment));
        });
    }
}

ConfigurationPropertiesBeans

ConfigurationPropertiesBeans實現了BeanPostPorocessor介面,該介面我們也叫後置處理器,作用是在Bean物件在例項化和依賴注入完畢後,在顯示呼叫初始化方法的前後新增我們自己的邏輯。注意是Bean例項化完畢後及依賴注入完成後觸發的。

我們可以在這個後置處理器的回撥方法中,掃描指定註解的bean,收集這些屬性,用來觸發事件變更。

@Component
public class ConfigurationPropertiesBeans implements BeanPostProcessor {

    private Map<String,List<FieldPair>> fieldMapper=new HashMap<>();

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName)
        throws BeansException {
        Class clz=bean.getClass();
        if(clz.isAnnotationPresent(RefreshScope.class)){ //如果某個bean宣告瞭RefreshScope註解,說明需要進行動態更新
            for(Field field:clz.getDeclaredFields()){
                Value value=field.getAnnotation(Value.class);
                List<String> keyList=getPropertyKey(value.value(),0);
                for(String key:keyList){
                    //使用List<FieldPair>儲存的目的是,如果在多個bean中存在相同的key,則全部進行替換
                    fieldMapper.computeIfAbsent(key,(k)->new ArrayList()).add(new FieldPair(bean,field,value.value()));
                }
            }
        }
        return bean;
    }
    //獲取key資訊,也就是${value}中解析出value這個屬性
    private List<String> getPropertyKey(String value,int begin){
        int start=value.indexOf("${",begin)+2;
        if(start<2){
            return new ArrayList<>();
        }
        int middle=value.indexOf(":",start);
        int end=value.indexOf("}",start);
        String key;
        if(middle>0&&middle<end){
            key=value.substring(start,middle);
        }else{
            key=value.substring(start,end);
        }
        //如果是這種用法,就需要遞迴,@Value("${swagger2.host:127.0.0.1:${server.port:8080}}")
        List<String> keys=getPropertyKey(value,end);
        keys.add(key);
        return keys;
    }

    public Map<String, List<FieldPair>> getFieldMapper() {
        return fieldMapper;
    }
}

RefreshScope

定義註解來實現指定需要動態重新整理類的識別。

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RefreshScope {

}

FieldPair

這個類中主要通過PropertyPlaceholderHelper將字串裡的佔位符內容,用我們配置的properties裡的替換。

public class FieldPair {
    private static PropertyPlaceholderHelper propertyPlaceholderHelper=new PropertyPlaceholderHelper("${","}",":",true);
    private Object bean;
    private Field field;
    private String value;

    public FieldPair(Object bean, Field field, String value) {
        this.bean = bean;
        this.field = field;
        this.value = value;
    }

    public void resetValue(Environment environment){
        boolean access=field.isAccessible();
        if(!access){
            field.setAccessible(true);
        }
        //從新從environment中將佔位符替換為新的值
        String resetValue=propertyPlaceholderHelper.replacePlaceholders(value,((ConfigurableEnvironment) environment)::getProperty);
        try {
           //通過反射更新
            field.set(bean,resetValue);
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }
}

訪問測試ConfigController

@RefreshScope
@RestController
public class ConfigController {

    @Value("${name}")
    private String name;

    @Value("${job}")
    private String job;

    @GetMapping
    public String get(){
        return name+":"+job;
    }
}

基於自定義PropertySourceLocator擴充套件

由於在上述程式碼中,我們建立了一個PropertySourceLocator介面,並且在整個配置載入過程中,我們都是基於PropertySourceLocator擴充套件點來進行載入的,所以也就是意味著除了上述使用的Zookeeper作為遠端配置裝載以外,我們還可以通過擴充套件PropertySourceLocator來實現其他的擴充套件,具體實現如下

CustomPropertySourceLocator

建立一個MapPropertySource作為Environment的屬性源。

public class CustomPropertySourceLocator implements PropertySourceLocator{

    @Override
    public PropertySource<?> locate(Environment environment, ConfigurableApplicationContext applicationContext) {
        Map<String, Object> source = new HashMap<>();
        source.put("age","18");
        MapPropertySource propertiesPropertySource = new MapPropertySource("configCenter",source);
        return propertiesPropertySource;
    }
}

spring.factories

由於CustomPropertySourceLocator是自定義擴充套件點,所以我們需要在spring.factories檔案中定義它的擴充套件實現,修改如下

com.gupaoedu.example.zookeepercuratordemo.config.PropertySourceLocator=\
  com.gupaoedu.example.zookeepercuratordemo.config.ZookeeperPropertySourceLocator,\
  com.gupaoedu.example.zookeepercuratordemo.config.CustomPropertySourceLocator

ConfigController

接下來,我們通過下面的程式碼進行測試,從結果可以看到,我們自己定義的propertySource被載入到Environment中了。

@RefreshScope
@RestController
public class ConfigController {

    @Value("${name}")
    private String name;

    @Value("${job}")
    private String job;

    @Value("${age}")
    private String age;

    @GetMapping
    public String get(){
        return name+":"+job+":"+age;
    }
}

關注[跟著Mic學架構]公眾號,獲取更多精品原創

相關文章