Theia:可扩展的注解式配置注入组件

Theia 是一个 java 语言编写的,支持自定义扩展的注解式配置加载与注入组件,旨在以注解的方式加载任何可以被表示成 Properties 对象的配置,并注入给目标对象,同时支持当配置内容发生变更时回调更新。配置文件的来源可以是本地文件、网络,以及第三方配置系统。Theia 默认支持从 ClassPath 加载本地配置文件,并支持以 SPI 的方式扩展以支持更多的配置来源,例如从 ZK 加载配置等。

特性一览:

  • 支持以注解的方式加载多种配置数据源,并注入给配置对象。
  • 支持预注入,预注入会校验配置的合法性,如果不合法则会放弃注入,避免配置出错影响服务的正常运行。
  • 支持配置变更时回调更新,默认关闭,并允许用户配置是否启用。
  • 内置基本类型转换器,用于将 String 类型配置项转换成目标类型对象。
  • 支持自定义类型转换器,以实现一些定制化的类型转换。
  • 支持以原生字符串或 Properties 对象的形式注入。
  • 支持监听注入过程(InjectEventListener)和更新过程(UpdateEventListener)。
  • 支持加载系统环境变量,并注入给配置对象。
  • 支持 ${} 占位符替换,使用指定的配置项替换占位符。
  • 支持以 SPI 的方式扩展以支持更多类型的配置数据源。
  • 对于 Spring 应用,支持自动扫描、加载并初始化配置对象。

开源地址:https://github.com/plotor/theia

快速接入

这里以加载并注入 ClassPath 配置文件 configurable_options.properties 为例,接入过程分为 4 步:

  1. 定义一个实现了 Options 接口的配置类 ExampleOptions;
  2. 为 ExampleOptions 类添加 @Configurable 注解,用于指定配置数据源路径;
  3. 调用 ConfigManager#initialize 方法初始化所有被管理的配置项;
  4. 调用 ConfigManager#getOptions 方法拿到目标 options 实例,以获取对应的配置信息。

ExampleOptions 的部分实现如下,完整实现可以参考源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Configurable(Constants.CP_PREFIX + "configurable_options")
public class ExampleOptions extends AbstractOptions {

private static final long serialVersionUID = -8145624960779711094L;

@Attribute(name = "myFiles")
private File[] files;

@Attribute(defaultValue = "15")
private int number;

@Attribute(name = "property.message")
private String propMessage;

@Attribute(defaultValue = "1780000")
public long longValue;

@Attribute(name = "another.long.value", defaultValue = "1000000")
public long anotherLongValue;

private Double floatingPointNumber;

@Attribute
private String fieldMessage;

@Attribute
private Boolean trueFalse;

@Attribute(name = "list", converter = ListConverter.class)
public List<String> list;

@Attribute(converter = SetConverter.class)
public Set<String> set;

// ... 省略部分实现

@Override
public void update() {
// 当配置发生变更时回调执行此方法
}

@Override
public boolean validate() {
// 此处实现配置校验逻辑
}

}

初始化配置管理器:

1
2
3
4
5
6
7
final ConfigManager configManager = ConfigManager.getInstance();
// 初始化配置管理器
configManager.initialize("org.zhenchao.theia.example");
// 获取 options 实例
final ExampleOptions options = configManager.getOptions(ExampleOptions.class);
// 获取具体的配置项
System.out.println(options.getPropMessage());

好啦,就这么简单,接下去就可以愉快的使用配置项啦!

如果是 Spring 应用,则只需要在对应的 Options 类上添加 @Component 注解,并在 Spring 配置文件中添加如下配置:

1
<bean class="org.zhenchao.theia.SpringInitializer"/>

Spring 框架在启动期间会自动扫描所有被 @Component 注解的配置 Options 类,并完成加载和初始化过程。

使用指南

本小节针对快速接入中的各个步骤进行详细说明。首先来看 步骤 1 ,对于需要注入的 options,需要先实现 Options 接口,或继承 AbstractOptions 抽象类。Options 接口定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface Options extends Serializable {

/**
* This method will be invoked after successfully injected.
*/
void update();

/**
* Validate that the configuration is correctly.
*
* @return {@code true} means correctly, or {@code false}.
*/
boolean validate();

}

其中 Options#update 方法会在成功完成注入时回调,可以用于对配置字段的二次解析。方法 Options#validate 需要由应用自己实现对于配置的合法性校验,该方法会在预注入时调用,如果返回 false 则会放弃后续的正式注入操作,并抛出异常。

然后( 步骤 2 ),需要使用 @Configurable 注解为 options 关联对应的数据源,该注解定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public @interface Configurable {

/** The configuration resource, eg. ZK:/theia/example */
String resource() default "";

/** Alias for {@link #resource()} */
String value() default "";

/**
* Auto configure setting.
*
* {@code true} means the options will be detected and auto injected,
* otherwise you should instantiate and configure the options by manual.
*/
boolean autoConfigure() default true;

/**
* Autoload configuration when found source update.
* {@code true} means ignore the {@link Constants#COMMONS_CONFIG_AUTOLOAD} config,
* default is {@link false}.
*/
boolean autoload() default false;

}

配置项 Configurable#autoConfigure 默认为 true,表示允许 ConfigManager 在初始化时自动实例化并注入配置项值,否则需要由开发人员自己完成实例化,并主动调用 ConfigInjector#configureBean(Options) 方法完成配置项值的注入。

配置项 Configurable#autoload 默认为 false,当设置为 true 时则会在每次配置变更时回调执行 Options#update 方法,而忽略 __commons_config_autoload 配置。该配置项主要应用于加载 raw text 的场景,此时源配置不满足 Properties 文件格式,所以不能简单的添加 __commons_config_autoload=true 配置项以控制是否回调更新,这种场景下可以通过 Configurable#autoload 配置项来默认启用更新。

完成与数据源的关联之后,接下来( 步骤 3 )需要使用 @Attribute 注解为各个字段关联对应的配置项,注解定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public @interface Attribute {

/** Property name */
String name() default "";

/** Alias for {@link #name()} */
String value() default "";

/**
* Configure required.
*
* {@code true} means this field must be configured, otherwise will throw {@link ConfigException}.
*/
boolean required() default true;

/** The default value when missing config. */
String defaultValue() default "";

/** Whether inject this field with {@link java.util.Properties} or {@link String} raw type. */
boolean raw() default false;

/** Convert the string to target field type. */
Class<? extends Converter> converter() default VoidConverter.class;

}

各个配置项说明如下:

  • namevalue:用于将当前 field 与对应的配置项名称进行关联,如果未指定则以当前属性名称作为配置项名称,强烈建议配置。
  • required:表示当前配置项是必须的,默认为 true,如果未指定默认值,且对应的配置项缺失则会抛出 ConfigException 异常。
  • defaultValue:默认值,如果对应的配置项缺失,则采用默认值注入。
  • raw:是否以原生类型(String 或 Properties)进行注入,需要注意的是,一个 options 中只能定义一个 raw=true 的配置项,且与一般的注入方式互斥。
  • converter:自定义类型转换器,会将 String 类型转换成目标类型后再进行注入。

注解 @Attribute 可以修饰 field,也可以修饰 getter 或 setter 方法,如果未明确指定 name,则会基于注解的属性或方法(getter 或 setter)自动计算 name 值,但是强烈建议手动配置 name 值,避免出错。类型转换器不是必须的,配置库内置了对以下类型的自动转换:

类型 转换器 说明
boolean BooleanConverter 用于将字符串转换成 boolean 类型
char CharacterConverter 用于将字符串转换成 char 类型,提取字符串的首字母
byte NumberConverter 用于将字符串转换成 byte 类型,可以使用 @NumberRadix 指定原始值的进制类型,默认为 10 进制
short NumberConverter 用于将字符串转换成 short 类型,可以使用 @NumberRadix 指定原始值的进制类型,默认为 10 进制
int NumberConverter 用于将字符串转换成 int 类型,可以使用 @NumberRadix 指定原始值的进制类型,默认为 10 进制
long NumberConverter 用于将字符串转换成 long 类型,可以使用 @NumberRadix 指定原始值的进制类型,默认为 10 进制
float NumberConverter 用于将字符串转换成 float 类型,可以使用 @NumberRadix 指定原始值的进制类型,默认为 10 进制
double NumberConverter 用于将字符串转换成 double 类型,可以使用 @NumberRadix 指定原始值的进制类型,默认为 10 进制
String StringConverter 以字符串类型进行注入,区别于 raw 类型的 String 注入,后者使用整个配置文件进行注入
Array ArrayConverter 用于将字符串按照英文逗号进行分割,并转换成目标数组类型,仅支持一维数组转换
Date DateConverter 用于将字符串转换成 Date 类型,需要指定 @DatePattern
Calendar CalendarConverter 用于将字符串转换成 Calendar 类型,依赖 DateConverter
Object GenericConverter 将字符串转换成目标类型,相应的类需要具备一个包含 String 类型参数的构造方法

以上转换器无需手动指定,配置库会依据目标类型自动检测,如果手动指定了类型转换器,则优先级更高。

最后( 步骤 4 ),需要调用 ConfigManager#initialize 方法初始化和注入所有的配置项,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
final ConfigManager configManager = ConfigManager.getInstance();
final int count = configManager.initialize("org.zhenchao.theia.manager");
Assert.assertEquals(4, count);
Assert.assertNotNull(configManager.getOptions(Options1.class));
Assert.assertNotNull(configManager.getOptions(Options2.class));
Assert.assertNotNull(configManager.getOptions(Options3.class));
Assert.assertNotNull(configManager.getOptions(Options4.class));
Assert.assertNull(configManager.getOptions(Options5.class));
Assert.assertSame(configManager.getOptions(Options1.class), configManager.getOptions(Options1.class));
Assert.assertNotSame(configManager.getOptions(Options1.class), configManager.getOptions(Options2.class));

// configure by manual
final Options5 options5 = new Options5();
configManager.getInjector().configureBean(options5);
Assert.assertNotNull(configManager.getOptions(Options5.class));
Assert.assertSame(options5, configManager.getOptions(Options5.class));

ConfigManager 在执行初始化(即调用 ConfigManager#initialize 方法)时允许指定扫描 Options 的根包名,如果没有设置则会扫描所有的包,推荐设置。

ConfigManager 提供了 ConfigManager#getOptions 方法用于依据类型获取对应的 options 实例。

工具类 Parser 定义了 Parser#toListParser#toSet 方法,抽象了字符串数组到 List 和 Set 类型的转换,可以依据场景考虑使用。

最后来聊聊监听机制,配置库定义了两种监听器:InjectEventListener 和 UpdateEventListener。其中,InjectEventListener 用于监听注入过程,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface InjectEventListener extends EventListener {

/**
* This method will be invoked before injection.
*
* @param options The options bean that will be injected.
*/
void prevHandle(final Options options);

/**
* This method will be invoked after injection.
*
* @param options The options bean that has been injected.
*/
void postHandle(final Options options);

}

此类监听器会在执行注入过程前后被调用,可以调用 ConfigInjector#registerInjectListener 方法和 ConfigInjector#removeInjectListener 方法分别注册和注销监听器。

UpdateEventListener 则用于监听更新过程,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface UpdateEventListener extends EventListener {

/**
* This method will be invoked before update.
*
* @param options The options bean that will be updated.
*/
void prevHandle(Options options);

/**
* This method will be invoked after update.
*
* @param options The options bean that has been updated.
*/
void postHandle(Options options);

}

此类监听器会在调用 Options#update 方法前后被调用,可以调用 ConfigInjector#registerUpdateListener 方法和 ConfigInjector#removeUpdateListener 方法分别注册和注销监听器。

如何扩展

除了内建对 ClassPath 路径下配置的加载,Theia 还允许用户对支持的配置数据源进行扩展。接入一个新的数据源只需要继承 AbstractSourceProvider 抽象类即可,然后在项目的 /META-INF/services 目录下新建一个名为 org.zhenchao.theia.source.provider.SourceProvider 的文件,添加以下内容:

1
2
org.zhenchao.theia.source.provider.ClasspathSourceProvider
// your source provider class name here

配置库基于 jdk 内置的 SPI 机制加载所有的 SourceProvider。最后调用 ConfUtils#registerPrefix 静态方法注册对应的 prefix 标识即可。

下面以从 zookeeper 加载配置为例演示如何实现扩展,首先继承 AbstractSourceProvider 实现一个 ZkSourceProvider,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
public class ZkSourceProvider extends AbstractSourceProvider implements SourceProvider {

private final CuratorFramework zkClient;

private final Set<Source> sourceRegistry = new HashSet<>();

public ZkSourceProvider() {
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
this.zkClient = CuratorFrameworkFactory.newClient("127.0.0.1:2181", retryPolicy);
this.zkClient.start();
}

@Override
protected Properties doLoadProperties(final Source source, final PropertiesBuilder builder) throws ConfigException {
final Class<?> optionsClass = source.getOptionsClass();
final String resourceName = this.resourceName(source);
log.info("Load zk configuration, resource[{}], options[{}].", resourceName, optionsClass);

try {
final String zkPath = this.toZkPath(resourceName);
final byte[] bytes = zkClient.getData().forPath(zkPath);
if (null == bytes || 0 == bytes.length) {
log.warn("No zk property value resolved, path[{}].", zkPath);
return builder.build();
}
final String data = Bytes.toString(bytes);
if (StringUtils.isBlank(data)) {
log.warn("No zk property value resolved, path[{}].", zkPath);
return builder.build();
}

if (log.isDebugEnabled()) {
log.debug("Get zk property, path[{}], value[{}].", zkPath, data);
}

final Properties properties = this.toProperties(data);
if (!properties.isEmpty()) {
builder.addAll(ConfUtils.toMap(properties));
}
return builder.build();
} catch (Throwable t) {
log.error("Load zk configuration error, resource[{}], optionsClass[{}]", resourceName, optionsClass, t);
throw new ConfigException("load zk configuration error, " +
"resource: " + resourceName + ", options: " + optionsClass, t);
}

}

@Override
public void postLoad(Source source) {
if (!this.tryRegisterListener(source)) {
throw new IllegalStateException("register zk listener error, " +
"resource: " + source.getResourceName() + ", options: " + source.getOptionsClass());
}
}

@Override
protected String resourceName(Source source) {
String resourceName = source.getResourceName();
Validate.isTrue(ConfUtils.isZkResource(resourceName), "invalid zk resource name: " + resourceName);
return resourceName;
}

@Override
public boolean support(Source source) {
return StringUtils.startsWithIgnoreCase(super.resourceName(source), Constants.ZK_PREFIX);
}

@Override
public int priority() {
return 0;
}

private String toZkPath(String resourceName) {
return resourceName.substring(Constants.ZK_PREFIX.length()).trim();
}

/**
* Register zk data change listener.
*
* @param source
* @return
*/
private boolean tryRegisterListener(final Source source) {
if (sourceRegistry.contains(source)) {
return true;
}

final String zkPath = this.toZkPath(this.resourceName(source));
log.info("Register zk data change listener for path[{}].", zkPath);
try {
zkClient.getData()
.usingWatcher((CuratorWatcher) event -> {
final Watcher.Event.EventType eventType = event.getType();
// uninterested zk event
if (!Watcher.Event.EventType.NodeDataChanged.equals(eventType)) {
log.info("Uninterested zk event type: {}, and ignore it.", eventType);
return;
}

final String eventPath = event.getPath();
try {
if (zkPath.equals(eventPath)) {
log.info("Refresh zk configuration, path[{}].", eventPath);
ConfigInjector.getInstance().reload(source);
} else {
log.debug("[{}] unexpected change, and ignore it, path[{}].", zkPath, eventPath);
}
} catch (Throwable t) {
throw new ConfigException("refresh zk configuration error, path: " + eventPath, t);
}
})
.forPath(zkPath);
} catch (Throwable t) {
log.error("Try register zk data change listener error, path: {}", zkPath, t);
return false;
}
sourceRegistry.add(source);
return true;
}
}

然后编写 /META-INF/services/org.zhenchao.theia.source.provider.SourceProvider 文件:

1
2
org.zhenchao.theia.source.provider.ClasspathSourceProvider
org.zhenchao.theia.source.provider.ZkSourceProvider

最后一步,注册 prefix 标识(不区分大小写):

1
ConfUtils.registerPrefix("ZK");

实现原理

Theia 在设计和实现上主要分为两大模块:

  1. 从数据源拉取配置数据,并封装成 Properties 对象;
  2. 基于反射机制从 Properties 对象中获取对应的配置项并注入给目标对象对应的属性上。

同时监听数据源,当数据源更新时以回调的方式更新本地配置。

整体设计图如下:

image

SourceProvider 用于从数据源加载配置数据并封装成 Properties 对象,同时注册到对应数据源的监听器以监听配置更新。ConfigInjector 会解析 options 配置,并从 Properties 中获取对应的配置项,调用类型转换器 Converter 转成目标类型,并最终注入到目标 options 中。

注意事项

  1. 对于同一类 options 而言,不允许注册多个实例,否则会抛出 ConfigException 异常。
  2. 如果希望在注入时支持系统环境变量,可以构造一个 new PropertiesBuilderFactory(true, true) 对象,并调用 ConfigInjector#setBuilderFactory 方法予以设置。
  3. 方法 ConfigInjector#reset 会清空 ConfigInjector 管理的所有 options 实例,但是不会清空对应 options 实例已注入的属性值。
  4. 方法 ConfigManager#reset 会在 ConfigInjector#reset 的基础上清空 ConfigManager 的初始化状态。
  5. raw 类型是唯一的,且与一般类型互斥。
  6. 不允许注入 static 类型的属性。
  7. 自定义类型转换器的优先级高于系统内建的类型转换器,在实现自定义转换器时请保证代码质量。
  8. 尽量认真实现 Options#validate 方法,对配置的正确性严格控制。
  9. 被 ConfigInjector 管理的 options 实例是可能被多线程共享的,最好只允许配置库对实例进行修改。
  10. 请勿在 Listener、Options#updateOptions#validate 中实现阻塞逻辑。

鸣谢

设计灵感来自 zlib-config,在此表示感谢。