2. 需求分析与功能规划
好,咱们进入第二章。上一章我们把项目架子搭起来了,现在该往里面填最核心的东西——数据库。
做图片管理系统,说白了就是管两件事:文件存哪儿 和 文件信息记在哪儿。文件存磁盘或对象存储,信息存数据库。这一章我们就搞定后者。
2.1 先想清楚:我们要存哪些信息?
别急着写代码,先拿纸笔列一列。我个人习惯是,先画个思维导图,把一张图片从上传到被使用的全生命周期过一遍。
核心思路: 一张图片在系统里,除了文件本身,还需要记录「它是谁上传的」「什么时候上传的」「多大」「什么格式」「被哪些文章引用了」「有没有被处理过」等等。这些信息就是数据库表的字段来源。
我列一下最基础的需求:
- 文件标识:唯一 ID、原始文件名、存储路径
- 文件属性:文件大小、MIME 类型、宽高尺寸
- 时间线:创建时间、更新时间
- 归属:上传用户 ID、所属相册或分组
- 状态:是否被删除(软删除)、是否公开
- 扩展信息:图片描述、标签、MD5 哈希(用于去重)
嗯,这里要注意:MD5 哈希 是我在项目中吃过亏才加上的。有一次用户批量上传,同一个文件传了 5 遍,磁盘空间直接爆了。从那以后,我所有文件系统都会在入库前计算哈希,先查重再落盘。
2.2 表结构设计:一张核心表就够了?
初期我们只需要一张主表 images。但别小看这一张表,字段设计的好坏直接决定后面好不好扩展。
我见过很多新手把字段类型乱用,比如用 VARCHAR 存文件大小,用 TEXT 存宽高。这不行。数据库设计要「对得起数据类型」。
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
id |
Integer |
Primary Key, Auto Increment | 自增主键,内部标识 |
uuid |
String(36) |
Unique, Not Null, Index | 对外暴露的唯一标识,防止 ID 遍历 |
original_name |
String(255) |
Not Null | 用户上传时的原始文件名 |
storage_path |
String(512) |
Not Null, Unique | 文件在存储系统中的相对路径 |
file_size |
BigInteger |
Not Null | 文件字节数,用 BigInteger 防止大文件溢出 |
mime_type |
String(50) |
Not Null | 如 image/jpeg, image/png |
width |
Integer |
Nullable | 图片宽度(像素),非图片文件可为空 |
height |
Integer |
Nullable | 图片高度(像素) |
md5_hash |
String(32) |
Index | 文件 MD5,用于去重和完整性校验 |
description |
Text |
Nullable | 图片描述或备注 |
is_deleted |
Boolean |
Default False | 软删除标记 |
is_public |
Boolean |
Default True | 是否公开可见 |
uploader_id |
Integer |
Foreign Key, Index | 关联用户表(后续章节实现) |
created_at |
DateTime |
Not Null, Default now | 记录创建时间 |
updated_at |
DateTime |
Not Null, On Update now | 记录最后更新时间 |
小技巧: 为什么不用 id 直接对外暴露?你想想看,如果用户知道图片 ID 是自增的,他完全可以遍历 ID 下载所有图片。用 UUID 对外,既保证了唯一性,又防止了信息泄露。我在第一个版本就踩过这个坑,后来全部重构了。
2.3 用 SQLAlchemy 把表变成 Python 代码
表设计好了,接下来用 SQLAlchemy 的 ORM 把它翻译成 Python 类。ORM 的好处是,你操作数据库就像操作普通对象一样,不用写原生 SQL。
先安装依赖:
pip install sqlalchemy pymysql
然后创建模型文件 app/models/image.py:
from datetime import datetime
import uuid
from sqlalchemy import (
Column, Integer, String, BigInteger, Text,
Boolean, DateTime, ForeignKey, Index
)
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class Image(Base):
__tablename__ = 'images'
id = Column(Integer, primary_key=True, autoincrement=True)
uuid = Column(
String(36), unique=True, nullable=False, index=True,
default=lambda: str(uuid.uuid4())
)
original_name = Column(String(255), nullable=False)
storage_path = Column(String(512), nullable=False, unique=True)
file_size = Column(BigInteger, nullable=False)
mime_type = Column(String(50), nullable=False)
width = Column(Integer, nullable=True)
height = Column(Integer, nullable=True)
md5_hash = Column(String(32), index=True)
description = Column(Text, nullable=True)
is_deleted = Column(Boolean, default=False)
is_public = Column(Boolean, default=True)
uploader_id = Column(Integer, ForeignKey('users.id'), index=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(
DateTime, nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow
)
def __repr__(self):
return f"<Image(uuid='{self.uuid}', name='{self.original_name}')>"
这里有几个细节我想强调一下:
uuid字段的默认值:我用lambda: str(uuid.uuid4())而不是直接传uuid.uuid4()。为什么?因为直接传的话,所有新记录的 UUID 会在 Python 启动时一次性生成,变成同一个值。用 lambda 可以保证每次创建新对象时重新生成。BigInteger存文件大小:图片文件动辄几十 MB,用Integer最大只能存 2GB 左右,不够用。BigInteger 可以存到 9.22EB,放心。onupdate参数:这个参数会在每次更新记录时自动把当前时间写进去,省得你手动维护updated_at。
注意: 生产环境中 datetime.utcnow 建议配合 server_default 和 server_onupdate 使用,让数据库自己管理时间,避免应用服务器时间不一致导致的问题。这里为了演示清晰,先用 Python 端处理。
2.4 建立数据库连接与初始化
模型写好了,得有个地方初始化数据库连接。我习惯在 app/database.py 里统一管理:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session
from app.config import Config
engine = create_engine(
Config.SQLALCHEMY_DATABASE_URI,
pool_size=10,
max_overflow=20,
echo=False # 开发时可设为 True 查看 SQL 日志
)
SessionLocal = scoped_session(sessionmaker(bind=engine))
def init_db():
"""创建所有表结构"""
from app.models.image import Base
Base.metadata.create_all(bind=engine)
def get_db():
"""获取数据库会话(用于依赖注入)"""
db = SessionLocal()
try:
yield db
finally:
db.close()
然后在 app/config.py 里配置数据库连接字符串:
import os
class Config:
# 优先从环境变量读取,方便部署
DB_HOST = os.getenv('DB_HOST', 'localhost')
DB_PORT = os.getenv('DB_PORT', '3306')
DB_USER = os.getenv('DB_USER', 'root')
DB_PASSWORD = os.getenv('DB_PASSWORD', 'password')
DB_NAME = os.getenv('DB_NAME', 'image_manager')
SQLALCHEMY_DATABASE_URI = (
f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}"
f"@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4"
)
个人经验: 连接池的 pool_size 和 max_overflow 要根据并发量调整。我之前一个项目没配连接池,高并发时数据库连接数直接打满,服务挂了。一般 Web 应用 pool_size=10, max_overflow=20 起步,后续用压测数据调优。
2.5 验证一下:跑通创建表的流程
写个简单的脚本测试:
# scripts/init_db.py
import sys
sys.path.append('..')
from app.database import init_db, engine
from app.models.image import Base
if __name__ == '__main__':
print("正在创建数据库表...")
Base.metadata.create_all(bind=engine)
print("完成!")
# 查看已创建的表
inspector = inspect(engine)
tables = inspector.get_table_names()
print(f"当前数据库中的表:{tables}")
运行后,如果看到 ['images'] 输出,说明表创建成功。你可以用 MySQL 客户端连上去看看,字段类型、约束、索引都应该和我们设计的一致。
2.6 本章知识结构图
下面这张图把本章的核心逻辑串起来了,从需求分析到表设计,再到 ORM 实现和初始化,一目了然:
2.7 避坑指南 & 最佳实践
最后,分享几个我在实际项目中踩过的坑,希望能帮你省点时间:
- 字段类型别凑合:文件大小用
Integer存?等你遇到 2.1GB 的图片就傻眼了。宽高用VARCHAR存?查询时没法做数值比较。类型选错,后面改表结构代价很大。 - 索引不是越多越好:每个字段都加索引,写入性能会急剧下降。一般只在
uuid、md5_hash、uploader_id、created_at这些高频查询字段上加索引就够了。 - 软删除 vs 硬删除:我建议默认用软删除(
is_deleted标记)。用户误删了图片,你还能从回收站捞回来。等确认数据确实不需要了,再跑定时任务物理删除。 - 字符集用 utf8mb4:MySQL 的
utf8最多存 3 字节的 Unicode,存不了 emoji 和生僻字。用utf8mb4才能兼容所有字符。连接字符串里加上?charset=utf8mb4。