Theia is a Java-based, extensible annotation-driven configuration loading and injection component. It is designed to load any configuration that can be represented as a Properties object using annotations and inject it into target objects, with support for callback updates when configuration content changes. Configuration sources can include local files, network resources, and third-party configuration systems. Theia supports loading local configuration files from the ClassPath by default and allows extension via SPI to support additional configuration sources, such as loading configuration from ZooKeeper.
- Supports loading configuration from multiple data sources via annotations and injecting into configuration objects.
- Supports pre-injection, which validates configuration legality and aborts injection if invalid, preventing configuration errors from affecting service operation.
- Supports callback updates when configuration changes (disabled by default, user-configurable).
- Built-in type converters for converting String type configuration items to target type objects.
- Supports custom type converters for implementing customized type conversions.
- Supports injection in the form of raw strings or Properties objects.
- Supports listening to the injection process (InjectEventListener) and update process (UpdateEventListener).
- Supports loading system environment variables and injecting into configuration objects.
- Supports
${}placeholder replacement, using specified configuration items to replace placeholders. - Supports extension via SPI to accommodate more types of configuration data sources.
- For Spring applications, supports automatic scanning, loading, and initialization of configuration objects.
Here's an example of loading and injecting a ClassPath configuration file configurable_options.properties. The integration process consists of 4 steps:
- Define a configuration class
ExampleOptionsthat implements theOptionsinterface; - Add the
@Configurableannotation to theExampleOptionsclass to specify the configuration data source path; - Call the
ConfigManager#initializemethod to initialize all managed configuration items; - Call the
ConfigManager#getOptionsmethod to get the target options instance and retrieve the corresponding configuration information.
Partial implementation of ExampleOptions is shown below. For the complete implementation, please refer to the source code:
@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;
// ... partial implementation omitted
@Override
public void update() {
// This method will be called back when configuration changes
}
@Override
public boolean validate() {
// Implement configuration validation logic here
}
}Initialize the configuration manager:
final ConfigManager configManager = ConfigManager.getInstance();
// Initialize the configuration manager
configManager.initialize("org.zhenchao.theia.example");
// Get the options instance
final ExampleOptions options = configManager.getOptions(ExampleOptions.class);
// Get specific configuration items
System.out.println(options.getPropMessage());That's it! Now you can use the configuration items!
For Spring applications, simply add the @Component annotation to the corresponding Options class and add the following configuration to the Spring configuration file:
<bean class="org.zhenchao.theia.SpringInitializer"/>The Spring framework will automatically scan all Options classes annotated with @Component during startup and complete the loading and initialization process.
This section provides detailed explanations for each step in the quick start guide. First, let's look at Step 1: For options that need injection, you must first implement the Options interface or extend the AbstractOptions abstract class. The Options interface is defined as follows:
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();
}The Options#update method will be called back after successful injection and can be used for secondary parsing of configuration fields. The Options#validate method needs to be implemented by the application to validate the configuration. This method is called during pre-injection, and if it returns false, the subsequent formal injection operation will be aborted and an exception will be thrown.
Then (Step 2), you need to use the @Configurable annotation to associate the options with the corresponding data source. The annotation is defined as follows:
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;
}The configuration item Configurable#autoConfigure defaults to true, meaning the ConfigManager is allowed to automatically instantiate and inject configuration values during initialization. Otherwise, developers need to complete the instantiation themselves and actively call the ConfigInjector#configureBean(Options) method to inject configuration values.
The configuration item Configurable#autoload defaults to false. When set to true, the Options#update method will be called back on every configuration change, ignoring the __commons_config_autoload configuration. This configuration item is mainly used for loading raw text scenarios, where the source configuration does not conform to the Properties file format, so you cannot simply add the __commons_config_autoload=true configuration item to control whether to callback for updates. In such scenarios, you can enable updates by default through the Configurable#autoload configuration item.
After associating with the data source, the next step (Step 3) is to use the @Attribute annotation to associate each field with the corresponding configuration item. The annotation is defined as follows:
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;
}Description of each configuration item:
nameandvalue: Used to associate the current field with the corresponding configuration item name. If not specified, the current attribute name will be used as the configuration item name. Configuration is strongly recommended.required: Indicates whether the current configuration item is required. Defaults totrue. If no default value is specified and the corresponding configuration item is missing, aConfigExceptionwill be thrown.defaultValue: Default value. If the corresponding configuration item is missing, the default value will be used for injection.raw: Whether to inject in raw type (String or Properties). Note that only oneraw=trueconfiguration item can be defined in an options, and it is mutually exclusive with the general injection method.converter: Custom type converter that will convert the String type to the target type before injection.
The @Attribute annotation can be applied to fields, as well as getter or setter methods. If name is not explicitly specified, the name value will be automatically calculated based on the annotated attribute or method (getter or setter). However, it is strongly recommended to manually configure the name value to avoid errors. Type converters are not mandatory. The configuration library has built-in automatic conversion for the following types:
| Type | Converter | Description |
|---|---|---|
| boolean | BooleanConverter | Converts string to boolean type |
| char | CharacterConverter | Converts string to char type, extracts the first character of the string |
| byte | NumberConverter | Converts string to byte type, can use @NumberRadix to specify the radix type of the original value, defaults to decimal |
| short | NumberConverter | Converts string to short type, can use @NumberRadix to specify the radix type of the original value, defaults to decimal |
| int | NumberConverter | Converts string to int type, can use @NumberRadix to specify the radix type of the original value, defaults to decimal |
| long | NumberConverter | Converts string to long type, can use @NumberRadix to specify the radix type of the original value, defaults to decimal |
| float | NumberConverter | Converts string to float type, can use @NumberRadix to specify the radix type of the original value, defaults to decimal |
| double | NumberConverter | Converts string to double type, can use @NumberRadix to specify the radix type of the original value, defaults to decimal |
| String | StringConverter | Injects as string type, different from raw type String injection, the latter uses the entire configuration file for injection |
| Array | ArrayConverter | Splits the string by comma and converts to the target array type, only supports one-dimensional array conversion |
| Date | DateConverter | Converts string to Date type, requires specifying @DatePattern |
| Calendar | CalendarConverter | Converts string to Calendar type, depends on DateConverter |
| Object | GenericConverter | Converts string to target type, the corresponding class needs to have a constructor with a String type parameter |
The above converters do not need to be specified manually; the configuration library will automatically detect based on the target type. If a type converter is manually specified, it will have higher priority.
Finally (Step 4), you need to call the ConfigManager#initialize method to initialize and inject all configuration items, as follows:
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));When ConfigManager performs initialization (i.e., calling the ConfigManager#initialize method), you can specify the root package name for scanning Options. If not set, all packages will be scanned. Setting it is recommended.
ConfigManager provides the ConfigManager#getOptions method to get the corresponding options instance by type.
The utility class Parser defines Parser#toList and Parser#toSet methods, abstracting the conversion from string arrays to List and Set types, which can be used as needed.
Finally, let's discuss the listener mechanism. The configuration library defines two types of listeners: InjectEventListener and UpdateEventListener. InjectEventListener is used to listen to the injection process and is defined as follows:
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);
}This type of listener will be called before and after the injection process. You can call ConfigInjector#registerInjectListener method and ConfigInjector#removeInjectListener method to register and unregister listeners respectively.
UpdateEventListener is used to listen to the update process and is defined as follows:
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);
}This type of listener will be called before and after calling the Options#update method. You can call ConfigInjector#registerUpdateListener method and ConfigInjector#removeUpdateListener method to register and unregister listeners respectively.
In addition to built-in support for loading configuration from the ClassPath, Theia also allows users to extend the supported configuration data sources. To integrate a new data source, simply extend the AbstractSourceProvider abstract class, then create a file named org.zhenchao.theia.source.provider.SourceProvider in the project's /META-INF/services directory with the following content:
org.zhenchao.theia.source.provider.ClasspathSourceProvider
// your source provider class name here
The configuration library loads all SourceProvider instances based on the JDK's built-in SPI mechanism. Finally, call the ConfUtils#registerPrefix static method to register the corresponding prefix identifier.
Below is an example demonstrating how to implement an extension by loading configuration from ZooKeeper. First, extend AbstractSourceProvider to implement a ZkSourceProvider, as follows:
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;
}
}Then create the /META-INF/services/org.zhenchao.theia.source.provider.SourceProvider file:
org.zhenchao.theia.source.provider.ClasspathSourceProvider
org.zhenchao.theia.source.provider.ZkSourceProvider
Finally, register the prefix identifier (case-insensitive):
ConfUtils.registerPrefix("ZK");Theia's design and implementation are mainly divided into two major modules:
- Fetch configuration data from data sources and encapsulate it into Properties objects;
- Use reflection mechanism to retrieve corresponding configuration items from Properties objects and inject them into the corresponding properties of target objects.
At the same time, it listens to data sources and updates local configuration via callbacks when data sources are updated.
The overall design diagram is as follows:
SourceProvider is used to load configuration data from data sources and encapsulate it into Properties objects, while registering listeners to the corresponding data sources to monitor configuration updates. ConfigInjector parses options configuration, retrieves corresponding configuration items from Properties, calls type converter Converter to convert to the target type, and finally injects into the target options.
- For the same type of options, registering multiple instances is not allowed, otherwise a
ConfigExceptionwill be thrown. - If you want to support system environment variables during injection, you can construct a
new PropertiesBuilderFactory(true, true)object and set it by calling theConfigInjector#setBuilderFactorymethod. - The
ConfigInjector#resetmethod will clear all options instances managed byConfigInjector, but will not clear the already injected attribute values of the corresponding options instances. - The
ConfigManager#resetmethod will clear the initialization state ofConfigManagerin addition to whatConfigInjector#resetdoes. - The raw type is unique and mutually exclusive with general types.
- Injecting static type attributes is not allowed.
- Custom type converters have higher priority than system built-in type converters. Please ensure code quality when implementing custom converters.
- Carefully implement the
Options#validatemethod to strictly control the correctness of configuration. - Options instances managed by
ConfigInjectormay be shared by multiple threads, so it's best to only allow the configuration library to modify instances. - Do not implement blocking logic in Listeners,
Options#update, andOptions#validate.
Design inspiration comes from zlib-config. Thanks for the contribution.
