Apollo开发实战

2020/04/06 Apollo

Apollo开发实战

Apollo使用小经验

公共配置

公共配置需要提取出来,达到多个项目复用的目的。我们是将Apollo的命名空间整理出来,定义在一个类里面,主要是一些公共配置的命名空间,比如Eureka、Redis、MQ这些,需要在多个服务中使用。通过@EnableApolloConfig来指定项目需要用到的配置空间。 Apollo命名空间定义

public interface FangJiaConfigConsts {

    /**
    * 默认命名空间
    */
    String DEFAULT_NAMESPACE = "application";
    
    /**
    * 公共Redis命名空间
    */
    String COMMON_REDIS_NAMESPACE = "1001.common-redis";
    
    /**
    * 公共Web命名空间
    */
    String COMMON_WEB_NAMESPACE = "1001.common-web";

}

使用方需要使用哪些配置就指定哪些空间。 Apollo命名空间使用

@Data
@Configuration
@EnableApolloConfig({FangJiaConfigConsts.DEFAULT_NAMESPACE,FangJiaConfigConsts.COMMON_WEB_NAMESPACE })
public class ApplicationConfig {

}

Apollo动态调整日志级别

生产环境出问题后,往往需要更详细的日志来辅助我们排查问题。这个时候会将日志级别调低一点,比如debug级别,这样就能输出更详细的日志信息。目前都是通过修改配置,然后重启服务来让日志的修改生效。 通过整合Apollo我们可以做到在配置中心动态调整日志级别的配置,服务不用重启即可刷新日志级别,非常方便。 最常见的就是我们用Feign来调用接口,正常情况下是不开启Feign的日志输出,当需要排查问题的时候,就可以调整下日志级别,查看Feign的请求信息以及返回的数据。 在项目中增加一个日志监听修改刷新的类,当有loggin.level相关配置发生修改的时候,就向Spring发送EnvironmentChangeEvent事件。 日志级别动态调整

@Service
public class LoggerLevelRefresher implements ApplicationContextAware {
private ApplicationContext applicationContext;

    @ApolloConfig
    private Config config;

    @PostConstruct
    private void initialize() {
        refreshLoggingLevels(config.getPropertyNames());
    }

    @ApolloConfigChangeListener
    private void onChange(ConfigChangeEvent changeEvent) {
        refreshLoggingLevels(changeEvent.changedKeys());
    }

    private void refreshLoggingLevels(Set<String> changedKeys) {
        boolean loggingLevelChanged = false;
        for (String changedKey : changedKeys) {
            if (changedKey.startsWith("logging.level.")) {
                loggingLevelChanged = true;
                break;
            }
        }

        if (loggingLevelChanged) {
            System.out.println("Refreshing logging levels");
            this.applicationContext.publishEvent(new EnvironmentChangeEvent(chan­gedKeys));
            System.out.println("Logging levels refreshed");
        }
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }

}

Apollo之前基本上都是重启服务,后面用了Spring Boot Admin,也可以解决这个需求,在Spring Boot Admin中有个Logger的菜单,可以动态调整日志级别。

Apollo存储加密

一些比较重要的配置信息,比如密码之类的敏感配置,我们希望将配置加密存储,以保证安全性。Apollo框架本身没有提供数据加密的功能,如果想要实现数据加密的功能有两种方式,第一种是改Apollo的源码,增加加解密的逻辑;第二种比较简单,是基于第三方的框架来对数据进行解密。 jasypt-spring-boot是一个基于Spring Boot开发的框架,可以将properties中加密的内容自动解密,在Apollo中也可以借助jasypt-spring-boot这个框架来实现数据的加、解密操作。 jasypt-spring-boot GitHub地址:https://github.com/ulisesbocchio/jasypt-spring-boot。 将我们需要加密的配置通过jasypt-spring-boot提供的方法进行加密,然后将加密的内容配置在Apollo中,当项目启动的时候,jasypt-spring-boot会将Apollo加密的配置进行解密,从而让使用者获取到解密之后的内容。 创建一个新的Maven项目,加入Apollo和jasypt的依赖。

<dependency>
    <groupId>com.ctrip.framework.apollo</groupId>
    <artifactId>apollo-client</artifactId>
    <version>1.1.0</version>
</dependency>
<!--jasypt加密-->
<dependency>
    <groupId>com.github.ulisesbocchio</groupId>
    <artifactId>jasypt-spring-boot-starter</artifactId>
    <version>1.16</version>
</dependency>

添加配置信息

server.port=8081
app.id=SampleApp
apollo.meta=http://localhost:8080
apollo.bootstrap.enabled=true
apollo.bootstrap.namespaces=application
jasypt.encryptor.password=yinjihaunkey
·jasypt.encryptor.password:配置加密的Key。

创建一个加密的工具类,用于加密配置。

public class EncryptUtil {

    /**
    * 制表符、空格、换行符 PATTERN
    */
    private static Pattern BLANK_PATTERN = Pattern.compile("\\s*|\t|\r|\n");

    /**
    * 加密Key
    */
    private static String PASSWORD = "yinjihaunkey";

    /**
    * 加密算法
    */
    private static String ALGORITHM = "PBEWithMD5AndDES";

    public static Map<String, String> getEncryptedParams(String input) {
        //输出流
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(1024);
        PrintStream cacheStream = new PrintStream(byteArrayOutputStream);
        
        //更换数据输出位置
        System.setOut(cacheStream);

        //加密参数组装
        String[] args = {"input=" + input, "password=" + PASSWORD, "algorithm=" + ALGORITHM};
        JasyptPBEStringEncryptionCLI.main(args);

        //执行加密后的输出
        String message = byteArrayOutputStream.toString();
        String str = replaceBlank(message);
        int index = str.lastIndexOf("-");

        //返回加密后的数据
        Map<String, String> result = new HashMap<String, String>();
        result.put("input", str.substring(index + 1));
        result.put("password", PASSWORD);
        return result;
    }

    /**
    * 替换制表符、空格、换行符
    *
    * @param str
    * @return
    */
    private static String replaceBlank(String str) {
        String dest = "";
        if (!StringUtils.isEmpty(str)) {
            Matcher matcher = BLANK_PATTERN.matcher(str);
            dest = matcher.replaceAll("");
        }
        return dest;
    }
    
    public static void main(String[] args) {
        System.out.println(getEncryptedParams("hello"));
    }
}

执行main方法,可以得到如下输出:

{input=0JK4mrGjPUxkB4XuqEv2YQ==, password=yinjihaunkey}

input就是hello加密之后的内容,将input的值复制存储到Apollo中,存储的格式需要按照一定的规则才行:

test.input = ENC(0JK4mrGjPUxkB4XuqEv2YQ==)

需要将加密的内容用ENC包起来,这样jasypt才会去解密这个值。 使用时可以直接根据名称注入配置。

@Value("${test.input}")
private String input;

input的值就是解密后的值,使用者不需要关心解密逻辑,jasypt框架已经在内部处理好了。 jasypt整合Apollo也有一些不足的地方,目前笔者发现了下面两个问题:

  • 在配置中心修改值后,项目中的值不会刷新。
  • 注入Config对象获取的值无法解密。 Config对象注入 ```java @ApolloConfig private Config config;

@GetMapping(“/config/getUserName3”) public String getUserName3() { return config.getProperty(“test.input”, “yinjihuan”); }

上面列举的两个问题,跟jasypt的实现方式是有关系的,这意味着这种加密的方式可能只适合数据库密码之类的情况,启动时是可以解密的,而且只是用一次,如果是某些比较核心的业务配置需要加密的话,jasypt是支持不了的,无法做到实时更新。

## 扩展Apollo支持存储加解密
前面章节中给大家介绍了如何使用jasypt为Apollo中的配置进行加解密操作,基本的需求是能够实现的,但还是有一些不足的地方。
jasypt只是在启动的时候将Spring中带有ENC(××)这种格式的配置进行解密,当配置发生修改时无法更新。由于Apollo框架本身没有这种对配置加解密的功能,如果我们想实现加解密,并且能够动态更新,就需要对Apollo的源码做一些修改来满足需求。
对源码修改还需要重新打包,笔者在这里介绍一个比较简单的实现方式,就是创建一个跟Apollo框架中一模一样的类名进行覆盖,这样也不用替换已经在使用的客户端。
如果配置中心存储的内容是加密的,意味着Apollo客户端从配置中心拉取下来的配置也是加密之后的,我们需要在配置拉取下来之后就对配置进行解密,然后再走后面的流程,比如绑定到Spring中。在这个业务点进行切入后,配置中心加密的内容就可以自动变成解密后的明文,对使用者透明。
通过分析Apollo的源码,笔者找到了一个最合适的切入点来做这件事情,这个类就是com.ctrip.framework.apollo.internals.DefaultConfig,DefaultConfig是Coonfig接口的实现类,配置的初始化和获取都会经过DefaultConfig的处理。
在DefaultConfig内部有一个更新配置的方法updateConfig,可以在这个方法中对加密的数据进行解密处理。
扩展Apollo源码加解密
```java
private void updateConfig(Properties newConfigProperties, ConfigSourceType sourceType) {
    Set<Object> keys = newConfigProperties.keySet();
        for (Object k : keys) {
        String key = k.toString();
        String value = newConfigProperties.getProperty(key);
        // 加密Value
        if (value.startsWith("ENC(") && value.endsWith(")")) {
        logger.debug("加密Value {}", value);
        // 解密然后重新赋值
    try {
        String decryptValue = AesEncryptUtils.aesDecrypt(value.substring(3, value.length()-1), DECRYPT_KEY);
        newConfigProperties.setProperty(key, decryptValue);
        } catch (Exception e) {
            logger.error("加密配置解密失败", e);
        }
        }
        }
    m_configProperties.set(newConfigProperties);
    m_sourceType = sourceType;
}

这里使用了AES来解密,也就是说配置中心的加密内容也需要用相同的加密算法进行加密,至于格式,还是用ENC(xx)来标识这就是一个加密的配置内容。解密之后将解密的明文内容重新赋值到Properties中,其他流程不变。 创建一个加密测试类,加密配置内容,复制存储到Apollo中。

public class Test {
    public static void main(String[] args) {
    String msg = "hello yinjihaun";
    try {
    String encryptMsg = AesEncryptUtils.aesEncrypt(msg, "1111222233334444");
    System.out.println(encryptMsg);
    } catch (Exception e) {
    e.printStackTrace();
    }
    }
}

输出内容如下:

Ke4LIPGOp3jCwbIHtmhmBA==

存储到Apollo中需要用ENC将加密内容包起来,如下:

test.input = ENC(Ke4LIPGOp3jCwbIHtmhmBA==)

还是用之前的代码进行测试,用Config获取和Spring注入的方式可以成功地获取到解密的数据,并且在配置中心修改后也能实时推送到客户端成功解密。

Apollo结合Zuul实现动态路由

网关作为流量的入口,尽量不要频繁地重启,而是选择用默认的路由规则来访问接口,这样每当有新的服务上线时无须修改路由规则。也可以通过动态刷新路由的方式来避免网关重启。 Zuul中刷新路由只需要发布一个RoutesRefreshedEvent事件即可,我们可以监听Apollo的修改,当路由发生变化的时候就发送RoutesRefreshedEvent事件来进行刷新。


@Component
public class ZuulPropertiesRefresher implements ApplicationContextAware {

    private static final Logger logger = LoggerFactory.getLogger(ZuulPropertiesR­efresher.class);

    private ApplicationContext applicationContext;

    @Autowired
    private RouteLocator routeLocator;

    @ApolloConfigChangeListener
    public void onChange(ConfigChangeEvent changeEvent) {
        boolean zuulPropertiesChanged = false;
        for (String changedKey : changeEvent.changedKeys()) {
            if (changedKey.startsWith("zuul.")) {
                zuulPropertiesChanged = true;
                break;
            }
        }

        if (zuulPropertiesChanged) {
            refreshZuulProperties(changeEvent);
        }
    }

    private void refreshZuulProperties(ConfigChangeEvent changeEvent) {
        logger.info("Refreshing zuul properties!");
        this.applicationContext.publishEvent(new EnvironmentChangeEvent(changeEv­ent.changedKeys()));
        this.applicationContext.publishEvent(new RoutesRefreshedEvent(routeLocat­or));
        logger.info("Zuul properties refreshed!");
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }
}

在Apollo配置中心添加下面的路由规则:

zuul.routes.cxytiandi.path = /**
zuul.routes.cxytiandi.url = http://github.com

访问服务地址,可以显示GitHub的内容,然后修改uri的值为http://cxytiandi.com,重新访问服务地址可以看到路由生效了。

自动加载Apollo数据库配置

公共配置需要提取出来,达到多个项目复用的目的。将Druid数据库默认配置提取出来,然后自动加载。


@Slf4j
@UtilityClass
public class EnvironmentUtil {
    private final ConfigPropertySourceFactory configPropertySourceFactory = SpringInjector
            .getInstance(ConfigPropertySourceFactory.class);

    public boolean isApolloEnabled(ConfigurableEnvironment environment) {
        String key = environment.getProperty("apollo.bootstrap.enabled");
        return key == null || "true".equalsIgnoreCase(key);
    }

    /**
     * 添加Apollo配置
     *
     * @param apolloNamespace Apollo公共配置namespace名
     */
    public void addLast(ConfigurableEnvironment environment, String apolloNamespace) {
        if (!isApolloEnabled(environment)) {
            return;
        }
        // 获取environment中的apollo启动配置(在apollo.bootstrap.namespaces中指定的配置)
        PropertySource<?> apolloConfig = environment.getPropertySources().get("ApolloBootstrapPropertySources");
        if (!(apolloConfig instanceof CompositePropertySource)) {
            return;
        }
        Config config = ConfigService.getConfig(apolloNamespace);
        ConfigPropertySource configPropertySource = configPropertySourceFactory.getConfigPropertySource(apolloNamespace, config);
        // 把config加入到bootstrap配置(优先级高于应用的静态application.properties配置)
        ((CompositePropertySource) apolloConfig).addPropertySource(configPropertySource);
    }

    public void addLast(ConfigurableEnvironment environment, String configName, Map<String, Object> map) {
        MapPropertySource ps = new MapPropertySource(configName, map);
        environment.getPropertySources().addLast(ps);
    }

    public void addLast(ConfigurableEnvironment environment, Resource resource) {
        try {
            Properties properties = PropertiesLoaderUtils.loadProperties(resource);
            environment.getPropertySources().addLast(new PropertiesPropertySource(resource.toString(), properties));
        } catch (IOException e) {
            log.error("fail to load resource " + resource, e);
        }
    }

    public void bind(ConfigurableEnvironment environment, Object bean, String... prefixList) {
        if (bean == null || prefixList.length == 0) {
            return;
        }
        MutablePropertySources propertySources = environment.getPropertySources();
        Binder binder = new Binder(ConfigurationPropertySources.from(propertySources),
                new PropertySourcesPlaceholdersResolver(propertySources),
                ApplicationConversionService.getSharedInstance());
        ResolvableType type = ResolvableType.forClass(bean.getClass());
        Bindable<?> target = Bindable.of(type).withExistingValue(bean);
        for (String prefix : prefixList) {
            binder.bind(prefix, target, (BindHandler) new ThrowErrorsBindHandler(BindHandler.DEFAULT));
        }
    }
}

加载默认的数据库配置


@Slf4j
public class DruidEnvLoader implements EnvironmentPostProcessor {

    @Override
    public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
        if (EnvironmentUtil.isApolloEnabled(environment)) {
            log.info("@@load common.mysql");
            // mysql配置
            EnvironmentUtil.addLast(environment, "common.mysql");
        }
        // 加载druid默认配置模板
        EnvironmentUtil.addLast(environment, new ClassPathResource("DataSourceTemplate.properties"));
        // 初始化数据源环境变量
        initDataSourceEnvironment(environment);
    }

    private void initDataSourceEnvironment(ConfigurableEnvironment environment) {
        // 获取druid配置模板
        MutablePropertySources propertySources = environment.getPropertySources();
        // 获取应用的数据库名称列表
        String databases = environment.getProperty("application.databases", "");
        for (String dbName : databases.split("\\s*,\\s*")) {
            if (dbName.isEmpty()) {
                continue;
            }

            // 获取数据库连接串模板
            String jdbcConnStr = environment.getProperty("spring.datasource.druid.url", "");
            Map<String, Object> props = new HashMap<>();
            jdbcConnStr = jdbcConnStr.replaceAll("#host#", "\\${datasource_" + dbName + "_host}");
            jdbcConnStr = jdbcConnStr.replaceAll("#port#", "\\${datasource_" + dbName + "_port:3306}");
            jdbcConnStr = jdbcConnStr.replaceAll("#name#", "\\${datasource_" + dbName + "_name}");
            props.put(String.format("spring.datasource.%s.url", dbName), jdbcConnStr);
            props.put(String.format("spring.datasource.%s.username", dbName), String.format("${datasource_%s_username}", dbName));
            props.put(String.format("spring.datasource.%s.password", dbName), String.format("${datasource_%s_password}", dbName));
            MapPropertySource ps = new MapPropertySource("DataSource_" + dbName, props);
            propertySources.addLast(ps);

            String host = "datasource_" + dbName + "_host";
            if (!environment.containsProperty(host)) {
                throw new BeanCreationException("未找到数据库" + dbName + "的配置,请联系运维人员");
            }
        }
    }
}

Search

    微信好友

    博士的沙漏

    Table of Contents