02
项目一:企业级 Web 后端管理系统
Enterprise Admin Backend · Axum + PostgreSQL + JWT + RBAC
企业后台管理系统是后端开发的标配项目。几乎每个公司都有这么一套:员工登录后台、管理员分配角色、操作全留痕。这一章我们用 Rust 从零搭一套:Axum 做 Web 层、SQLx 操作 PostgreSQL、Redis 做缓存、JWT 鉴权、RBAC 权限,最后 Docker 一把梭上线。这套东西改改表名,就是一个能交付的毕设或练手作品。
需求分析:这个系统到底管什么
别一上来就写代码,先列功能清单。一个企业后台,核心就这几块:
| 模块 | 要实现的功能 |
| 鉴权 | 注册、登录、登出、Token 刷新(access + refresh 双 Token) |
| 用户管理 | 用户列表分页、新增、编辑、删除(逻辑删除)、改密码 |
| 角色权限 | 角色 CRUD、权限分配,RBAC 模型(用户-角色-权限三级) |
| 部门/菜单 | 部门树形结构、菜单树、角色挂菜单、动态路由 |
| 日志/字典 | 操作日志记录与查询、数据字典管理(带缓存) |
| 监控 | 在线用户、服务器状态、接口耗时统计 |
架构设计:四层分层,各管各的事
企业级项目最怕大泥球。我们用经典四层分层,每层只干自己的活,层与层之间靠接口/结构体打招呼:
┌─────────────────────────────────────────────┐
│ Controller 层(handlers/) 收 HTTP 请求,参数校验,返回 JSON
├─────────────────────────────────────────────┤
│ Service 层(services/) 业务逻辑,事务编排,权限判断
├─────────────────────────────────────────────┤
│ Repository 层(repositories/) 写 SQL,操作数据库
├─────────────────────────────────────────────┤
│ Domain 层(models/) 结构体定义:表映射、请求/响应 DTO
└─────────────────────────────────────────────┘
横切:middleware(鉴权/日志/CORS) + error.rs(统一错误) + config(配置)
数据库设计:先把地基打好
RBAC 的核心是三张表:users(用户)、roles(角色)、permissions(权限),再加两张关联表把它们串起来。下面是完整建表 DDL(PostgreSQL 方言):
migrations/0001_init.sql —— 核心表结构
-- 用户表
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL, -- argon2 哈希,永不存明文
nickname VARCHAR(50),
status SMALLINT NOT NULL DEFAULT 1, -- 1启用 0禁用
deleted SMALLINT NOT NULL DEFAULT 0, -- 逻辑删除
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 角色表
CREATE TABLE roles (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE, -- ADMIN / USER
name VARCHAR(50) NOT NULL,
remark VARCHAR(255)
);
-- 权限表(菜单/按钮/接口三类)
CREATE TABLE permissions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code VARCHAR(100) NOT NULL UNIQUE, -- user:list / user:create
name VARCHAR(50) NOT NULL,
type VARCHAR(20) NOT NULL -- menu / button / api
);
-- 用户-角色 关联表(多对多)
CREATE TABLE user_roles (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
-- 角色-权限 关联表
CREATE TABLE role_permissions (
role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id BIGINT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
-- 操作日志表
CREATE TABLE operation_logs (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR(50),
module VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL,
path VARCHAR(255),
ip VARCHAR(50),
cost_ms INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_log_user ON operation_logs(username);
CREATE INDEX idx_log_created ON operation_logs(created_at);
SQLx 迁移怎么跑
别手动进数据库敲 SQL。用 SQLx 自带的迁移:文件放 migrations/ 目录,主程序里一行 sqlx::migrate!("./migrations").run(&pool).await? 就自动按文件名顺序执行。好处是换台机器、换个数据库,跑起来就自动建表,Docker 部署时尤其省心。文件名前缀用 0001_、0002_ 保证执行顺序。
项目初始化:Cargo.toml 与目录结构
先把依赖一次配好。下面这份 Cargo.toml 就是项目一的全部家底(PostgreSQL 版):
Cargo.toml
[package]
name = "rust-admin"
version = "0.1.0"
edition = "2024"
[dependencies]
# Web 框架 + 异步运行时
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
# 数据库(PostgreSQL)+ 缓存
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio", "chrono", "uuid"] }
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
# 序列化 + 参数校验
serde = { version = "1", features = ["derive"] }
serde_json = "1"
validator = { version = "0.20", features = ["derive"] }
# 日志 + 错误处理
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
thiserror = "2"
anyhow = "1"
# JWT + 密码哈希(argon2)
jsonwebtoken = "9"
argon2 = { version = "0.5", features = ["std"] }
password-hash = { version = "0.5", features = ["std"] }
# 配置 + 时间 + UUID + 中间件
config = { version = "0.15", features = ["toml"] }
dotenvy = "0.15"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true
目录结构(企业级标准分层)
rust-admin/
├── Cargo.toml
├── .env # 环境变量(不进 git)
├── config/default.toml # 应用配置
├── migrations/ # SQLx 迁移文件
│ ├── 0001_init.sql
│ └── 0002_dict.sql
└── src/
├── main.rs # 入口
├── error.rs # 统一错误类型
├── config.rs # 配置结构体
├── models/ # 第 4 层:数据模型 + DTO
├── repositories/ # 第 3 层:写 SQL
├── services/ # 第 2 层:业务逻辑
├── handlers/ # 第 1 层:HTTP 接口
├── middleware/ # 鉴权 / 日志 / CORS
└── utils/ # jwt / 响应封装
核心一:配置管理(对标 SpringBoot 的 application.yml)
配置别写死在代码里。用 config crate 读 config.toml,再让环境变量覆盖——生产环境改配置不用重新编译。
src/config.rs
use serde::Deserialize;
/// 应用总配置(强类型,读错字段编译期就报错)
#[derive(Debug, Clone, Deserialize)]
pub struct AppConfig {
pub server: ServerConfig,
pub database: DatabaseConfig,
pub redis: RedisConfig,
pub jwt: JwtConfig,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ServerConfig { pub host: String, pub port: u16 }
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseConfig {
pub url: String,
pub max_connections: u32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RedisConfig { pub url: String }
#[derive(Debug, Clone, Deserialize)]
pub struct JwtConfig {
pub secret: String,
pub access_expire_secs: i64,
}
impl AppConfig {
/// 加载:先读 config/default.toml,再用 APP__ 前缀的环境变量覆盖
pub fn load() -> anyhow::Result<Self> {
dotenvy::dotenv().ok();
let cfg = config::Config::builder()
.add_source(config::File::with_name("config/default"))
// 环境变量如 APP__DATABASE__URL 优先级更高,方便容器注入
.add_source(config::Environment::with_prefix("APP").separator("__"))
.build()?;
Ok(cfg.try_deserialize()?)
}
}
核心二:数据库连接池 + Redis
数据库连接是稀缺资源,不能每次请求都新建连接(那样慢死)。用连接池复用连接——对标 Java 的 HikariCP。
src/main.rs(入口与连接池初始化)
mod config; mod error; mod handlers; mod middleware;
mod models; mod repositories; mod services; mod utils;
use sqlx::postgres::PgPoolOptions;
use std::sync::Arc;
/// 应用全局状态:Axum 把它 clone 给每个请求(连接池是 Arc 包裹,clone 很便宜)
#[derive(Clone)]
pub struct AppState {
pub config: Arc<config::AppConfig>,
pub pg: sqlx::PgPool,
pub redis: redis::aio::ConnectionManager,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. 初始化日志(对标 logback)
tracing_subscriber::fmt().with_env_filter("info").init();
// 2. 读配置
let config = Arc::new(config::AppConfig::load()?);
// 3. PostgreSQL 连接池
let pg = PgPoolOptions::new()
.max_connections(config.database.max_connections)
.connect(&config.database.url)
.await?;
// 跑迁移(对标 Flyway)
sqlx::migrate!("./migrations").run(&pg).await?;
tracing::info!("数据库就绪");
// 4. Redis 连接(ConnectionManager 自带断线重连)
let redis_client = redis::Client::open(config.redis.url.as_str())?;
let redis = redis::aio::ConnectionManager::new(redis_client).await?;
tracing::info!("Redis 就绪");
// 5. 组路由并启动
let state = AppState { config, pg, redis };
let app = handlers::router(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
tracing::info!("服务启动: http://0.0.0.0:8080");
axum::serve(listener, app).await?;
Ok(())
}
$ cargo run
[INFO] 数据库就绪
[INFO] Redis 就绪
[INFO] 服务启动: http://0.0.0.0:8080
核心三:统一错误处理(对标 @RestControllerAdvice)
Java 里你写一堆 try-catch 抛异常。Rust 更优雅:定义一个 AppError 枚举,所有错误都变成它,再实现 IntoResponse 自动转成 JSON。一处定义,全局生效。
src/error.rs
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde::Serialize;
/// 业务错误码
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("业务错误: {0}")]
Business(u16, String), // (code, message)
#[error("数据库错误: {0}")]
Database(#[from] sqlx::Error),
#[error("JWT错误: {0}")]
Jwt(#[from] jsonwebtoken::errors::Error),
#[error("参数校验失败: {0}")]
Validation(String),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
/// 统一响应体:前端拿到的永远是这个结构
#[derive(Debug, Serialize)]
pub struct ApiResponse<T> {
code: i32,
message: String,
data: Option<T>,
}
impl<T: Serialize> ApiResponse<T> {
pub fn ok(data: T) -> Self {
Self { code: 0, message: "成功".into(), data: Some(data) }
}
}
/// 关键一步:错误自动转成 HTTP 响应
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code, msg) = match self {
AppError::Business(c, m) => (
match c { 401 => StatusCode::UNAUTHORIZED,
403 => StatusCode::FORBIDDEN,
404 => StatusCode::NOT_FOUND,
_ => StatusCode::BAD_REQUEST },
c as i32, m),
AppError::Database(_) => {
tracing::error!("数据库异常: {self}");
(StatusCode::INTERNAL_SERVER_ERROR, 500, "数据库错误".into())
}
e => { (StatusCode::INTERNAL_SERVER_ERROR, 500, e.to_string()) }
};
(status, Json(ApiResponse<()> { code, message: msg, data: None })).into_response()
}
}
妙这招为什么这么爽
注意那个 #[from] sqlx::Error——它让数据库错误自动"升级"成 AppError。于是业务代码里你只写 Result<T, AppError>,数据库出错、JWT 出错,直接用 ? 问号操作符往上抛,框架自动接住并翻译成统一 JSON。不用到处 try-catch,错误处理代码少写一半。这就是 Rust 错误处理比 Java 优雅的地方。
核心四:JWT 鉴权与登录(argon2 哈希密码)
密码永远不能明文存。我们用 argon2(目前最推荐的密码哈希算法,比 bcrypt 新)。登录成功后签发 access token。
src/utils/jwt.rs
use chrono::Utc;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: i64, // 用户ID
pub username: String,
pub exp: usize, // 过期时间戳
}
pub fn make_token(secret: &str, user_id: i64, username: &str, expire_secs: i64)
-> Result<String, jsonwebtoken::errors::Error>
{
let exp = (Utc::now() + chrono::Duration::seconds(expire_secs)).timestamp() as usize;
let claims = Claims { sub: user_id, username: username.into(), exp };
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
}
pub fn verify_token(secret: &str, token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let data = decode::<Claims>(token, &DecodingKey::from_secret(secret.as_bytes()),
&Validation::default())?;
Ok(data.claims)
}
src/services/auth_service.rs(注册 + 登录)
use argon2::{password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2};
use crate::error::AppError;
/// 密码哈希:加盐,每次盐都随机
pub fn hash_password(raw: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default().hash_password(raw.as_bytes(), &salt)?.to_string();
Ok(hash)
}
/// 校验密码:把明文和存的哈希比一比
pub fn verify_password(raw: &str, hash: &str) -> Result<bool, AppError> {
let parsed = PasswordHash::new(hash)?;
Ok(Argon2::default().verify_password(raw.as_bytes(), &parsed).is_ok())
}
/// 登录:查用户 → 查状态 → 验密码 → 发 token
pub async fn login(pg: &sqlx::PgPool, secret: &str,
username: &str, password: &str) -> Result<String, AppError> {
let user = sqlx::query_as::<_, User>(
"SELECT * FROM users WHERE username = $1 AND deleted = 0")
.bind(username)
.fetch_optional(pg).await?
.ok_or_else(|| AppError::Business(401, "用户名或密码错误".into()))?;
if user.status != 1 {
return Err(AppError::Business(403, "账号已禁用".into()));
}
if !verify_password(password, &user.password_hash)? {
return Err(AppError::Business(401, "用户名或密码错误".into()));
}
let token = make_token(secret, user.id, &user.username, 7200)?;
Ok(token)
}
两个登录失败提示为什么要写成一样
看上面:用户名不存在、密码错误,返回的都是同一句"用户名或密码错误"。这是故意的——如果区分"用户名不存在"和"密码错误",黑客就能拿你的接口枚举哪些用户名真实存在(撞库前置探测)。安全的做法是让攻击者分不出来。这种细节,Java 老炮写 SpringSecurity 时也讲究,Rust 项目照样不能漏。
核心五:鉴权中间件(Extractor 自动塞当前用户)
Axum 最妙的设计是 Extractor:你只要在 handler 参数里写个类型,它就自动从请求里把数据解出来。我们写个 AuthUser,自动校验 JWT、把当前用户塞进 handler。
src/middleware/auth.rs
use axum::{extract::{FromRef, FromRequestParts}, http::request::Parts};
use crate::{AppState, utils::jwt::verify_token, error::AppError};
/// 鉴权后自动注入的当前用户
#[derive(Debug, Clone)]
pub struct AuthUser { pub id: i64, pub username: String }
/// 实现提取器:handler 里写 AuthUser,它就自动校验 token
impl FromRequestParts<AppState> for AuthUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState)
-> Result<Self, Self::Rejection>
{
// 1. 从 Authorization 头拿 Bearer token
let header = parts.headers.get("Authorization")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| AppError::Business(401, "未登录".into()))?;
let token = header.strip_prefix("Bearer ")
.ok_or_else(|| AppError::Business(401, "Token格式错".into()))?;
// 2. 验签 + 取 claims
let claims = verify_token(&state.config.jwt.secret, token)?;
Ok(AuthUser { id: claims.sub, username: claims.username })
}
}
用它保护接口——handler 直接加个参数就行
/// GET /api/users/me —— 只有登录用户能访问
pub async fn get_me(
State(state): State<AppState>,
auth: AuthUser, // 这一行就完成了鉴权!
) -> Result<Json<ApiResponse<User>>, AppError> {
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(auth.id)
.fetch_one(&state.pg).await?;
Ok(Json(ApiResponse::ok(user)))
}
$ curl http://localhost:8080/api/users/me
{ "code": 401, "message": "未登录", "data": null }
$ curl -H "Authorization: Bearer eyJhbGciOi..." http://localhost:8080/api/users/me
{ "code": 0, "message": "成功", "data": { "id": 1, "username": "admin" } }
API 设计规范:统一响应与分页
接口一多,前端最烦的就是"每个接口返回格式都不一样"。所以我们定死两条规矩:所有响应都是 ApiResponse 包裹,所有列表接口都带分页。
| 操作 | 方法 + 路径 | 说明 |
| 注册/登录 | POST /api/auth/register | 无需鉴权 |
| 用户列表 | GET /api/users?page=1&size=10 | 需 user:list 权限 |
| 用户详情 | GET /api/users/:id | RESTful,资源在路径里 |
| 修改用户 | PUT /api/users/:id | 全量更新 |
| 删除用户 | DELETE /api/users/:id | 逻辑删除 |
接口文档用 utoipa 自动生成:给 handler 加几个 derive,启动后访问 /swagger-ui 就能看到在线文档,还能直接点按钮发请求调试。前后端联调再也不用嘴对嘴。
其余模块一句话带过(套路都一样)
| 模块 | 实现套路 |
| 角色权限 RBAC | 登录后把 user→roles→permissions 查出权限码集合,存进 Redis。中间件里查 has_permission("user:delete")。 |
| 部门管理 | 表加 parent_id 自关联,一次全查出在内存里递归拼成树。 |
| 菜单管理 | 同部门树形结构,角色挂 menu_id 列表,前端拿到后动态生成路由。 |
| 操作日志 | 写个 middleware:请求进来记开始时间,next.run(req).await 跑完算耗时,异步写 operation_logs 表。 |
| 数据字典 | 字典类型/字典项 CRUD,热点字典加载进 Redis 缓存,改了就失效缓存。 |
| 系统监控 | 在线用户数查 Redis、服务器状态用 sysinfo crate、接口耗时从日志统计。 |
加分项:Redis 缓存 + 分页查询 + 日志中间件
上面那几行"一句话带过"的模块,挑三个最常用的,把代码补全。企业后台里字典数据读多写少,天生适合缓存。
用 Redis 缓存字典(读缓存 -> 没有再查库 -> 写回缓存)
/// 查字典:先查 Redis,miss 了再查 PG,然后回填缓存
pub async fn get_dict(redis: &mut redis::aio::ConnectionManager,
pg: &sqlx::PgPool, code: &str) -> anyhow::Result<Vec<DictItem>> {
let key = format!("dict:{code}");
// 1. 先试缓存
let cached: Option<String> = redis::cmd("GET").arg(&key).query_async(redis).await?;
if let Some(json) = cached {
return Ok(serde_json::from_str(&json)?);
}
// 2. 缓存 miss,查数据库
let items = sqlx::query_as::<_, DictItem>(
"SELECT * FROM dict_items WHERE type_code = $1 ORDER BY sort")
.bind(code).fetch_all(pg).await?;
// 3. 回填缓存,TTL 5 分钟
let json = serde_json::to_string(&items)?;
redis::cmd("SET").arg(&key).arg(&json)
.arg("EX").arg(300).query_async(redis).await?;
Ok(items)
}
分页查询(动态拼 WHERE,LIMIT/OFFSET)
/// GET /api/users?page=1&page_size=10&keyword=张
pub async fn page_users(pg: &sqlx::PgPool,
page: u64, size: u64, keyword: Option<&str>)
-> Result<PageResult<User>, AppError>
{
let page = page.max(1);
let size = size.clamp(1, 100); // 防一次查 10000 条
let offset = (page - 1) * size;
// 总数
let total: i64 = sqlx::query_scalar(
"SELECT count(*) FROM users WHERE deleted = 0 AND ($1::text IS NULL OR username LIKE '%'||$1||'%')")
.bind(keyword).fetch_one(pg).await?;
// 当前页数据
let list = sqlx::query_as::<_, User>(
"SELECT * FROM users WHERE deleted = 0 AND ($1::text IS NULL OR username LIKE '%'||$1||'%')
ORDER BY id DESC LIMIT $2 OFFSET $3")
.bind(keyword).bind(size).bind(offset)
.fetch_all(pg).await?;
Ok(PageResult { total, page, page_size: size, list })
}
操作日志中间件(对标 Spring AOP 环绕通知)
use axum::{extract::{Request, State}, middleware::Next, response::Response};
use std::time::Instant;
/// 记录每个请求的耗时和路径,异步写库
pub async fn access_log(State(state): State<AppState>, req: Request, next: Next) -> Response {
let start = Instant::now();
let method = req.method().clone();
let path = req.uri().path().to_string();
let resp = next.run(req).await; // 继续往后走
let cost = start.elapsed().as_millis() as i64;
tracing::info!(method = %method, path = %path, cost_ms = cost, "请求完成");
// 生产:异步写 operation_logs 表,不阻塞响应
resp
}
$ curl http://localhost:8080/api/users
[INFO] method=GET path=/api/users cost_ms=3 请求完成
{ "code": 0, "data": { "total": 42, "page": 1, "list": [...] } }
测试:单元测试 + 集成测试
Rust 的测试是内置在标准库的,不用装任何框架——cargo test 一把梭。单元测试写在源码同目录,集成测试写在 tests/ 目录。
单元测试(写在 utils/jwt.rs 底部)
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_roundtrip() {
let token = make_token("secret", 1, "admin", 3600).unwrap();
let claims = verify_token("secret", &token).unwrap();
assert_eq!(claims.sub, 1);
assert_eq!(claims.username, "admin");
}
#[test]
fn expired_token_rejected() {
let token = make_token("secret", 1, "admin", -1).unwrap(); // 已过期
assert!(verify_token("secret", &token).is_err());
}
#[test]
fn password_hash_and_verify() {
let hash = hash_password("abc123").unwrap();
assert!(verify_password("abc123", &hash).unwrap());
assert!(!verify_password("wrong", &hash).unwrap());
}
}
集成测试(tests/api_test.rs,对标 MockMvc)
use tower::ServiceExt; // oneshot
#[tokio::test]
async fn register_then_login() {
let app = build_test_app().await;
// 1. 注册
let resp = app.clone().oneshot(
Request::builder().method("POST").uri("/api/auth/register")
.header("Content-Type", "application/json")
.body(Body::from(r#"{"username":"u1","email":"u@a.com","password":"abc123"}"#)).unwrap()
).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 2. 登录,应拿到 access_token
let resp = app.oneshot(
Request::builder().method("POST").uri("/api/auth/login")
.header("Content-Type", "application/json")
.body(Body::from(r#"{"username":"u1","password":"abc123"}"#)).unwrap()
).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
$ cargo test
Compiling rust-admin v0.1.0
Running tests/api_test.rs
test register_then_login ... ok
test result: ok. 2 passed; 0 failed
部署:Docker 多阶段构建,镜像只要 30MB
Rust 编译出来是静态二进制,不依赖运行时(不像 Java 还得带个 JRE)。多阶段构建:第一阶段编译,第二阶段只把二进制抠出来。
Dockerfile
# 阶段一:编译
FROM rust:1.90-bookworm AS builder
WORKDIR /app
# 先只拷依赖清单编译一次,利用 Docker 层缓存
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs \
&& cargo build --release && rm -rf src
# 再拷真源码正式编译
COPY src ./src
COPY migrations ./migrations
RUN cargo build --release
# 阶段二:运行(对标 JRE 精简镜像)
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/rust-admin .
COPY config ./config
COPY migrations ./migrations
EXPOSE 8080
CMD ["./rust-admin"]
docker-compose.yml(app + PostgreSQL + Redis 一把起)
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: admin
volumes: [pg_data:/var/lib/postgresql/data]
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 5s
retries: 10
redis:
image: redis:7-alpine
app:
build: .
depends_on:
postgres: { condition: service_healthy }
environment:
APP__DATABASE__URL: "postgres://postgres:secret@postgres:5432/admin"
APP__REDIS__URL: "redis://redis:6379"
ports: ["8080:8080"]
volumes:
pg_data:
$ docker compose up -d
[+] Started 3/3
$ curl http://localhost:8080/health
{ "status": "ok" }
Nginx 反向代理(对外只暴露 80/443,Rust 藏在后面)
server {
listen 80;
server_name api.example.com;
# 把 /api 开头的请求转给本机 8080 的 Rust 服务
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# SSE 流式输出必须关缓冲,否则前端收不到逐字
location /api/chat/stream {
proxy_pass http://127.0.0.1:8080;
proxy_buffering off; # 关键!不关缓冲流式会卡住
proxy_cache off;
proxy_read_timeout 300s;
}
}
为什么 Nginx 要放前面: SSL 终止、限流、静态资源、多实例负载均衡,都交给 Nginx,Rust 只管业务。
.github/workflows/ci.yml(提交代码自动检查+测试+构建)
name: CI
on: { push: { branches: [main] }, pull_request: {} }
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { components: [rustfmt, clippy] }
- uses: Swatinem/rust-cache@v2
- name: 格式检查
run: cargo fmt --check
- name: Clippy(零警告)
run: cargo clippy -- -D warnings
- name: 跑测试
run: cargo test
为什么要先写个空 main.rs 编译一遍
这是 Docker 构建加速的经典技巧。Rust 编译依赖极慢(axum + sqlx 全套下来首次编译要几分钟)。如果每次改一行代码都重新装依赖,CI 会跑死。先只拷 Cargo.toml 编个空壳,这一层有缓存,之后只要依赖没变,Docker 直接复用,真正编译你的业务代码只要几十秒。这个 trick 几乎每个 Rust Dockerfile 都在用。
记
本章小结
① 四层架构 handlers → services → repositories → models,层与层不越界。
② 统一错误:thiserror 枚举 + IntoResponse,问号操作符一路 ? 到底。
③ 密码用 argon2 加盐哈希,登录失败提示统一;JWT 用 Extractor 自动鉴权。
④ 部署用 Docker 多阶段构建,最终镜像约 30MB,compose 一把起 app + PG + Redis。