An ability to change the parameters of a server in runtime, without recompiling and restarting the server is very useful. These parameters could be thresholds of various values, coefficients of formulas, queue size, and so on -- everything that must remain unchanged between releases, but, if necessary, be modified at any moment. Different companies name these parameters differently, but I prefer to name them Business Configuration.
There are many options for implementing the task of distributing the configuration, I want to share one of the options I implemented - using MongoDB and local caching.
There is an admin panel server, which we want to manage the business configuration.
The servers which require the configs will have a shared database with the admin panel, there are plenty of them.
Applications have 2 databases - Postgres and MongoDB.
We chose MongoDB to store the configuration, because of the dynamic document schema. We decided to store all the types of configuration in a single table, but with different keys, so each record has its own schema and fields.
API servers require the configuration at the startup, so to ensure it's already provided we need to force-set an initial value if it's not there at the server's startup. We decided not to add any notifications on updates from Admin Panel, but let the API Servers poll the configs and cache it locally.
To edit the configuration we decided to keep it simple. So Admin panel will have CRUD operations on configs using JSON representation. And to keep it safe we need to validate the provided json against the existing schema of the configs.
The first thing we need is the API of the provider, it should encapsulate the fetching, deserializing, and caching:
public interface ConfigProvider<T> {
T getConfig();
}
In the implementation, in fact, we access the database and extract a field from the fetched entity. The field we consider to be an independent config.
We know the name of the field in the MongoDB document. It's mapped to a field of the entity in the java class, a getter of which we can call to get the config value.
It’s convenient to make the code generalized, so to access the getter I decided to use LambdaMetafactory.
public static <E, T> Function<E, T> provideGetter(String fieldName, Class<T> targetType, Class<E> sourceType) {
String getterName = "get" + fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);
try {
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodType type = MethodType.methodType(targetType);
MethodHandle virtual = lookup.findVirtual(sourceType, getterName, type);
CallSite callSite = LambdaMetafactory.metafactory(lookup,
"apply",
MethodType.methodType(Function.class),
MethodType.methodType(Object.class, Object.class),
virtual, MethodType.methodType(targetType, sourceType)
);
return (Function<E, T>) callSite.getTarget().invokeExact();
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
In the method above, we convert the getter to a Function that will be used when fetching the config from the entity.
There are two advantages of LambdaMetaFactory:
As already told above, we need to ensure that all the expected data is stored in MongoDB.
To do this, each of the ConfigProviders must set the default value of its parameter.
The generalized config provider class looks like this:
public class ConfigProviderImpl<T, E extends ConfigEntity>
implements ConfigProvider<T> {
private final ConfigService<E> configService;
private final String fieldName;
private final Function<E, T> getter;
private final T defaultValue;
@SuppressWarnings("unchecked")
public ConfigProviderImpl(ConfigService<E> configService,
String fieldName, T defaultValue) {
this.configService = configService;
this.fieldName = fieldName;
this.defaultValue = defaultValue;
this.getter = provideGetter(fieldName,
(Class<T>) defaultValue.getClass(),
configService.getType());
}
public T getConfig() {
return Optional.ofNullable(configService.getConfig())
.map(getter)
.orElse(defaultValue);
}
void ensureCreated() {
configService.ensureCreated(fieldName, defaultValue);
}
}
If, for some reason, the expected field is not set, the getConfig
returns the default value.
The ensureCreated
should be called at the application startup, so we can use a BeanPostProcessor for it.
public Object postProcessAfterInitialization(Object bean, String beanName)
throws BeansException {
if (bean instanceof ConfigProviderImpl) {
((ConfigProviderImpl<?, ?>) bean).ensureCreated();
}
return bean;
}
The only thing left is the implementation of the ConfigService#.ensureCreated(fieldName, defaultValue)
, so let’s jump to the implementation first.
public class ConfigService<E extends ConfigEntity> {
public static final String COLLECTION_NAME = "app_config";
private final MongoOperations mongoOperations;
private final E initial;
...
public void ensureCreated(String fieldName, Object value) {
mongoOperations.upsert(
Query.query(Criteria.where("_id").is(initial.getId())),
Update.update("_id", initial.getId()),
getType(), COLLECTION_NAME);
Query query = Query.query(Criteria
.where("_id").is(initial.getId())
.and(fieldName).isNull());
Update update = new Update();
update.set(fieldName, value);
mongoOperations.findAndModify(query, update, getType(), COLLECTION_NAME);
}
@SuppressWarnings("unchecked")
public Class<E> getType() {
return (Class<E>) initial.getClass();
}
}
As we need to have the type of the entity and its identifier for fetching and creating the configs, we just pass the empty entity with a hardcoded id in it - initial
. The rest is trivial: upsert is used to ensure the entity exists and then we set the field if it’s null, so we don’t override existing values.
Configs are often read and rarely changed so it’s worth caching them. I prefer Guava’s memoizeWithExpiration
here as a handy API and with no overhead costs.
This is what it looks like:
private final Supplier<E> configEntitySupplier =
memoizeWithExpiration(this::fetchConfig, 60, TimeUnit.SECONDS);
...
public E getConfig() {
return configEntitySupplier.get();
}
private E fetchConfig() {
Query query = Query.query(Criteria.where("_id").is(initial.getId()));
return mongoOperations.findOne(query, getType(), COLLECTION_NAME);
}
The entity should implement interface ConfigEntity
, which provides getId
for ConfigService and has a hardcoded id.
For instance:
@Data
public class AppConfigEntity implements ConfigEntity {
private String id = "app_config";
...
The code is generic, so to bind providers and services up we need to create the configuration:
@Bean
public ConfigProvider<ConfigA> appConfigAProvider(ConfigService<AppConfigEntity> appConfigService) {
ConfigA defaultValue = new ConfigA(20, .7);
return new ConfigProviderImpl<>(appConfigService, "configA", defaultValue);
}
@Bean
public ConfigService<AppConfigEntity> appConfigService(MongoOperations mongoOperations) {
return new ConfigService<>(mongoOperations, new AppConfigEntity());
}
... // and so on for each config
And not to forget about bean of the BeanPostProcessor.
Now you can inject ConfigProvider<ConfigA> appConfigAProvider
and access the config!
Using the solution described above is simple and effective for managing the business configuration of applications. It allows you to change the setup of the servers without any frameworks and additional services safely and easily. And also extend the features by creating new provider instances.
The source code is available on github.