Skip to main content

sz_rust_core/
health.rs

1//! 健康检查端点 — `/health`
2//!
3//! 提供轻量级健康检查端点,用于容器编排(K8s liveness/readiness)和负载均衡探测。
4//!
5//! ## 设计原则
6//!
7//! - **liveness 探针**:进程存活即返回 200,不检查依赖(避免级联重启)
8//! - **readiness 探针**:可附加 [`HealthCheck`] 子检查(DB/Cache 连通性),全部通过才返回 200
9//! - **响应格式**:JSON,与 [`crate::response::ApiResponse`] 保持一致
10//!
11//! ## 用法
12//!
13//! ```ignore
14//! use sz_rust_core::health::{HealthRegistry, HealthCheck};
15//! use axum::Router;
16//!
17//! let registry = HealthRegistry::new();
18//! let router: Router = registry.router_at("/health");
19//! ```
20//!
21//! ## 端点
22//!
23//! - `GET /health/`:liveness 探针(始终 200)
24//! - `GET /health/ready`:readiness 探针(执行所有子检查)
25
26use crate::response::ApiResponse;
27use axum::response::{IntoResponse, Response};
28use axum::routing::get;
29use axum::Router;
30use parking_lot::Mutex;
31use serde_json::{json, Value};
32use std::sync::Arc;
33use std::time::{Duration, Instant};
34
35/// 默认单个检查超时(3 秒)
36pub const DEFAULT_CHECK_TIMEOUT: Duration = Duration::from_secs(3);
37
38/// 健康检查子项(trait)
39///
40/// 实现此 trait 的检查器会被注册到 [`HealthRegistry`],在 `/health/ready` 中按序执行。
41pub trait HealthCheck: Send + Sync {
42    /// 检查器名称(如 "database"、"redis")
43    fn name(&self) -> &str;
44
45    /// 执行检查,返回 `Ok(())` 表示健康,`Err(msg)` 表示异常 + 错误描述
46    fn check(&self) -> Result<(), String>;
47}
48
49/// 健康检查注册表
50///
51/// 管理多个 [`HealthCheck`] 子检查,提供 liveness / readiness 探针。
52#[derive(Clone)]
53pub struct HealthRegistry {
54    checks: Arc<Mutex<Vec<Arc<dyn HealthCheck>>>>,
55    /// 单个检查的超时时间
56    timeout: Duration,
57}
58
59impl Default for HealthRegistry {
60    fn default() -> Self {
61        Self {
62            checks: Arc::new(Mutex::new(Vec::new())),
63            timeout: DEFAULT_CHECK_TIMEOUT,
64        }
65    }
66}
67
68impl HealthRegistry {
69    /// 创建注册表(默认 3 秒超时)
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// 创建带自定义超时的注册表
75    pub fn with_timeout(timeout: Duration) -> Self {
76        Self {
77            checks: Arc::new(Mutex::new(Vec::new())),
78            timeout,
79        }
80    }
81
82    /// 注册一个健康检查子项
83    pub fn register<C: HealthCheck + 'static>(&self, check: C) -> &Self {
84        self.checks.lock().push(Arc::new(check));
85        self
86    }
87
88    /// liveness 探针:进程存活即健康
89    pub fn liveness(&self) -> Response {
90        ApiResponse::success(json!({"status": "ok"}), "ok").into_response()
91    }
92
93    /// readiness 探针:执行所有子检查
94    ///
95    /// 全部通过返回 `code=1`,任一失败返回 `code=0` + 详细错误信息。
96    /// 单个检查超过 `timeout` 视为失败。
97    ///
98    /// ## 异步实现
99    ///
100    /// 每个检查通过 `tokio::task::spawn_blocking` 丢到阻塞线程池执行,
101    /// 通过 `tokio::time::timeout` 控制超时。这样不会阻塞 tokio executor,
102    /// 即使被频繁探测也不会拖累主服务吞吐量。
103    pub async fn readiness(&self) -> Response {
104        let checks = self.checks.lock().clone();
105        let mut results = serde_json::Map::new();
106        let mut all_ok = true;
107
108        for check in &checks {
109            let name = check.name().to_string();
110            let started = Instant::now();
111            let result = self.run_with_timeout(check).await;
112            let elapsed_ms = started.elapsed().as_millis();
113
114            match result {
115                Ok(()) => {
116                    results.insert(name, json!({"status": "ok", "elapsed_ms": elapsed_ms}));
117                }
118                Err(err) => {
119                    all_ok = false;
120                    results.insert(
121                        name,
122                        json!({"status": "fail", "error": err, "elapsed_ms": elapsed_ms}),
123                    );
124                }
125            }
126        }
127
128        let data = json!({
129            "status": if all_ok { "ok" } else { "fail" },
130            "checks": Value::Object(results),
131        });
132
133        if all_ok {
134            ApiResponse::success(data, "ok").into_response()
135        } else {
136            ApiResponse::error_with_data("health check failed", data).into_response()
137        }
138    }
139
140    /// 在阻塞线程池中执行单个检查,超时则返回错误
141    ///
142    /// 使用 `tokio::task::spawn_blocking` 将同步 `check()` 调度到阻塞线程池,
143    /// 通过 `tokio::time::timeout` 控制超时。这样不会阻塞 tokio 异步 executor。
144    async fn run_with_timeout(&self, check: &Arc<dyn HealthCheck>) -> Result<(), String> {
145        // timeout=0 表示不超时
146        if self.timeout.is_zero() {
147            let check_clone = Arc::clone(check);
148            return tokio::task::spawn_blocking(move || check_clone.check())
149                .await
150                .unwrap_or_else(|_| Err("check thread panicked".to_string()));
151        }
152
153        let check_clone = Arc::clone(check);
154        let timeout = self.timeout;
155        match tokio::time::timeout(
156            timeout,
157            tokio::task::spawn_blocking(move || check_clone.check()),
158        )
159        .await
160        {
161            Ok(Ok(result)) => result,
162            Ok(Err(join_err)) => Err(format!("check thread panicked: {join_err}")),
163            Err(_) => Err(format!("timeout after {}ms", timeout.as_millis())),
164        }
165    }
166
167    /// 构建带路径前缀的 Router(如 `/health`)
168    ///
169    /// - `GET {prefix}/` → liveness
170    /// - `GET {prefix}/ready` → readiness(异步执行子检查)
171    pub fn router_at(&self, prefix: &str) -> Router {
172        let liveness_self = self.clone();
173        let readiness_self = self.clone();
174        let liveness_path = format!("{prefix}/");
175        let readiness_path = format!("{prefix}/ready");
176        Router::new()
177            .route(
178                &liveness_path,
179                get(move || {
180                    let this = liveness_self.clone();
181                    std::future::ready(this.liveness())
182                }),
183            )
184            .route(
185                &readiness_path,
186                get(move || {
187                    let this = readiness_self.clone();
188                    async move { this.readiness().await }
189                }),
190            )
191    }
192}
193
194/// 默认健康检查 Router(路径前缀 `/health`,无子检查)
195pub fn default_health_router() -> Router {
196    HealthRegistry::new().router_at("/health")
197}
198
199// ============================================================================
200// 内置 HealthCheck 实现
201// ============================================================================
202
203/// 静态检查(用于测试或占位)
204pub struct StaticCheck {
205    name: String,
206    ok: bool,
207}
208
209impl StaticCheck {
210    /// 创建一个总是通过的静态检查
211    pub fn ok(name: impl Into<String>) -> Self {
212        Self {
213            name: name.into(),
214            ok: true,
215        }
216    }
217    /// 创建一个总是失败的静态检查
218    pub fn fail(name: impl Into<String>) -> Self {
219        Self {
220            name: name.into(),
221            ok: false,
222        }
223    }
224}
225
226impl HealthCheck for StaticCheck {
227    fn name(&self) -> &str {
228        &self.name
229    }
230    fn check(&self) -> Result<(), String> {
231        if self.ok {
232            Ok(())
233        } else {
234            Err("static check failed".to_string())
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use axum::body::Body;
243    use axum::http::{Method, Request, StatusCode};
244    use http_body_util::BodyExt;
245    use tower::ServiceExt;
246
247    async fn fetch_body(resp: Response) -> Value {
248        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
249        serde_json::from_slice(&bytes).unwrap()
250    }
251
252    async fn send_get(router: Router, uri: &str) -> Response {
253        let req = Request::builder()
254            .method(Method::GET)
255            .uri(uri)
256            .body(Body::empty())
257            .unwrap();
258        router.oneshot(req).await.unwrap()
259    }
260
261    // ====================================================================
262    // liveness 探针
263    // ====================================================================
264
265    #[tokio::test]
266    async fn test_liveness_returns_200() {
267        let registry = HealthRegistry::new();
268        let resp = registry.liveness();
269        assert_eq!(resp.status(), StatusCode::OK);
270    }
271
272    #[tokio::test]
273    async fn test_liveness_response_body() {
274        let registry = HealthRegistry::new();
275        let resp = registry.liveness();
276        let json = fetch_body(resp).await;
277        assert_eq!(json["code"], 1);
278        assert_eq!(json["msg"], "ok");
279        assert_eq!(json["data"]["status"], "ok");
280    }
281
282    #[tokio::test]
283    async fn test_liveness_content_type() {
284        let registry = HealthRegistry::new();
285        let resp = registry.liveness();
286        assert_eq!(
287            resp.headers().get("content-type").unwrap(),
288            "application/json; charset=utf-8"
289        );
290    }
291
292    // ====================================================================
293    // readiness 探针 - 无子检查
294    // ====================================================================
295
296    #[tokio::test]
297    async fn test_readiness_empty_registry_returns_ok() {
298        let registry = HealthRegistry::new();
299        let resp = registry.readiness().await;
300        assert_eq!(resp.status(), StatusCode::OK);
301        let json = fetch_body(resp).await;
302        assert_eq!(json["code"], 1);
303        assert_eq!(json["data"]["status"], "ok");
304        assert!(json["data"]["checks"].is_object());
305    }
306
307    // ====================================================================
308    // readiness 探针 - 静态检查器
309    // ====================================================================
310
311    #[tokio::test]
312    async fn test_readiness_all_checks_pass() {
313        let registry = HealthRegistry::new();
314        registry.register(StaticCheck::ok("database"));
315        registry.register(StaticCheck::ok("redis"));
316
317        let resp = registry.readiness().await;
318        assert_eq!(resp.status(), StatusCode::OK);
319        let json = fetch_body(resp).await;
320        assert_eq!(json["code"], 1);
321        assert_eq!(json["data"]["status"], "ok");
322        assert_eq!(json["data"]["checks"]["database"]["status"], "ok");
323        assert_eq!(json["data"]["checks"]["redis"]["status"], "ok");
324    }
325
326    #[tokio::test]
327    async fn test_readiness_one_check_fails() {
328        let registry = HealthRegistry::new();
329        registry.register(StaticCheck::ok("database"));
330        registry.register(StaticCheck::fail("redis"));
331
332        let resp = registry.readiness().await;
333        assert_eq!(resp.status(), StatusCode::OK);
334        let json = fetch_body(resp).await;
335        assert_eq!(json["code"], 0);
336        assert_eq!(json["data"]["status"], "fail");
337        assert_eq!(json["data"]["checks"]["database"]["status"], "ok");
338        assert_eq!(json["data"]["checks"]["redis"]["status"], "fail");
339        assert!(json["data"]["checks"]["redis"]["error"].is_string());
340    }
341
342    #[tokio::test]
343    async fn test_readiness_all_checks_fail() {
344        let registry = HealthRegistry::new();
345        registry.register(StaticCheck::fail("db1"));
346        registry.register(StaticCheck::fail("db2"));
347
348        let resp = registry.readiness().await;
349        let json = fetch_body(resp).await;
350        assert_eq!(json["code"], 0);
351        assert_eq!(json["data"]["status"], "fail");
352    }
353
354    // ====================================================================
355    // Router 集成
356    // ====================================================================
357
358    #[tokio::test]
359    async fn test_router_liveness_endpoint() {
360        let router = default_health_router();
361        let resp = send_get(router, "/health/").await;
362        assert_eq!(resp.status(), StatusCode::OK);
363        let json = fetch_body(resp).await;
364        assert_eq!(json["data"]["status"], "ok");
365    }
366
367    #[tokio::test]
368    async fn test_router_readiness_endpoint() {
369        let registry = HealthRegistry::new();
370        registry.register(StaticCheck::ok("db"));
371        let router = registry.router_at("/health");
372
373        let resp = send_get(router, "/health/ready").await;
374        assert_eq!(resp.status(), StatusCode::OK);
375        let json = fetch_body(resp).await;
376        assert_eq!(json["code"], 1);
377        assert_eq!(json["data"]["checks"]["db"]["status"], "ok");
378    }
379
380    #[tokio::test]
381    async fn test_router_unknown_path_returns_404() {
382        let router = default_health_router();
383        let resp = send_get(router, "/health/unknown").await;
384        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
385    }
386
387    // ====================================================================
388    // 超时测试
389    // ====================================================================
390
391    struct SlowCheck {
392        name: String,
393        delay: Duration,
394    }
395
396    impl HealthCheck for SlowCheck {
397        fn name(&self) -> &str {
398            &self.name
399        }
400        fn check(&self) -> Result<(), String> {
401            std::thread::sleep(self.delay);
402            Ok(())
403        }
404    }
405
406    #[tokio::test]
407    async fn test_readiness_timeout_handled() {
408        let registry = HealthRegistry::with_timeout(Duration::from_millis(100));
409        registry.register(SlowCheck {
410            name: "slow".to_string(),
411            delay: Duration::from_millis(500),
412        });
413
414        let resp = registry.readiness().await;
415        let json = fetch_body(resp).await;
416        assert_eq!(json["code"], 0);
417        assert_eq!(json["data"]["status"], "fail");
418        assert_eq!(json["data"]["checks"]["slow"]["status"], "fail");
419        assert!(json["data"]["checks"]["slow"]["error"]
420            .as_str()
421            .unwrap()
422            .contains("timeout"));
423    }
424
425    #[tokio::test]
426    async fn test_readiness_fast_check_passes_within_timeout() {
427        let registry = HealthRegistry::with_timeout(Duration::from_secs(3));
428        registry.register(SlowCheck {
429            name: "fast".to_string(),
430            delay: Duration::from_millis(10),
431        });
432
433        let resp = registry.readiness().await;
434        let json = fetch_body(resp).await;
435        assert_eq!(json["code"], 1);
436        assert_eq!(json["data"]["checks"]["fast"]["status"], "ok");
437    }
438
439    #[tokio::test]
440    async fn test_readiness_zero_timeout_no_limit() {
441        // timeout=0 表示不超时
442        let registry = HealthRegistry::with_timeout(Duration::ZERO);
443        registry.register(SlowCheck {
444            name: "fast".to_string(),
445            delay: Duration::from_millis(10),
446        });
447
448        let resp = registry.readiness().await;
449        let json = fetch_body(resp).await;
450        assert_eq!(json["code"], 1);
451        assert_eq!(json["data"]["checks"]["fast"]["status"], "ok");
452    }
453
454    // ====================================================================
455    // 注册表行为
456    // ====================================================================
457
458    #[test]
459    fn test_registry_default_is_empty() {
460        let registry = HealthRegistry::new();
461        assert_eq!(registry.checks.lock().len(), 0);
462    }
463
464    #[test]
465    fn test_registry_default_timeout_is_3s() {
466        let registry = HealthRegistry::new();
467        assert_eq!(registry.timeout, DEFAULT_CHECK_TIMEOUT);
468        assert_eq!(registry.timeout, Duration::from_secs(3));
469    }
470
471    #[test]
472    fn test_registry_register_increases_count() {
473        let registry = HealthRegistry::new();
474        registry.register(StaticCheck::ok("a"));
475        registry.register(StaticCheck::ok("b"));
476        registry.register(StaticCheck::ok("c"));
477        assert_eq!(registry.checks.lock().len(), 3);
478    }
479
480    #[test]
481    fn test_registry_clone_shares_state() {
482        let registry = HealthRegistry::new();
483        let cloned = registry.clone();
484        cloned.register(StaticCheck::ok("shared"));
485        assert_eq!(registry.checks.lock().len(), 1);
486    }
487
488    #[test]
489    fn test_static_check_ok_passes() {
490        let check = StaticCheck::ok("test");
491        assert!(check.check().is_ok());
492    }
493
494    #[test]
495    fn test_static_check_fail_fails() {
496        let check = StaticCheck::fail("test");
497        assert!(check.check().is_err());
498    }
499
500    #[test]
501    fn test_static_check_name() {
502        let check = StaticCheck::ok("my_check");
503        assert_eq!(check.name(), "my_check");
504    }
505
506    // ====================================================================
507    // 默认 router 集成
508    // ====================================================================
509
510    #[tokio::test]
511    async fn test_default_health_router_liveness() {
512        let router = default_health_router();
513        let resp = send_get(router, "/health/").await;
514        assert_eq!(resp.status(), StatusCode::OK);
515    }
516
517    #[tokio::test]
518    async fn test_default_health_router_readiness_empty() {
519        let router = default_health_router();
520        let resp = send_get(router, "/health/ready").await;
521        let json = fetch_body(resp).await;
522        assert_eq!(json["code"], 1);
523    }
524
525    // ====================================================================
526    // 自定义前缀
527    // ====================================================================
528
529    #[tokio::test]
530    async fn test_router_at_custom_prefix() {
531        let registry = HealthRegistry::new();
532        let router = registry.router_at("/status");
533
534        let resp = send_get(router, "/status/").await;
535        assert_eq!(resp.status(), StatusCode::OK);
536
537        let registry2 = HealthRegistry::new();
538        let router2 = registry2.router_at("/status");
539        let resp2 = send_get(router2, "/status/ready").await;
540        assert_eq!(resp2.status(), StatusCode::OK);
541    }
542
543    #[tokio::test]
544    async fn test_router_at_nested_prefix() {
545        let registry = HealthRegistry::new();
546        let router = registry.router_at("/api/v1/health");
547
548        let resp = send_get(router, "/api/v1/health/").await;
549        assert_eq!(resp.status(), StatusCode::OK);
550    }
551}