From 88868050ba2a479aaeff3bdf15bc896de182b1de Mon Sep 17 00:00:00 2001 From: shay7sev Date: Thu, 29 Jan 2026 14:37:31 +0800 Subject: [PATCH] feat(project): init --- .cargo/config.toml | 5 + .gitignore | 3 + Cargo.toml | 43 ++++++++ README.md | 203 ++++++++++++++++++++++++++++++++++++++ src/error.rs | 126 +++++++++++++++++++++++ src/lib.rs | 11 +++ src/telemetry.rs | 81 +++++++++++++++ tests/integration_test.rs | 155 +++++++++++++++++++++++++++++ 8 files changed, 627 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/telemetry.rs create mode 100644 tests/integration_test.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..b7c4f6d --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[registries.kellnr] +index = "sparse+https://kellnr.shay7sev.site/api/v1/crates/" + +[net] +git-fetch-with-cli = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e551aa3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +Cargo.lock +.env diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7efcae6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "common-telemetry" +version = "0.1.0" +edition = "2024" +description = "Microservice infrastructure library" + +publish = ["kellnr"] + +[features] +# 默认开启所有功能 +default = ["full"] +full = ["error", "telemetry"] + +# --- Error 模块依赖 --- +# 开启 error feature 将引入 thiserror, axum, serde +error = ["dep:thiserror", "dep:axum", "dep:serde", "dep:serde_json"] + +# --- Telemetry 模块依赖 --- +# 开启 telemetry feature 将引入 tracing 全家桶 +telemetry = ["dep:tracing", "dep:tracing-subscriber", "dep:tracing-appender"] + +[dependencies] +# Error 相关 +thiserror = { version = "2.0.18", optional = true } +axum = { version = "0.8.8", optional = true } +serde = { version = "1.0", features = ["derive"], optional = true } +serde_json = { version = "1.0", optional = true } + +# Telemetry 相关 +tracing = { version = "0.1", optional = true } +tracing-subscriber = { version = "0.3", features = [ + "env-filter", + "json", +], optional = true } +tracing-appender = { version = "0.2", optional = true } + +# --- 新增测试依赖 --- +[dev-dependencies] +tokio = { version = "1", features = ["full"] } +tower = { version = "0.5.3", features = ["util"] } # 用于模拟 Web 请求 +http = "1.0" +http-body-util = "0.1" # 用于读取 Body +tempfile = "3.8" # 用于创建临时日志目录 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e62fc7a --- /dev/null +++ b/README.md @@ -0,0 +1,203 @@ +# Microservice Common Lib (Rust) + +这是微服务架构的通用基础库,旨在统一所有服务的错误处理标准、日志格式以及分布式链路追踪。 + +## ✨ 核心特性 + +* **统一错误处理 (Error)**: + * 基于 `thiserror` 和 `anyhow` 的最佳实践。 + * **双 Token 支持**: 明确区分 `AccessTokenExpired` (20001) 和 `RefreshTokenExpired` (20002)。 + * **Axum 集成**: 实现了 `IntoResponse`,自动将错误转换为标准 JSON 格式并设置正确的 HTTP 状态码。 +* **可观测性 (Telemetry)**: + * 基于 `tracing` 生态。 + * 支持 **JSON 结构化日志** (适配 ELK/Loki)。 + * 支持 **控制台 + 文件双写**。 + * 支持 **非阻塞 (Non-blocking)** 异步日志写入与按天滚动。 +* **模块化设计**: 通过 Feature Flags 按需引入。 + +--- + +## 🚀 发布指南 + +你可以选择将此库发布到私有 Cargo 仓库 (Kellnr),或者直接作为 Git 依赖发布到 Gitea。 + +### 方法一:发布到 Kellnr (推荐) + +Kellnr 是一个私有的 Crates.io 镜像与仓库。 + +#### 1. 配置本地 Cargo +在本项目根目录(或全局 `~/.cargo/config.toml`)创建/修改 `.cargo/config.toml`,注册你的私有仓库: + +```toml +# .cargo/config.toml +[registries.kellnr] +# 注册表名称自定义,这里叫 "kellnr" +index = "sparse+https://kellnr.shay7sev.site/api/v1/crates/" + +[net] +git-fetch-with-cli = true +``` + +#### 2. 登录认证 +使用你的 Kellnr 账户 Token 进行登录(只需执行一次): + +```bash +cargo login --registry kellnr +``` + +#### 3. 修改 Cargo.toml +确保 `Cargo.toml` 中配置了禁止发布到公网,并指定了私有仓库: + +```toml +[package] +name = "common-telemetry" +version = "0.1.0" +# ... +publish = ["kellnr"] # 关键:防止误发到 crates.io +``` + +#### 4. 执行发布 +```bash +cargo publish --registry kellnr +``` + +--- + +### 方法二:推送到 Gitea (Git 依赖) + +如果你不想走 Cargo Registry 流程,可以直接作为 Git 仓库使用。 + +```bash +# 初始化 git (如果尚未初始化) +git init +git branch -M main + +# 添加远程仓库 (替换为你的实际仓库地址) +git remote add origin ssh://git@gitea.shay7sev.site:2222/admin/common-telemetry.git + +# 推送代码 +git add . +git commit -m "Initial commit" +git push -u origin main +``` + +--- + +## 📦 如何在其他服务中使用 + +假设你要在 `user-service` 中使用此库。 + +### 1. 引入依赖 + +#### 方式 A: 通过 Kellnr 引入 (如果已发布) +在 `user-service` 的 `Cargo.toml` 中: + +```toml +[dependencies] +# 指定 registry +common-telemetry = { version = "0.1", registry = "kellnr", features = ["full"] } +``` +*注意:使用此方式,`user-service` 项目也需要在 `.cargo/config.toml` 中配置 registry 地址。* + +#### 方式 B: 通过 Gitea (Git) 引入 (简单直接) +在 `user-service` 的 `Cargo.toml` 中: + +```toml +[dependencies] +# 指定 git 地址 +common-telemetry = { git = "ssh://git@gitea.shay7sev.site:2222/admin/common-telemetry.git", branch = "main", features = ["full"] } +``` + +### 2. 代码集成示例 + +#### A. 初始化日志 (main.rs) + +```rust +use common_lib::telemetry::{self, TelemetryConfig}; + +#[tokio::main] +async fn main() { + // 1. 配置 + let config = TelemetryConfig { + service_name: "user-service".into(), + log_level: "info".into(), // 或 "debug,sqlx=error" + log_to_file: true, // 生产环境建议开启 + log_dir: Some("./logs".into()), + log_file: Some("user.log".into()), + }; + + // 2. 初始化 Tracing + // !!! 警告:必须将 guard 赋值给一个变量 (_guard),并保持它在 main 函数整个生命周期内存活 + // 否则日志文件写入线程会被立即销毁! + let _guard = telemetry::init(config); + + tracing::info!("User Service started success!"); + + // ... 启动 Axum ... +} +``` + +#### B. 错误处理与响应 (Handler) + +```rust +use axum::{Json, response::IntoResponse}; +use common_lib::AppError; // 引入统一错误 + +async fn get_profile(token: String) -> Result, AppError> { + // 模拟:Token 过期 + if token_is_expired(&token) { + // 直接返回枚举,库会自动处理为 JSON Response (Code: 20001) + // 并且会自动打印 Error 级别的日志 + return Err(AppError::AccessTokenExpired); + } + + // 模拟:数据库错误 + let user = db::find_user().await + .map_err(|e| AppError::DbError(e.to_string()))?; + + Ok(Json(user)) +} +``` + +--- + +## ⚙️ 功能模块说明 (Feature Flags) + +为了减少编译时间和依赖大小,你可以按需开启功能: + +| Feature | 说明 | 包含依赖 | +| :--- | :--- | :--- | +| **`default`** | 默认开启所有功能 | `full` | +| **`full`** | 全功能集合 | `error`, `telemetry` | +| **`error`** | 仅使用错误处理与 HTTP 响应 | `thiserror`, `axum`, `serde` | +| **`telemetry`** | 仅使用日志与链路追踪 | `tracing`, `tracing-subscriber`, `tracing-appender` | + +**示例 (只用日志模块):** +```toml +common-telemetry = { version = "0.1", default-features = false, features = ["telemetry"] } +``` + +## 📝 错误码对照表 + +前端开发请参考以下业务状态码 (Code): + +| Code | 枚举 | 含义 | 前端建议动作 | +| :--- | :--- | :--- | :--- | +| `0` | `Success` | 成功 | - | +| `10000` | `ServerError` | 服务器内部错误 | 提示“系统繁忙” | +| `10001` | `BadRequest` | 请求参数错误 | 提示错误信息 | +| `20000` | `Unauthorized` | 未授权/签名无效 | 跳转登录 | +| **`20001`** | **`AccessTokenExpired`** | **Access Token 过期** | **使用 Refresh Token 静默刷新** | +| **`20002`** | **`RefreshTokenExpired`** | **Refresh Token 过期** | **强制登出,跳转登录页** | +| `20003` | `PermissionDenied` | 权限不足 | 提示无权访问 | + +--- + +## 🛠 开发注意事项 + +1. **Cargo.lock**: 本项目已将 `Cargo.lock` 加入 `.gitignore`,这是作为 Library 的最佳实践。 +2. **测试**: 运行集成测试以验证全链路功能。 + ```bash + # 使用 --nocapture 查看详细步骤打印 + cargo test -- --nocapture + ``` \ No newline at end of file diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..0fec408 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,126 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// 业务状态码 (Business Code) +/// 前端根据此 Code 进行逻辑跳转 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[repr(u32)] +pub enum BizCode { + Success = 0, + + // 10xxx: 服务端通用错误 + ServerError = 10000, + BadRequest = 10001, + + // 20xxx: 认证授权相关 (双 Token 核心逻辑) + Unauthorized = 20000, // 通用未授权/签名错误 + AccessTokenExpired = 20001, // 前端捕获 -> 用 RefreshToken 换新 AccessToken (静默) + RefreshTokenExpired = 20002, // 前端捕获 -> 强制退出到登录页 + PermissionDenied = 20003, +} + +/// 全局应用错误枚举 +#[derive(Error, Debug)] +pub enum AppError { + // --- 基础设施错误 --- + #[error("Database error: {0}")] + DbError(String), // 实际建议: DbError(#[from] sqlx::Error) + + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + // --- 业务逻辑错误 --- + #[error("Resource not found: {0}")] + NotFound(String), + + #[error("Invalid parameters: {0}")] + InvalidParam(String), + + // --- 认证相关 --- + #[error("Authentication failed")] + AuthError, + + #[error("Access token expired")] + AccessTokenExpired, + + #[error("Refresh token expired")] + RefreshTokenExpired, + + #[error("Permission denied: {0}")] + PermissionDenied(String), +} + +/// 响应给前端的 JSON 结构 +#[derive(Serialize)] +pub struct ErrorResponse { + pub code: u32, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub trace_id: Option, // 可选:返回 trace_id 方便排查 +} + +impl AppError { + // 映射 HTTP 状态码 (给网关/浏览器看) + fn http_status(&self) -> StatusCode { + match self { + AppError::DbError(_) | AppError::IoError(_) => StatusCode::INTERNAL_SERVER_ERROR, + AppError::NotFound(_) => StatusCode::NOT_FOUND, + AppError::InvalidParam(_) => StatusCode::BAD_REQUEST, + AppError::AuthError | AppError::AccessTokenExpired | AppError::RefreshTokenExpired => { + StatusCode::UNAUTHORIZED + } + AppError::PermissionDenied(_) => StatusCode::FORBIDDEN, + } + } + + // 映射业务状态码 (给前端代码看) + fn biz_code(&self) -> BizCode { + match self { + AppError::DbError(_) | AppError::IoError(_) => BizCode::ServerError, + AppError::NotFound(_) | AppError::InvalidParam(_) => BizCode::BadRequest, + AppError::AuthError => BizCode::Unauthorized, + AppError::AccessTokenExpired => BizCode::AccessTokenExpired, // 关键 + AppError::RefreshTokenExpired => BizCode::RefreshTokenExpired, // 关键 + AppError::PermissionDenied(_) => BizCode::PermissionDenied, + } + } +} + +// 核心:实现 Axum 的 IntoResponse +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = self.http_status(); + let biz_code = self.biz_code(); + let message = self.to_string(); + + // 尝试获取当前的 Trace ID (如果有 telemetry 功能) + // 这里只是简单的占位,实际需要配合 opentelemetry 获取 span id + let trace_id = None; + + // 1. 自动记录错误日志 (利用 tracing) + // 这样业务代码里只需要 return Err(...),不需要手动 error!(...) + #[cfg(feature = "telemetry")] + { + tracing::error!( + %status, + code = ?biz_code, + error = %message, + "Request failed" + ); + } + + // 2. 构建 JSON + let body = Json(ErrorResponse { + code: biz_code as u32, + message, + trace_id, + }); + + (status, body).into_response() + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..db83a1d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,11 @@ +// 只有开启了 'error' feature 才会编译 error 模块 +#[cfg(feature = "error")] +pub mod error; + +// 只有开启了 'telemetry' feature 才会编译 telemetry 模块 +#[cfg(feature = "telemetry")] +pub mod telemetry; + +// 方便外部直接 use common_lib::AppError; +#[cfg(feature = "error")] +pub use error::{AppError, BizCode}; diff --git a/src/telemetry.rs b/src/telemetry.rs new file mode 100644 index 0000000..b74c2a3 --- /dev/null +++ b/src/telemetry.rs @@ -0,0 +1,81 @@ +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::{EnvFilter, Registry, fmt, layer::SubscriberExt, util::SubscriberInitExt}; + +/// 遥测配置 +pub struct TelemetryConfig { + pub service_name: String, // 服务名 (如 "user-service") + pub log_level: String, // 日志级别 (如 "info" 或 "debug,sqlx=error") + pub log_to_file: bool, // 是否输出到文件 + pub log_dir: Option, // 日志目录 + pub log_file: Option, // 日志文件名 +} + +impl Default for TelemetryConfig { + fn default() -> Self { + Self { + service_name: "unknown-service".into(), + log_level: "info".into(), + log_to_file: false, + log_dir: None, + log_file: None, + } + } +} + +/// 初始化 Tracing 系统 +/// +/// # 返回值 +/// 返回 `Option`。 +/// **重要**:这个 Guard 必须一直存活到 main 函数结束。如果被丢弃,文件日志将无法写入。 +pub fn init(config: TelemetryConfig) -> Option { + // 1. 设置过滤器 (RUST_LOG 环境变量优先,否则用 config) + let env_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&config.log_level)); + + // 2. 基础的 Console 输出层 + let stdout_layer = fmt::layer() + .json() // 生产环境通常用 json + .with_file(true) + .with_line_number(true) + .with_writer(std::io::stdout); + + // 3. 准备注册表 + let subscriber = Registry::default().with(env_filter); + + // 4. 判断是否需要文件输出 + if config.log_to_file { + let dir = config.log_dir.unwrap_or_else(|| "logs".into()); + let file = config.log_file.unwrap_or_else(|| "app.log".into()); + + // 设置滚动日志 (每天轮转) + let file_appender = tracing_appender::rolling::daily(&dir, &file); + + // 设置非阻塞写入 (关键性能点) + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + + let file_layer = fmt::layer() + .json() + .with_file(true) + .with_line_number(true) + .with_writer(non_blocking) + .with_ansi(false); // 文件不需要颜色 + + // 组合:Console + File + subscriber.with(stdout_layer).with(file_layer).init(); + + tracing::info!( + "Telemetry initialized (Console + File) for {}", + config.service_name + ); + Some(guard) + } else { + // 组合:Console Only + subscriber.with(stdout_layer).init(); + + tracing::info!( + "Telemetry initialized (Console Only) for {}", + config.service_name + ); + None + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 0000000..6c82e3f --- /dev/null +++ b/tests/integration_test.rs @@ -0,0 +1,155 @@ +use axum::{Router, body::Body, routing::get}; +use common_telemetry::{ + error::AppError, + telemetry::{self, TelemetryConfig}, +}; +use http::{Request, StatusCode}; +use serde_json::Value; +use std::{fs, time::Duration}; +use tower::ServiceExt; // for oneshot + +// --- 模拟业务 Handler --- + +// 模拟场景1:Access Token 过期 +async fn handler_access_expired() -> Result { + // 模拟业务逻辑判断... + Err(AppError::AccessTokenExpired) +} + +// 模拟场景2:Refresh Token 过期 +async fn handler_refresh_expired() -> Result { + Err(AppError::RefreshTokenExpired) +} + +// 模拟场景3:正常成功 +async fn handler_success() -> Result { + // 这里打印一条日志,测试 Tracing 是否工作 + tracing::info!(user_id = 10086, "User accessed success handler"); + Ok("Success Data".to_string()) +} + +#[tokio::test] +async fn test_full_flow_error_and_logging() { + println!(">>> [Step 0] Test Initializing..."); + // 1. 准备环境:创建一个临时目录存放日志,防止污染项目 + let temp_dir = tempfile::tempdir().unwrap(); + let log_dir_path = temp_dir.path().to_str().unwrap().to_string(); + let log_filename = "test_app.log"; + + // 2. 初始化 Tracing (模拟生产环境配置) + let config = TelemetryConfig { + service_name: "test-service".into(), + log_level: "info".into(), + log_to_file: true, // 开启文件写入 + log_dir: Some(log_dir_path.clone()), + log_file: Some(log_filename.to_string()), + }; + + // !!! 必须持有 guard,否则日志不会写入 + let _guard = telemetry::init(config); + + // 给一点时间让 tracing 系统初始化 + tokio::time::sleep(Duration::from_millis(50)).await; + + // 3. 构建 Axum Router + let app = Router::new() + .route("/access-expired", get(handler_access_expired)) + .route("/refresh-expired", get(handler_refresh_expired)) + .route("/success", get(handler_success)); + + println!(">>> [Step 1] Running Case A: Access Token Expired..."); + // --- 测试用例 A: Access Token 过期 --- + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/access-expired") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + // 验证 HTTP 状态码 (401) + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + // 验证 JSON Body + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body_bytes).unwrap(); + + // 核心验证:code 必须是 20001 (BizCode::AccessTokenExpired) + // 前端根据这个 code 进行静默刷新 + assert_eq!(body["code"], 20001); + + // --- 测试用例 B: Refresh Token 过期 --- + println!(">>> [Step 2] Running Case B: Refresh Token Expired..."); + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/refresh-expired") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body_bytes).unwrap(); + + // 核心验证:code 必须是 20002 (BizCode::RefreshTokenExpired) + // 前端根据这个 code 强制登出 + assert_eq!(body["code"], 20002); + + // --- 测试用例 C: 验证日志文件是否生成 --- + println!(">>> [Step 3] Running Case C: Log File Verification..."); + // 先请求一次成功接口,触发 tracing::info! + let _ = app + .clone() + .oneshot( + Request::builder() + .uri("/success") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + // 稍微等待异步日志写入磁盘 + println!(" Waiting for logs to flush..."); + tokio::time::sleep(Duration::from_millis(200)).await; + + // 验证文件 + let mut found_file = false; + let paths = fs::read_dir(temp_dir.path()).unwrap(); + + for path in paths { + let entry = path.unwrap(); + let file_name = entry.file_name().into_string().unwrap(); + + println!(" Found file in temp dir: {}", file_name); + + // tracing-appender 滚动日志通常格式为: filename.YYYY-MM-DD + if file_name.starts_with(log_filename) { + found_file = true; + let content = fs::read_to_string(entry.path()).unwrap(); + println!(" Log Content Preview: {}", content.trim()); + + assert!( + content.contains("User accessed success handler"), + "日志内容丢失!" + ); + assert!(content.contains("\"user_id\":10086"), "结构化字段丢失!"); + } + } + + assert!(found_file, "未在临时目录找到日志文件!"); + println!(" [Success] Case C Passed (Log file verified)"); + + // temp_dir 会在作用域结束时自动删除清理 +}