-
Notifications
You must be signed in to change notification settings - Fork 21
Description
Goal: Give users more control over how configurations are updated.
The load
process currently has the following properties over which developers have little control:
- If a configuration file contains a node that does not correspond to some configuration element, this node is simply discarded.
- If a node that corresponds to some configuration element is missing in the configuration file, the default value of that configuration element is used.
- If a node contains an invalid value or a value that is not supported any longer (e.g. some
enum
constant), there is no way to replace that value during the loading process.
In many cases, the current behavior is not a problem as users can manually update a configuration file to a newer version. However, it would be convenient for users if this library allowed developers to implement automatic updates for their configurations.
A simple example is the renaming of configuration elements: Currently, if a configuration element (e.g. a class field) of some configuration type is renamed without manually updating the configuration file prior to loading a configuration of that configuration type, the information that was stored in the configuration file for that element is simply lost, as there is no way for developers to access this information during loading.
Implementation-wise, the easiest way to change all three properties listed above is to give developers full access to the Map
instance returned by the YAML parser and let them do whatever they want with it. While this solution is easy to implement, it has two major drawbacks: First, accessing (deeply) nested values involves quite a lot of casting. Second, map keys used by developers might not have a mapping even though they appear to have one, possibly causing errors at runtime or confusion at development. The second problem is mostly caused by number types: Because the map instance returned by the YAML parser would, once this feature is implemented, only contain valid target types as map keys (in the case of numbers Long
, and Double
), a call like, for example map.get(1)
would always return null
, as 1
is of type int
and would, therefore, never have a mapping.
A proper solution would be to transform the Map
instance returned by the YAML parser to some internal tree-like or node-based representation and to then give developers access to this representation only. While this might result a clean API that abstracts away the underlying map and remedies the problems mentioned above, implementing such an API requires a lot of effort as major parts of the current implementation of this library would have to be rewritten. Also, the transformation of map instances to such a node-based representation might add a lot of computation and memory overhead at runtime.
A middle way can be reached by providing a small wrapper around the map instance. That wrapper can provide an API for accessing nested values without the need for casting and also support common operations like put
or rename
for adding or moving values around, respectively.
For example, this new Update-API could introduce a MapView
interface whose implementation serves as a wrapper for the Map
instance returned by the YAML parser, and a MapViewActions
class that contains factories for common operations. These factories could return Consumer
s which allows chaining them together via the andThen
method of the Consumer
interface (see second example).
public interface MapView {
interface Key {}
static Key key(String... parts) { /* return key */ }
Map<?, ?> getDelegate();
void put(Key key, Object value);
Object remove(Key key);
Object get(Key key);
Boolean getBoolean(Key key);
Long getLong(Key key);
Double getDouble(Key key);
String getString(Key key);
List<?> getList(Key key);
Map<?, ?> getMap(Key key);
}
public final class MapViewActions {
public static Consumer<MapView> put(Key key, Object value) {
return view -> { /* do stuff */ };
}
public static Consumer<MapView> remove(Key key) {
return view -> { /* do stuff */ };
}
public static Consumer<MapView> move(Key fromKey, Key toKey) {
return view -> { /* do stuff */ };
}
public static Consumer<MapView> copy(Key fromKey, Key toKey) {
return view -> { /* do stuff */ };
}
}
Using these two classes, developers could write a custom Consumer
that serves as an update function for a given configuration. Once written, that Consumer
could then be passed to this library via a ConfigurationProperties
object. For example, for a configuration type that has a configuration element (i.e. a class field or a record component) with name version
, the update function could use that field to bring some configuration file up-to-date to the newest version, as shown below.
public final class Example {
private static final String CURRENT_VERSION = "1.2.0";
public static void main(String[] args) {
final Consumer<MapView> updater = view -> {
final Key versionKey = key("version");
String version = view.getString(versionKey);
while (!CURRENT_VERSION.equals(version)) {
if (version == null) throw new RuntimeException("Missing version");
switch (version) {
case "1.0.0" -> update_v1_0_0_to_v1_1_0().accept(view);
case "1.1.0" -> update_v1_1_0_to_v1_2_0().accept(view);
default -> throw new RuntimeException("Invalid version: " + version);
}
version = view.getString(versionKey);
}
};
final ConfigurationProperties properties = ConfigurationProperties.newBuilder()
.setUpdater(updater)
.build();
// use this properties object when loading the configuration...
}
private static Consumer<MapView> update_v1_0_0_to_v1_1_0() {
return MapViewActions.put(key("a", "b"), "SOME_VALUE")
.andThen(copy(key("a", "b"), key("a", "c")))
.andThen(move(key("a", "b"), key("a", "d")))
.andThen(remove(key("a", "c")))
.andThen(put(key("version"), "1.1.0"));
}
private static Consumer<MapView> update_v1_1_0_to_v1_2_0() {
return MapViewActions.put(key("version"), "1.2.0");
}
}
An API to make version updates easier could additionally be added as part of this new Update-API or in a future release.