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(changedKeys));
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(ZuulPropertiesRefresher.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(changeEvent.changedKeys()));
this.applicationContext.publishEvent(new RoutesRefreshedEvent(routeLocator));
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 + "的配置,请联系运维人员");
}
}
}
}