Skip to content

Commit 944494a

Browse files
authored
Merge pull request #21 from simplecloudapp/refactor/config-factory
feat: wasn't happy with my ConfigFactory so improved it immensely
2 parents 3059603 + 738f28e commit 944494a

File tree

1 file changed

+148
-15
lines changed
  • plugin-shared/src/main/kotlin/app/simplecloud/plugin/api/shared/config

1 file changed

+148
-15
lines changed

plugin-shared/src/main/kotlin/app/simplecloud/plugin/api/shared/config/ConfigFactory.kt

Lines changed: 148 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,61 @@ package app.simplecloud.plugin.api.shared.config
33
import app.simplecloud.plugin.api.shared.exception.ConfigurationException
44
import app.simplecloud.plugin.api.shared.repository.GenericEnumSerializer
55
import kotlinx.coroutines.*
6+
import org.slf4j.LoggerFactory
67
import org.spongepowered.configurate.ConfigurationOptions
78
import org.spongepowered.configurate.kotlin.objectMapperFactory
89
import org.spongepowered.configurate.yaml.NodeStyle
910
import org.spongepowered.configurate.yaml.YamlConfigurationLoader
1011
import java.io.File
1112
import java.nio.file.*
13+
import java.util.concurrent.atomic.AtomicReference
1214
import kotlin.coroutines.CoroutineContext
1315

1416
/**
1517
* A configuration factory that loads, saves and watches configuration files.
1618
* The factory automatically reloads the configuration when the file changes.
1719
*
20+
* Features:
21+
* - Thread-safe configuration handling
22+
* - File watching with automatic reloading
23+
* - Configuration change callbacks
24+
* - Validation support
25+
*
1826
* Usage:
1927
* ```
2028
* // Using create
2129
* val factory = ConfigFactory.create<MyConfig>(File("config.yaml"))
2230
*
2331
* // Using create with custom coroutineContext
2432
* val factory = ConfigFactory.create<MyConfig>(File("config.json"), Dispatchers.Default)
33+
*
34+
* // Add change listener
35+
* factory.onConfigChanged { oldConfig, newConfig ->
36+
* println("Config updated from $oldConfig to $newConfig")
37+
* }
38+
*
39+
* // Save the modified config
40+
* factory.save(modifiedConfig)
41+
*
42+
* // Optionally with validation
43+
* factory.save(modifiedConfig) { config ->
44+
* config.someField.isNotEmpty() // return true/false for validation
45+
* }
2546
* ```
2647
*/
27-
class ConfigFactory<T>(
48+
class ConfigFactory<T : Any>(
2849
private val file: File,
2950
private val configClass: Class<T>,
3051
private val coroutineContext: CoroutineContext = Dispatchers.IO
3152
) : AutoCloseable {
3253

33-
private var config: T? = null
54+
private val logger = LoggerFactory.getLogger(ConfigFactory::class.java)
55+
private val configRef = AtomicReference<T>()
3456
private val path: Path = file.toPath()
3557
private var watchJob: Job? = null
58+
private val changeListeners = mutableListOf<suspend (T?, T) -> Unit>()
59+
private val saveLock = Object()
60+
private val scope = CoroutineScope(coroutineContext + SupervisorJob())
3661

3762
private val configurationLoader = YamlConfigurationLoader.builder()
3863
.path(path)
@@ -45,45 +70,145 @@ class ConfigFactory<T>(
4570
}
4671
.build()
4772

48-
fun loadOrCreate(defaultConfig: T) {
73+
/**
74+
* Loads existing configuration or creates a new one with default values.
75+
* @param defaultConfig The default configuration to use if no file exists
76+
* @param validator Optional validation function for the configuration
77+
*/
78+
fun loadOrCreate(defaultConfig: T, validator: ((T) -> Boolean)? = null) {
4979
if (!configClass.isInstance(defaultConfig)) {
5080
throw IllegalArgumentException("Default config must be an instance of ${configClass.name}")
5181
}
5282

5383
if (file.exists()) {
54-
loadConfig()
84+
loadConfig(validator)
5585
} else {
56-
createDefaultConfig(defaultConfig)
86+
createDefaultConfig(defaultConfig, validator)
5787
}
5888

5989
registerWatcher()
6090
}
6191

62-
private fun createDefaultConfig(defaultConfig: T) {
92+
/**
93+
* Checks if the configuration file exists.
94+
* @return true if the configuration file exists
95+
*/
96+
fun exists(): Boolean = file.exists()
97+
98+
/**
99+
* Adds a listener that will be called whenever the configuration changes.
100+
* @param listener The listener function that receives old and new config
101+
*/
102+
fun onConfigChanged(listener: suspend (T?, T) -> Unit) {
103+
synchronized(changeListeners) {
104+
changeListeners.add(listener)
105+
}
106+
}
107+
108+
private fun createDefaultConfig(defaultConfig: T, validator: ((T) -> Boolean)?) {
63109
path.parent?.let { Files.createDirectories(it) }
64110
Files.createFile(path)
65111

66-
val node = configurationLoader.createNode()
67-
node.set(configClass, defaultConfig)
68-
configurationLoader.save(node)
69-
config = defaultConfig
112+
if (validator?.invoke(defaultConfig) == false) {
113+
throw ConfigurationException("Default configuration failed validation")
114+
}
115+
116+
synchronized(saveLock) {
117+
try {
118+
val node = configurationLoader.createNode()
119+
node.set(configClass, defaultConfig)
120+
configurationLoader.save(node)
121+
runBlocking(coroutineContext) {
122+
updateConfig(defaultConfig)
123+
}
124+
} catch (e: Exception) {
125+
throw ConfigurationException("Failed to save default configuration", e)
126+
}
127+
}
70128
}
71129

130+
/**
131+
* Gets the current configuration.
132+
* @throws IllegalStateException if configuration is not loaded
133+
*/
72134
fun getConfig(): T {
73-
return config ?: throw IllegalStateException("Configuration not loaded or invalid type")
135+
return configRef.get() ?: throw IllegalStateException("Configuration not loaded or invalid type")
74136
}
75137

138+
/**
139+
* Manually reloads the configuration from disk.
140+
* @param validator Optional validation function for the loaded configuration
141+
* @throws ConfigurationException if loading or validation fails
142+
*/
76143
@Throws(ConfigurationException::class)
77-
private fun loadConfig() {
144+
fun reloadConfig(validator: ((T) -> Boolean)? = null) {
145+
loadConfig(validator)
146+
}
147+
148+
@Throws(ConfigurationException::class)
149+
private fun loadConfig(validator: ((T) -> Boolean)? = null) {
78150
try {
79151
val node = configurationLoader.load(ConfigurationOptions.defaults())
80-
config = node.get(configClass)
152+
val loadedConfig = node.get(configClass)
81153
?: throw ConfigurationException("Failed to parse configuration file")
154+
155+
if (validator?.invoke(loadedConfig) == false) {
156+
throw ConfigurationException("Configuration failed validation")
157+
}
158+
159+
runBlocking(coroutineContext) {
160+
updateConfig(loadedConfig)
161+
}
82162
} catch (e: Exception) {
83163
throw ConfigurationException("Failed to load configuration", e)
84164
}
85165
}
86166

167+
/**
168+
* Saves the provided configuration to disk.
169+
* @param config The configuration to save
170+
* @param validator Optional validation function to run before saving
171+
* @throws ConfigurationException if saving or validation fails
172+
*/
173+
@Throws(ConfigurationException::class)
174+
fun save(config: T, validator: ((T) -> Boolean)? = null) {
175+
if (validator?.invoke(config) == false) {
176+
throw ConfigurationException("Configuration failed validation")
177+
}
178+
179+
synchronized(saveLock) {
180+
try {
181+
val node = configurationLoader.createNode()
182+
node.set(configClass, config)
183+
configurationLoader.save(node)
184+
runBlocking(coroutineContext) {
185+
updateConfig(config)
186+
}
187+
} catch (e: Exception) {
188+
throw ConfigurationException("Failed to save configuration", e)
189+
}
190+
}
191+
}
192+
193+
private suspend fun updateConfig(newConfig: T) {
194+
val oldConfig = configRef.get()
195+
configRef.set(newConfig)
196+
197+
val listeners = synchronized(changeListeners) {
198+
changeListeners.toList()
199+
}
200+
201+
listeners.forEach { listener ->
202+
try {
203+
withContext(coroutineContext) {
204+
listener(oldConfig, newConfig)
205+
}
206+
} catch (e: Exception) {
207+
logger.error("Error in config change listener", e)
208+
}
209+
}
210+
}
211+
87212
private fun registerWatcher(): Job {
88213
val watchService = FileSystems.getDefault().newWatchService()
89214
path.parent?.register(
@@ -92,7 +217,7 @@ class ConfigFactory<T>(
92217
StandardWatchEventKinds.ENTRY_MODIFY
93218
)
94219

95-
return CoroutineScope(coroutineContext).launch {
220+
return scope.launch {
96221
watchService.use { watchService ->
97222
while (isActive) {
98223
val key = watchService.take()
@@ -118,17 +243,25 @@ class ConfigFactory<T>(
118243
try {
119244
loadConfig()
120245
} catch (e: ConfigurationException) {
121-
println("Failed to reload configuration: ${e.message}")
246+
logger.error("Failed to reload configuration: ${e.message}", e)
122247
}
123248
}
249+
250+
else -> {}
124251
}
125252
}
126253

127254
override fun close() {
255+
scope.cancel()
128256
watchJob?.cancel()
129257
}
130258

131259
companion object {
260+
/**
261+
* Creates a new ConfigFactory instance.
262+
* @param file The configuration file
263+
* @param coroutineContext The coroutine context to use for async operations
264+
*/
132265
inline fun <reified T : Any> create(
133266
file: File,
134267
coroutineContext: CoroutineContext = Dispatchers.IO

0 commit comments

Comments
 (0)