This commit is contained in:
2026-04-17 13:57:48 +03:00
parent 031910e848
commit 5759a223d2
11 changed files with 466 additions and 87 deletions

View File

@@ -1,70 +1,80 @@
package su.xserver.iikocon;
import io.vertx.config.ConfigRetriever;
import io.vertx.config.ConfigRetrieverOptions;
import io.vertx.config.ConfigStoreOptions;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.core.http.HttpServer;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.healthchecks.HealthChecks;
import io.vertx.ext.healthchecks.Status;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.handler.BodyHandler;
import io.vertx.ext.web.handler.SessionHandler;
import io.vertx.ext.web.handler.StaticHandler;
import io.vertx.ext.web.sstore.LocalSessionStore;
import io.vertx.ext.web.sstore.SessionStore;
import io.vertx.ext.web.sstore.redis.RedisSessionStore;
import io.vertx.mysqlclient.MySQLConnectOptions;
import io.vertx.redis.client.Redis;
import io.vertx.redis.client.RedisAPI;
import io.vertx.redis.client.RedisOptions;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.PoolOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import su.xserver.iikocon.config.AppConfig;
import su.xserver.iikocon.service.DataBaseService;
import su.xserver.iikocon.service.HealthCheckService;
import su.xserver.iikocon.service.RedisService;
public class MainVerticle extends AbstractVerticle {
private static final Logger log = LoggerFactory.getLogger(MainVerticle.class);
private Pool dbPool;
private final Logger log = LoggerFactory.getLogger("[MainVerticle]");
private DataBaseService db;
private RedisService redis;
private HttpServer httpServer;
private AppConfig config;
private UserService userService;
@Override
public void start(Promise<Void> startPromise) {
// Конфигурация из переменных окружения
JsonObject config = new JsonObject()
.put("db_host", System.getenv().getOrDefault("DB_HOST", "localhost"))
.put("db_port", Integer.parseInt(System.getenv().getOrDefault("DB_PORT", "3306")))
.put("db_name", System.getenv().getOrDefault("DB_NAME", "admin_db"))
.put("db_user", System.getenv().getOrDefault("DB_USER", "admin_user"))
.put("db_password", System.getenv().getOrDefault("DB_PASSWORD", "admin_pass"))
.put("http_port", Integer.parseInt(System.getenv().getOrDefault("HTTP_PORT", "8080")));
log.info("Starting with config: {}", config.encodePrettily());
ConfigStoreOptions classpathStore = new ConfigStoreOptions()
.setType("file")
.setFormat("json")
.setConfig(new JsonObject().put("path", "config.json").put("hierarchical", true))
.setOptional(false);
// Подключение к MariaDB
MySQLConnectOptions connectOptions = new MySQLConnectOptions()
.setHost(config.getString("db_host"))
.setPort(config.getInteger("db_port"))
.setDatabase(config.getString("db_name"))
.setUser(config.getString("db_user"))
.setPassword(config.getString("db_password"));
ConfigRetrieverOptions options = new ConfigRetrieverOptions()
.addStore(classpathStore);
PoolOptions poolOptions = new PoolOptions().setMaxSize(5);
dbPool = Pool.pool(vertx, connectOptions, poolOptions);
ConfigRetriever retriever = ConfigRetriever.create(vertx, options);
// Инициализация сервисов
userService = new UserService(dbPool);
retriever.getConfig()
.onSuccess(cfg -> {
config = AppConfig.from(cfg);
// Инициализация БД (создание таблицы users)
userService.initDatabase()
.onSuccess(v -> log.info("Database initialized successfully"))
.onFailure(err -> {
log.error("Failed to initialize database", err);
startPromise.fail(err);
return;
});
db = new DataBaseService(vertx, config.database);
redis = new RedisService(vertx, config.redis);
// Инициализация сервисов
userService = new UserService(db.getPool());
// Инициализация БД (создание таблицы users)
userService.initDatabase()
.onSuccess(v -> log.info("Database initialized successfully"))
.onFailure(err -> {
log.error("Failed to initialize database", err);
startPromise.fail(err);
return;
});
Router router = initRouter();
startHttp(router, startPromise);
})
.onFailure(startPromise::fail);
}
private Router initRouter() {
// Настройка сессий (используем LocalSessionStore для простоты)
SessionStore sessionStore = LocalSessionStore.create(vertx);
@@ -95,27 +105,8 @@ public class MainVerticle extends AbstractVerticle {
});
// Health Checks
HealthChecks hc = HealthChecks.create(vertx);
hc.register("database", promise ->
dbPool.getConnection().onComplete(ar -> {
if (ar.succeeded()) {
ar.result().close();
promise.complete(Status.OK());
} else {
promise.fail(ar.cause());
}
})
);
router.get("/health").handler(rc ->
hc.checkStatus().onComplete(ar -> {
if (ar.succeeded()) {
rc.response().end(ar.result().toJson().encodePrettily());
} else {
rc.response().setStatusCode(503).end(ar.cause().getMessage());
}
})
);
HealthCheckService healthCheckService = new HealthCheckService(vertx, redis, db);
healthCheckService.registerHealthCheck(router);
// API маршруты
AuthHandler authHandler = new AuthHandler(userService);
@@ -169,26 +160,37 @@ public class MainVerticle extends AbstractVerticle {
.setCachingEnabled(false)
.setIndexPage("index.html"));
// Запуск HTTP сервера
int port = config.getInteger("http_port");
vertx.createHttpServer()
.requestHandler(router)
.listen(port).onComplete(http -> {
if (http.succeeded()) {
log.info("HTTP server started on port {}", port);
startPromise.complete();
} else {
log.error("Failed to start HTTP server", http.cause());
startPromise.fail(http.cause());
}
return router;
}
private void startHttp(Router router, Promise<Void> startPromise) {
// Запуск HTTP-сервера
httpServer = vertx.createHttpServer();
httpServer.requestHandler(router).listen(config.server.port, config.server.host)
.onSuccess(server -> {
log.info("HTTP server started on port {}", server.actualPort());
startPromise.complete();
})
.onFailure(throwable -> {
log.error(throwable.getMessage());
startPromise.fail(throwable);
});
}
@Override
public void stop(Promise<Void> stopPromise) {
if (dbPool != null) {
dbPool.close();
}
stopPromise.complete();
this.httpServer.close()
.onSuccess(server -> {
log.info("Stop HTTP server");
this.db.disconnect();
this.redis.disconnect();
this.vertx.close()
.onSuccess(vertx -> {
log.info("Stop Vert.x");
stopPromise.complete();
})
.onFailure(throwable -> log.info(throwable.getMessage()));
})
.onFailure(throwable -> log.info(throwable.getMessage()));
}
}

View File

@@ -0,0 +1,105 @@
package su.xserver.iikocon.config;
import io.vertx.core.json.JsonObject;
import java.util.Map;
public class AppConfig {
public ServerConfig server;
public DatabaseConfig database;
public RedisConfig redis;
public static AppConfig from(JsonObject json) {
JsonObject resolved = json.copy();
applyEnvOverrides(resolved);
return resolved.mapTo(AppConfig.class);
}
private static void applyEnvOverrides(JsonObject config) {
Map<String, String> env = System.getenv();
env.forEach((key, value) -> {
String path = envKeyToPath(key);
if (path != null && exists(config, path)) {
setValue(config, path, value);
}
});
}
/**
* SERVER__MAX_POOL_SIZE -> server.maxPoolSize
*/
private static String envKeyToPath(String envKey) {
if (!envKey.contains("__")) {
return null;
}
String[] levels = envKey.toLowerCase().split("__");
for (int i = 0; i < levels.length; i++) {
levels[i] = toCamelCase(levels[i]);
}
return String.join(".", levels);
}
private static String toCamelCase(String value) {
String[] parts = value.split("_");
StringBuilder sb = new StringBuilder(parts[0]);
for (int i = 1; i < parts.length; i++) {
sb.append(Character.toUpperCase(parts[i].charAt(0)))
.append(parts[i].substring(1));
}
return sb.toString();
}
private static boolean exists(JsonObject json, String path) {
String[] parts = path.split("\\.");
JsonObject current = json;
for (int i = 0; i < parts.length - 1; i++) {
if (!current.containsKey(parts[i])) {
return false;
}
current = current.getJsonObject(parts[i]);
}
return current.containsKey(parts[parts.length - 1]);
}
private static void setValue(JsonObject json, String path, String value) {
String[] parts = path.split("\\.");
JsonObject current = json;
for (int i = 0; i < parts.length - 1; i++) {
current = current.getJsonObject(parts[i]);
}
String key = parts[parts.length - 1];
Object oldValue = current.getValue(key);
current.put(key, cast(value, oldValue));
}
private static Object cast(String value, Object oldValue) {
if (oldValue instanceof Integer) return Integer.parseInt(value);
if (oldValue instanceof Long) return Long.parseLong(value);
if (oldValue instanceof Boolean) return Boolean.parseBoolean(value);
if (oldValue == null) return value;
return value;
}
public JsonObject json() {
return new JsonObject()
.put("server", server.json().getJsonObject("server"))
.put("database", database.json().getJsonObject("database"))
.put("redis", redis.json().getJsonObject("redis"));
}
@Override
public String toString() {
return json().encode();
}
}

View File

@@ -0,0 +1,26 @@
package su.xserver.iikocon.config;
import io.vertx.core.json.JsonObject;
public class DatabaseConfig {
public String host;
public int port;
public String database;
public String user;
public String password;
public int connectionTimeout;
public int maxPoolSize;
public JsonObject json() {
return new JsonObject()
.put("database", new JsonObject()
.put("host", host)
.put("port", port)
.put("user", user)
.put("password", password)
.put("database", database)
.put("connectionTimeout", connectionTimeout)
.put("maxPoolSize", maxPoolSize)
);
}
}

View File

@@ -0,0 +1,22 @@
package su.xserver.iikocon.config;
import io.vertx.core.json.JsonObject;
public class RedisConfig {
public String host;
public int port;
public String password;
public int maxPoolSize;
public int maxWaitingHandlers;
public JsonObject json() {
return new JsonObject()
.put("redis", new JsonObject()
.put("host", host)
.put("port", port)
.put("password", password)
.put("maxPoolSize", maxPoolSize)
.put("maxWaitingHandlers", maxWaitingHandlers)
);
}
}

View File

@@ -0,0 +1,16 @@
package su.xserver.iikocon.config;
import io.vertx.core.json.JsonObject;
public class ServerConfig {
public int port;
public String host;
public JsonObject json() {
return new JsonObject()
.put("server", new JsonObject()
.put("port", port)
.put("host", host)
);
}
}

View File

@@ -0,0 +1,46 @@
package su.xserver.iikocon.service;
import io.vertx.core.Vertx;
import io.vertx.mysqlclient.MySQLConnectOptions;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.PoolOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import su.xserver.iikocon.config.DatabaseConfig;
public class DataBaseService {
private final Logger log = LoggerFactory.getLogger("[DataBaseService]");
private final DatabaseConfig config;
private final Pool pool;
public DataBaseService(Vertx vertx, DatabaseConfig config) {
log.info("Initialization");
this.config = config;
this.pool = Pool.pool(vertx, this.getMySQLConnectOptions(), this.getPoolOptions());
}
public Pool getPool() {
return this.pool;
}
public void disconnect() {
this.pool.close();
log.info("Connection is closed!");
}
private MySQLConnectOptions getMySQLConnectOptions() {
return new MySQLConnectOptions()
.setHost(this.config.host)
.setPort(this.config.port)
.setDatabase(this.config.database)
.setUser(this.config.user)
.setPassword(this.config.password);
}
private PoolOptions getPoolOptions() {
return new PoolOptions()
.setMaxSize(this.config.maxPoolSize)
.setConnectionTimeout(this.config.connectionTimeout)
.setName(this.config.database);
}
}

View File

@@ -0,0 +1,44 @@
package su.xserver.iikocon.service;
import io.vertx.core.Vertx;
import io.vertx.ext.healthchecks.Status;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.healthchecks.HealthCheckHandler;
import java.util.Collections;
public class HealthCheckService {
private final RedisService redisService;
private final DataBaseService dbService;
private final Vertx vertx;
public HealthCheckService(Vertx vertx, RedisService redisService, DataBaseService dbService) {
this.vertx = vertx;
this.redisService = redisService;
this.dbService = dbService;
}
public void registerHealthCheck(Router router) {
HealthCheckHandler healthCheckHandler = HealthCheckHandler.create(vertx);
// Redis check
healthCheckHandler.register("redis", future -> redisService.getRedisApi().ping(Collections.emptyList())
.onSuccess(response -> {
if ("PONG".equalsIgnoreCase(response.toString())) {
future.complete(Status.OK());
} else {
future.tryFail("Unexpected Redis response: " + response);
}
})
.onFailure(err -> future.tryFail("Redis ping failed: " + err.getMessage())));
// Database check
healthCheckHandler.register("database", future -> dbService.getPool().query("SELECT 1").execute()
.onSuccess(rs -> future.complete(Status.OK()))
.onFailure(t -> future.fail("Database is not reachable: " + t.getMessage())));
// Регистрируем endpoint /health
router.get("/health").handler(healthCheckHandler);
}
}

View File

@@ -0,0 +1,48 @@
package su.xserver.iikocon.service;
import io.vertx.core.Vertx;
import io.vertx.redis.client.Redis;
import io.vertx.redis.client.RedisAPI;
import io.vertx.redis.client.RedisOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import su.xserver.iikocon.config.RedisConfig;
public class RedisService {
private final Logger log = LoggerFactory.getLogger("[RedisService]");
private final RedisConfig config;
private final Redis redis;
public RedisService(Vertx vertx, RedisConfig config) {
log.info("Initialization");
this.config = config;
this.redis = Redis.createClient(vertx, this.getRedisOptions());
}
public Redis getRedis() {
return this.redis;
}
public RedisAPI getRedisApi() {
return RedisAPI.api(this.redis);
}
public void disconnect() {
this.redis.close();
log.info("Connection is closed!");
}
private RedisOptions getRedisOptions() {
RedisOptions options = new RedisOptions()
.addConnectionString(String.format("redis://%s:%s",
this.config.host,
this.config.port))
.setMaxPoolSize(this.config.maxPoolSize)
.setMaxWaitingHandlers(this.config.maxWaitingHandlers);
if (this.config.password != null) {
options.setPassword(this.config.password);
}
return options;
}
}

View File

@@ -0,0 +1,22 @@
{
"server": {
"port": 8080,
"host": "0.0.0.0"
},
"database": {
"host": "localhost",
"port": 3306,
"user": "user",
"password": "password",
"database": "database",
"connectionTimeout": 5000,
"maxPoolSize": 8
},
"redis": {
"host": "localhost",
"port": 6379,
"password": null,
"maxPoolSize": 6,
"maxWaitingHandlers": 6
}
}

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN" monitorInterval="60">
<!-- Папка и имя приложения -->
<Properties>
<Property name="LOG_DIR">logs</Property>
<Property name="APP_NAME">iiko-app</Property>
</Properties>
<!-- Консоль -->
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n" charset="UTF-8"/>
</Console>
<!-- Основной rolling файл -->
<RollingFile name="File"
fileName="${LOG_DIR}/${APP_NAME}.log"
filePattern="${LOG_DIR}/${APP_NAME}.%d{yyyy-MM-dd}.log.gz">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n" charset="UTF-8"/>
<Policies>
<TimeBasedTriggeringPolicy interval="1" modulate="true"/>
</Policies>
<DefaultRolloverStrategy max="30" totalSizeCap="1GB"/>
</RollingFile>
<!-- Файл только для ошибок -->
<RollingFile name="ErrorFile"
fileName="${LOG_DIR}/${APP_NAME}-error.log"
filePattern="${LOG_DIR}/${APP_NAME}-error.%d{yyyy-MM-dd}.log.gz">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n" charset="UTF-8"/>
<Policies>
<TimeBasedTriggeringPolicy interval="1" modulate="true"/>
</Policies>
<Filters>
<ThresholdFilter level="error" onMatch="ACCEPT" onMismatch="DENY"/>
</Filters>
<DefaultRolloverStrategy max="30" totalSizeCap="500MB"/>
</RollingFile>
</Appenders>
<Loggers>
<Root level="info">
<AppenderRef ref="Console"/>
<AppenderRef ref="File"/>
<AppenderRef ref="ErrorFile"/>
</Root>
</Loggers>
</Configuration>