LOGO

Version Jitpack

Config is an agnostic key-based configuration library, built on top of the concept of holding reference to a value in the configuration instead of holding the value itself.

Map oriented

The library is totally map-oriented, it stores and reads values from Maps, also even if there is a concept of Storage classes, which is the medium of the data storage, and they could store data in any format, most of the common implementations uses Maps and all keys are String-only, this is intentional, and we focus only in storing data in Maps.

All values are serialized upfront before being stored, in other words, when they are sent to a Storage medium, they are already serialized using built-in and custom serializers. Any library capable of rendering Maps could take advantage of Config.

Key based/Pointer oriented

Config uses a pointer-oriented data access and write, this allows data changes to be reflected all across the application, and config-reloading could be simply implemented by using File Watchers.

Agnostic

Config does not care about the final format of the configuration file, it only manages data in a Map. The class responsible for loading and saving this Map in the configuration is the Backend class. It also provides which types are supported to be stored in the Map without the serialization and deserialization process.

We provide common backend implementations for most used configuration languages, such as Json, Yaml, Toml and XML. Since all of those libraries are able to render Maps as Strings, they become very handful for application configuration.

Serialization supported

Serialization of custom types are supported through Serializers registry and Serializer implementation, and it is very easy to implement your own serializer. Also, API provides interfaces for implementing serialization of complex types.

Type information retention

Config depends on types provided by TypeInfo tokens for implementing Serialization for Map, List and user defined types.

This allows generic types to be serialized correctly and using the right serializer, instead of relying on the type of the object itself. This means that types must be explicitly provided, and the information must be retained at runtime.

Getting Started

Config and official backends are available through jitpack.io.

Gradle (Kotlin)

val config_version by project

repositories {
    mavenCentral()
    maven("https://jitpack.io")
}

dependencies {
    implementation("com.github.JonathanxD.Config:Config:$config_version")
    implementation("com.github.JonathanxD.Config:Config-Toml:$config_version") // Toml backend
}

Reading and writing values

Given the following Toml config:

[server]
addr = "0.0.0.0"
port = 8080

You could write something like this to read and write values:

public class ServerConfig {
    private final Config config;
    private final Key<String> addr;
    private final Key<Long> port;
    
    public ServerConfig(Path configPath) {
        ConfigIO io = ConfigIO.path(configPath, StandardCharsets.UTF_8);
        Backend backend = new TomlBackend(io);
        this.config = new Config(backend);
        this.config.load(); // Config file must exists
        
        Key<Void> serverSection = this.config.getRootKey().getKeySection("server"); 
        this.addr = serverSection.getKey("addr", CommonTypes.STRING);
        this.port = serverSection.getKey("port", CommonTypes.LONG);
    }
    
    public String getAddress() {
        return this.addr.getValue();
    }
    
    public long getPort() {
        return this.port.getValue();
    }
    
    public void setPort(long port) {
        this.port.setValue(port);
    }
    
    public void save() {
        this.config.save();
    }
}

Serialization

Config already implements serializers for basic types and date types, and some JwIUtils library types, such as Text and TypeInfo (you could find all here), however, sometimes you want to work with your custom types (or 3rd party types) which does not have default serializers implemented, for this, Config provides Serializers class as registry base for Serializer implementations.

Basic Serializer

record User(String name, String email) {
    
}

public class UserSerializer implements Serializer<User> {
    @Override
    public void serialize(User value,
                          Key<User> key,
                          TypeInfo<?> typeInfo,
                          Storage storage,
                          Serializers serializers) {
        key.getKey("name", String.class).setValue(value.name());
        key.getKey("email", String.class).setValue(value.email());
    }

    @Override
    public User deserialize(Key<User> key,
                              TypeInfo<?> typeInfo,
                              Storage storage,
                              Serializers serializers) {

        String name = key.getKey("name", String.class).getValue();
        String email = key.getKey("email", String.class).getValue();

        return new User(name, email);
    }
}

Also, sometimes you just want to store a single value, for this you could use Key.getAs to transform the Key type:

record User(String name) {
    
}

public class UserSerializer implements Serializer<User> {
    @Override
    public void serialize(User value,
                          Key<User> key,
                          TypeInfo<?> typeInfo,
                          Storage storage,
                          Serializers serializers) {
        key.getAs(String.class).setValue(value.name());
    }

    @Override
    public User deserialize(Key<User> key,
                              TypeInfo<?> typeInfo,
                              Storage storage,
                              Serializers serializers) {

        return new User(key.getAs(String.class).getValue());
    }
}

Calling other serializers

Config automatically invoke other serializers to proceed with the serialization when a value is not supported by the backend, so the code below will work correctly and serialize the LocalDate as well.

record User(LocalDate registrationDate, String name) {
    
}

public class UserSerializer implements Serializer<User> {
    @Override
    public void serialize(User value,
                          Key<User> key,
                          TypeInfo<?> typeInfo,
                          Storage storage,
                          Serializers serializers) {
        key.getKey("registrationDate", LocalDate.class).setValue(value.email());
        key.getKey("name", String.class).setValue(value.name());
    }

    @Override
    public User deserialize(Key<User> key,
                            TypeInfo<?> typeInfo,
                            Storage storage,
                            Serializers serializers) {

        LocalDate registrationDate = key.getKey("registrationDate", LocalDate.class).getValue();
        String name = key.getKey("name", String.class).getValue();

        return new User(registrationDate, name);
    }
}

However, when working with complex types which need an intermediate storage, this is not enough. For these cases, you could use the Serializers provided to serialize and deserialize methods. See below a hardcore version of serialize which does the same thing as the version above, however using intermediate storage:

record User(LocalDate registrationDate, String name) {

}

public class UserSerializer implements Serializer<User> {
    
    @Override
    public void serialize(User value, Key<User> key, TypeInfo<?> typeInfo, Storage storage, Serializers serializers) {
        Map<Object, Object> newMap = new LinkedHashMap<>();
        Map<String, Object> temp = new LinkedHashMap<>();

        Storage newStorage = Storage.createMapStorage(key, temp);

        {
            Key<LocalDate> regDateKey = key.getAs("registrationDate", LocalDate.class, newStorage);
            Object date = serializers.serializeUncheckedAndGet(value.getRegistrationDate(), regDateKey);
            temp.clear();
            newMap.put("registrationDate", date);
        }

        {
            Key<String> nameKey = key.getAs("name", String.class, newStorage);
            Object name = serializers.serializeUncheckedAndGet(value.getName(), nameKey);
            temp.clear();
            newMap.put("name", name);
        }

        storage.pushValue(key, newMap);
    }

    @Override
    public User deserialize(Key<User> key,
                            TypeInfo<?> typeInfo,
                            Storage storage,
                            Serializers serializers) {

        LocalDate registrationDate = key.getKey("registrationDate", LocalDate.class).getValue();
        String name = key.getKey("name", String.class).getValue();

        return new User(registrationDate, name);
    }
}

This approach is used by ListSerializer and MapSerializer to correctly serialize values in a controlled Storage medium. When you call serializers.serialize* with this intermediate storage, instead of serializing the value inside the Storage provided to serialize method, it does by serializing the value inside the provided Storage medium, thus serializing the value inside a controlled context, which will not overwrite or modify the values already stored.

With this, introspecting the values and changing the structure of them is more safe, as it will not damage the main storage medium (commonly the Config storage), and at in the end of serialization process, you can push to the main storage only the values you care about.

Backend class

Config itself does not implement any configuration format, instead, it backs the configuration loading and saving logic to a backend.

Currently, the following backend implementations are officially supported:

You could choose one of these backends to load and save your configuration. Also, you are free to write your own Backend implementation, as they are very simple. Config works solely with Java Map, List, String and primitive types.

Also, Config is not a real-time configuration editor, it will not save the configuration for every update that occurs in the Config object, you need to manually save and load using Config.save and Config.load. If you need a real-time configuration editor with reload capabilities, you could easily write a File Watcher or Scheduler to reload configuration using those methods.

Jackson Backend

Writes and reads json using Jackson ObjectMapper.

Jackson-XML Backend

Writes and reads XML using Jackson XmlMapper.

Json backend

Writes and reads json using JSON.simple.

Yaml Backend

Writes and reads Yaml 1.1 using snakeyaml.

Toml Backend

Reads TOML compliant to 1.0.0-rc.1 using tomlj and writes TOML using a custom implementation of TOML rendering.

Note

The Toml backend is able to read any TOML configuration file compliant to 1.0.0-rc.1, however, for writing Toml config, it uses an experimental renderer as there is no recent and maintained Toml library for Java which allows Toml rendering.

This means that Toml files saved by TOML backend ends up being different from the original, and is not rendered in the best way it could be following the Toml standards.

YAML 1.2 Backend

Reads and writes Yaml 1.2 using snakeyaml-engine.

Note

There is a Yaml 1.2 backend which uses snakeyaml-engine to read and write yaml files, however, since Config is fully map-oriented, yaml files which does not have any keys, like this one:

- First
- Second
- Third

Are loaded normally, but an intermediate section is created, named .. This section allows loading those values normally, and rendering them is made through a special logic which keeps this structure (as long as there is no new keys defined).

Also, Config calls the Backend to resolve the root key, then the Yaml backend resolve the . as default key, thus allowing to load those values seamlessly without careing about the intermediate section:

public class ConfigLoader {
    public static Config loadYaml() {
        // Yaml loading logic...
        Config config = new Config(new YamlBackend(...));
        TypeInfo<List<String>> stringListTypeInfo = TypeInfo.builderOf(List.class).of(String.class).buildGeneric();
        Key<List<String>> values = config.getRootKey().getAs(stringListTypeInfo);
    }
}

It is important to know this, because creating a configuration with a key named . with a List value will trigger this behavior. But only for Yaml 1.2 backend.

Maps inside the List

When there is a map inside the list, like this:

- First
- Second
- Third
- Somedata: value

Config will be able to handle this situation, but with limited capabilities. It is able to resolve the somedata key and change its value:

public class ConfigLoader {
    public static Config loadYaml() {
        // Yaml loading logic...
        Config config = new Config(new YamlBackend(...));
        Key<List<Object>> values = config.getRootKey().getAs(CommonTypes.LIST_OF_OBJECT);
        Key<String> somedata = values.getKey("somedata", String.class);
        somedata.setValue("newValue");
    }
}

It is able to create new maps inside the list when needed:

public class ConfigLoader {
    public static Config loadYaml() {
        // Yaml loading logic...
        Config config = new Config(new YamlBackend(...));
        Key<List<Object>> values = config.getRootKey().getAs(CommonTypes.LIST_OF_OBJECT);
        Key<String> somedata = values.getKey("somedata2", String.class); // new map is created to handle this key
        somedata.setValue("newValue2"); 
    }
}

And access and change values in a specific index of a list (which must exists):

public class ConfigLoader {
    public static Config loadYaml() {
        // Yaml loading logic...
        Config config = new Config(new YamlBackend(...));
        Key<List<Object>> values = config.getRootKey().getAs(CommonTypes.LIST_OF_OBJECT);
        Key<String> index0 = IndexKey.forKeyAndIndex(values, 0);
        index0.setValue("newValue2"); 
    }
}

However, not all features that Yaml 1.2 supports were tested.

Config class

Config is a fully map-based Storage implementation, it uses a LinkedHashMap as storage medium, while checks for supported value types using Backend.supports. Thus, every value stored in this class is directly stored in the wrapped LinkedHashMap, and when configuration need to be saved to file, it just sends an unmodifiable copy of this map to the backend (it must be a copy and unmodifiable to avoid concurrency issues).

Also, Config is not Thread-safe by default, this means that to concurrently write values, you need to implement a locking logic to allow only one modification at a time. We plan to provide a concurrent capable implementation in the future.

Key

The most important class of the Config library. A Key represents a pointer to a value in the configuration file, tied to a key-path. With this pointer you are able to read and write values directly to the Storage medium without even needing to hold a reference to the Storage itself. Also, you could have different pointers to the same value, and changing a value through one pointer will be reflected in all other pointers (they are not really reflected, they just read the value from the same path, so if the value changes, anyone pointing to this path has access to the new value).

KeySpec

Is a specification of a Key name and type, with KeySpec you refer to a part of the key path. It is used to implement a more readable Key access with constant path parts.

Serializers

Config also supports serializers. This is very important for the library as it only works with Maps, Lists, Strings and primitive types (different backends could provide support for others types, but the official ones does not).

Serializers are registered and provided through Serializers class, custom serializers must implement Serializer interface and be registered in Config.getSerializers.