use serde::de::DeserializeOwned;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("配置文件读取失败: {path} — {source}")]
FileRead {
path: String,
#[source]
source: std::io::Error,
},
#[error("配置文件解析失败: {path} — {source}")]
Parse {
path: String,
#[source]
source: serde_yml::Error,
},
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct AppConfig {
#[serde(default)]
pub app: AppSection,
#[serde(default)]
pub database: DatabaseSection,
#[serde(default)]
pub cache: CacheSection,
#[serde(default)]
pub addons: AddonsSection,
#[serde(default)]
pub log: LogSection,
#[serde(default)]
pub server: ServerSection,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AppSection {
#[serde(default)]
pub app_host: String,
#[serde(default)]
pub app_namespace: String,
#[serde(default = "default_true")]
pub with_route: bool,
#[serde(default = "default_true")]
pub with_event: bool,
#[serde(default = "default_default_app")]
pub default_app: String,
#[serde(default = "default_timezone")]
pub default_timezone: String,
#[serde(default = "default_true")]
pub auto_multi_app: bool,
#[serde(default = "default_app_map")]
pub app_map: HashMap<String, String>,
#[serde(default = "default_deny_app_list")]
pub deny_app_list: Vec<String>,
}
impl Default for AppSection {
fn default() -> Self {
Self {
app_host: String::new(),
app_namespace: String::new(),
with_route: true,
with_event: true,
default_app: default_default_app(),
default_timezone: default_timezone(),
auto_multi_app: true,
app_map: default_app_map(),
deny_app_list: default_deny_app_list(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseSection {
#[serde(default = "default_mysql")]
pub default: String,
#[serde(default = "default_true")]
pub auto_timestamp: bool,
#[serde(default = "default_datetime_format")]
pub datetime_format: String,
#[serde(default)]
pub connections: HashMap<String, DatabaseConnection>,
}
impl Default for DatabaseSection {
fn default() -> Self {
Self {
default: default_mysql(),
auto_timestamp: true,
datetime_format: default_datetime_format(),
connections: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseConnection {
#[serde(default = "default_mysql")]
pub r#type: String,
#[serde(default)]
pub hostname: String,
#[serde(default)]
pub database: String,
#[serde(default)]
pub username: String,
#[serde(default, skip_serializing)]
pub password: String,
#[serde(default = "default_port_8802")]
pub hostport: u16,
#[serde(default = "default_charset_utf8mb4")]
pub charset: String,
#[serde(default)]
pub prefix: String,
#[serde(default)]
pub deploy: u8,
#[serde(default)]
pub rw_separate: bool,
#[serde(default = "default_true")]
pub fields_strict: bool,
#[serde(default = "default_true")]
pub break_reconnect: bool,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct CacheSection {
#[serde(default = "default_cache_memory")]
pub default: String,
#[serde(default)]
pub stores: HashMap<String, CacheStore>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct CacheStore {
#[serde(default)]
pub r#type: String,
#[serde(default)]
pub capacity: usize,
#[serde(default)]
pub levels: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct AddonsSection {
#[serde(default = "default_addons_path")]
pub addons_path: String,
#[serde(default)]
pub priority: AddonsPriority,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct AddonsPriority {
#[serde(default)]
pub p0: Vec<String>,
#[serde(default)]
pub p1: Vec<String>,
#[serde(default)]
pub p2: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct LogSection {
#[serde(default = "default_log_file")]
pub default: String,
#[serde(default)]
pub channels: HashMap<String, LogChannel>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct LogChannel {
#[serde(default)]
pub r#type: String,
#[serde(default)]
pub path: String,
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default)]
pub max_files: u32,
#[serde(default)]
pub format: String,
}
fn default_true() -> bool {
true
}
fn default_default_app() -> String {
"index".to_string()
}
fn default_timezone() -> String {
"Asia/Shanghai".to_string()
}
fn default_app_map() -> HashMap<String, String> {
let mut map = HashMap::new();
map.insert("oapc".to_string(), "oapc".to_string());
map.insert("admin".to_string(), "admin".to_string());
map.insert("api".to_string(), "api".to_string());
map.insert("farm".to_string(), "farm".to_string());
map.insert("oapi".to_string(), "oapi".to_string());
map.insert("cashier".to_string(), "cashier".to_string());
map.insert("scene".to_string(), "scene".to_string());
map
}
fn default_deny_app_list() -> Vec<String> {
vec!["common".to_string()]
}
#[derive(Debug, Clone, Deserialize)]
pub struct ServerSection {
#[serde(default = "default_server_host")]
pub host: String,
#[serde(default = "default_server_port")]
pub port: u16,
}
impl Default for ServerSection {
fn default() -> Self {
Self {
host: default_server_host(),
port: default_server_port(),
}
}
}
fn default_server_host() -> String {
"0.0.0.0".to_string()
}
fn default_server_port() -> u16 {
8080
}
fn default_mysql() -> String {
"mysql".to_string()
}
fn default_datetime_format() -> String {
"Y-m-d H:i:s".to_string()
}
fn default_port_8802() -> u16 {
8802
}
fn default_charset_utf8mb4() -> String {
"utf8mb4".to_string()
}
fn default_cache_memory() -> String {
"memory".to_string()
}
fn default_addons_path() -> String {
"addons".to_string()
}
fn default_log_file() -> String {
"file".to_string()
}
fn default_log_level() -> String {
"info".to_string()
}
impl AppConfig {
#[tracing::instrument(skip_all)]
pub fn load_from_dir(config_dir: impl AsRef<Path>) -> Result<Self, ConfigError> {
let dir = config_dir.as_ref();
let mut config = AppConfig {
app: load_section(&dir.join("app.yml"), AppSection::default())?,
database: load_section(&dir.join("database.yml"), DatabaseSection::default())?,
cache: load_section(&dir.join("cache.yml"), CacheSection::default())?,
addons: load_section(&dir.join("addons.yml"), AddonsSection::default())?,
log: load_section(&dir.join("log.yml"), LogSection::default())?,
server: load_section(&dir.join("server.yml"), ServerSection::default())?,
};
config.apply_env_overrides();
Ok(config)
}
#[tracing::instrument(skip(self))]
pub fn apply_env_overrides(&mut self) {
for (conn_name, conn) in &mut self.database.connections {
let prefix = format!("SZ_DB_{}", conn_name.to_uppercase());
let env_key = format!("{}_PASSWORD", prefix);
if let Ok(password) = std::env::var(&env_key) {
if !password.is_empty() {
conn.password = password;
}
}
let env_key = format!("{}_HOSTNAME", prefix);
if let Ok(hostname) = std::env::var(&env_key) {
if !hostname.is_empty() {
conn.hostname = hostname;
}
}
let env_key = format!("{}_HOSTPORT", prefix);
if let Ok(hostport_str) = std::env::var(&env_key) {
if !hostport_str.is_empty() {
if let Ok(hostport) = hostport_str.parse() {
conn.hostport = hostport;
}
}
}
}
}
pub fn default_connection(&self) -> Option<&DatabaseConnection> {
self.database.connections.get(&self.database.default)
}
}
fn load_section<T: DeserializeOwned + Default>(path: &Path, default: T) -> Result<T, ConfigError> {
if !path.exists() {
return Ok(default);
}
let content = std::fs::read_to_string(path).map_err(|e| ConfigError::FileRead {
path: path.display().to_string(),
source: e,
})?;
serde_yml::from_str(&content).map_err(|e| ConfigError::Parse {
path: path.display().to_string(),
source: e,
})
}
pub struct ConfigWatcher {
config_dir: std::path::PathBuf,
shared_config: Arc<parking_lot::RwLock<AppConfig>>,
poll_interval_secs: u64,
last_mtimes: parking_lot::RwLock<HashMap<String, std::time::SystemTime>>,
}
pub struct ConfigWatcherHandle {
cancel: tokio_util::sync::CancellationToken,
}
impl ConfigWatcherHandle {
pub fn stop(&self) {
self.cancel.cancel();
}
}
impl ConfigWatcher {
pub fn new(
config_dir: impl Into<std::path::PathBuf>,
shared_config: Arc<parking_lot::RwLock<AppConfig>>,
) -> Self {
Self {
config_dir: config_dir.into(),
shared_config,
poll_interval_secs: 5,
last_mtimes: parking_lot::RwLock::new(HashMap::new()),
}
}
#[must_use]
pub fn with_poll_interval(mut self, secs: u64) -> Self {
self.poll_interval_secs = secs;
self
}
fn init_mtimes(&self) {
let files = self.config_files();
let mut mtimes = self.last_mtimes.write();
for file in &files {
if let Ok(meta) = std::fs::metadata(file) {
if let Ok(mtime) = meta.modified() {
mtimes.insert(file.display().to_string(), mtime);
}
}
}
}
fn config_files(&self) -> Vec<std::path::PathBuf> {
let names = [
"app.yml",
"database.yml",
"cache.yml",
"addons.yml",
"log.yml",
"server.yml",
];
names.iter().map(|n| self.config_dir.join(n)).collect()
}
fn has_changes(&self) -> bool {
let files = self.config_files();
let mtimes = self.last_mtimes.read();
for file in &files {
if let Ok(meta) = std::fs::metadata(file) {
if let Ok(mtime) = meta.modified() {
let key = file.display().to_string();
if let Some(last) = mtimes.get(&key) {
if last != &mtime {
return true;
}
} else {
return true;
}
}
}
}
false
}
fn update_mtimes(&self) {
let files = self.config_files();
let mut mtimes = self.last_mtimes.write();
for file in &files {
if let Ok(meta) = std::fs::metadata(file) {
if let Ok(mtime) = meta.modified() {
mtimes.insert(file.display().to_string(), mtime);
}
}
}
}
pub fn start(self) -> ConfigWatcherHandle {
let cancel = tokio_util::sync::CancellationToken::new();
let cancel_clone = cancel.clone();
self.init_mtimes();
let config_dir = self.config_dir.clone();
let shared_config = self.shared_config.clone();
let poll_interval = std::time::Duration::from_secs(self.poll_interval_secs);
let watcher = self;
tokio::spawn(async move {
let mut ticker = tokio::time::interval(poll_interval);
ticker.tick().await;
loop {
tokio::select! {
_ = cancel_clone.cancelled() => {
tracing::info!("配置热重载监听已停止");
break;
}
_ = ticker.tick() => {
if watcher.has_changes() {
tracing::info!("检测到配置文件变化,正在重新加载...");
match AppConfig::load_from_dir(&config_dir) {
Ok(new_config) => {
*shared_config.write() = new_config;
watcher.update_mtimes();
tracing::info!("配置热重载完成");
}
Err(e) => {
tracing::error!("配置热重载失败,保留旧配置: {e}");
watcher.update_mtimes();
}
}
}
}
}
}
});
ConfigWatcherHandle { cancel }
}
}
#[cfg(test)]
mod tests {
use super::*;
static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn test_default_config() {
let config = AppConfig::default();
assert!(config.app.auto_multi_app);
assert!(config.app.with_route);
assert_eq!(config.app.default_app, "index");
assert_eq!(config.app.default_timezone, "Asia/Shanghai");
assert_eq!(config.app.app_map.len(), 7);
assert!(config.app.app_map.contains_key("oapc"));
assert_eq!(config.app.deny_app_list, vec!["common"]);
assert_eq!(config.database.default, "mysql");
assert!(config.database.auto_timestamp);
assert_eq!(config.database.datetime_format, "Y-m-d H:i:s");
assert_eq!(config.server.host, "0.0.0.0");
assert_eq!(config.server.port, 8080);
}
#[test]
fn test_load_from_yaml_string() {
let yaml = r#"
app_host: "https://example.com"
default_app: "api"
auto_multi_app: true
app_map:
oapc: oapc
admin: admin
"#;
let app: AppSection = serde_yml::from_str(yaml).unwrap();
assert_eq!(app.app_host, "https://example.com");
assert_eq!(app.default_app, "api");
assert!(app.auto_multi_app);
assert_eq!(app.app_map.len(), 2);
}
#[test]
fn test_load_from_dir() {
let config_dir = std::env::current_dir().ok().and_then(|d| {
let mut current = d.clone();
for _ in 0..5 {
if current.join("config").exists() {
return Some(current.join("config"));
}
if let Some(parent) = current.parent() {
current = parent.to_path_buf();
} else {
break;
}
}
None
});
if let Some(config_dir) = config_dir {
let config = AppConfig::load_from_dir(&config_dir).unwrap();
assert_eq!(config.app.default_app, "index");
assert!(config.app.auto_multi_app);
assert_eq!(config.app.app_map.len(), 7);
assert_eq!(config.app.deny_app_list, vec!["common"]);
assert_eq!(config.database.default, "mysql");
assert_eq!(config.database.connections.len(), 5);
assert!(config.database.connections.contains_key("mysql"));
assert!(config.database.connections.contains_key("njszjt"));
assert!(config.database.connections.contains_key("ljclz"));
assert!(config.database.connections.contains_key("food"));
assert!(config.database.connections.contains_key("oceanbase"));
let mysql = config.database.connections.get("mysql").unwrap();
assert_eq!(mysql.hostname, "localhost");
assert_eq!(mysql.hostport, 8802);
assert_eq!(mysql.charset, "utf8mb4");
assert_eq!(mysql.prefix, "sz_");
let ljclz = config.database.connections.get("ljclz").unwrap();
assert_eq!(ljclz.charset, "utf8");
assert_eq!(ljclz.prefix, "ims_");
let oceanbase = config.database.connections.get("oceanbase").unwrap();
assert_eq!(oceanbase.hostport, 2881);
assert_eq!(oceanbase.hostname, "localhost");
assert_eq!(config.cache.default, "memory");
assert!(config.cache.stores.contains_key("memory"));
assert_eq!(config.addons.addons_path, "addons");
assert_eq!(config.addons.priority.p0.len(), 3);
assert_eq!(config.log.default, "file");
assert!(config.log.channels.contains_key("file"));
assert_eq!(config.server.host, "0.0.0.0");
assert_eq!(config.server.port, 8080);
}
}
#[test]
fn test_load_missing_file_uses_default() {
let temp_dir = std::env::temp_dir().join("sz_rust_config_test_missing");
let _ = std::fs::create_dir_all(&temp_dir);
let config = AppConfig::load_from_dir(&temp_dir).unwrap();
assert!(config.app.auto_multi_app);
assert_eq!(config.database.default, "mysql");
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_env_override_password() {
let _env_guard = ENV_TEST_LOCK.lock().unwrap();
std::env::remove_var("SZ_DB_MYSQL_PASSWORD");
let mut config = AppConfig::default();
config.database.connections.insert(
"mysql".to_string(),
DatabaseConnection {
r#type: "mysql".to_string(),
hostname: "localhost".to_string(),
database: "test".to_string(),
username: "root".to_string(),
password: String::new(),
hostport: 3306,
charset: "utf8mb4".to_string(),
prefix: "sz_".to_string(),
deploy: 0,
rw_separate: false,
fields_strict: true,
break_reconnect: true,
},
);
std::env::set_var("SZ_DB_MYSQL_PASSWORD", "secret123");
config.apply_env_overrides();
assert_eq!(config.database.connections["mysql"].password, "secret123");
std::env::remove_var("SZ_DB_MYSQL_PASSWORD");
}
#[test]
fn test_env_override_empty_ignored() {
let _env_guard = ENV_TEST_LOCK.lock().unwrap();
std::env::remove_var("SZ_DB_MYSQL_PASSWORD");
let mut config = AppConfig::default();
config.database.connections.insert(
"mysql".to_string(),
DatabaseConnection {
r#type: "mysql".to_string(),
hostname: "localhost".to_string(),
database: "test".to_string(),
username: "root".to_string(),
password: "existing".to_string(),
hostport: 3306,
charset: "utf8mb4".to_string(),
prefix: "sz_".to_string(),
deploy: 0,
rw_separate: false,
fields_strict: true,
break_reconnect: true,
},
);
std::env::set_var("SZ_DB_MYSQL_PASSWORD", "");
config.apply_env_overrides();
assert_eq!(config.database.connections["mysql"].password, "existing");
std::env::remove_var("SZ_DB_MYSQL_PASSWORD");
}
#[test]
fn test_default_connection() {
let mut config = AppConfig::default();
config.database.default = "mysql".to_string();
config.database.connections.insert(
"mysql".to_string(),
DatabaseConnection {
r#type: "mysql".to_string(),
hostname: "localhost".to_string(),
database: "test".to_string(),
username: "root".to_string(),
password: String::new(),
hostport: 3306,
charset: "utf8mb4".to_string(),
prefix: "sz_".to_string(),
deploy: 0,
rw_separate: false,
fields_strict: true,
break_reconnect: true,
},
);
let conn = config.default_connection();
assert!(conn.is_some());
assert_eq!(conn.unwrap().hostname, "localhost");
}
#[test]
fn test_default_connection_missing() {
let config = AppConfig::default();
assert!(config.default_connection().is_none());
}
#[test]
fn test_env_override_hostname() {
let _env_guard = ENV_TEST_LOCK.lock().unwrap();
std::env::remove_var("SZ_DB_MYSQL_HOSTNAME");
let mut config = AppConfig::default();
config.database.connections.insert(
"mysql".to_string(),
DatabaseConnection {
r#type: "mysql".to_string(),
hostname: "localhost".to_string(),
database: "test".to_string(),
username: "root".to_string(),
password: String::new(),
hostport: 3306,
charset: "utf8mb4".to_string(),
prefix: "sz_".to_string(),
deploy: 0,
rw_separate: false,
fields_strict: true,
break_reconnect: true,
},
);
std::env::set_var("SZ_DB_MYSQL_HOSTNAME", "10.0.0.5");
config.apply_env_overrides();
assert_eq!(config.database.connections["mysql"].hostname, "10.0.0.5");
std::env::remove_var("SZ_DB_MYSQL_HOSTNAME");
}
#[test]
fn test_env_override_hostport() {
let _env_guard = ENV_TEST_LOCK.lock().unwrap();
std::env::remove_var("SZ_DB_MYSQL_HOSTPORT");
let mut config = AppConfig::default();
config.database.connections.insert(
"mysql".to_string(),
DatabaseConnection {
r#type: "mysql".to_string(),
hostname: "localhost".to_string(),
database: "test".to_string(),
username: "root".to_string(),
password: String::new(),
hostport: 3306,
charset: "utf8mb4".to_string(),
prefix: "sz_".to_string(),
deploy: 0,
rw_separate: false,
fields_strict: true,
break_reconnect: true,
},
);
std::env::set_var("SZ_DB_MYSQL_HOSTPORT", "8802");
config.apply_env_overrides();
assert_eq!(config.database.connections["mysql"].hostport, 8802);
std::env::remove_var("SZ_DB_MYSQL_HOSTPORT");
}
#[test]
fn test_env_override_hostport_invalid_ignored() {
let _env_guard = ENV_TEST_LOCK.lock().unwrap();
std::env::remove_var("SZ_DB_MYSQL_HOSTPORT");
let mut config = AppConfig::default();
config.database.connections.insert(
"mysql".to_string(),
DatabaseConnection {
r#type: "mysql".to_string(),
hostname: "localhost".to_string(),
database: "test".to_string(),
username: "root".to_string(),
password: String::new(),
hostport: 3306,
charset: "utf8mb4".to_string(),
prefix: "sz_".to_string(),
deploy: 0,
rw_separate: false,
fields_strict: true,
break_reconnect: true,
},
);
std::env::set_var("SZ_DB_MYSQL_HOSTPORT", "not-a-number");
config.apply_env_overrides();
assert_eq!(config.database.connections["mysql"].hostport, 3306);
std::env::remove_var("SZ_DB_MYSQL_HOSTPORT");
}
#[test]
fn test_parse_error() {
let bad_yaml = "default: mysql\n bad: : : indent";
let result: Result<DatabaseSection, _> = serde_yml::from_str(bad_yaml);
let _ = result;
}
#[test]
fn test_config_watcher_has_changes_false_on_init() {
let dir = std::env::temp_dir().join("sz_rust_watcher_test_init");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("app.yml"), "default_app: test\n").unwrap();
let config = AppConfig::load_from_dir(&dir).unwrap();
let shared = Arc::new(parking_lot::RwLock::new(config));
let watcher = ConfigWatcher::new(&dir, shared);
watcher.init_mtimes();
assert!(!watcher.has_changes());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_config_watcher_detects_file_modification() {
let dir = std::env::temp_dir().join("sz_rust_watcher_test_modify");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("app.yml"), "default_app: before\n").unwrap();
let config = AppConfig::load_from_dir(&dir).unwrap();
let shared = Arc::new(parking_lot::RwLock::new(config));
let watcher = ConfigWatcher::new(&dir, shared);
watcher.init_mtimes();
assert!(!watcher.has_changes());
std::thread::sleep(std::time::Duration::from_millis(50));
std::fs::write(dir.join("app.yml"), "default_app: after\n").unwrap();
assert!(watcher.has_changes());
watcher.update_mtimes();
assert!(!watcher.has_changes());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_config_watcher_detects_new_file() {
let dir = std::env::temp_dir().join("sz_rust_watcher_test_new");
let _ = std::fs::create_dir_all(&dir);
let config = AppConfig::load_from_dir(&dir).unwrap();
let shared = Arc::new(parking_lot::RwLock::new(config));
let watcher = ConfigWatcher::new(&dir, shared);
watcher.init_mtimes();
std::fs::write(dir.join("app.yml"), "default_app: new\n").unwrap();
assert!(watcher.has_changes());
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_config_watcher_hot_reload() {
let dir = std::env::temp_dir().join("sz_rust_watcher_test_hot");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("app.yml"), "default_app: before\n").unwrap();
let config = AppConfig::load_from_dir(&dir).unwrap();
let shared = Arc::new(parking_lot::RwLock::new(config));
let watcher = ConfigWatcher::new(&dir, shared.clone()).with_poll_interval(1);
let handle = watcher.start();
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
std::thread::sleep(std::time::Duration::from_millis(100));
std::fs::write(dir.join("app.yml"), "default_app: hot_reloaded\n").unwrap();
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
let current = shared.read().clone();
assert_eq!(current.app.default_app, "hot_reloaded");
handle.stop();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let _ = std::fs::remove_dir_all(&dir);
}
}