第 8 章 · 文件上传与配置分环境
本章目标:掌握 Flask Config 类与多环境配置(Development / Production / Testing);实现商品封面文件上传与 UPLOAD_FOLDER 管理;理解 .env 环境变量加载;区分开发静态托管与生产 Nginx 职责;在 api-demo 中完成可部署的配置分层。
学时建议:4 小时(含 1.5 小时上传跟练)
前置:本模块 ch01 应用工厂;ch04 WTForms;ch05 Product 模型;ch07 API(本章同时覆盖 Web 表单与 API 上传,文件上传 request.files 为本章新引入)。运维概念见 ops-deploy 相关章节。
8.1 为什么需要分环境配置
同一套代码在开发机、测试机、生产机上行为不同:调试开关、数据库地址、密钥、上传目录、CORS 白名单均不应硬编码。
┌─────────────────┐
APP_ENV ─────────►│ config.py │
│ Development │──► DEBUG=True, SQLite
│ Production │──► DEBUG=False, PostgreSQL
│ Testing │──► 内存库、禁用 CSRF
└─────────────────┘
▲
.env 文件注入敏感变量
| 环境 | 典型特征 |
|---|---|
| Development | DEBUG=True,详细错误页,本地 SQLite |
| Production | DEBUG=False,PostgreSQL,密钥来自环境变量 |
| Testing | 快速测试库,TESTING=True |
本章基于虚构项目 api-demo;域名示例 https://api.example.com 仅用于说明 CDN/Nginx 地址,不涉及真实业务配置。
8.2 Config 类层次设计
api_demo/config.py:
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
class Config:
"""所有环境共享的默认项。"""
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-change-me")
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URL",
f"sqlite:///{BASE_DIR / 'instance' / 'api_demo.db'}",
)
# 上传
MAX_CONTENT_LENGTH = 2 * 1024 * 1024 # 全局请求体上限 2MB
UPLOAD_FOLDER = BASE_DIR / "uploads"
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
# CORS(ch07)
CORS_ORIGINS = []
@staticmethod
def init_app(app):
"""子类可覆盖:创建目录、校验配置等。"""
app.config["UPLOAD_FOLDER"].mkdir(parents=True, exist_ok=True)
class DevelopmentConfig(Config):
DEBUG = True
CORS_ORIGINS = ["http://127.0.0.1:5173", "http://localhost:3000"]
@staticmethod
def init_app(app):
Config.init_app(app)
# 开发期可由 Flask 托管 /uploads(见 8.7)
class ProductionConfig(Config):
DEBUG = False
# 生产 SECRET_KEY 必须由环境变量提供
SECRET_KEY = os.environ["SECRET_KEY"]
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "").split(",")
@staticmethod
def init_app(app):
Config.init_app(app)
if not os.environ.get("SECRET_KEY"):
raise RuntimeError("生产环境必须设置 SECRET_KEY 环境变量")
class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
WTF_CSRF_ENABLED = False
UPLOAD_FOLDER = BASE_DIR / "tests" / "tmp_uploads"
config_map = {
"development": DevelopmentConfig,
"production": ProductionConfig,
"testing": TestingConfig,
"default": DevelopmentConfig,
}
应用工厂加载:
# api_demo/__init__.py
import os
from flask import Flask
from .config import config_map
def create_app(config_name=None):
if config_name is None:
config_name = os.getenv("APP_ENV", "development")
app = Flask(__name__)
config_class = config_map.get(config_name, config_map["default"])
app.config.from_object(config_class)
config_class.init_app(app)
# 初始化扩展 ...
return app
| 配置项 | 作用 |
|---|---|
MAX_CONTENT_LENGTH | 超限返回 413 |
UPLOAD_FOLDER | 磁盘存储根目录 |
ALLOWED_EXTENSIONS | 白名单扩展名 |
8.3 .env 与环境变量
禁止将生产 SECRET_KEY、数据库密码提交 Git。使用 python-dotenv 在本地加载 .env:
pip install python-dotenv
.env.example(提交仓库):
APP_ENV=development
SECRET_KEY=change-me-in-production
DATABASE_URL=sqlite:///instance/api_demo.db
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:3000
UPLOAD_FOLDER=uploads
.env(本地使用,加入 .gitignore):
APP_ENV=development
SECRET_KEY=local-dev-secret-not-for-prod
DATABASE_URL=postgresql://api_demo:pass@localhost:5432/api_demo
run.py 或 wsgi.py 入口:
from dotenv import load_dotenv
load_dotenv() # 必须在 create_app 之前
from api_demo import create_app
app = create_app()
| 变量 | 说明 |
|---|---|
APP_ENV | development / production / testing(自定义变量;Flask 3.x 已移除内置 FLASK_ENV,故不用旧名) |
SECRET_KEY | 会话签名,泄露需轮换 |
DATABASE_URL | SQLAlchemy 连接串 |
CORS_ORIGINS | 逗号分隔的前端 Origin 列表 |
十二要素:配置与代码分离,部署时由平台(systemd、Docker、K8s Secret)注入环境变量。
8.4 模型字段与文件名策略
api_demo/models/product.py:
from api_demo.extensions import db
class Product(db.Model):
__tablename__ = "products"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
slug = db.Column(db.String(200), unique=True, nullable=False)
price = db.Column(db.Numeric(10, 2), default=0)
stock = db.Column(db.Integer, default=0)
cover_filename = db.Column(db.String(255), nullable=True) # 仅存文件名
is_published = db.Column(db.Boolean, default=False)
@property
def cover_url(self):
if not self.cover_filename:
return None
return f"/uploads/products/{self.cover_filename}"
存储策略:数据库只存相对文件名,完整 URL 由 cover_url 属性或序列化层拼接,便于切换 CDN 前缀。
def save_cover(file_storage, slug):
"""校验扩展名与大小,保存到 UPLOAD_FOLDER/products/。"""
from flask import current_app
from werkzeug.utils import secure_filename
import uuid
filename = secure_filename(file_storage.filename or "")
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
allowed = current_app.config["ALLOWED_EXTENSIONS"]
if ext not in allowed:
raise ValueError(f"不允许的扩展名: {ext}")
subdir = current_app.config["UPLOAD_FOLDER"] / "products"
subdir.mkdir(parents=True, exist_ok=True)
new_name = f"{slug}-{uuid.uuid4().hex[:8]}.{ext}"
path = subdir / new_name
file_storage.save(path)
return new_name