Web项目工程化规范 — CI/CD与测试¶
核心知识点速查表¶
| 知识点 | 说明 |
|---|---|
| 五、自动化部署(CI/CD) | 见对应章节 |
| 六、测试 | 见对应章节 |
| 七、日志系统 | 见对应章节 |
五、自动化部署(CI/CD)¶
为什么需要¶
不用 CI/CD 的后果: - 每次上线手动 SSH 到服务器 → git pull → 重启服务,费时易错 - 忘了跑测试就部署,线上直接炸 - "上次部署的是哪个版本?"——没人记得
5.1 GitHub Actions 完整流程 [推荐]¶
创建 .github/workflows/deploy.yml:
# .github/workflows/deploy.yml —— 自动测试 + 部署流程
name: CI/CD Pipeline # 工作流名称
on:
push:
branches: [main] # 推送到 main 时触发
pull_request:
branches: [main] # PR 到 main 时触发(只跑测试)
jobs:
# ===== 第一步:测试 =====
test:
runs-on: ubuntu-latest # 运行环境
steps:
- uses: actions/checkout@v4 # 拉取代码
- name: Set up Python # 安装 Python
uses: actions/setup-python@v5
with:
python-version: "3.12" # 指定 Python 版本
- name: Cache pip packages # 缓存依赖,加速后续构建
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
- name: Install dependencies # 安装依赖
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt # 开发/测试依赖
- name: Lint with Ruff # 代码检查
run: ruff check . # 快速检查代码规范
- name: Run tests with coverage # 运行测试并统计覆盖率
run: pytest --cov=app --cov-report=xml --cov-fail-under=80
# --cov=app 统计 app 目录的覆盖率
# --cov-report=xml 生成 XML 报告(可上传到 Codecov)
# --cov-fail-under=80 覆盖率低于 80% 则失败
- name: Upload coverage to Codecov # 上传覆盖率报告(可选)
uses: codecov/codecov-action@v4
if: github.event_name == 'push' # 只在 push 时上传
with:
file: ./coverage.xml
# ===== 第二步:部署(仅 push 到 main 时执行) =====
deploy:
needs: test # 测试通过才部署
runs-on: ubuntu-latest
if: github.event_name == 'push' # PR 不触发部署
steps:
- name: Deploy to server via SSH # SSH 到服务器执行部署
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }} # 服务器 IP(在 GitHub Secrets 配置)
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }} # SSH 私钥
script: |
cd /opt/t2d-api # 进入项目目录
git pull origin main # 拉最新代码
source .venv/bin/activate # 激活虚拟环境
pip install -r requirements.txt # 安装/更新依赖
sudo systemctl reload t2d-api # 优雅重载服务(不断连接)
5.2 配置 GitHub Secrets [必须]¶
在 GitHub 仓库 → Settings → Secrets and variables → Actions,添加:
| Secret 名 | 值 |
|---|---|
SERVER_HOST | 你的服务器公网 IP |
SERVER_USER | SSH 登录用户名(不要用 root) |
SSH_PRIVATE_KEY | 服务器对应的 SSH 私钥内容 |
5.3 手动部署脚本(GitHub Actions 的简化替代)[必须]¶
如果暂时不用 GitHub Actions,至少写个部署脚本:
#!/bin/bash
# deploy.sh —— 手动部署脚本,SSH 到服务器后执行
set -e # 任何命令失败立即停止
echo "=== 拉取最新代码 ==="
cd /opt/t2d-api
git pull origin main
echo "=== 安装依赖 ==="
source .venv/bin/activate
pip install -r requirements.txt
echo "=== 运行数据库迁移 ==="
# alembic upgrade head # 如果用了 Alembic
echo "=== 重启服务 ==="
sudo systemctl reload t2d-api
echo "=== 检查服务状态 ==="
sudo systemctl status t2d-api --no-pager
echo "=== 部署完成 ==="
六、测试¶
为什么需要¶
不写测试的后果: - 改了一个函数,另一个功能悄悄坏了——你不知道 - 上线前手动测一遍要 2 小时——你懒得测,直接上 - 出了 bug,回归测试全靠人工——效率极低
6.1 测试分类¶
| 类型 | 测什么 | 数量 | 速度 |
|---|---|---|---|
| 单元测试 | 单个函数/方法 | 最多 | 最快 |
| 集成测试 | API 接口、数据库操作 | 适量 | 中等 |
| E2E 测试 | 完整用户流程 | 少量 | 最慢 |
6.2 pytest 基础用法 [必须]¶
项目结构:
tests/
├── __init__.py
├── conftest.py # 共享的 fixtures(测试夹具)
├── test_species.py # 物种相关测试
├── test_samples.py # 样本相关测试
└── test_auth.py # 认证相关测试
conftest.py(共享测试配置):
# tests/conftest.py —— pytest 共享夹具,所有测试文件自动可用
import pytest
from fastapi.testclient import TestClient # FastAPI 自带的测试客户端
from app.main import app # 导入 FastAPI 应用实例
@pytest.fixture # 装饰器,声明这是一个夹具
def client():
"""创建测试用的 HTTP 客户端"""
with TestClient(app) as c: # TestClient 模拟 HTTP 请求
yield c # yield 让测试函数使用这个客户端
# with 块结束后自动清理资源
单元测试示例:
# tests/test_species.py —— 物种查询相关测试
def test_calculate_diversity_index():
"""测试多样性指数计算函数"""
# Arrange(准备)
abundances = [0.5, 0.3, 0.2] # 三个物种的相对丰度
# Act(执行)
from app.services.diversity import shannon_index
result = shannon_index(abundances) # 调用被测函数
# Assert(断言)
assert round(result, 4) == 1.0297 # 检查结果是否正确
API 接口测试示例(同步方式,适合简单场景):
# tests/test_api.py —— API 接口集成测试(同步 TestClient)
def test_get_species_list(client):
"""测试获取物种列表接口"""
response = client.get("/api/v1/species") # 发送 GET 请求
assert response.status_code == 200 # 状态码应该是 200
data = response.json() # 解析 JSON 响应
assert "items" in data # 响应中应该有 items 字段
assert isinstance(data["items"], list) # items 应该是列表
def test_create_sample_without_auth(client):
"""测试未认证时创建样本应该被拒绝"""
response = client.post(
"/api/v1/samples",
json={"name": "test_sample"} # 提交数据
)
assert response.status_code == 401 # 应该返回 401 未认证
def test_upload_invalid_file(client):
"""测试上传无效文件格式应该报错"""
response = client.post(
"/api/v1/upload",
files={"file": ("test.exe", b"fake", "application/octet-stream")}
)
assert response.status_code == 422 # 422 验证错误
assert "不支持的文件格式" in response.json()["detail"]
6.2.1 异步测试(httpx.AsyncClient)[推荐]¶
如果你的 FastAPI 路由用了 async def,推荐用 httpx.AsyncClient 替代 TestClient,完整走异步链路:
# tests/conftest.py —— 异步测试夹具(2025+ 推荐方式)
import pytest
from httpx import AsyncClient, ASGITransport # httpx 异步客户端
from app.main import app
@pytest.fixture
async def async_client():
"""创建异步测试客户端"""
transport = ASGITransport(app=app) # 直接对接 ASGI 应用,不走网络
async with AsyncClient(
transport=transport,
base_url="http://test" # 基础 URL(不会真的发网络请求)
) as client:
yield client
# tests/test_api_async.py —— 异步 API 测试
import pytest
@pytest.mark.asyncio # 声明这是异步测试
async def test_get_species_async(async_client):
"""异步版本的接口测试"""
response = await async_client.get("/api/v1/species")
assert response.status_code == 200
data = response.json()
assert "items" in data
何时用哪个? 同步路由 (
def) 用TestClient就够了;异步路由 (async def) 用httpx.AsyncClient确保完整的异步生命周期。混用 sync/async 客户端容易导致事件循环冲突。
6.3 测试覆盖率 [推荐]¶
# 安装覆盖率工具
pip install pytest-cov # pytest 的覆盖率插件
# 运行测试并查看覆盖率
pytest --cov=app --cov-report=term-missing # 终端显示,标出未覆盖的行号
# --cov=app 统计 app 目录的代码覆盖率
# --cov-report=term-missing 显示哪些行没被测试覆盖
# 生成 HTML 覆盖率报告(浏览器打开更直观)
pytest --cov=app --cov-report=html # 生成 htmlcov/ 目录
# 用浏览器打开 htmlcov/index.html 查看
pyproject.toml 中统一配置 pytest:
# pyproject.toml 中的 pytest 配置
[tool.pytest.ini_options]
testpaths = ["tests"] # 测试文件所在目录
python_files = ["test_*.py"] # 测试文件命名模式
addopts = "-v --cov=app --cov-report=term-missing" # 默认参数
asyncio_mode = "auto" # 自动处理异步测试
[tool.coverage.run]
source = ["app"] # 统计哪个目录的覆盖率
omit = ["app/core/config.py"] # 排除配置文件
[tool.coverage.report]
fail_under = 80 # 覆盖率低于 80% 视为失败
show_missing = true # 显示未覆盖行号
七、日志系统¶
为什么需要¶
不写日志的后果: - 线上出 bug,print() 早就被你删了——无法定位问题 - 用户反馈"打不开",你看不到任何错误记录 - 要查上周三的一个异常,日志文件 2GB——打不开
7.1 日志工具对比¶
| 工具 | 特点 | 推荐场景 |
|---|---|---|
| logging(标准库) | 内置、无依赖、生态最好 | 大型项目、和第三方库兼容 |
| loguru | 零配置、开箱即用、语法优美 | 小中型项目(本文推荐) |
| structlog | 结构化日志、处理器链、最快 | 大型项目、需要日志聚合 |
7.2 loguru 配置(推荐方案)[必须]¶
# app/core/logging.py —— 日志配置
import sys
from loguru import logger # loguru 的 logger 是全局单例
from app.core.config import settings # 读取配置
def setup_logging():
"""配置日志系统"""
# 移除默认处理器
logger.remove() # 清掉 loguru 默认的 stderr 输出
# 开发环境:彩色输出到控制台(人类可读)
if settings.debug:
logger.add(
sys.stderr, # 输出到终端
level="DEBUG", # 显示 DEBUG 及以上级别
format="<green>{time:HH:mm:ss}</green> | " # 绿色时间
"<level>{level: <8}</level> | " # 彩色级别
"<cyan>{name}:{function}:{line}</cyan> | " # 来源
"{message}", # 消息内容
colorize=True, # 启用彩色
)
else:
# 生产环境:JSON 格式输出到文件(机器可读,方便日志聚合工具解析)
logger.add(
"logs/app.log", # 日志文件路径
level="INFO", # 生产环境只记录 INFO 及以上
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name}:{line} | {message}",
rotation="100 MB", # 单个文件超过 100MB 自动轮转
retention="30 days", # 保留最近 30 天的日志
compression="gz", # 旧日志自动压缩为 .gz
serialize=True, # 输出 JSON 格式(结构化日志)
encoding="utf-8", # 编码
)
# 错误日志单独存一份(方便快速定位严重问题)
logger.add(
"logs/error.log",
level="ERROR", # 只记录 ERROR 和 CRITICAL
rotation="50 MB",
retention="90 days", # 错误日志保留更久
compression="gz",
)
在 FastAPI 中使用:
# app/main.py
from loguru import logger
from app.core.logging import setup_logging
setup_logging() # 启动时初始化日志
@app.get("/api/v1/species/{species_id}")
async def get_species(species_id: int):
logger.info(f"查询物种 ID: {species_id}") # 记录正常操作
try:
result = await species_service.get(species_id)
return result
except Exception as e:
logger.error(f"查询物种失败: {e}") # 记录错误
raise
7.3 日志级别说明¶
| 级别 | 用途 | 示例 |
|---|---|---|
| DEBUG | 开发调试细节 | SQL 查询: SELECT * FROM species WHERE id=1 |
| INFO | 正常业务操作 | 用户 admin 登录成功 |
| WARNING | 值得注意但不影响运行 | API 调用频率接近限制: 95/100 |
| ERROR | 出错但系统仍运行 | 上传文件解析失败: invalid FASTA format |
| CRITICAL | 系统级严重错误 | 数据库连接断开 |
7.4 请求级日志追踪(中间件模式)[推荐]¶
生产环境最关键的需求:每条日志都能追溯到是哪个请求产生的。
# app/middleware/logging.py —— 请求日志中间件
import uuid
from loguru import logger
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""给每个请求分配唯一 ID,自动记录请求/响应日志"""
async def dispatch(self, request: Request, call_next):
# 生成唯一请求 ID
request_id = str(uuid.uuid4())[:8] # 取前 8 位够用了
# 绑定上下文(loguru 的 contextualize 会自动附加到后续所有日志)
with logger.contextualize(request_id=request_id):
logger.info(
f"{request.method} {request.url.path}",
extra={"client_ip": request.client.host}
)
response = await call_next(request)
logger.info(
f"响应 {response.status_code}",
extra={"status_code": response.status_code}
)
# 把 request_id 也返回给客户端(方便排查问题)
response.headers["X-Request-ID"] = request_id
return response
为什么重要? 用户报告 "500 错误",你只要拿到
X-Request-ID就能在日志里精准定位,不用在几千行日志里大海捞针。structlog 和 loguru 都支持这种模式。
7.5 systemd + logrotate(系统级日志管理)[进阶]¶
如果不用 loguru 的内置轮转,可以用 Linux 系统的 logrotate:
# /etc/logrotate.d/t2d-api —— 系统级日志轮转配置
/opt/t2d-api/logs/*.log {
daily # 每天轮转一次
rotate 30 # 保留 30 份
compress # 压缩旧日志
delaycompress # 延迟一天再压缩(方便查看昨天的)
missingok # 日志文件不存在也不报错
notifempty # 空文件不轮转
create 0640 www-data www-data # 新文件的权限和所有者
postrotate
systemctl reload t2d-api # 轮转后通知应用重新打开文件
endscript
}