Files
blog-press/docs/FastAPI/FastAPIGuide.md
2025-12-10 19:56:57 +08:00

586 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 一、简介
  [FastAPI](https://fastapi.tiangolo.com/zh/) 是一个用于构建 API 的现代、快速(高性能)的 web 框架,使用 Python 并基于标准的 Python 类型提示。
  安装:
```cmd
pip install fastapi
pip install uvicorn[standard] # ASGI服务器
```
# 二、数据层
## 2.1 数据库ORM SQLAlchemy
### 2.1.1 配置与连接
```python
# database.py - 数据库配置
from sqlalchemy import create_engine, MetaData
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session
from typing import Generator
import os
from dotenv import load_dotenv
load_dotenv()
# 数据库配置
DATABASE_URL = f"mysql+pymysql://{os.getenv("DATABASE_URL", "root:123456@127.0.0.1:3306/test")}"
# 创建引擎
engine = create_engine(
DATABASE_URL,
echo=True, # 显示 SQL 语句(开发环境)
pool_size=20, # 连接池大小
max_overflow=40, # 最大溢出连接数
pool_pre_ping=True, # 连接前 ping
pool_recycle=3600, # 连接回收时间(秒)
)
# 创建会话工厂
SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine,
expire_on_commit=False, # 提交后不使实例过期
)
# 声明基类
Base = declarative_base()
# 依赖注入:获取数据库会话
def get_db() -> Generator[Session, None, None]:
"""
获取数据库会话
使用 yield 确保会话正确关闭
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
```
>[!WARNING]
>需要把pool_pre_ping=True打开否则会出现超过pool_recycle时间后数据库断线。
### 2.1.2 数据库基类
```python
from sqlalchemy import Column, BigInteger, String, DateTime, event
from sqlalchemy.ext.declarative import declared_attr
from config.auth import context_sub
from config.database import Base
from datetime import datetime
from utils.common import camel_to_snake
from id_generator import options, generator
# https://github.com/yitter/IdGenerator/tree/master/Python
options = options.IdGeneratorOptions(worker_id=23)
idgen = generator.DefaultIdGenerator()
idgen.set_id_generator(options)
# 第二层基类包含ID
class IdBase(Base):
__abstract__ = True
id = Column(BigInteger, primary_key=True, index=True)
@declared_attr
def __tablename__(cls):
# 自动把数据库实体类名驼峰转为数据库表名下划线
return camel_to_snake(cls.__name__)
# 自动填充id
@event.listens_for(IdBase, 'before_insert', propagate=True)
def before_insert_listener(mapper, connection, target):
if target.id is None:
target.id = idgen.next_id()
# 第二层基类包含ID和审计字段
class AuditBase(IdBase):
__abstract__ = True
create_time = Column(DateTime, nullable=True, default=datetime.now)
create_by = Column(String(255), nullable=True)
update_time = Column(DateTime, nullable=True, default=datetime.now, onupdate=datetime.now)
update_by = Column(String(255), nullable=True)
@event.listens_for(AuditBase, 'before_insert', propagate=True)
def before_insert(mapper, connection, target):
value = context_sub.get(None)
if value is not None:
target.create_by = value
@event.listens_for(AuditBase, 'before_update', propagate=True)
def before_update(mapper, connection, target):
value = context_sub.get(None)
if value is not None:
target.update_by = value
```
  id采用yitter雪花id。
  审计字段中的create_time和update_time通过SQLAlchemy参数配置。
  create_by和update_by字段为登录认证中存储的账号信息。
### 2.1.3 数据库模型
```python
from sqlalchemy import (BigInteger, Boolean, Column, Integer, DECIMAL, String, LargeBinary)
from sqlalchemy.orm import relationship
from models.base import AuditBase, IdBase
class Blog(AuditBase):
title = Column(String(255), nullable=False, comment="博客标题")
top_value = Column(Integer, nullable=False, default=0, comment="置顶值 越大越靠前")
is_great = Column(Boolean, nullable=False, default=False, comment="是否是精品")
category_id = Column(BigInteger, nullable=False, comment="博客类别")
summary = Column(String(255), nullable=False, comment="博客内容概要")
content_id = Column(BigInteger, nullable=False, comment="博客内容")
word_count = Column(Integer, nullable=False, default=0, comment="字数统计")
read_duration = Column(DECIMAL(10, 2), nullable=False, default=0.00, comment="阅读时长")
is_approved = Column(Boolean, nullable=False, default=False, comment="是否发布")
# 分类关系(多对一)
category = relationship(
"BlogCategory",
# 与BlogCategory的blogs属性建立双向关系
back_populates="blogs",
# 只级联保存和合并操作,不级联删除(删除博客不应删除分类
cascade="save-update, merge",
# 明确指定连接条件
# 如果数据库设置了外键可以省略
primaryjoin="foreign(Blog.category_id) == BlogCategory.id"
)
# 内容关系(一对一)
content = relationship(
"BlogContent",
# 与BlogContent的blog属性建立双向关系
back_populates="blog",
# 完全级联操作:保存、合并、刷新、删除等所有操作都会级联
cascade="all, delete-orphan",
# 设置为False表示一对一关系返回单个对象而不是列表
uselist=False,
# 确保内容只有一个父博客与delete-orphan配合使用
single_parent=True,
# 明确指定连接条件
primaryjoin="foreign(Blog.content_id) == BlogContent.id"
)
# 评论(一对多)
comments = relationship(
"BlogComment",
# 与BlogComment的blog属性建立双向关系
back_populates="blog",
# 完全级联操作:博客删除时自动删除所有评论
cascade="all, delete-orphan",
# 明确指定连接条件
primaryjoin="Blog.id == foreign(BlogComment.blog_id)"
)
class BlogCategory(AuditBase):
name = Column(String(45), nullable=False, comment="类别名称")
blogs = relationship(
"Blog",
# 与Blog的category属性建立双向关系
back_populates="category",
# 完全级联操作:分类删除时自动删除所有关联的博客
# 警告:这会级联删除分类下的所有博客,包括博客的内容、访问记录和评论
cascade="all, delete-orphan",
# 明确指定连接条件
primaryjoin="BlogCategory.id == foreign(Blog.category_id)"
)
class BlogContent(IdBase):
content = Column(LargeBinary, nullable=False, comment="博客内容")
blog = relationship(
"Blog",
# 与Blog的content属性建立双向关系
back_populates="content",
# 设置为False表示一对一关系
uselist=False,
# 明确指定连接条件
primaryjoin="BlogContent.id == foreign(Blog.content_id)"
)
class BlogComment(AuditBase):
blog_id = Column(BigInteger, nullable=False, comment="博客ID")
parent_id = Column(BigInteger, nullable=False, comment="父评论ID")
name = Column(String(255), nullable=False, comment="评论人昵称")
website = Column(String(255), nullable=True, comment="评论人网站")
ip_address = Column(String(45), nullable=False, comment="评论人IP")
user_agent = Column(String(255), nullable=False, comment="评论人浏览器信息")
content = Column(String(255), nullable=False, comment="评论内容")
is_approved = Column(Boolean, nullable=False, default=False, comment="是否通过")
blog = relationship(
"Blog",
# 与Blog的comments属性建立双向关系
back_populates="comments",
# 明确指定连接条件
primaryjoin="foreign(BlogComment.blog_id) == Blog.id"
)
```
  这里没有通过在数据库建立外键而是通过SQLAlchemy中的relationship来建立。
  其中primaryjoin明确指定了连接条件。如果为一对一需要设置uselist=False和single_parent=True返回单个对象而不是列表。
  cascade表示级联操作
```
# cascade 的完整可选值列表:
# 基本级联操作
"save-update" # 保存/更新时级联
"merge" # 合并会话时级联
"refresh-expire" # 刷新过期对象时级联
"expunge" # 从会话中移除时级联
"delete" # 删除时级联
"delete-orphan" # 成为孤儿时删除
# 快捷组合
"all" # 包含除 delete-orphan 外的所有操作
"all, delete-orphan" # 包含所有操作
"none" # 禁用所有级联(默认)
# 其他组合
"save-update, merge" # 常用组合
"save-update, merge, delete" # 包含删除
"save-update, merge, refresh-expire"
```
## 2.2 数据验证pydantic
```python
from typing import Optional
from pydantic import BaseModel, Field, field_validator
from datetime import datetime
class BlogQuery(BaseModel):
"""博客查询参数"""
category: Optional[str] = Field(None, description="分类名称")
title: Optional[str] = Field(None, description="标题关键词")
year: Optional[int] = Field(None, description="发布年份", ge=2000, le=datetime.now().year)
@field_validator('year')
def validate_year(cls, v):
if v is not None and v > datetime.now().year:
raise ValueError('年份不能超过当前年份')
return v
class BlogBase(BaseModel):
"""博客基础模型"""
title: str = Field(..., min_length=1, max_length=255, description="博客标题")
top_value: int = Field(default=0, ge=0, description="置顶值,越大越靠前", alias="topValue")
is_great: bool = Field(default=False, description="是否是精品", alias="isGreat")
category: str = Field(..., min_length=1, max_length=45, description="分类名称")
content: Optional[str] = Field(None, description="博客内容")
is_approved: bool = Field(default=False, description="是否已发布", alias="isApproved")
@field_validator('title')
def title_not_empty(cls, v):
if not v or not v.strip():
raise ValueError('标题不能为空')
return v.strip()
@field_validator('category')
def category_not_empty(cls, v):
if not v or not v.strip():
raise ValueError('分类不能为空')
return v.strip()
class BlogCreate(BlogBase):
pass
class BlogUpdate(BlogBase):
pass
class BlogResponse(BlogBase):
id: int = Field(..., description="博客ID")
summary: Optional[str] = Field(None, description="内容摘要")
word_count: Optional[int] = Field(None, description="字数统计", alias="wordCount")
read_duration: Optional[float] = Field(None, description="阅读时长", alias="readDuration")
visit_count: Optional[int] = Field(0, description="访问次数", alias="visitCount")
create_time: datetime = Field(..., description="创建时间", alias="createTime")
update_time: datetime = Field(..., description="更新时间", alias="updateTime")
class Config:
from_attributes = True
populate_by_name = True
json_encoders = {
# 自定义 datetime 类型的序列化格式
datetime: lambda dt: dt.strftime('%Y-%m-%d %H:%M:%S')
}
class BlogCategoryResponse(BaseModel):
name: str = Field(..., description="分类名称")
count: int = Field(..., description="博客数量")
class Config:
from_attributes = True
populate_by_name = True
class BlogStatsResponse(BaseModel):
blog_count: int = Field(..., description="博客总数", alias="blogCount")
category_count: int = Field(..., description="分类总数", alias="categoryCount")
word_count: int = Field(..., description="总字数", alias="wordCount")
class BlogVisitResponse(BaseModel):
ip: str = Field(..., description="IP地址")
os: str = Field(..., description="操作系统")
browser: str = Field(..., description="浏览器")
uri: str = Field(..., description="访问路径")
title: str = Field(None, description="博客标题")
visit_time: datetime = Field(..., description="访问时间", alias="visitTime")
class Config:
from_attributes = True
populate_by_name = True
json_encoders = {
datetime: lambda dt: dt.strftime('%Y-%m-%d %H:%M:%S')
}
class BlogLatestResponse(BaseModel):
id: int = Field(..., description="博客ID")
title: str = Field(..., description="博客标题")
class Config:
from_attributes = True
populate_by_name = True
class BlogAdjacentResponse(BaseModel):
id: int = Field(..., description="博客ID")
title: str = Field(..., description="博客标题")
class Config:
from_attributes = True
populate_by_name = True
class BlogCommentCreate(BaseModel):
parent_id: int = Field(default=0, ge=0, description="父评论ID0表示顶级评论", alias="parentId")
name: str = Field(..., min_length=1, max_length=50, description="评论人昵称")
website: Optional[str] = Field(None, description="评论人网站")
content: str = Field(..., min_length=1, max_length=1000, description="评论内容")
@field_validator('name')
def name_not_empty(cls, v):
if not v or not v.strip():
raise ValueError('昵称不能为空')
return v.strip()
@field_validator('content')
def content_not_empty(cls, v):
if not v or not v.strip():
raise ValueError('评论内容不能为空')
return v.strip()
class BlogCommentResponse(BlogCommentCreate):
id: int = Field(..., description="评论ID")
ip_address: str = Field(..., description="IP地址", alias="ipAddress")
user_agent: str = Field(..., description="浏览器信息", alias="userAgent")
create_time: datetime = Field(..., description="创建时间", alias="createTime")
class Config:
from_attributes = True
populate_by_name = True
json_encoders = {
# 自定义 datetime 类型的序列化格式
datetime: lambda dt: dt.strftime('%Y-%m-%d %H:%M:%S')
}
```
  `@field_validator`为Pydantic V2的验证器装饰器。
  `class Config`为全局配置类,`from_attributes=True`表示允许从SQLAlchemy等ORM对象创建。
# 三、服务层
## 3.1 查询操作
```python
def query_blog_by_id(db: Session, blog_id: int) -> BlogResponse:
check_blog_exist(db, blog_id)
stmt = (select(
Blog.id,
Blog.title,
Blog.top_value,
Blog.is_great,
BlogCategory.name.label("category"),
Blog.summary,
BlogContent.content,
Blog.word_count,
Blog.read_duration,
func.count(BlogVisit.id).label("visitCount"),
Blog.is_approved,
Blog.create_time,
Blog.update_time
).where(Blog.id == blog_id)
.outerjoin(BlogCategory, Blog.category_id == BlogCategory.id)
.outerjoin(BlogContent, Blog.content_id == BlogContent.id)
.outerjoin(BlogVisit, Blog.id == BlogVisit.blog_id))
blog = db.execute(stmt).first()
return BlogResponse.model_validate(blog)
```
  条件和分页查询
```python
def query_blog_by_condition(db: Session, blog_query: BlogQuery) -> List[BlogResponse]:
stmt = get_query_blog_by_condition_stmt(blog_query)
results = db.execute(stmt).fetchall()
return [BlogResponse.model_validate(result) for result in results]
def get_query_blog_by_condition_stmt(blog_query: BlogQuery):
stmt = (select(
Blog.id,
Blog.title,
Blog.top_value,
Blog.is_great,
BlogCategory.name.label("category"),
Blog.summary,
BlogContent.content,
Blog.word_count,
Blog.read_duration,
func.count(BlogVisit.id).label("visitCount"),
Blog.is_approved,
Blog.create_time,
Blog.update_time
).where(Blog.is_approved == 1)
.outerjoin(BlogCategory, Blog.category_id == BlogCategory.id)
.outerjoin(BlogContent, Blog.content_id == BlogContent.id)
.outerjoin(BlogVisit, Blog.id == BlogVisit.blog_id)
.group_by(Blog.id))
conditions = []
if blog_query.category:
conditions.append(BlogCategory.name == blog_query.category)
if blog_query.title:
conditions.append(Blog.title.like(f"%{blog_query.title}%"))
if blog_query.year:
conditions.append(func.extract('year', Blog.create_time) == blog_query.year)
if conditions:
stmt = stmt.where(and_(*conditions))
stmt = stmt.order_by(desc(Blog.is_great), desc(Blog.update_time))
return stmt
def query_blog_by_condition_page(db: Session, blog_query: BlogQuery,
current_page: int = 1, page_size: int = 10) -> PageResult[BlogResponse]:
stmt = get_query_blog_by_condition_stmt(blog_query)
return paginate_query(db, stmt, current_page, page_size)
def paginate_query(db: Session, query, current_page: int = 1, page_size: int = 10) -> PageResult:
"""通用分页查询函数"""
# 计算总记录数
total = db.execute(select(func.count()).select_from(query.subquery())).scalar_one_or_none() or 0
# 计算总页数
total_pages = (total + page_size - 1) // page_size if page_size != 0 else 0
# 执行分页查询
results = db.execute(query.offset((current_page - 1) * page_size).limit(page_size)).all()
# 转换为字典列表
records = [row._asdict() if hasattr(row, "_asdict") else dict(row) for row in results]
return PageResult(
current=current_page,
size=page_size,
total=total,
pages=total_pages,
records=records
)
```
## 3.2 新增操作
```python
def add_blog(db: Session, blog: BlogCreate) -> bool:
word_count = get_word_count(blog.content)
db_blog = Blog(
title=blog.title,
top_value=blog.top_value,
is_great=blog.is_great,
category_id=add_blog_category(db, blog.category),
summary=get_blog_summary(blog.content),
word_count=word_count,
read_duration=get_read_duration(word_count),
is_approved=blog.is_approved
)
# 级联新增
db_blog.content = BlogContent(content=blog.content.encode('utf-8'))
db.add(db_blog)
db.commit()
db.refresh(db_blog)
return True
```
  这里的`db_blog.content`可以直接赋值`BlogContent`对象实现级联新增,不用传`content_id`
>[!WARNING]
>这里的category和blog是多对一的关系且需要一定的逻辑处理不能直接赋值`BlogCategory`对象,否则每新增一条博客都会新增一个`category`
## 3.3 更新操作
```python
def update_blog(db: Session, blog_id: int, blog: BlogUpdate) -> bool:
db_blog = check_blog_exist(db, blog_id)
db_blog.title = blog.title
db_blog.top_value = blog.topValue
db_blog.is_great = blog.isGreat
db_blog.category_id = update_blog_category(db, blog.category)
# 级联更新
db_blog.content = BlogContent(content=blog.content.encode('utf-8'))
word_count = get_word_count(blog.content)
db_blog.summary = get_blog_summary(blog.content),
db_blog.word_count = word_count
db_blog.read_duration = get_read_duration(word_count)
db_blog.is_approved = blog.isApproved
db.commit()
db.refresh(db_blog)
return True
```
## 3.4 删除操作
```python
def delete_blog(db: Session, blog_id: int) -> bool:
db_blog = check_blog_exist(db, blog_id)
# 级联删除
db.delete(db_blog)
db.commit()
return True
```