项目背景与目标
前面的能力需要在同一个运行闭环中验证。本项目实现一个最小任务服务:从环境变量读取配置,把任务写入 MySQL,通过 HTTP API 对外提供创建和查询能力,并用日志与测试验证关键路径。
输入是任务标题和项目 ID,输出是带 ID、状态的 JSON。验收标准:新环境能按说明安装依赖;缺少关键配置时立即失败;接口能创建和查询任务;测试不依赖真实数据库。
项目边界是最小可复现服务,不包含用户系统、容器编排、连接池调优和生产部署。
整体架构
HTTP 请求
│
▼
FastAPI 路由与参数校验
│
▼
TaskRepository 数据访问边界
│
▼
MySQL engineering_notes.tasks
建议目录:
task-service/
├── app/
│ ├── __init__.py
│ ├── config.py
│ ├── db.py
│ └── main.py
├── tests/
│ └── test_api.py
├── sql/
│ └── init.sql
├── .env.example
├── .gitignore
└── requirements.txt
核心流程
- 进程启动时校验数据库配置并初始化日志。
- FastAPI 校验请求体,将合法数据传给仓储对象。
- 仓储使用参数化 SQL 写入或查询,并负责提交、回滚和释放连接。
- 路由把结果转换为响应模型;可预期的“不存在”返回 404。
- 测试用内存仓储替换真实数据库,验证 HTTP 契约。
关键实现
依赖与忽略规则:
# requirements.txt
fastapi
uvicorn
mysql-connector-python
pytest
httpx
安装成功后生成锁定快照可使用 python -m pip freeze > requirements.lock。教程中的顶层依赖保持可读,实际部署使用经过测试的锁定版本,避免不同时间安装到不兼容组合。
# .gitignore
.venv/
.env
__pycache__/
.pytest_cache/
*.log
配置模板只提供变量名和安全的本地默认值:
# .env.example
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=app_user
DB_PASSWORD=replace-me
DB_NAME=engineering_notes
LOG_LEVEL=INFO
.env.example 可以提交,复制出的 .env 不能提交。示例程序仍从进程环境读取变量;是否使用 dotenv 加载器是独立选择。
项目自己的数据库初始化脚本放在 sql/init.sql,不要依赖读者手工拼接前文片段:
CREATE DATABASE IF NOT EXISTS engineering_notes
CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE engineering_notes;
CREATE TABLE IF NOT EXISTS projects (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tasks (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
project_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(200) NOT NULL,
status ENUM('todo', 'doing', 'done') NOT NULL DEFAULT 'todo',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_tasks_project FOREIGN KEY (project_id)
REFERENCES projects(id) ON DELETE CASCADE
);
INSERT IGNORE INTO projects (id, name)
VALUES (1, 'engineering-basics');
生产项目应使用迁移工具记录后续结构变化;这个初始化脚本只负责建立首次运行所需的最小结构,并创建验收命令使用的 project_id=1 示例项目。
配置文件只读取环境,不保存秘密:
# app/config.py
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
db_host: str
db_port: int
db_user: str
db_password: str
db_name: str
def load_settings() -> Settings:
return Settings(
db_host=os.environ.get("DB_HOST", "127.0.0.1"),
db_port=int(os.environ.get("DB_PORT", "3306")),
db_user=os.environ["DB_USER"],
db_password=os.environ["DB_PASSWORD"],
db_name=os.environ.get("DB_NAME", "engineering_notes"),
)
数据库访问集中在仓储中:
# app/db.py
from collections.abc import Callable
from typing import Any
from mysql.connector import MySQLConnection
class TaskRepository:
def __init__(self, connection_factory: Callable[[], MySQLConnection]) -> None:
self.connection_factory = connection_factory
def create(self, project_id: int, title: str) -> dict[str, Any]:
connection = self.connection_factory()
cursor = connection.cursor(dictionary=True)
try:
cursor.execute(
"INSERT INTO tasks (project_id, title) VALUES (%s, %s)",
(project_id, title),
)
task_id = int(cursor.lastrowid)
connection.commit()
return {"id": task_id, "project_id": project_id,
"title": title, "status": "todo"}
except Exception:
connection.rollback()
raise
finally:
cursor.close()
connection.close()
def get(self, task_id: int) -> dict[str, Any] | None:
connection = self.connection_factory()
cursor = connection.cursor(dictionary=True)
try:
cursor.execute(
"SELECT id, project_id, title, status FROM tasks WHERE id = %s",
(task_id,),
)
return cursor.fetchone()
finally:
cursor.close()
connection.close()
应用入口使用工厂组装依赖,不把连接细节塞进路由。测试可以直接传入替代仓储,因此导入模块时不需要数据库配置:
# app/main.py
import logging
import mysql.connector
from fastapi import FastAPI, HTTPException, status
from fastapi.responses import JSONResponse
from mysql.connector import Error as MySQLError
from pydantic import BaseModel, Field
from app.config import load_settings
from app.db import TaskRepository
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger(__name__)
class TaskCreate(BaseModel):
project_id: int = Field(gt=0)
title: str = Field(min_length=1, max_length=200)
class Task(BaseModel):
id: int
project_id: int
title: str
status: str
def build_repository() -> TaskRepository:
settings = load_settings()
def connection_factory():
return mysql.connector.connect(
host=settings.db_host,
port=settings.db_port,
user=settings.db_user,
password=settings.db_password,
database=settings.db_name,
charset="utf8mb4",
)
return TaskRepository(connection_factory)
def create_app(repository=None) -> FastAPI:
active_repository = repository or build_repository()
app = FastAPI(title="Engineering Task Service")
@app.exception_handler(MySQLError)
def database_error_handler(request, exc):
logger.exception("database_error path=%s", request.url.path)
return JSONResponse(status_code=503, content={"detail": "database unavailable"})
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
def create_task(payload: TaskCreate):
task = active_repository.create(payload.project_id, payload.title)
logger.info(
"task_created task_id=%s project_id=%s",
task["id"], payload.project_id,
)
return task
@app.get("/tasks/{task_id}", response_model=Task)
def get_task(task_id: int):
task = active_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
return task
return app
测试通过替换仓储隔离数据库:
# tests/test_api.py
from fastapi.testclient import TestClient
from app.main import create_app
class FakeRepository:
def create(self, project_id: int, title: str):
return {"id": 1, "project_id": project_id,
"title": title, "status": "todo"}
def get(self, task_id: int):
if task_id != 1:
return None
return {"id": 1, "project_id": 1,
"title": "tested", "status": "todo"}
client = TestClient(create_app(FakeRepository()))
def test_create_task():
response = client.post("/tasks", json={"project_id": 1, "title": "tested"})
assert response.status_code == 201
assert response.json()["id"] == 1
def test_missing_task():
response = client.get("/tasks/999")
assert response.status_code == 404
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
启动与验收
先由数据库管理员执行初始化脚本并按 02.12 创建应用账号,然后启动应用:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
export DB_HOST=127.0.0.1
export DB_PORT=3306
export DB_USER=app_user
export DB_PASSWORD='replace-me'
export DB_NAME=engineering_notes
pytest -q
uvicorn app.main:create_app --factory --host 127.0.0.1 --port 8000
另一个终端验收:
curl -i -X POST http://127.0.0.1:8000/tasks \
-H 'Content-Type: application/json' \
-d '{"project_id":1,"title":"finish engineering stage"}'
curl -i http://127.0.0.1:8000/tasks/1
curl -i http://127.0.0.1:8000/health
.env.example 只写变量名和非敏感示例,不写真实密码。若使用 .env 加载工具,也仍应让生产环境通过部署系统注入秘密。
遇到的问题
- Uvicorn 启动时提示缺少配置:应用工厂在启动阶段校验环境变量;核对变量名和启动进程实际继承的环境。
- 单元测试意外连接真实数据库:测试应把替代仓储传给
create_app(),不能在测试中调用无参数工厂。 - 数据库错误直接成为 500:日志应记录异常堆栈,对外响应不暴露连接信息。重复项目、外键不存在等可预期错误可映射为 409 或 400。
- 同步数据库调用放在
async def:会阻塞事件循环。示例使用普通def,由框架在线程池处理同步端点;高并发场景应评估异步驱动或明确线程池容量。 - 每次请求新建连接成本高:本阶段优先保证生命周期正确,下一步可引入有上限、可观测的连接池。
结果、评估与阶段复盘
这个项目把阶段能力连成了完整链路:Linux 提供运行环境,Shell 固化操作,Git 追踪修改,MySQL 持久化数据,HTTP 和 FastAPI 建立服务边界,日志与测试提供排查和回归依据。
真正的变化不是文件数量增加,而是完成标准发生了变化:程序不仅要返回正确结果,还要能在新环境启动、在失败时留下证据、在修改后自动验证。
改进方向
- 增加数据库集成测试、迁移工具和连接池。
- 为日志加入请求 ID,并补充健康检查和指标。
- 增加更新、分页、幂等创建和统一错误模型。
- 后续再进入容器、持续集成和生产部署,不在本阶段提前堆叠复杂度。
License: CC BY-NC 4.0
Updated 3 hours ago
Was this article helpful? Give it a like.
0 comments


