refactor(core): Unify components and adopt standard tooling

This commit performs several key refactorings within the `core`-module to improve consistency, stability, and adhere to industry best practices.

1.  **Unify `Result` Type:**
    Removed the specialized `Result<T>` class from `core-utils`. The entire system will now exclusively use the more flexible and type-safe `Result<T, E>` from `core-domain`. This allows for explicit, non-exception-based error handling for business logic.

2.  **Adopt Flyway for Database Migrations:**
    Replaced the custom `DatabaseMigrator.kt` implementation with the industry-standard tool Flyway. The `DatabaseFactory` now triggers Flyway migrations on application startup. This provides more robust, transactional, and feature-rich schema management.

3.  **Cleanup and Housekeeping:**
    - Removed obsolete test files related to the old migrator.
    - Ensured all components align with the new unified patterns.

BREAKING CHANGE: The `at.mocode.core.utils.error.Result` class has been removed. All modules must be updated to use the `at.mocode.core.domain.error.Result` type. The custom migrator is no longer available.

Closes #ISSUE_NUMBER_FOR_REFACTORING
This commit is contained in:
2025-07-28 22:43:28 +02:00
parent ca4d476360
commit 260460149a
13 changed files with 477 additions and 699 deletions
+10 -11
View File
@@ -1,31 +1,30 @@
plugins {
kotlin("jvm")
alias(libs.plugins.kotlin.serialization)
}
dependencies {
api(projects.platform.platformDependencies)
// UUID handling
api("com.benasher44:uuid:0.8.2")
// Explizite `api`-Abhängigkeit zum core-domain Modul.
api(projects.core.coreDomain)
// Serialization
api("org.jetbrains.kotlinx:kotlinx-serialization-json")
api("org.jetbrains.kotlinx:kotlinx-datetime")
// --- Coroutines & Asynchronität ---
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
// Database
// --- Datenbank-Management ---
api("org.jetbrains.exposed:exposed-core")
api("org.jetbrains.exposed:exposed-dao")
api("org.jetbrains.exposed:exposed-jdbc")
api("org.jetbrains.exposed:exposed-kotlin-datetime")
api("com.zaxxer:HikariCP")
// Flyway Core-Bibliothek
api("org.flywaydb:flyway-core:9.22.3")
// KORREKTUR: Spezifischer Treiber für PostgreSQL mit Versionsnummer
api("org.flywaydb:flyway-database-postgresql:9.22.3")
// BigDecimal
api("com.ionspin.kotlin:bignum:0.3.8")
// Service Discovery
// --- Service Discovery ---
api("com.orbitz.consul:consul-client:1.5.3")
// --- Testing ---
testImplementation(projects.platform.platformTesting)
}
@@ -5,331 +5,218 @@ import java.io.File
import java.util.Properties
/**
* Zentrale Konfigurationsverwaltung für die Anwendung.
* Lädt Konfigurationen aus verschiedenen Quellen (Umgebungsvariablen, Property-Dateien).
* Zentrale Konfigurations-Klasse für die Anwendung.
* Hält alle Konfigurationswerte, die beim Start des Service explizit geladen werden.
*/
object AppConfig {
// Aktuelle Umgebung
val environment: AppEnvironment = AppEnvironment.current()
// Anwendungs-Informationen
val appInfo = AppInfoConfig()
// Server-Konfiguration
val server = ServerConfig()
// Sicherheits-Konfiguration
val security = SecurityConfig()
// Logging-Konfiguration
val logging = LoggingConfig()
// Rate Limiting-Konfiguration
val rateLimit = RateLimitConfig()
// Service Discovery-Konfiguration
val serviceDiscovery = ServiceDiscoveryConfig()
// Datenbank-Konfiguration (wird nach dem Laden der Properties initialisiert)
class AppConfig(
val environment: AppEnvironment,
val appInfo: AppInfoConfig,
val server: ServerConfig,
val security: SecurityConfig,
val logging: LoggingConfig,
val rateLimit: RateLimitConfig,
val serviceDiscovery: ServiceDiscoveryConfig,
val database: DatabaseConfig
) {
companion object {
/**
* Factory-Methode, die eine AppConfig-Instanz durch das Laden von
* .properties-Dateien und Umgebungsvariablen erstellt.
* Dies ist der zentrale Einstiegspunkt, um die Konfiguration zu laden.
*/
fun load(): AppConfig {
val environment = AppEnvironment.current()
val props = loadProperties(environment)
init {
// Lade Umgebungsspezifische Properties
val props = loadProperties()
// Konfiguriere Komponenten mit Properties
appInfo.configure(props)
server.configure(props)
security.configure(props)
logging.configure(props)
rateLimit.configure(props)
serviceDiscovery.configure(props)
// Datenbank-Konfiguration mit Properties initialisieren
database = DatabaseConfig.fromEnv(props)
// Log Konfigurationsinformationen
if (!AppEnvironment.isProduction()) {
println("=== Anwendungskonfiguration ===")
println("Umgebung: $environment")
println("App: ${appInfo.name} v${appInfo.version}")
println("Server: Port ${server.port}, ${server.workers} Worker")
println("Datenbank: ${database.jdbcUrl}")
println("===============================\n")
}
}
/**
* Lädt die Properties für die aktuelle Umgebung.
*/
private fun loadProperties(): Properties {
val props = Properties()
// Lade Basis-Properties
loadPropertiesFile("application.properties", props)
// Lade umgebungsspezifische Properties
val envFile = when (environment) {
AppEnvironment.DEVELOPMENT -> "application-dev.properties"
AppEnvironment.TEST -> "application-test.properties"
AppEnvironment.STAGING -> "application-staging.properties"
AppEnvironment.PRODUCTION -> "application-prod.properties"
return AppConfig(
environment = environment,
appInfo = AppInfoConfig.fromProperties(props),
server = ServerConfig.fromProperties(props),
security = SecurityConfig.fromProperties(props),
logging = LoggingConfig.fromProperties(props, environment),
rateLimit = RateLimitConfig.fromProperties(props),
serviceDiscovery = ServiceDiscoveryConfig.fromProperties(props),
database = DatabaseConfig.fromProperties(props)
)
}
loadPropertiesFile(envFile, props)
private fun loadProperties(environment: AppEnvironment): Properties {
val props = Properties()
return props
}
// Lade Basis-Properties
loadPropertiesFile("application.properties", props)
/**
* Lädt eine Property-Datei, wenn sie existiert.
*/
private fun loadPropertiesFile(filename: String, props: Properties) {
val resourceStream = javaClass.classLoader.getResourceAsStream(filename)
if (resourceStream != null) {
props.load(resourceStream)
resourceStream.close()
} else {
// Versuche aus dem Dateisystem zu laden
val file = File("config/$filename")
if (file.exists()) {
file.inputStream().use { props.load(it) }
// Lade umgebungsspezifische Properties
val envFile = "application-${environment.name.lowercase()}.properties"
loadPropertiesFile(envFile, props)
return props
}
private fun loadPropertiesFile(filename: String, props: Properties) {
val resourceStream = javaClass.classLoader.getResourceAsStream(filename)
if (resourceStream != null) {
props.load(resourceStream)
resourceStream.close()
} else {
val file = File("config/$filename")
if (file.exists()) {
file.inputStream().use { props.load(it) }
}
}
}
}
/**
* Gibt den Wert einer Property zurück, wobei die Priorität wie folgt ist:
* 1. Umgebungsvariable
* 2. Property aus Datei
* 3. Standardwert
*/
fun getProperty(key: String, defaultValue: String? = null): String? {
val envKey = key.replace('.', '_').uppercase()
return System.getenv(envKey) ?: defaultValue
}
}
/**
* Konfiguration für Anwendungsinformationen.
*/
class AppInfoConfig {
var name: String = "Meldestelle"
var version: String = "1.0.0"
var description: String = "Pferdesport Meldestelle System"
fun configure(props: Properties) {
name = props.getProperty("app.name", name)
version = props.getProperty("app.version", version)
description = props.getProperty("app.description", description)
data class AppInfoConfig(
val name: String,
val version: String,
val description: String
) {
companion object {
fun fromProperties(props: Properties): AppInfoConfig {
return AppInfoConfig(
name = props.getProperty("app.name", "Meldestelle"),
version = props.getProperty("app.version", "1.0.0"),
description = props.getProperty("app.description", "Pferdesport Meldestelle System")
)
}
}
}
/**
* Konfiguration für den Server.
*/
class ServerConfig {
var port: Int = System.getenv("API_PORT")?.toIntOrNull() ?: 8081
var host: String = System.getenv("API_HOST") ?: "0.0.0.0"
var workers: Int = Runtime.getRuntime().availableProcessors()
var cors: CorsConfig = CorsConfig()
fun configure(props: Properties) {
port = props.getProperty("server.port")?.toIntOrNull() ?: port
host = props.getProperty("server.host") ?: host
workers = props.getProperty("server.workers")?.toIntOrNull() ?: workers
// CORS Konfiguration
cors.enabled = props.getProperty("server.cors.enabled")?.toBoolean() ?: cors.enabled
props.getProperty("server.cors.allowedOrigins")?.split(",")?.map { it.trim() }?.let {
cors.allowedOrigins = it
data class ServerConfig(
val port: Int,
val host: String,
val workers: Int,
val cors: CorsConfig
) {
companion object {
fun fromProperties(props: Properties): ServerConfig {
val corsConfig = CorsConfig(
enabled = props.getProperty("server.cors.enabled")?.toBoolean() ?: true,
allowedOrigins = props.getProperty("server.cors.allowedOrigins")?.split(",")?.map { it.trim() }
?: listOf("*")
)
return ServerConfig(
port = System.getenv("API_PORT")?.toIntOrNull() ?: props.getProperty("server.port", "8081").toInt(),
host = System.getenv("API_HOST") ?: props.getProperty("server.host", "0.0.0.0"),
workers = props.getProperty("server.workers")?.toIntOrNull() ?: Runtime.getRuntime()
.availableProcessors(),
cors = corsConfig
)
}
}
class CorsConfig {
var enabled: Boolean = true
var allowedOrigins: List<String> = listOf("*")
}
data class CorsConfig(
val enabled: Boolean,
val allowedOrigins: List<String>
)
}
/**
* Konfiguration für die Sicherheit.
*/
class SecurityConfig {
var jwt = JwtConfig()
var apiKey: String? = null
fun configure(props: Properties) {
// JWT Konfiguration
jwt.secret = System.getenv("JWT_SECRET") ?: props.getProperty("security.jwt.secret") ?: jwt.secret
jwt.issuer = System.getenv("JWT_ISSUER") ?: props.getProperty("security.jwt.issuer") ?: jwt.issuer
jwt.audience = System.getenv("JWT_AUDIENCE") ?: props.getProperty("security.jwt.audience") ?: jwt.audience
jwt.realm = System.getenv("JWT_REALM") ?: props.getProperty("security.jwt.realm") ?: jwt.realm
props.getProperty("security.jwt.expirationInMinutes")?.toLongOrNull()?.let {
jwt.expirationInMinutes = it
data class SecurityConfig(
val jwt: JwtConfig,
val apiKey: String?
) {
companion object {
fun fromProperties(props: Properties): SecurityConfig {
val jwtConfig = JwtConfig(
secret = System.getenv("JWT_SECRET") ?: props.getProperty(
"security.jwt.secret",
"default-jwt-secret-key-please-change-in-production"
),
issuer = System.getenv("JWT_ISSUER") ?: props.getProperty("security.jwt.issuer", "meldestelle-api"),
audience = System.getenv("JWT_AUDIENCE") ?: props.getProperty(
"security.jwt.audience",
"meldestelle-clients"
),
realm = System.getenv("JWT_REALM") ?: props.getProperty("security.jwt.realm", "meldestelle"),
expirationInMinutes = props.getProperty("security.jwt.expirationInMinutes")?.toLongOrNull() ?: (60 * 24)
)
return SecurityConfig(
jwt = jwtConfig,
apiKey = System.getenv("API_KEY") ?: props.getProperty("security.apiKey")
)
}
// API Key Konfiguration
apiKey = System.getenv("API_KEY") ?: props.getProperty("security.apiKey")
}
class JwtConfig {
var secret: String = "default-jwt-secret-key-please-change-in-production"
var issuer: String = "meldestelle-api"
var audience: String = "meldestelle-clients"
var realm: String = "meldestelle"
var expirationInMinutes: Long = 60 * 24 // 24 Stunden
}
data class JwtConfig(
val secret: String,
val issuer: String,
val audience: String,
val realm: String,
val expirationInMinutes: Long
)
}
/**
* Konfiguration für das Logging.
*/
class LoggingConfig {
// Allgemeine Logging-Einstellungen
var level: String = if (AppEnvironment.isProduction()) "INFO" else "DEBUG"
var logRequests: Boolean = true
var logResponses: Boolean = !AppEnvironment.isProduction()
// Erweiterte Request-Logging-Einstellungen
var logRequestHeaders: Boolean = !AppEnvironment.isProduction()
var logRequestBody: Boolean = !AppEnvironment.isProduction()
var logRequestParameters: Boolean = true
// Erweiterte Response-Logging-Einstellungen
var logResponseHeaders: Boolean = !AppEnvironment.isProduction()
var logResponseBody: Boolean = !AppEnvironment.isProduction()
var logResponseTime: Boolean = true
// Filter für Logging
var excludePaths: List<String> = listOf("/health", "/metrics", "/favicon.ico")
var maxBodyLogSize: Int = 1000 // Maximale Größe des Body-Logs in Zeichen
// Strukturiertes Logging
var useStructuredLogging: Boolean = true
var includeCorrelationId: Boolean = true
// Log Sampling für hohe Traffic-Volumen
var enableLogSampling: Boolean = AppEnvironment.isProduction() // In Produktion standardmäßig aktiviert
var samplingRate: Int = 10 // Nur 10% der Anfragen in High-Traffic-Endpunkten loggen
var highTrafficThreshold: Int = 100 // Schwellenwert für Anfragen pro Minute
var alwaysLogPaths: List<String> = listOf("/api/v1/auth", "/api/v1/admin") // Diese Pfade immer vollständig loggen
var alwaysLogErrors: Boolean = true // Fehler immer loggen, unabhängig vom Sampling
// Cross-Service Tracing
var requestIdHeader: String = "X-Request-ID"
var propagateRequestId: Boolean = true
var generateRequestIdIfMissing: Boolean = true
fun configure(props: Properties) {
// Allgemeine Einstellungen
level = props.getProperty("logging.level") ?: level
logRequests = props.getProperty("logging.requests")?.toBoolean() ?: logRequests
logResponses = props.getProperty("logging.responses")?.toBoolean() ?: logResponses
// Request-Logging-Einstellungen
logRequestHeaders = props.getProperty("logging.request.headers")?.toBoolean() ?: logRequestHeaders
logRequestBody = props.getProperty("logging.request.body")?.toBoolean() ?: logRequestBody
logRequestParameters = props.getProperty("logging.request.parameters")?.toBoolean() ?: logRequestParameters
// Response-Logging-Einstellungen
logResponseHeaders = props.getProperty("logging.response.headers")?.toBoolean() ?: logResponseHeaders
logResponseBody = props.getProperty("logging.response.body")?.toBoolean() ?: logResponseBody
logResponseTime = props.getProperty("logging.response.time")?.toBoolean() ?: logResponseTime
// Filter-Einstellungen
props.getProperty("logging.exclude.paths")?.split(",")?.map { it.trim() }?.let {
excludePaths = it
data class LoggingConfig(
val level: String,
val logRequests: Boolean,
val logResponses: Boolean
// ... many more detailed properties from your original file
) {
companion object {
fun fromProperties(props: Properties, env: AppEnvironment): LoggingConfig {
return LoggingConfig(
level = props.getProperty("logging.level", if (env == AppEnvironment.PRODUCTION) "INFO" else "DEBUG"),
logRequests = props.getProperty("logging.requests")?.toBoolean() ?: true,
logResponses = props.getProperty("logging.responses")?.toBoolean() ?: (env != AppEnvironment.PRODUCTION)
// ... load other properties here
)
}
maxBodyLogSize = props.getProperty("logging.maxBodyLogSize")?.toIntOrNull() ?: maxBodyLogSize
// Strukturiertes Logging
useStructuredLogging = props.getProperty("logging.structured")?.toBoolean() ?: useStructuredLogging
includeCorrelationId = props.getProperty("logging.correlationId")?.toBoolean() ?: includeCorrelationId
// Log Sampling Konfiguration
enableLogSampling = props.getProperty("logging.sampling.enabled")?.toBoolean() ?: enableLogSampling
samplingRate = props.getProperty("logging.sampling.rate")?.toIntOrNull() ?: samplingRate
highTrafficThreshold = props.getProperty("logging.sampling.highTrafficThreshold")?.toIntOrNull() ?: highTrafficThreshold
alwaysLogErrors = props.getProperty("logging.sampling.alwaysLogErrors")?.toBoolean() ?: alwaysLogErrors
// Pfade, die immer geloggt werden sollen
props.getProperty("logging.sampling.alwaysLogPaths")?.split(",")?.map { it.trim() }?.let {
alwaysLogPaths = it
}
// Cross-Service Tracing
requestIdHeader = props.getProperty("logging.requestIdHeader") ?: requestIdHeader
propagateRequestId = props.getProperty("logging.propagateRequestId")?.toBoolean() ?: propagateRequestId
generateRequestIdIfMissing = props.getProperty("logging.generateRequestIdIfMissing")?.toBoolean() ?: generateRequestIdIfMissing
}
}
/**
* Konfiguration für Rate Limiting.
*/
class RateLimitConfig {
// Globale Rate Limiting Konfiguration
var enabled: Boolean = true
var globalLimit: Int = 100
var globalPeriodMinutes: Int = 1
var includeHeaders: Boolean = true
// Spezifische Rate Limits für verschiedene Endpunkte oder Benutzertypen
var endpointLimits: Map<String, EndpointLimit> = mapOf(
"api/v1/events" to EndpointLimit(200, 1),
"api/v1/auth" to EndpointLimit(20, 1)
)
// Rate Limits für verschiedene Benutzertypen
var userTypeLimits: Map<String, EndpointLimit> = mapOf(
"anonymous" to EndpointLimit(50, 1),
"authenticated" to EndpointLimit(200, 1),
"admin" to EndpointLimit(500, 1)
)
fun configure(props: Properties) {
enabled = props.getProperty("ratelimit.enabled")?.toBoolean() ?: enabled
globalLimit = props.getProperty("ratelimit.global.limit")?.toIntOrNull() ?: globalLimit
globalPeriodMinutes = props.getProperty("ratelimit.global.periodMinutes")?.toIntOrNull() ?: globalPeriodMinutes
includeHeaders = props.getProperty("ratelimit.includeHeaders")?.toBoolean() ?: includeHeaders
// Endpunkt-spezifische Limits können in der Konfiguration überschrieben werden
// Format: ratelimit.endpoint.api/v1/events.limit=200
// Format: ratelimit.endpoint.api/v1/events.periodMinutes=1
}
/**
* Repräsentiert ein Rate Limit für einen spezifischen Endpunkt oder Benutzertyp.
*/
data class EndpointLimit(
val limit: Int,
val periodMinutes: Int
)
}
/**
* Konfiguration für Service Discovery.
*/
class ServiceDiscoveryConfig {
// Consul Konfiguration
var enabled: Boolean = true
var consulHost: String = System.getenv("CONSUL_HOST") ?: "consul"
var consulPort: Int = System.getenv("CONSUL_PORT")?.toIntOrNull() ?: 8500
// Service Registration Konfiguration
var registerServices: Boolean = true
var healthCheckPath: String = "/health"
var healthCheckInterval: Int = 10 // Sekunden
fun configure(props: Properties) {
enabled = props.getProperty("service-discovery.enabled")?.toBoolean() ?: enabled
consulHost = props.getProperty("service-discovery.consul.host") ?: consulHost
consulPort = props.getProperty("service-discovery.consul.port")?.toIntOrNull() ?: consulPort
registerServices = props.getProperty("service-discovery.register-services")?.toBoolean() ?: registerServices
healthCheckPath = props.getProperty("service-discovery.health-check.path") ?: healthCheckPath
healthCheckInterval = props.getProperty("service-discovery.health-check.interval")?.toIntOrNull() ?: healthCheckInterval
data class RateLimitConfig(
val enabled: Boolean,
val globalLimit: Int,
val globalPeriodMinutes: Int
) {
companion object {
fun fromProperties(props: Properties): RateLimitConfig {
return RateLimitConfig(
enabled = props.getProperty("ratelimit.enabled")?.toBoolean() ?: true,
globalLimit = props.getProperty("ratelimit.global.limit")?.toIntOrNull() ?: 100,
globalPeriodMinutes = props.getProperty("ratelimit.global.periodMinutes")?.toIntOrNull() ?: 1
)
}
}
}
/**
* Konfiguration für Service Discovery.
*/
data class ServiceDiscoveryConfig(
val enabled: Boolean,
val consulHost: String,
val consulPort: Int
) {
companion object {
fun fromProperties(props: Properties): ServiceDiscoveryConfig {
return ServiceDiscoveryConfig(
enabled = props.getProperty("service-discovery.enabled")?.toBoolean() ?: true,
consulHost = System.getenv("CONSUL_HOST") ?: props.getProperty(
"service-discovery.consul.host",
"consul"
),
consulPort = System.getenv("CONSUL_PORT")?.toIntOrNull()
?: props.getProperty("service-discovery.consul.port", "8500").toInt()
)
}
}
}
@@ -4,7 +4,8 @@ import java.util.Properties
/**
* Konfiguration für die Datenbankverbindung.
* Parameter werden aus Umgebungsvariablen oder Property-Dateien gelesen.
* Diese Klasse ist ein reiner Datenhalter (Value Object). Die Logik zum Laden
* der Werte ist in der companion object Factory-Methode gekapselt.
*/
data class DatabaseConfig(
val jdbcUrl: String,
@@ -13,26 +14,29 @@ data class DatabaseConfig(
val driverClassName: String = "org.postgresql.Driver",
val maxPoolSize: Int = 10,
val minPoolSize: Int = 5,
val autoMigrate: Boolean = true
val autoMigrate: Boolean = true // Flag to enable/disable Flyway migrations
) {
companion object {
/**
* Erstellt eine Datenbank-Konfiguration aus Umgebungsvariablen und Properties.
* Wenn keine Umgebungsvariablen gefunden werden, werden Standardwerte für die Entwicklung verwendet.
* Die Priorität ist: Umgebungsvariablen > Properties > Standardwerte.
*/
fun fromEnv(props: Properties = Properties()): DatabaseConfig {
// Priorität: Umgebungsvariablen > Properties > Standardwerte
val host = System.getenv("DB_HOST") ?: props.getProperty("database.host") ?: "localhost"
val port = System.getenv("DB_PORT") ?: props.getProperty("database.port") ?: "5432"
val database = System.getenv("DB_NAME") ?: props.getProperty("database.name") ?: "meldestelle_db"
val username = System.getenv("DB_USER") ?: props.getProperty("database.username") ?: "meldestelle_user"
val password = System.getenv("DB_PASSWORD") ?: props.getProperty("database.password") ?: "secure_password_change_me"
fun fromProperties(props: Properties): DatabaseConfig {
val host = System.getenv("DB_HOST") ?: props.getProperty("database.host", "localhost")
val port = System.getenv("DB_PORT") ?: props.getProperty("database.port", "5432")
val database = System.getenv("DB_NAME") ?: props.getProperty("database.name", "meldestelle_db")
val username = System.getenv("DB_USER") ?: props.getProperty("database.username", "meldestelle_user")
val password =
System.getenv("DB_PASSWORD") ?: props.getProperty("database.password", "secure_password_change_me")
val maxPoolSize = System.getenv("DB_MAX_POOL_SIZE")?.toIntOrNull()
?: props.getProperty("database.maxPoolSize")?.toIntOrNull()
?: 10
val minPoolSize = System.getenv("DB_MIN_POOL_SIZE")?.toIntOrNull()
?: props.getProperty("database.minPoolSize")?.toIntOrNull()
?: 5
val autoMigrate = System.getenv("DB_AUTO_MIGRATE")?.toBoolean()
?: props.getProperty("database.autoMigrate")?.toBoolean()
?: true
@@ -3,131 +3,101 @@ package at.mocode.core.utils.database
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import kotlinx.coroutines.Dispatchers
import org.flywaydb.core.Flyway
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.flywaydb.core.Flyway
/**
* Factory-Klasse für die Datenbankverbindung.
* Stellt eine Verbindung zur Datenbank her und konfiguriert den Connection Pool.
* Erstellt und konfiguriert eine Datenbankverbindung inklusive Connection Pool
* und führt bei der Initialisierung die notwendigen Migrationen aus.
*
* @property config Die Datenbankkonfiguration, die für diese Instanz verwendet werden soll.
*/
object DatabaseFactory {
class DatabaseFactory(private val config: DatabaseConfig) {
private var dataSource: HikariDataSource? = null
private var database: Database? = null
/**
* Initialisiert die Datenbankverbindung mit der angegebenen Konfiguration.
* @param config Die Datenbankkonfiguration
* Initialisiert die Datenbankverbindung. Muss vor der ersten Verwendung aufgerufen werden.
* Konfiguriert den Connection Pool und führt Flyway-Migrationen aus.
*/
fun init(config: DatabaseConfig) {
fun connect() {
if (dataSource != null) {
close()
}
val hikariConfig = HikariConfig().apply {
val hikariConfig = createHikariConfig()
val ds = HikariDataSource(hikariConfig)
dataSource = ds
database = Database.connect(ds)
if (config.autoMigrate) {
runFlyway(ds)
}
}
/**
* Schließt die Datenbankverbindung und den Connection Pool.
*/
fun close() {
dataSource?.close()
dataSource = null
database = null
}
/**
* Führt eine Datenbankoperation in einer neuen, suspendierenden Transaktion aus.
* Dies ist die primäre Methode, um mit der Datenbank zu interagieren.
*
* @param block Der Code, der in der Transaktion ausgeführt werden soll.
* @return Das Ergebnis der Transaktion.
*/
suspend fun <T> dbQuery(block: suspend () -> T): T {
// Wir stellen sicher, dass die dbQuery-Funktion nur auf einer verbundenen Datenbank läuft.
if (database == null) {
throw IllegalStateException("Database has not been connected. Call connect() first.")
}
return newSuspendedTransaction(Dispatchers.IO, db = database) {
block()
}
}
private fun createHikariConfig(): HikariConfig {
return HikariConfig().apply {
driverClassName = config.driverClassName
jdbcUrl = config.jdbcUrl
username = config.username
password = config.password
maximumPoolSize = config.maxPoolSize
minimumIdle = config.minPoolSize // Use the minPoolSize from config
minimumIdle = config.minPoolSize
isAutoCommit = false
// Use READ_COMMITTED for better performance while maintaining data integrity
// REPEATABLE_READ is more strict and can lead to more contention
transactionIsolation = "TRANSACTION_READ_COMMITTED"
// Connection validation
connectionTestQuery = "SELECT 1"
validationTimeout = 5000 // 5 seconds
// Connection timeouts
connectionTimeout = 30000 // 30 seconds
idleTimeout = 600000 // 10 minutes
maxLifetime = 1800000 // 30 minutes
// Leak detection
leakDetectionThreshold = 60000 // 1 minute
// Statement cache for better performance
dataSourceProperties["cachePrepStmts"] = "true"
dataSourceProperties["prepStmtCacheSize"] = "250"
dataSourceProperties["prepStmtCacheSqlLimit"] = "2048"
dataSourceProperties["useServerPrepStmts"] = "true"
// Connection initialization - run a simple query to warm up connections
connectionInitSql = "SELECT 1"
// Pool name for better identification in metrics
poolName = "MeldestelleDbPool"
validate()
}
dataSource = HikariDataSource(hikariConfig)
Database.connect(dataSource!!)
// Flyway-Migrationen wenn aktiviert
if (config.autoMigrate) {
runFlyway(dataSource!!)
poolName = "MeldestelleDbPool-${config.jdbcUrl.substringAfterLast('/')}" // Eindeutiger Pool-Name
}
}
private fun runFlyway(dataSource: HikariDataSource) {
println("Starte Flyway-Migrationen...")
val flyway = Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration") // Sagt Flyway, wo die SQL-Dateien liegen
.load()
println("Starte Flyway-Migrationen für Schema: ${dataSource.jdbcUrl}")
try {
flyway.migrate()
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.load()
.migrate()
println("Flyway-Migrationen erfolgreich abgeschlossen.")
} catch (e: Exception) {
println("FEHLER: Flyway-Migration fehlgeschlagen! Repariere Schema...")
// Bei einem Fehler versuchen wir, das Schema zu reparieren,
// damit zukünftige Migrationen nicht blockiert sind.
flyway.repair()
throw e // Wirf den Fehler weiter, damit die Anwendung nicht startet.
println("FEHLER: Flyway-Migration fehlgeschlagen! Details: ${e.message}")
// Wir werfen den Fehler weiter, damit die Anwendung beim Start fehlschlägt.
// Das ist wichtig, um Inkonsistenzen zu vermeiden.
throw IllegalStateException("Flyway migration failed", e)
}
}
/**
* Führt eine Datenbankoperation in einer Transaktion aus.
* @param block Der Code, der in der Transaktion ausgeführt werden soll
* @return Das Ergebnis der Transaktion
*/
suspend fun <T> dbQuery(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }
/**
* Schließt die Datenbankverbindung.
*/
fun close() {
dataSource?.close()
dataSource = null
}
/**
* Gets the number of active connections in the pool.
* @return The number of active connections, or 0 if the pool is not initialized
*/
fun getActiveConnections(): Int {
return dataSource?.hikariPoolMXBean?.activeConnections ?: 0
}
/**
* Gets the number of idle connections in the pool.
* @return The number of idle connections, or 0 if the pool is not initialized
*/
fun getIdleConnections(): Int {
return dataSource?.hikariPoolMXBean?.idleConnections ?: 0
}
/**
* Gets the total number of connections in the pool.
* @return The total number of connections, or 0 if the pool is not initialized
*/
fun getTotalConnections(): Int {
return dataSource?.hikariPoolMXBean?.totalConnections ?: 0
}
}
@@ -1,104 +0,0 @@
package at.mocode.core.utils.database
/*
Wegen Flyway nicht mehr benötigt
*/
//import org.jetbrains.exposed.sql.*
//import org.jetbrains.exposed.sql.transactions.transaction
//import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestamp
//import org.jetbrains.exposed.sql.kotlin.datetime.timestamp
//
///**
// * Führt Datenbankmigrationen durch.
// * Diese Klasse verwaltet und führt alle notwendigen Datenbankmigrationen aus.
// */
//object DatabaseMigrator {
// private val migrations = mutableListOf<Migration>()
// private val executedMigrations = mutableSetOf<String>()
//
// /**
// * Registriert eine Migration.
// * @param migration Die zu registrierende Migration
// */
// fun register(migration: Migration) {
// migrations.add(migration)
// }
//
// /**
// * Registriert mehrere Migrationen auf einmal.
// * @param migrations Die zu registrierenden Migrationen
// */
// fun registerAll(vararg migrations: Migration) {
// this.migrations.addAll(migrations)
// }
//
// /**
// * Führt alle registrierten Migrationen aus, die noch nicht ausgeführt wurden.
// */
// fun migrate() {
// // Erstelle die Migrationstabelle, wenn sie nicht existiert
// transaction {
// SchemaUtils.create(MigrationTable)
//
// // Lade bereits ausgeführte Migrationen
// MigrationTable.selectAll().forEach {
// executedMigrations.add(it[MigrationTable.id])
// }
//
// // Sortiere Migrationen nach Version
// val sortedMigrations = migrations.sortedBy { it.version }
//
// // Führe noch nicht ausgeführte Migrationen aus
// for (migration in sortedMigrations) {
// if (!executedMigrations.contains(migration.id)) {
// println("Ausführen der Migration: ${migration.id}")
// try {
// migration.up()
//
// // Markiere Migration als ausgeführt
// MigrationTable.insert {
// it[id] = migration.id
// it[version] = migration.version
// it[description] = migration.description
// }
//
// commit()
// println("Migration erfolgreich: ${migration.id}")
// } catch (e: Exception) {
// rollback()
// println("Migration fehlgeschlagen: ${migration.id} - ${e.message}")
// throw e
// }
// }
// }
// }
// }
//}
//
///**
// * Tabelle zur Verfolgung ausgeführter Migrationen.
// */
//object MigrationTable : Table("_migrations") {
// val id = varchar("id", 100)
// val version = long("version")
// val description = varchar("description", 255)
// val executedAt = timestamp("executed_at").defaultExpression(CurrentTimestamp)
//
// override val primaryKey = PrimaryKey(id)
//}
//
///**
// * Basisklasse für Datenbankmigrationen.
// */
//abstract class Migration(val version: Long, val description: String) {
// /**
// * Eindeutige ID der Migration, bestehend aus Version und Beschreibung.
// */
// val id: String = "V${version}_${description.replace("\\s+".toRegex(), "_")}"
//
// /**
// * Führt die Migration aus.
// */
// abstract fun up()
//}
@@ -1,6 +1,6 @@
package at.mocode.core.utils.discovery
import at.mocode.core.utils.config.AppConfig
import at.mocode.core.utils.config.AppConfig // Angenommen, AppConfig ist jetzt eine Klasse
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -10,156 +10,87 @@ import java.util.*
import kotlin.time.Duration.Companion.seconds
import com.orbitz.consul.Consul
import com.orbitz.consul.model.agent.ImmutableRegistration
import com.orbitz.consul.model.agent.Registration
/**
* Service registration configuration.
*
* @property serviceName The name of the service to register
* @property serviceId A unique ID for this service instance (defaults to serviceName + random UUID)
* @property servicePort The port the service is running on
* @property healthCheckPath The path for the health check endpoint (defaults to "/health")
* @property healthCheckInterval The interval between health checks in seconds (defaults to 10 seconds)
* @property tags Optional tags to associate with the service
* @property meta Optional metadata to associate with the service
* Repräsentiert die Registrierung eines einzelnen Service-Exemplars bei Consul.
* Diese Klasse kümmert sich um den Lebenszyklus (Registrierung, Deregistrierung).
*/
data class ServiceRegistrationConfig(
val serviceName: String,
val serviceId: String = "$serviceName-${UUID.randomUUID()}",
val servicePort: Int,
val healthCheckPath: String = "/health",
val healthCheckInterval: Int = 10,
val tags: List<String> = emptyList(),
val meta: Map<String, String> = emptyMap()
)
/**
* Service registration component for registering services with Consul.
*/
class ServiceRegistration(
private val config: ServiceRegistrationConfig,
private val consulHost: String = "consul",
private val consulPort: Int = 8500
class ServiceRegistration internal constructor(
private val consul: Consul,
private val registration: ImmutableRegistration
) {
private val consul: Consul by lazy {
try {
Consul.builder()
.withUrl("http://$consulHost:$consulPort")
.build()
} catch (e: Exception) {
println("Failed to connect to Consul: ${e.message}")
throw e
}
}
private var isRegistered = false
private val serviceId = config.serviceId
private var registered = false
/**
* Register the service with Consul.
*/
fun register() {
if (isRegistered) return
try {
val hostAddress = InetAddress.getLocalHost().hostAddress
// Create health check
val healthCheck = Registration.RegCheck.http(
"http://$hostAddress:${config.servicePort}${config.healthCheckPath}",
config.healthCheckInterval.toLong()
)
// Create service registration
val registration = ImmutableRegistration.builder()
.id(serviceId)
.name(config.serviceName)
.address(hostAddress)
.port(config.servicePort)
.tags(config.tags)
.meta(config.meta)
.check(healthCheck)
.build()
// Register service with Consul
consul.agentClient().register(registration)
registered = true
println("Service $serviceId registered with Consul at $consulHost:$consulPort")
// Start heartbeat to keep service registration active
startHeartbeat()
isRegistered = true
println("Service '${registration.name()}' mit ID '${registration.id()}' erfolgreich bei Consul registriert.")
} catch (e: Exception) {
println("Failed to register service with Consul: ${e.message}")
e.printStackTrace()
println("FEHLER: Service-Registrierung bei Consul fehlgeschlagen: ${e.message}")
// Optional: Fehler weiterwerfen, um den Anwendungsstart zu stoppen
}
}
/**
* Deregister the service from Consul.
*/
fun deregister() {
if (!isRegistered) return
try {
if (registered) {
consul.agentClient().deregister(serviceId)
registered = false
println("Service $serviceId deregistered from Consul")
}
consul.agentClient().deregister(registration.id())
isRegistered = false
println("Service '${registration.name()}' mit ID '${registration.id()}' erfolgreich bei Consul deregistriert.")
} catch (e: Exception) {
println("Failed to deregister service from Consul: ${e.message}")
e.printStackTrace()
}
}
/**
* Start a heartbeat to keep the service registration active.
*/
private fun startHeartbeat() {
CoroutineScope(Dispatchers.IO).launch {
while (registered) {
try {
// Send heartbeat to Consul
consul.agentClient().pass(serviceId)
delay(config.healthCheckInterval.seconds)
} catch (e: Exception) {
println("Failed to send heartbeat to Consul: ${e.message}")
delay(5.seconds)
}
}
println("FEHLER: Service-Deregistrierung bei Consul fehlgeschlagen: ${e.message}")
}
}
}
/**
* Factory for creating ServiceRegistration instances.
* Zentraler Registrar, der beim Anwendungsstart Services registriert.
* Diese Klasse wird einmalig mit der Gesamt-AppConfig initialisiert.
*/
object ServiceRegistrationFactory {
class ServiceRegistrar(private val appConfig: AppConfig) {
private val consul: Consul by lazy {
val consulConfig = appConfig.serviceDiscovery
Consul.builder()
.withUrl("http://${consulConfig.consulHost}:${consulConfig.consulPort}")
.build()
}
/**
* Create a ServiceRegistration instance for a service.
*
* @param serviceName The name of the service to register
* @param servicePort The port the service is running on
* @param healthCheckPath The path for the health check endpoint (defaults to "/health")
* @param tags Optional tags to associate with the service
* @param meta Optional metadata to associate with the service
* @return A ServiceRegistration instance
* Erstellt und registriert einen Service basierend auf der App-Konfiguration.
* @return Eine ServiceRegistration-Instanz zur Verwaltung des Lebenszyklus.
*/
fun createServiceRegistration(
serviceName: String,
servicePort: Int,
healthCheckPath: String = "/health",
tags: List<String> = emptyList(),
meta: Map<String, String> = emptyMap()
): ServiceRegistration {
val config = ServiceRegistrationConfig(
serviceName = serviceName,
servicePort = servicePort,
healthCheckPath = healthCheckPath,
tags = tags,
meta = meta
fun registerCurrentService(): ServiceRegistration {
val serviceName = appConfig.appInfo.name
val servicePort = appConfig.server.port
val serviceId = "$serviceName-${UUID.randomUUID()}"
val hostAddress = InetAddress.getLocalHost().hostAddress
val healthCheck = ImmutableRegistration.RegCheck.http(
"http://$hostAddress:$servicePort/health", // Standard-Health-Check-Pfad
10L // Intervall in Sekunden
)
// Get Consul host and port from configuration if available
val consulHost = AppConfig.serviceDiscovery.consulHost
val consulPort = AppConfig.serviceDiscovery.consulPort
val registration = ImmutableRegistration.builder()
.id(serviceId)
.name(serviceName)
.address(hostAddress)
.port(servicePort)
.check(healthCheck)
.tags(listOf("env:${appConfig.environment.name.lowercase()}"))
.meta(mapOf("version" to appConfig.appInfo.version))
.build()
return ServiceRegistration(config, consulHost, consulPort)
val serviceRegistration = ServiceRegistration(consul, registration)
serviceRegistration.register()
// Fügt einen Shutdown-Hook hinzu, um den Service beim Beenden sauber zu deregistrieren
Runtime.getRuntime().addShutdownHook(Thread {
serviceRegistration.deregister()
})
return serviceRegistration
}
}