//////////////////////////////////////// // Programming project code // UniBw M, 2022, 2023, 2024 // www.unibw.de/inf2 // (c) Mark Minas (mark.minas@unibw.de) //////////////////////////////////////// package pp.util.config; import java.io.File; import java.io.FileReader; import java.io.IOException; import java.io.Reader; import java.lang.System.Logger; import java.lang.System.Logger.Level; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.Target; import java.lang.reflect.Array; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.function.Function; import static java.lang.annotation.ElementType.FIELD; import static java.lang.annotation.RetentionPolicy.RUNTIME; /** * Abstract base class for representing configurations that can be read from properties files. * Subclasses can define fields annotated with {@link Property} to specify the configuration keys * and optionally {@link Separator} to specify custom separators for array values. */ public abstract class Config { private static final String BLANK_SEQ = " *"; /** * Annotation for specifying the property key for a field. */ @Retention(RUNTIME) @Target(FIELD) @Documented protected @interface Property { /** * The key of the property. */ String value(); } /** * Annotation for specifying a custom separator for array values. */ @Retention(RUNTIME) @Target(FIELD) @Documented protected @interface Separator { /** * The separator for array values. */ String value(); } private static final Logger LOGGER = System.getLogger(Config.class.getName()); private static final Map, Function> CONVERTER_MAP = new HashMap<>(); static { CONVERTER_MAP.put(String.class, x -> x); CONVERTER_MAP.put(byte.class, Byte::parseByte); CONVERTER_MAP.put(short.class, Short::parseShort); CONVERTER_MAP.put(int.class, Integer::parseInt); CONVERTER_MAP.put(long.class, Long::parseLong); CONVERTER_MAP.put(boolean.class, Boolean::parseBoolean); CONVERTER_MAP.put(float.class, Float::parseFloat); CONVERTER_MAP.put(double.class, Double::parseDouble); CONVERTER_MAP.put(Byte.class, Byte::parseByte); CONVERTER_MAP.put(Short.class, Short::parseShort); CONVERTER_MAP.put(Integer.class, Integer::parseInt); CONVERTER_MAP.put(Long.class, Long::parseLong); CONVERTER_MAP.put(Boolean.class, Boolean::parseBoolean); CONVERTER_MAP.put(Float.class, Float::parseFloat); CONVERTER_MAP.put(Double.class, Double::parseDouble); } /** * Reads the specified properties file and sets the values of this config using {@link #readFrom(Properties)}. * * @param file the properties file to read * @throws IOException if an I/O error occurs * @see #readFrom(Properties) */ public void readFrom(File file) throws IOException { try (Reader reader = new FileReader(file)) { final Properties properties = new Properties(); properties.load(reader); readFrom(properties); } } /** * Sets the values of fields annotated with {@link Property} using the specified properties. * Array fields can be split into components using the default separator (",") or a custom separator * specified with {@link Separator}. * * @param props the properties to read from */ public void readFrom(Properties props) { for (Class clazz = getClass(); Config.class.isAssignableFrom(clazz); clazz = clazz.getSuperclass()) { for (Field field : clazz.getDeclaredFields()) { final Property keyAnnot = field.getAnnotation(Property.class); if (keyAnnot != null && props.containsKey(keyAnnot.value())) { try { final String text = props.getProperty(keyAnnot.value()); final Object value = createValue(text, field); setField(field, value); } catch (IllegalAccessException ex) { LOGGER.log(Level.ERROR, "Cannot access " + field, ex); //NON-NLS } } } } } /** * Reads the specified properties file and sets the values of this config if the file exists, * otherwise uses default values. This method is a convenience version of {@link #readFrom(File)} * that checks the existence of the specified file and does nothing if the file does not exist. * * @param file the properties file to read, if it exists */ public void readFromIfExists(File file) { if (!file.exists()) { LOGGER.log(Level.INFO, "There is no config file {0}; using default configuration", //NON-NLS file.getAbsolutePath()); return; } try { readFrom(file); LOGGER.log(Level.INFO, "Successfully read config from {0}", file.getAbsolutePath()); //NON-NLS } catch (IOException e) { LOGGER.log(Level.WARNING, "Cannot read config file " + file.getAbsolutePath(), e); //NON-NLS } } /** * Converts a string value from the properties file into an object that can be assigned to the specified field. * For array fields, the string value is first split into components. * * @param value the string value to convert * @param field the field to set with the converted value * @return an object of the appropriate type for the field */ private Object createValue(String value, Field field) { if (!field.getType().isArray()) return convertToType(value, field.getType()); // the field is an array final Separator sepAnn = field.getDeclaredAnnotation(Separator.class); final String sep = sepAnn == null ? "," : sepAnn.value(); final String[] split = value.split(BLANK_SEQ + sep + BLANK_SEQ, -1); final Object array = Array.newInstance(field.getType().componentType(), split.length); for (int i = 0; i < split.length; i++) Array.set(array, i, convertToType(split[i], field.getType().componentType())); return array; } /** * Converts a string value into an object of the specified type. * * @param value the string value to convert * @param targetType the target type to convert to * @return an object of the specified type */ protected Object convertToType(String value, Class targetType) { Function handler = CONVERTER_MAP.get(targetType); if (handler != null) return handler.apply(value); throw new IllegalArgumentException("Cannot translate " + value + " to " + targetType); } /** * Returns a string representation of the configuration object, including all properties and their values. * * @return a string representation of the configuration object */ @Override public String toString() { final List propertyStrings = getPropertyStrings(); propertyStrings.sort(String.CASE_INSENSITIVE_ORDER); return "[\n" + String.join(",\n", propertyStrings) + "\n]"; } /** * Retrieves all property strings of the configuration object. * * @return a list of property strings */ private List getPropertyStrings() { final List propertyStrings = new ArrayList<>(); for (Class clazz = getClass(); Config.class.isAssignableFrom(clazz); clazz = clazz.getSuperclass()) { for (Field field : clazz.getDeclaredFields()) { final String stringRepresentation = getStringRepresentation(field); if (stringRepresentation != null) propertyStrings.add(stringRepresentation); } } return propertyStrings; } /** * Retrieves the string representation of a field annotated with {@link Property}. * * @param field the field to retrieve the string representation for * @return the string representation of the field, or null if the field is not annotated with {@link Property} */ private String getStringRepresentation(Field field) { final Property keyAnnotation = field.getAnnotation(Property.class); if (keyAnnotation != null) { try { final Object fieldValue = getField(field); final String valueString = asString(fieldValue); return keyAnnotation.value() + " -> " + field.getName() + " = " + valueString; } catch (IllegalAccessException e) { LOGGER.log(Level.ERROR, "Cannot access " + field, e); //NON-NLS } } return null; } /** * Converts an object to its string representation. For arrays, string representations of their components are produced. * * @param value the object to convert * @return the string representation of the object */ private String asString(Object value) { if (value == null) return "null"; //NON-NLS if (!value.getClass().isArray()) return value.toString(); final int length = Array.getLength(value); final List components = new ArrayList<>(length); for (int i = 0; i < length; i++) { final Object component = Array.get(value, i); components.add(asString(component)); } return "{" + String.join(", ", components) + "}"; } /** * Sets the value of a field, making it accessible if necessary. * * @param field the field to set * @param value the value to set * @throws IllegalAccessException if the field cannot be accessed */ private void setField(Field field, Object value) throws IllegalAccessException { boolean inaccessible = !field.canAccess(this); if (inaccessible) field.setAccessible(true); field.set(this, value); if (inaccessible) field.setAccessible(false); LOGGER.log(Level.TRACE, "Set {0} to {1}", field, value); //NON-NLS } /** * Retrieves the value of a field, making it accessible if necessary. * * @param field the field to retrieve the value from * @return the value of the field * @throws IllegalAccessException if the field cannot be accessed */ private Object getField(Field field) throws IllegalAccessException { boolean inaccessible = !field.canAccess(this); if (inaccessible) field.setAccessible(true); final Object value = field.get(this); if (inaccessible) field.setAccessible(false); return value; } }