ConfiJ is a type-safe, strongly defined, minimal-dependency Java configuration library. It enforces a contract on the configuration as Java-types.
Getting Started
confij-core
itself has no external dependencies.
1. Repository
ConfiJ maven artifacts are available on Maven Central.
repositories {
mavenCentral()
}
dependencies {
implementation 'ch.kk7:confij-core:0.6.6:nodeps'
}
Artifacts with two different classifiers are published. The nodeps
ones do not depend on any non-shadowed 3rd party libs and are generally prefered. The default ch.kk7:confij-core:0.6.6
might introduce dependency issues only if an incompatible lib is already on the classpath (for example an old com.fasterxml:classmate
).
2. Quick Start
interface ServerConfig { (1)
String name();
URL externalUrl();
}
ServerConfig serverConfig = ConfijBuilder.of(ServerConfig.class)
.loadFrom("server.properties") (2)
.build(); (3)
# content of a file named server.properties
name=deepthough
externalUrl=https://example.com:8042/think
1 | Define your configuration type, in this case as a Java-interface |
2 | Load from various sources, in this case from a single properties file |
3 | Bind the source to a configuration instance of matching type |
The corresponding properties file must have the same attributes as in the interface. ConfiJ will make sure no unknown keys are present and bind all configuration strings to their configuration types.
Configuration Sources
3. Source Pipelines
ConfiJ loads sources one after the other. Each consecutive source overrides (merges) attributes of the previous ones. The last source therefore has the highest preference. Typically, this is useful to load a set of default values first, then continue with application specifics and environment specific values getting more and more detailed. Source types can be freely mixed, meaning you can load from classpath, then from a remote file, then from envionment variables and so on.
interface ServerConfig {
String name();
URL externalUrl();
@Default("1")
int line();
@Default("30s") (1)
Duration timeout();
}
ServerConfig serverConfig = ConfijBuilder.of(ServerConfig.class)
.loadFrom("classpath:generic.properties") (2)
.loadFrom("server.properties") (3)
.loadFrom("sys:app") (4)
.build();
1 | Default values are always loaded first. |
2 | then override with a file from the classpath root.
|
3 | then override with a file on disk (this is the first time where name is defined)
|
4 | then override with system properties prefixed by app
|
This will result in the final configuration:
ServerConfig {
externalUrl=https://example.com:8042/think,
line=3,
name=deepthough,
timeout=PT30S
}
Validation generally only applies to the final configuration. It is allowed to have incomplete configurations while there are still more configuration sources to load. However, it is never allowed to have an unknown configuration value. Confij has a default fail-fast strategy such that you don’t have to worry about legacy configs.
4. Default Values
Configuration default can be defined directly in your configuration interface using the @Default
annotation.
For more complex scenarios where the default value is not easily stingifyable,
use a default
method.
interface HasDefaults {
@Default("a default value")
String aString();
@Default("23")
int aNumber();
default List<Boolean> aListOfBooleans() {
return Arrays.asList(true, false);
}
default int aNumberPlus1() {
return aNumber() + 1;
}
}
The default method is called only when no other configuration values is defined.
However only @Default
values can be used for templating.
5. The AnySource
The AnySource
simply takes a string and determines resource location and type based on it.
It is the most compact form to specify a source unless you require special settings.
ConfijBuilder.of(Config.class)
.loadFrom("nested.properties") // properties file in current working directory
.loadFrom(new File("nested.properties").getAbsolutePath()) // equivalent
.loadFrom("file:nested.properties") // equivalent
.loadFrom("classpath:nested${key}.yaml") // a YAML file on the classpath root
.loadFrom("${key}:nestedvalue.yaml") // ...with variable replacements
.loadFrom("sys:some.prefix") // from system properties
.loadFrom("env:some_prefix") // from environment variables
.build();
Variable substitutions are supported anywhere in the template string (see Templating). They take into account the previously loaded configuration keys. Replacement keys are always absolute (relative to the configuration root).
Most commonly the resource location (file, classpath,…) is detected based on a URI scheme (see Section 6.1, “Resource Providers”). The configuration format (yaml, json,…) is usually detected based on the ending.
6. Source as Code
For more complicated scenarios where Chapter 5, The AnySource is not suited, a ConfijSource
must be passed explicitly.
Confij distinguishes between where something is read from (Resource) and what format it is.
ServerConfig serverConfig = ConfijBuilder.of(ServerConfig.class)
.loadFrom(ClasspathResource.ofName("generic.properties"))
.loadFrom(FileResource.ofFile("server.properties"), PropertiesFormat.withoutPrefix())
.loadFrom(SystemPropertiesSource.withPrefix("app"))
.build();
6.1. Resource Providers
Configuration sources that can load from a file can usually load from other input streams as well. For those, the source location and source format can be combined freely.
Chapter 5, The AnySource usually detects the location based on the scheme:
-
file:
- is the default scheme and loads from the local Filesystem -
classpath:
- load from classpath on the default classloader -
http:
or any other registered URL format -
git:
for GIT repositories, see Section 6.2, “GIT Resource Provider”
Register custom resource providers using a Chapter 7, ConfiJ’s ServiceLoaders for ch.kk7.confij.source.any.ConfijAnyResource
.
6.2. GIT Resource Provider
GIT resources require the ch.kk7:confij-git maven artifact.
|
Configuration sources can be fetched via GIT. For simple cases an Chapter 5, The AnySource can be used with a special URL format:
-
a fixed scheme 'git:'
-
followed by the URI-like path to the repository.
-
followed by the path to the file in the repository. If the repository doesn’t end with
.git
, separate the filepath with two slashes//
. -
optionally add a refspec pointing to a commit as fragment (separated from the rest by
#
). The default refspec is HEAD.
# annonymous https GIT repository containing /some/file.yaml
git:https://example.com/repo.git/some/file.yaml
# double slash is required if not ending with .git
git:https://example.com/repo//some/file.yaml
# BasicAuth
git:https://user:password@example.com/repo//some/file.yaml
# annonymous ssh
git:ssh://example.com/annonymous.git/some/file.yaml
# annonymous native git protocol: twice 'git' (sic)
git:git://example.com/annonymous.git/some/file.yaml
# refspec of a commit as SHA1-prefix
git:git://example.com/repo.git/file.yaml#4a4255c
# previous commit of default HEAD (usually the master branch)
git:git://example.com/repo.git/file.yaml#HEAD~1
# file on a GIT-branch named 'fuu'
git:git://example.com/repo.git/file.yaml#refs/heads/fuu
# file tagged with 'v1.0.42'
git:git://example.com/repo.git/file.yaml#refs/tags/v1.0.42
For more complex scenarios like custom SSL authentication consider extending
the existing GitResource
.
6.3. YAML Format
YAML format requires the ch.kk7:confij-yaml maven artifact.
|
YAML is a recommended configuration source format since it maps nicely on a tree-structure. Collection types can be represented as lists or as maps with numerical keys.
YAML can be loaded from all the Section 6.1, “Resource Providers”.
The AnySource
expects a case-insensitive file ending of .yaml
or .yml
.
interface ComplexYaml {
List<String> listOfStrings();
Map<String, Integer> mapOfIntegers();
Map<String, Integer> mapOfIntegersClone();
LinkedList<String> anotherList();
OffsetDateTime date();
@Key("true") boolean isTrue();
@Key("false") boolean isFalse();
}
# complex.yaml
listOfStrings:
- String
- String on
a single line
- "Double quotation marks\t"
mapOfIntegers: &ref
canonical: 12345
decimal: +12_345
sexagesimal: 3:25:45
octal: 030071
hexadecimal: 0x3039
mapOfIntegersClone: *ref
anotherList:
0: null
1: big
date: 2001-12-14t21:59:43.10-05:00
true: true
false: off
If the YAML source file contain multi-docs, they are loaded in order. Internally all values are reduced to Maps of Maps or Strings.
6.4. HOCON/JSON Format
HOCON format requires the ch.kk7:confij-hocon maven artifact.
|
HOCON (Human-Optimized Config Object Notation) is a format for human-readable data, and a superset of JSON and .properties.
HOCON can be loaded from all the Section 6.1, “Resource Providers”.
The AnySourceImpl
expects a case-insensitive file ending of .hocon
, .json
or .conf
.
The default behaviour will first perform substitutions according to the HOCON Specification, before ConfiJ’s templating will kick in.
// HOCON will resolve this ${a} internally
a: 1
b: ${a}" and two"
// HOCON ignores ${a} but ConfiJ will substitute it
a: 1
b: "${a} and two"
6.5. Properties Format
Java properties files are part of the ch.kk7:confij-core
due to not having any 3rd party dependencies,
but are otherwise not recommended, since it is a flat key-value format.
Property files can be loaded from all Section 6.1, “Resource Providers”.
The AnySourceImpl
expects a case-insensitive file ending of .properties
.
interface Config {
String key();
Nested nest(); (1)
List<Nested> listOfNest(); (2)
Map<String, Nested> mapOfNest(); (3)
}
interface Nested {
int x();
int y();
}
# nested.properties
key=value
(1)
nest.x=0
nest.y=1
(2)
listOfNest.0.x=10
listOfNest.0.y=11
listOfNest.1.y=20
listOfNest.2.y=21
(3)
mapOfNest.mykey.x=100
mapOfNest.mykey.y=101
mapOfNest.myotherkey.x=200
1 | Per default, nested configuration keys are separated with a dot from each other. |
2 | Collection types simply require numerical keys (starting at 0). |
3 | Map types can use any key excluding the delimiter. |
6.6. Environment Variables and System Properties Format
Environment variables and system properties work similar to the file based Section 6.5, “Properties Format”.
System properties have to be dot separated, whereas envvars are underline separated.
To read an envvar specify a URI like env:<prefix>
, where every envvar to be considered
must start with that prefix and will have that prefix stripped away.
For example an envvar FUU_bar_xy
with a prefix FUU
is mapped to a property called bar.xy
(case-sensitive).
interface Configs {
String fromEnvvar();
String fromSysprop();
@Default("${env:HOME}")
Path myHome();
@Default("${sys:file.separator}")
String fileSep();
}
> export A_PREFIX_fromEnvvar='some value'
> java -Danother.prefix.fromSysprop="another value" ...
An often used alternative is to rely on Chapter 10, built-in Templating to read specific envvars.
7. ConfiJ’s ServiceLoaders
ConfiJ allows utilizing Java’s ServiceLoader mechanism to register additional services like source formats or validators. All you have to do is to add the file with the service-interface name to META-INF/services. In this service file you list the fully qualified classnames of your constraint validator classes (one per line).
// +file: META-INF/services/ch.kk7.confij.source.file.resource.ConfijAnyResource
public static class FooResource implements ConfijAnyResource {
@Override
public Optional<ConfijResource> maybeHandle(String path) {
return Optional.of((ConfijResource) resolver -> Stream.of(new ResourceContent("foo=bar", path)))
.filter(__ -> path.startsWith("foo:"));
}
}
interface Foo {
String foo();
}
Foo foo = ConfijBuilder.of(Foo.class)
.loadFrom("foo:fuuuuu.properties")
.build();
To change the priority of a service loader instance implement the ServiceLoaderPriority
.
public static class AnUnimportantFoo extends FooResource implements ServiceLoaderPriority {
@Override
public int getPriority() {
return ServiceLoaderPriority.DEFAULT_PRIORITY - 1000;
}
@Override
public Optional<ConfijResource> maybeHandle(String path) {
return Optional.of((ConfijResource) resolver -> Stream.of(new ResourceContent("foo=OTHER", path)))
.filter(__ -> path.startsWith("foo:"));
}
}
Binding
8. Value Mapping
When values are read from a configuration files they are first available as Strings. They are then transparently mapped to Objects or pimitives to match the defined type.
interface MappedConfig {
String aString();
Double pi(); (1)
@Key("int") (2)
int anInt();
int[] intArray(); (3)
@Base64 (4)
byte[] base64();
Duration duration(); (5)
}
# mapped.yaml
aString: a value
pi: 3.141
int: 42
intArray: [1, 2, 3]
base64: AQIDBA==
duration: 7nanoseconds
1 | mapping to primitives or java.lang objects |
2 | configuration keys can be changed if the attribute name doesn’t fit |
3 | arrays are usually mapped from lists (see Chapter 9, Nested Configuration) |
4 | or the default behavior can be changed with a custom ValueMapping . |
8.1. Default Value Mapping
The default mapping behavior attempts to map from string to target type in the following order:
-
if the attribute is annotated with a custom
@ValueMapper
enforce that one (see Section 8.3, “Custom Value Mapping”) -
primitives types, where
null
values are mapped to their empty types: a non-existentint
becomes 0. booleans either have to be lowercasetrue
orfalse
, except for source languages enforcing another definition (like yaml). -
explicit mappings, for example:
java.nio.file.Path java.io.File java.time.Duration java.time.Period java.util.Optional ...
-
all Enum-types based on the Enum’s name
-
classes with a static method named
valueOf(String)
orfromString(String)
-
classes with a constructor taking a single String as input argument
Arrays/Collections are not considered leafs. In order to map a comma separated
configuration string to a string[] you have to annotate it with @Separated .
|
8.2. Built-in Value Mapping
8.2.1. Base64 encoded
@Base64
expects to bind a base64-encoded string to an array or collection of bytes.
interface Base64Encoded {
@Base64
byte[] base64Arr();
@Base64(decoder = Base64Decoder.RFC4648_URLSAFE)
List<Byte> base64List();
}
8.2.2. Comma Separated
@Separated
expects to bind a delimited string to an array or collection of arbitrary type.
interface SeparatedConfig {
@Separated
List<String> commaSeparated();
@Separated(separator = "#")
int[] hashSeparated();
Set<String> usuallyAList();
}
commaSeparated=comma,separated,values
hashSeparated=1#2#3
usuallyAList.0=common
usuallyAList.1=list
usuallyAList.2=notation
8.3. Custom Value Mapping
If the default mapping does not fit or the target type is unknown, you generally have two options: programmatic the ConfiJBuilder or with an annotation on the affected attribute.
If a custom mapping holds for all types in the whole configuration, it’s easiest to set it in the builder when creating the configuration instance.
EmptyColorHolder colorHolder = ConfijBuilder.of(EmptyColorHolder.class)
.bindValuesForClassWith(Color::decode, java.awt.Color.class)
If a custom mapping should only be applied to specific attributes,
annotate them with @ValueMapper(Class)
.
The mapper class must have an accessible empty constructor.
static class ColorDecoder implements ValueMapperFactory {
@Override
public Optional<ValueMapperInstance<?>> maybeForType(BindingType bindingType) {
return Optional.of(Color::decode);
}
}
interface Favourites {
@ValueMapper(ColorDecoder.class)
Color favouriteColor();
}
In order to pass compile time options to the mapper instance,
introduce fresh custom annotation, annotated with @ValueMapper(Class)
.
See for example the implementation of Section 8.2.2, “Comma Separated”.
9. Nested Configuration
Configurations can themselves contain child configurations recursively. Since every configuration is organized in a tree-structure, we call them branches.
interface ServerConfig {
Timing timing();
URL url();
}
interface Timing {
Duration keepAlive();
Duration timeout();
}
Typical branches contain, but are not limited to:
-
nested interfaces (also with generics)
-
Collection of interfaces, Objects (leafs), other Collections,…
-
Array types of Objects, primitives, other Arrays,…
-
Map types where keys are Strings and values are again anything
interface DbConfig {
List<ServerConfig> dbConnections();
Map<String, String> additionalParameters();
byte[] privateKey();
Nested<Timing> defaultTiming();
}
interface Nested<T> {
boolean active();
T wrapped();
}
DbConfig dbConfig = ConfijBuilder.of(DbConfig.class)
.loadFrom(new ExplicitPropertiesSource()
.set("dbConnections.0.url", "https://db0.example.com")
.set("dbConnections.0.timing.keepAlive", "30s")
.set("additionalParameters.somekey", "somevalue"))
.build();
If the standard Collection
interfaces are used ConfiJ will use an immutable representation of it.
But any instance can be used as long as it has an empty constructor (but at the loss of immutability).
Same holds for Map<String,?>
types.
Interface proxies are Java-serializable if all the customized values are serializable, too.
Java Bean types (POJO) are not supported yet. Please contribute. |
Templating
All configuration values might contain placeholders referencing other values. Placeholders are only resolved after all sources were loaded. Therefore it is possible for an unfinished configuration to contain undefined references.
Additionally some source paths are subject to templating (see Chapter 5, The AnySource).
10. built-in Templating
Any property can contain placeholders wrapped in ${ }
.
These referenced properties can themselves reference other properties.
interface Salutation {
@Default("John")
String name();
@Default("Hi ${name}")
String hello();
}
given: name=
yields: hello → Hi John
given: name=Bob
yields: hello → Hi Bob
given: hello=Cya
yields: hello → Cya
To disable templating support, see Chapter 11, Custom Templating.
10.1. Nested Properties
Variables are always relative to the node that defines them.
interface Letter {
Salutation salutation();
@Default("I dearly miss you, ${salutation.name}!") (1)
String body();
Regards regards();
}
interface Regards {
@Default("${.salutation.name}'s friend") (2)
String sender();
@Default("Sincierly\n${sender}")
String regards();
}
Letter letter = ConfijBuilder.of(Letter.class).build();
assertThat(letter.regards().sender()).isEqualTo("John's friend");
assertThat(letter.body()).isEqualTo("I dearly miss you, John!");
1 | Variables can reference nested properties. Default segment separator is a dot.
Note that the reference is still relative to where body is defined. |
2 | To reference a property relative to the root configuration, prefix it with a dot.
In this example Letter is the root. |
10.2. Recursive Properties
Variables themselves can be built out of other variables.
interface Recursive {
@Default("Alice")
String party1();
@Default("Bob")
String party2();
@Default("1")
int victimsId();
@Default("Poor ${party${victimsId}}!")
String victim();
}
11. Custom Templating
It is possible register a custom templating functionality globally or per property. One use case is to disable templating per property.
interface Noop {
@NoopResolver
String canContainDollar();
}
@NoopResolver
interface GlobalNoop {
String canContainDollar();
}
ConfijBuilder.of(BuilderNoop.class).templatingDisabled()
A custom templating engine can be registered in the same ways. Every custom resolver must provide an empty constructor.
static class FooResolver implements ValueResolver {
@Override
public String resolveValue(ConfijNode baseLeaf, String valueToResolve) {
return "foo";
}
}
interface CustomResolver {
@Default("everything becomes foo")
@VariableResolver(FooResolver.class)
String everyVariableIsFoo();
}
Post-Processing
12. Non-Null Validator
A recursive non-null validator is part of core. It verifies that the final configuration has all values set.
It will not complain about null-values per default. To activate it add any @NotNull
or @NonNull
annotation to your config definition.
Add such an annotation to your configuration root to apply it everywhere as non-null is enforced in all child-configuration values as well.
To again allow for null values, use the opposite @Nullable
.
Any annotation named alike will do as long as it is available at runtime.
@NotNull
interface NothingIsNull {
String willCrashIfNull();
@Nullable Long explicitlyNullable();
Optional<Integer> allowedToBeEmpty();
}
It is recommended to use Optional<> to explicitly mark optional configuration values instead of @Nullable .
|
Core provides a @NonNullValidator.Nullable
annotation but you can also restrict yourself to Hibernate annotations in case you are
using the Chapter 13, JSR303 Validator already.
This validator can be disabled for the whole configuration:
ConfijBuilder.of(NothingIsNull.class).validationAllowsNull().build()
13. JSR303 Validator
JSR303 Validator format requires the ch.kk7:confij-validation maven artifact.
|
Straight forward Hibernate-Validation. Validation is applied to the configuration as a last step after templating.
interface Jsr303Validated {
@NotEmpty
String mandatory();
@Pattern(regexp = "[A-Z]*")
String uppercase();
}
The JSR303 validator works together with the Chapter 12, Non-Null Validator, however this means that the semantics of @NonNull
is applied
recursively to all child-nodes, which is usually desirable for configurations.
ch.kk7:confij-validation depends on hibernate-validator >= 6.1 in order to support non-getter properties, too.
|
14. Logging
ConfiJ core relies on java.util.logging
(JUL) only due to it’s zero-dependency strategy.
If ch.kk7:confij-slf4j
is found on the classpath, logging uses the slf4j-framework.
To support a custom logging-framework, register a Chapter 7, ConfiJ’s ServiceLoaders for ConfijLogFactory
.
15. Live Reloading
ConfiJ treats configurations are immutable objects. Using mutable objects like arrays is generally possible, but not advised. In any case ConfiJ will always create a completely new configuration if one of the values changes.
If a reload strategy is used, the ConfiJBuilder.buildWrapper()
must be used, as it allows to register to change notifications.
interface ClientConfig {
Endpoint database();
Endpoint backend();
}
interface Endpoint {
URL url();
Duration timeout();
boolean active();
}
ConfijWrapper<ClientConfig> wrapper = ConfijBuilder.of(ClientConfig.class)
.loadFrom(source)
.reloadPeriodically(Duration.ofSeconds(2))
.buildWrapper();
ClientConfig currentConfig = wrapper.get(); // always the most up-to-date value
You chan be notified on a change on any configuration value (it doesn’t have to be the root).
If one of the nested configuration values changes, all parent objects will be notified, too.
For example if database.active
swaps, a change handler on database
will trigger too.
wrapper.getReloadNotifier().registerReloadHandler(
reloadEvent -> resetHttpClient(reloadEvent.getNewValue()), (1)
currentConfig.backend()); (2)
wrapper.getReloadNotifier().<Boolean>registerReloadHandler(
reloadEvent -> setDbActivityTo(reloadEvent.getNewValue()),
currentConfig.database(), "active"); (3)
1 | A handler receives a reload event, whenever this value (or a child/nested value) changes |
2 | The target object to attach the handler to. Can be any object… |
3 | …except primitives. For which a relative path to a parent object has to be set. |