use crate::{ConfigError, ConfigSource};
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
pub struct ConsulConfigSource {
url: String,
token: Option<String>,
prefix: String,
}
#[derive(Deserialize)]
struct ConsulKV {
#[serde(rename = "Key")]
key: String,
#[serde(rename = "Value")]
value: Option<String>,
}
impl ConsulConfigSource {
pub fn new(
url: impl Into<String>,
token: Option<String>,
prefix: impl Into<String>,
) -> Self {
Self {
url: url.into(),
token,
prefix: prefix.into(),
}
}
fn build_url(&self, index: Option<u64>) -> String {
let base = self.url.trim_end_matches('/');
let mut url = format!("{}/v1/kv/{}?recurse", base, self.prefix);
if let Some(idx) = index {
url.push_str(&format!("&index={}&wait=10s", idx));
}
url
}
async fn fetch(
&self,
client: &reqwest::Client,
index: Option<u64>,
) -> Result<(HashMap<String, Value>, u64), ConfigError> {
let url = self.build_url(index);
let mut req = client.get(&url);
if let Some(token) = &self.token {
req = req.header("X-Consul-Token", token);
}
let resp = req
.send()
.await
.map_err(|e| ConfigError::Remote(format!("Consul 请求失败: {}", e)))?;
let consul_index = resp
.headers()
.get("X-Consul-Index")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok((HashMap::new(), consul_index));
}
if !resp.status().is_success() {
return Err(ConfigError::Remote(format!(
"Consul 返回错误状态: {}",
resp.status()
)));
}
let kvs: Vec<ConsulKV> = resp
.json()
.await
.map_err(|e| ConfigError::Remote(format!("Consul 响应解析失败: {}", e)))?;
let mut map = HashMap::new();
for kv in kvs {
let key = kv
.key
.strip_prefix(&self.prefix)
.unwrap_or(&kv.key)
.trim_start_matches('/')
.to_string();
if let Some(val_b64) = kv.value {
match crate::base64_decode(&val_b64) {
Ok(bytes) => {
let val_str = String::from_utf8_lossy(&bytes).to_string();
map.insert(key, crate::parse_config_value(&val_str));
}
Err(e) => {
tracing::warn!("Consul 值 Base64 解码失败 (key={}): {}", key, e);
}
}
}
}
Ok((map, consul_index))
}
}
#[async_trait]
impl ConfigSource for ConsulConfigSource {
async fn load(&self) -> Result<HashMap<String, Value>, ConfigError> {
let client = reqwest::Client::new();
let (map, _) = self.fetch(&client, None).await?;
Ok(map)
}
async fn watch(
&self,
callback: Box<dyn Fn(HashMap<String, Value>) + Send + Sync>,
) -> Result<(), ConfigError> {
let url = self.url.clone();
let token = self.token.clone();
let prefix = self.prefix.clone();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
tracing::error!("Consul watch: 创建运行时失败: {}", e);
return;
}
};
rt.block_on(async move {
let src = ConsulConfigSource { url, token, prefix };
let client = reqwest::Client::new();
let mut last_index: u64 = 0;
loop {
match src.fetch(&client, Some(last_index)).await {
Ok((map, idx)) => {
if idx > last_index {
last_index = idx;
callback(map);
}
}
Err(e) => {
tracing::warn!("Consul watch 请求失败: {}", e);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
});
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[ignore = "需要真实 Consul 服务,通过 CONSUL_URL 环境变量指定地址"]
async fn test_consul_load_real() {
let url =
std::env::var("CONSUL_URL").unwrap_or_else(|_| "http://localhost:8500".to_string());
let src = ConsulConfigSource::new(url, None, "sz-orm-test/");
let data = src.load().await;
println!("Consul load result: {:?}", data);
}
#[tokio::test]
#[ignore = "需要真实 Consul 服务"]
async fn test_consul_load_unreachable() {
let src = ConsulConfigSource::new("http://localhost:19999", None, "test/");
let result = src.load().await;
assert!(result.is_err(), "连接不存在的 Consul 应返回错误");
}
}