/*
* This file is part of the Illarion project.
*
* Copyright © 2015 - Illarion e.V.
*
* Illarion is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Illarion is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*/
package illarion.common.config;
import org.bushe.swing.event.EventBus;
import org.jetbrains.annotations.Contract;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;
import org.xmlpull.v1.XmlSerializer;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
import static java.nio.file.StandardOpenOption.*;
/**
* This is the main class for the configuration system. It contains the storage for the configuration values and
* allows to apply changes to those values.
* <p>
* This class is fully thread save as the access is synchronized using a read/write lock. So reading access will work
* mostly without synchronization. How ever in case there are any changes done to the configuration or the
* configuration is saved or load the other parts of the application can't access the data of this configuration and
* are blocked until its save to read again.
* </p>
*
* @author Martin Karing <nitram@illarion.org>
*/
public class ConfigSystem implements Config {
/**
* The encoding used to encode the XML configuration files.
*/
@Nonnull
private static final String ENCODING = "UTF-8"; //$NON-NLS-1$
/**
* The logger instance that takes care for the logging output of this class.
*/
@Nonnull
private static final Logger LOGGER = LoggerFactory.getLogger(ConfigSystem.class);
/**
* The name of the root node in the XML file.
*/
@Nonnull
private static final String ROOT_NAME = "config"; //$NON-NLS-1$
/**
* This flag is set to {@code true} in case any changes where applied
* to the configuration. Only in case those changes got applied the
* configuration file needs to be saved at all.
*/
private boolean changed;
/**
* The load entries of the configuration file.
*/
@Nonnull
private final Map<String, Object> configEntries;
/**
* The file that stores the configuration.
*/
@Nonnull
private final Path configFile;
/**
* This lock is used to synchronize the access to the configuration system
* properly. A read write lock is used here because most of the time the
* configuration will be accessed reading.
*/
@Nonnull
private final ReadWriteLock lock;
/**
* Create a configuration object with a file as source. The configuration
* system will try to load the data from this source.
*
* @param source The configuration file that is supposed to be load
*/
@Deprecated
public ConfigSystem(@Nonnull File source) {
this(source.toPath());
}
public ConfigSystem(@Nonnull Path source) {
configFile = source;
configEntries = new HashMap<>();
lock = new ReentrantReadWriteLock();
loadConfig();
changed = false;
}
@Override
public boolean getBoolean(@Nonnull String key) {
Object value = getObject(key);
if (value == null) {
return false;
}
if (!(value instanceof Boolean)) {
LOGGER.warn("Illegal config entry for: {}", key);
return false;
}
return (Boolean) value;
}
@Override
public double getDouble(@Nonnull String key) {
Object value = getObject(key);
if (value == null) {
return 0.d;
}
if (!(value instanceof Double)) {
LOGGER.warn("Illegal config entry for: {}", key);
return 0.d;
}
return (Double) value;
}
@Nullable
@Override
public Path getPath(@Nonnull String key) {
Object value = getObject(key);
if (value == null) {
return null;
}
if (!(value instanceof String)) {
LOGGER.warn("Illegal config entry for: {}", key);
return null;
}
return Paths.get(value.toString());
}
@Override
public float getFloat(@Nonnull String key) {
Object value = getObject(key);
if (value == null) {
return 0.f;
}
if (!(value instanceof Float)) {
LOGGER.warn("Illegal config entry for: {}", key);
return 0.f;
}
return (Float) value;
}
@Override
public int getInteger(@Nonnull String key) {
Object value = getObject(key);
if (value == null) {
return 0;
}
if (!(value instanceof Integer)) {
LOGGER.warn("Illegal config entry for: {}", key);
return 0;
}
return (Integer) value;
}
@Nullable
public Object getObject(String key) {
Object value;
lock.readLock().lock();
try {
value = configEntries.get(key);
} finally {
lock.readLock().unlock();
}
if (value == null) {
LOGGER.warn("No config entry found for: {}", key);
return null;
}
return value;
}
@Nullable
@Override
public String getString(@Nonnull String key) {
Object value = getObject(key);
if (value == null) {
return null;
}
if (!(value instanceof String)) {
LOGGER.warn("Illegal config entry for: {}", key);
return null;
}
return value.toString();
}
@Override
public void remove(@Nonnull String key) {
configEntries.remove(key);
}
private interface ConfigTypeConverter {
@Nonnull
String getString(@Nonnull Object object);
Object getObject(@Nonnull String string);
}
private abstract static class AbstractConfigTypeConverter implements ConfigTypeConverter {
@Nonnull
@Override
public final String getString(@Nonnull Object object) {
return object.toString();
}
}
private enum ConfigTypes {
BooleanEntry("bool", Boolean.class, new AbstractConfigTypeConverter() {
@Override
public Boolean getObject(@Nonnull String string) {
return Boolean.valueOf(string);
}
}),
ByteEntry("byte", Byte.class, new AbstractConfigTypeConverter() {
@Override
public Byte getObject(@Nonnull String string) {
return Byte.valueOf(string);
}
}),
DoubleEntry("double", Double.class, new AbstractConfigTypeConverter() {
@Nonnull
@Override
public Double getObject(@Nonnull String string) {
return Double.valueOf(string);
}
}),
FileEntry("file", Path.class, new ConfigTypeConverter() {
@Nonnull
@Override
public String getString(@Nonnull Object object) {
return ((Path) object).toAbsolutePath().toString();
}
@Nonnull
@Override
public Path getObject(@Nonnull String string) {
return Paths.get(string);
}
}),
FloatEntry("float", Float.class, new AbstractConfigTypeConverter() {
@Nonnull
@Override
public Float getObject(@Nonnull String string) {
return Float.valueOf(string);
}
}),
IntegerEntry("int", Integer.class, new AbstractConfigTypeConverter() {
@Override
public Integer getObject(@Nonnull String string) {
return Integer.valueOf(string);
}
}),
LongEntry("long", Long.class, new AbstractConfigTypeConverter() {
@Override
public Long getObject(@Nonnull String string) {
return Long.valueOf(string);
}
}),
ShortEntry("short", Short.class, new AbstractConfigTypeConverter() {
@Override
public Short getObject(@Nonnull String string) {
return Short.valueOf(string);
}
}),
StringEntry("string", String.class, new AbstractConfigTypeConverter() {
@Nonnull
@Override
public String getObject(@Nonnull String string) {
return string;
}
});
@Nonnull
private final String typeName;
@Nonnull
private final Class<?> typeClass;
@Nonnull
private final ConfigTypeConverter converter;
ConfigTypes(@Nonnull String typeName, @Nonnull Class<?> typeClass, @Nonnull ConfigTypeConverter converter) {
this.typeClass = typeClass;
this.typeName = typeName;
this.converter = converter;
}
@Nonnull
@Contract(pure = true)
public String getTypeName() {
return typeName;
}
@Nonnull
@Contract(pure = true)
public Class<?> getTypeClass() {
return typeClass;
}
@Nonnull
@Contract(pure = true)
public ConfigTypeConverter getConverter() {
return converter;
}
}
@Override
public void save() {
if (!changed) {
return; // no changes applied
}
if (Files.isDirectory(configFile)) {
LOGGER.warn("Configuration not saved: config file set to illegal value.");
return;
}
lock.writeLock().lock();
try (OutputStream out = new GZIPOutputStream(
Files.newOutputStream(configFile, CREATE, TRUNCATE_EXISTING, WRITE))) {
XmlSerializer serializer = XmlPullParserFactory.newInstance().newSerializer();
serializer.setOutput(out, ENCODING);
serializer.startDocument(ENCODING, true);
serializer.startTag(null, ROOT_NAME);
for (Entry<String, Object> entry : configEntries.entrySet()) {
String key = entry.getKey();
@SuppressWarnings("ConstantConditions") Class<?> valueClass = entry.getValue().getClass();
String value = null;
String type = null;
//noinspection ConstantConditions
for (ConfigTypes configType : ConfigTypes.values()) {
if (configType.getTypeClass().equals(valueClass)) {
type = configType.getTypeName();
value = configType.getConverter().getString(entry.getValue());
break;
}
}
if (value == null) {
continue;
}
serializer.startTag(null, "entry");
serializer.attribute(null, "key", key);
serializer.attribute(null, "type", type);
serializer.attribute(null, "value", value);
serializer.endTag(null, "entry");
}
serializer.endTag(null, ROOT_NAME);
serializer.endDocument();
serializer.flush();
out.flush();
changed = false;
} catch (@Nonnull IOException e) {
LOGGER.error("Configuration not saved: error accessing config file.");
} catch (@Nonnull XmlPullParserException e) {
LOGGER.error("Configuration not saved: Error creating XML serializer");
} finally {
lock.writeLock().unlock();
}
}
@Override
public void set(@Nonnull String key, boolean value) {
set(key, Boolean.valueOf(value));
}
@Override
public void set(@Nonnull String key, double value) {
set(key, Double.valueOf(value));
}
@Override
public void set(@Nonnull String key, @Nonnull Path value) {
set(key, value.toAbsolutePath().toString());
}
@Override
public void set(@Nonnull String key, float value) {
set(key, Float.valueOf(value));
}
@Override
public void set(@Nonnull String key, int value) {
set(key, Integer.valueOf(value));
}
/**
* Set one entry of the configuration file to a new value. In this case the value is a Object value.
*
* @param key the key the value is stored with
* @param value the value that is stored along with the key
*/
public void set(@Nonnull String key, @Nonnull Object value) {
lock.writeLock().lock();
try {
if (value.equals(configEntries.get(key))) {
return;
}
configEntries.put(key, value);
reportChangedKey(key);
} finally {
lock.writeLock().unlock();
}
}
@Override
public void set(@Nonnull String key, @Nonnull String value) {
set(key, (Object) value);
}
/**
* Set the default value for a key. In this case the value is a boolean value. Setting default values does
* basically the same as setting the normal values, but only in case the key has no value yet.
* <p>
* <b>Note:</b> This method is not exposed by the {@link Config} interface.
* </p>
*
* @param key the key the value is stored with
* @param value the value that is stored along with the key
*/
public void setDefault(@Nonnull String key, boolean value) {
if (!(configEntries.get(key) instanceof Boolean)) {
set(key, value);
}
}
/**
* Set the default value for a key. In this case the value is a double value. Setting default values does
* basically the same as setting the normal values, but only in case the key has no value yet.
* <p>
* <b>Note:</b> This method is not exposed by the {@link Config} interface.
* </p>
*
* @param key the key the value is stored with
* @param value the value that is stored along with the key
*/
public void setDefault(@Nonnull String key, double value) {
if (!(configEntries.get(key) instanceof Double)) {
set(key, value);
}
}
/**
* Set the default value for a key. In this case the value is a Path value. Setting default values does basically
* the same as setting the normal values, but only in case the key has no value yet.
* <p>
* <b>Note:</b> This method is not exposed by the {@link Config} interface.
* </p>
*
* @param key the key the value is stored with
* @param value the value that is stored along with the key
*/
public void setDefault(@Nonnull String key, @Nonnull Path value) {
if (!(configEntries.get(key) instanceof String)) {
set(key, value);
}
}
/**
* Set the default value for a key. In this case the value is a float value. Setting default values does
* basically the same as setting the normal values, but only in case the key has no value yet.
* <p>
* <b>Note:</b> This method is not exposed by the {@link Config} interface.
* </p>
*
* @param key the key the value is stored with
* @param value the value that is stored along with the key
*/
public void setDefault(@Nonnull String key, float value) {
if (!(configEntries.get(key) instanceof Float)) {
set(key, value);
}
}
/**
* Set the default value for a key. In this case the value is a integer value. Setting default values does
* basically the same as setting the normal values, but only in case the key has no value yet.
* <p>
* <b>Note:</b> This method is not exposed by the {@link Config} interface.
* </p>
*
* @param key the key the value is stored with
* @param value the value that is stored along with the key
*/
public void setDefault(@Nonnull String key, int value) {
if (!(configEntries.get(key) instanceof Integer)) {
set(key, value);
}
}
/**
* Set the default value for a key. In this case the value is a String value. Setting default values does
* basically the same as setting the normal values, but only in case the key has no value yet.
* <p>
* <b>Note:</b> This method is not exposed by the {@link Config} interface.
* </p>
*
* @param key the key the value is stored with
* @param value the value that is stored along with the key
*/
public void setDefault(@Nonnull String key, @Nonnull String value) {
if (!(configEntries.get(key) instanceof String)) {
set(key, value);
}
}
/**
* Load the configuration from the file system.
*/
private void loadConfig() {
if (!Files.exists(configFile)) {
return;
}
if (Files.isDirectory(configFile)) {
LOGGER.warn("Configuration not loaded: Config file located at invalid location");
return;
}
lock.writeLock().lock();
try (InputStream in = new GZIPInputStream(Files.newInputStream(configFile, READ))) {
XmlPullParser parser = XmlPullParserFactory.newInstance().newPullParser();
parser.setInput(in, ENCODING);
Map<String, Object> loadedMap = new HashMap<>();
int currentTag = parser.nextToken();
while (currentTag != XmlPullParser.END_DOCUMENT) {
if ((currentTag == XmlPullParser.START_TAG) && "entry".equals(parser.getName())) {
String key = null;
String type = null;
String value = null;
int count = parser.getAttributeCount();
for (int i = 0; i < count; i++) {
String name = parser.getAttributeName(i);
//noinspection SwitchStatementWithoutDefaultBranch
switch (name) {
case "key":
key = parser.getAttributeValue(i);
break;
case "type":
type = parser.getAttributeValue(i);
break;
case "value":
value = parser.getAttributeValue(i);
break;
}
}
if ((key != null) && (type != null) && (value != null)) {
Object realValue = null;
//noinspection ConstantConditions
for (ConfigTypes configType : ConfigTypes.values()) {
if (type.equals(configType.getTypeName())) {
realValue = configType.getConverter().getObject(value);
break;
}
}
if (realValue != null) {
loadedMap.put(key, realValue);
}
}
}
currentTag = parser.nextToken();
}
if (loadedMap.isEmpty()) {
LOGGER.warn("Configuration not loaded: no config data load");
return;
}
configEntries.putAll(loadedMap);
} catch (@Nonnull FileNotFoundException e) {
LOGGER.warn("Configuration not loaded: config file disappeared.");
} catch (@Nonnull ClassCastException e) {
LOGGER.error("Configuration not loaded: illegal config data.");
} catch (@Nonnull IOException e) {
LOGGER.error("Configuration not loaded: error accessing the file system.");
} catch (@Nonnull XmlPullParserException e) {
LOGGER.error("Error while creating XML pull parser.", e);
} finally {
lock.writeLock().unlock();
}
}
/**
* Report the change of a entry of the configuration to all listeners set in
* this configuration.
*
* @param key the key that was changed
*/
private void reportChangedKey(@Nonnull String key) {
changed = true;
EventBus.publish(key, new ConfigChangedEvent(this, key));
}
}