Flask 框架深度解析

Flask 2026-04-20 14
预计阅读时间:55 分钟

Flask 框架深度解析

一、Flask 的核心理念:微框架,大智慧

Flask 是一个微框架(Microframework),但"微"并不意味着功能弱,而是指它保持了高度的简洁性和可扩展性。与 Django 的"大而全"不同,Flask 只提供 Web 开发的核心功能,其他一切都交给开发者选择和扩展。

1.1 为什么选择 Flask?

"""
Flask vs Django 对比:

Flask 的优势:
- 轻量级:核心代码量小,启动快
- 灵活:没有强制项目结构,自由度高
- 透明:没有魔法,容易理解底层原理
- 适合微服务和学习 Web 底层原理

Django 的优势:
- 功能完整:开箱即用,内置认证、管理后台等
- 规范性强:有明确的最佳实践和项目结构
- 适合大型单体应用和快速开发
"""

# 最简单的 Flask 应用
from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello():
    return 'Hello, World!'

if __name__ == '__main__':
    app.run(debug=True)
# 访问 http://127.0.0.1:5000/ 即可看到结果

1.2 Flask 项目结构演进

"""
Flask 项目结构推荐:

# 阶段1:单文件(适合微型项目)
myapp/
├── app.py
├── requirements.txt
└── .env

# 阶段2:包结构(适合小型项目)
myapp/
├── app/
│   ├── __init__.py
│   ├── routes.py
│   ├── models.py
│   ├── templates/
│   └── static/
├── config.py
├── run.py
└── requirements.txt

# 阶段3:工厂模式 + 蓝图(适合中大型项目)
myapp/
├── app/
│   ├── __init__.py          # 应用工厂
│   ├── extensions.py        # 扩展初始化
│   ├── models/
│   │   ├── __init__.py
│   │   ├── user.py
│   │   └── post.py
│   ├── blueprints/
│   │   ├── auth/
│   │   │   ├── __init__.py
│   │   │   ├── routes.py
│   │   │   └── forms.py
│   │   ├── blog/
│   │   │   ├── __init__.py
│   │   │   └── routes.py
│   │   └── api/
│   │       ├── __init__.py
│   │       └── resources.py
│   ├── templates/
│   ├── static/
│   └── utils/
├── migrations/              # Flask-Migrate
├── tests/
├── config.py
├── run.py
├── requirements.txt
└── .env
"""

二、Flask 核心基础

2.1 应用工厂模式

# app/__init__.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_mail import Mail
from flask_caching import Cache
from flask_cors import CORS
from config import config

# 初始化扩展(不绑定到具体应用)
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
mail = Mail()
cache = Cache()
cors = CORS()

def create_app(config_name='default'):
    """
    应用工厂函数
    根据配置名创建 Flask 应用实例
    """
    app = Flask(__name__)

    # 加载配置
    app.config.from_object(config[config_name])
    config[config_name].init_app(app)

    # 初始化扩展
    db.init_app(app)
    migrate.init_app(app, db)
    login_manager.init_app(app)
    mail.init_app(app)
    cache.init_app(app)
    cors.init_app(app)

    # 配置登录管理
    login_manager.login_view = 'auth.login'
    login_manager.login_message = '请先登录'

    # 注册蓝图
    from app.blueprints.main import main_bp
    from app.blueprints.auth import auth_bp
    from app.blueprints.blog import blog_bp
    from app.blueprints.api import api_bp

    app.register_blueprint(main_bp)
    app.register_blueprint(auth_bp, url_prefix='/auth')
    app.register_blueprint(blog_bp, url_prefix='/blog')
    app.register_blueprint(api_bp, url_prefix='/api')

    # 注册错误处理器
    register_error_handlers(app)

    # 注册 shell 上下文
    register_shell_context(app)

    # 注册模板过滤器
    register_template_filters(app)

    return app

def register_error_handlers(app):
    """注册错误处理器"""

    @app.errorhandler(400)
    def bad_request(e):
        return {'error': 'Bad Request', 'message': str(e)}, 400

    @app.errorhandler(404)
    def not_found(e):
        if app.config['DEBUG']:
            return {'error': 'Not Found', 'message': str(e)}, 404
        return {'error': 'Not Found'}, 404

    @app.errorhandler(500)
    def internal_error(e):
        db.session.rollback()
        app.logger.error(f'Server Error: {e}', exc_info=True)
        return {'error': 'Internal Server Error'}, 500

def register_shell_context(app):
    """注册 Flask shell 上下文"""
    from app.models import User, Post, Comment

    @app.shell_context_processor
    def make_shell_context():
        return {
            'db': db,
            'User': User,
            'Post': Post,
            'Comment': Comment
        }

def register_template_filters(app):
    """注册自定义模板过滤器"""
    import markdown
    from datetime import datetime

    @app.template_filter('markdown')
    def markdown_filter(text):
        return markdown.markdown(text, extensions=['extra', 'codehilite'])

    @app.template_filter('timeago')
    def timeago_filter(dt):
        now = datetime.utcnow()
        diff = now - dt

        if diff.days > 365:
            return f'{diff.days // 365}年前'
        elif diff.days > 30:
            return f'{diff.days // 30}个月前'
        elif diff.days > 0:
            return f'{diff.days}天前'
        elif diff.seconds > 3600:
            return f'{diff.seconds // 3600}小时前'
        elif diff.seconds > 60:
            return f'{diff.seconds // 60}分钟前'
        else:
            return '刚刚'

# config.py
import os
from datetime import timedelta
from dotenv import load_dotenv

load_dotenv()

class Config:
    """基础配置"""
    SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard-to-guess-string'

    # 数据库配置
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    SQLALCHEMY_RECORD_QUERIES = True

    # 会话配置
    PERMANENT_SESSION_LIFETIME = timedelta(days=7)

    # 邮件配置
    MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp.gmail.com')
    MAIL_PORT = int(os.environ.get('MAIL_PORT', 587))
    MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() == 'true'
    MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
    MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')

    # 分页配置
    POSTS_PER_PAGE = 10
    COMMENTS_PER_PAGE = 20

    # 上传配置
    MAX_CONTENT_LENGTH = 16 * 1024 * 1024  # 16MB
    UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), 'uploads')
    ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}

    @staticmethod
    def init_app(app):
        pass

class DevelopmentConfig(Config):
    """开发环境配置"""
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
        'sqlite:///' + os.path.join(os.path.dirname(__file__), 'data-dev.sqlite')

    # 启用调试工具栏
    DEBUG_TB_ENABLED = True
    DEBUG_TB_INTERCEPT_REDIRECTS = False

class TestingConfig(Config):
    """测试环境配置"""
    TESTING = True
    SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
        'sqlite:///' + os.path.join(os.path.dirname(__file__), 'data-test.sqlite')
    WTF_CSRF_ENABLED = False

class ProductionConfig(Config):
    """生产环境配置"""
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')

    @classmethod
    def init_app(cls, app):
        Config.init_app(app)

        # 生产环境日志配置
        import logging
        from logging.handlers import RotatingFileHandler

        if not app.debug:
            file_handler = RotatingFileHandler(
                'logs/flask.log', 
                maxBytes=10240, 
                backupCount=10
            )
            file_handler.setFormatter(logging.Formatter(
                '%(asctime)s %(levelname)s: %(message)s '
                '[in %(pathname)s:%(lineno)d]'
            ))
            file_handler.setLevel(logging.INFO)
            app.logger.addHandler(file_handler)
            app.logger.setLevel(logging.INFO)
            app.logger.info('Flask startup')

config = {
    'development': DevelopmentConfig,
    'testing': TestingConfig,
    'production': ProductionConfig,
    'default': DevelopmentConfig
}

2.2 蓝图(Blueprint)模块化

# app/blueprints/main/__init__.py
from flask import Blueprint

main_bp = Blueprint('main', __name__)

from . import routes

# app/blueprints/main/routes.py
from flask import render_template, request, current_app
from . import main_bp
from app.models import Post, Category
from app import cache

@main_bp.route('/')
@cache.cached(timeout=60)  # 缓存1分钟
def index():
    """首页"""
    page = request.args.get('page', 1, type=int)
    pagination = Post.query.filter_by(
        is_published=True
    ).order_by(
        Post.created_at.desc()
    ).paginate(
        page=page,
        per_page=current_app.config['POSTS_PER_PAGE'],
        error_out=False
    )

    posts = pagination.items

    return render_template(
        'main/index.html',
        posts=posts,
        pagination=pagination
    )

@main_bp.route('/about')
def about():
    """关于页面"""
    return render_template('main/about.html')

@main_bp.route('/search')
def search():
    """搜索"""
    query = request.args.get('q', '').strip()

    if not query:
        return render_template('main/search.html', results=[], query='')

    results = Post.query.filter(
        Post.is_published == True,
        Post.title.ilike(f'%{query}%') | Post.content.ilike(f'%{query}%')
    ).order_by(Post.created_at.desc()).limit(50).all()

    return render_template(
        'main/search.html',
        results=results,
        query=query
    )

@main_bp.route('/sitemap.xml')
def sitemap():
    """生成站点地图"""
    from flask import make_response, url_for

    posts = Post.query.filter_by(is_published=True).all()
    categories = Category.query.all()

    xml_content = ['<?xml version="1.0" encoding="UTF-8"?>']
    xml_content.append('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">')

    # 首页
    xml_content.append(f'''
    <url>
        <loc>{url_for('main.index', _external=True)}</loc>
        <changefreq>daily</changefreq>
        <priority>1.0</priority>
    </url>
    ''')

    # 文章页
    for post in posts:
        xml_content.append(f'''
        <url>
            <loc>{url_for('blog.post', slug=post.slug, _external=True)}</loc>
            <lastmod>{post.updated_at.strftime('%Y-%m-%d')}</lastmod>
            <changefreq>monthly</changefreq>
            <priority>0.8</priority>
        </url>
        ''')

    xml_content.append('</urlset>')

    response = make_response('\n'.join(xml_content))
    response.headers['Content-Type'] = 'application/xml'
    return response

# app/blueprints/auth/__init__.py
from flask import Blueprint

auth_bp = Blueprint('auth', __name__)

from . import routes, forms

# app/blueprints/auth/routes.py
from flask import render_template, redirect, url_for, flash, request, current_app
from flask_login import login_user, logout_user, login_required, current_user
from urllib.parse import urlparse
from . import auth_bp
from .forms import LoginForm, RegistrationForm, ChangePasswordForm, ResetPasswordRequestForm
from app import db
from app.models import User
from app.utils import send_email, generate_token, verify_token

@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
    """用户登录"""
    if current_user.is_authenticated:
        return redirect(url_for('main.index'))

    form = LoginForm()

    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data.lower()).first()

        if user and user.verify_password(form.password.data):
            login_user(user, remember=form.remember_me.data)
            user.update_last_login()

            next_page = request.args.get('next')
            if not next_page or urlparse(next_page).netloc != '':
                next_page = url_for('main.index')

            flash(f'欢迎回来,{user.username}!', 'success')
            return redirect(next_page)

        flash('邮箱或密码错误', 'danger')

    return render_template('auth/login.html', form=form)

@auth_bp.route('/logout')
@login_required
def logout():
    """用户登出"""
    logout_user()
    flash('您已成功退出登录', 'info')
    return redirect(url_for('main.index'))

@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
    """用户注册"""
    if current_user.is_authenticated:
        return redirect(url_for('main.index'))

    form = RegistrationForm()

    if form.validate_on_submit():
        user = User(
            username=form.username.data,
            email=form.email.data.lower(),
            password=form.password.data
        )
        db.session.add(user)
        db.session.commit()

        # 发送验证邮件
        token = generate_token(user.email, salt='email-confirm')
        confirm_url = url_for('auth.confirm_email', token=token, _external=True)
        send_email(
            subject='请验证您的邮箱',
            recipients=[user.email],
            template='auth/email/confirm_email',
            user=user,
            confirm_url=confirm_url
        )

        flash('注册成功!请查收验证邮件', 'success')
        login_user(user)
        return redirect(url_for('main.index'))

    return render_template('auth/register.html', form=form)

@auth_bp.route('/confirm/<token>')
@login_required
def confirm_email(token):
    """验证邮箱"""
    email = verify_token(token, salt='email-confirm')

    if not email:
        flash('验证链接无效或已过期', 'danger')
        return redirect(url_for('main.index'))

    if current_user.email != email:
        flash('验证链接与当前用户不匹配', 'danger')
        return redirect(url_for('main.index'))

    if current_user.email_confirmed:
        flash('邮箱已验证', 'info')
    else:
        current_user.email_confirmed = True
        db.session.commit()
        flash('邮箱验证成功!', 'success')

    return redirect(url_for('main.index'))

@auth_bp.route('/reset-password-request', methods=['GET', 'POST'])
def reset_password_request():
    """请求重置密码"""
    if current_user.is_authenticated:
        return redirect(url_for('main.index'))

    form = ResetPasswordRequestForm()

    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data.lower()).first()

        if user:
            token = generate_token(user.email, salt='password-reset')
            reset_url = url_for('auth.reset_password', token=token, _external=True)
            send_email(
                subject='重置密码',
                recipients=[user.email],
                template='auth/email/reset_password',
                user=user,
                reset_url=reset_url
            )

        flash('如果该邮箱已注册,您将收到重置密码邮件', 'info')
        return redirect(url_for('auth.login'))

    return render_template('auth/reset_password_request.html', form=form)

# app/blueprints/auth/forms.py
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed
from wtforms import StringField, PasswordField, BooleanField, SubmitField, TextAreaField
from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationError
from app.models import User

class LoginForm(FlaskForm):
    """登录表单"""
    email = StringField('邮箱', validators=[
        DataRequired(message='请输入邮箱'),
        Email(message='请输入有效的邮箱地址')
    ])
    password = PasswordField('密码', validators=[
        DataRequired(message='请输入密码')
    ])
    remember_me = BooleanField('记住我')
    submit = SubmitField('登录')

class RegistrationForm(FlaskForm):
    """注册表单"""
    username = StringField('用户名', validators=[
        DataRequired(message='请输入用户名'),
        Length(min=3, max=64, message='用户名长度必须在3-64个字符之间')
    ])
    email = StringField('邮箱', validators=[
        DataRequired(message='请输入邮箱'),
        Email(message='请输入有效的邮箱地址')
    ])
    password = PasswordField('密码', validators=[
        DataRequired(message='请输入密码'),
        Length(min=8, message='密码长度至少8个字符')
    ])
    password2 = PasswordField('确认密码', validators=[
        DataRequired(message='请再次输入密码'),
        EqualTo('password', message='两次输入的密码不一致')
    ])
    submit = SubmitField('注册')

    def validate_username(self, field):
        if User.query.filter_by(username=field.data).first():
            raise ValidationError('用户名已被使用')

    def validate_email(self, field):
        if User.query.filter_by(email=field.data.lower()).first():
            raise ValidationError('邮箱已被注册')

2.3 模型定义与数据库操作

# app/models/base.py
from datetime import datetime
from app import db
from sqlalchemy.ext.declarative import declared_attr

class BaseModel(db.Model):
    """基础模型类"""
    __abstract__ = True

    id = db.Column(db.Integer, primary_key=True)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

    def save(self):
        """保存到数据库"""
        db.session.add(self)
        db.session.commit()
        return self

    def delete(self):
        """从数据库删除"""
        db.session.delete(self)
        db.session.commit()

    @classmethod
    def get_or_create(cls, defaults=None, **kwargs):
        """获取或创建实例"""
        instance = cls.query.filter_by(**kwargs).first()
        if instance:
            return instance, False

        params = dict(kwargs)
        if defaults:
            params.update(defaults)

        instance = cls(**params)
        db.session.add(instance)
        db.session.commit()
        return instance, True

# app/models/user.py
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
from flask import current_app
from app import db, login_manager
from .base import BaseModel
import hashlib

class User(UserMixin, BaseModel):
    """用户模型"""
    __tablename__ = 'users'

    username = db.Column(db.String(64), unique=True, index=True, nullable=False)
    email = db.Column(db.String(120), unique=True, index=True, nullable=False)
    password_hash = db.Column(db.String(128), nullable=False)

    # 个人信息
    bio = db.Column(db.Text)
    avatar_hash = db.Column(db.String(32))
    location = db.Column(db.String(64))
    website = db.Column(db.String(128))

    # 状态
    is_active = db.Column(db.Boolean, default=True)
    email_confirmed = db.Column(db.Boolean, default=False)
    last_seen = db.Column(db.DateTime, default=datetime.utcnow)

    # 关系
    posts = db.relationship('Post', backref='author', lazy='dynamic', cascade='all, delete-orphan')
    comments = db.relationship('Comment', backref='author', lazy='dynamic', cascade='all, delete-orphan')

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        if self.email and not self.avatar_hash:
            self.avatar_hash = hashlib.md5(
                self.email.lower().encode('utf-8')
            ).hexdigest()

    @property
    def password(self):
        raise AttributeError('密码不可读')

    @password.setter
    def password(self, password):
        self.password_hash = generate_password_hash(password)

    def verify_password(self, password):
        """验证密码"""
        return check_password_hash(self.password_hash, password)

    def avatar(self, size=100, default='identicon'):
        """生成 Gravatar 头像 URL"""
        if not self.avatar_hash:
            self.avatar_hash = hashlib.md5(
                self.email.lower().encode('utf-8')
            ).hexdigest()
        return f'https://www.gravatar.com/avatar/{self.avatar_hash}?d={default}&s={size}'

    def generate_auth_token(self, expiration=3600):
        """生成 API 认证令牌"""
        s = Serializer(current_app.config['SECRET_KEY'], expires_in=expiration)
        return s.dumps({'id': self.id}).decode('utf-8')

    @staticmethod
    def verify_auth_token(token):
        """验证 API 认证令牌"""
        s = Serializer(current_app.config['SECRET_KEY'])
        try:
            data = s.loads(token)
        except:
            return None
        return User.query.get(data['id'])

    def update_last_login(self):
        """更新最后登录时间"""
        self.last_seen = datetime.utcnow()
        db.session.commit()

    def to_dict(self):
        """转换为字典"""
        return {
            'id': self.id,
            'username': self.username,
            'email': self.email,
            'bio': self.bio,
            'avatar': self.avatar(),
            'location': self.location,
            'website': self.website,
            'last_seen': self.last_seen.isoformat() if self.last_seen else None,
            'created_at': self.created_at.isoformat()
        }

    def __repr__(self):
        return f'<User {self.username}>'

@login_manager.user_loader
def load_user(user_id):
    """Flask-Login 用户加载回调"""
    return User.query.get(int(user_id))

# app/models/post.py
from app import db
from .base import BaseModel
from flask import url_for
import re

# 文章-标签关联表
post_tags = db.Table(
    'post_tags',
    db.Column('post_id', db.Integer, db.ForeignKey('posts.id', ondelete='CASCADE')),
    db.Column('tag_id', db.Integer, db.ForeignKey('tags.id', ondelete='CASCADE'))
)

class Post(BaseModel):
    """文章模型"""
    __tablename__ = 'posts'

    title = db.Column(db.String(200), nullable=False)
    slug = db.Column(db.String(200), unique=True, index=True)
    content = db.Column(db.Text, nullable=False)
    summary = db.Column(db.String(500))

    # 状态
    is_published = db.Column(db.Boolean, default=False)
    published_at = db.Column(db.DateTime)

    # 统计
    views_count = db.Column(db.Integer, default=0)
    likes_count = db.Column(db.Integer, default=0)
    comments_count = db.Column(db.Integer, default=0)

    # 外键
    author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    category_id = db.Column(db.Integer, db.ForeignKey('categories.id'))

    # 关系
    comments = db.relationship(
        'Comment',
        backref='post',
        lazy='dynamic',
        cascade='all, delete-orphan'
    )
    tags = db.relationship(
        'Tag',
        secondary=post_tags,
        backref=db.backref('posts', lazy='dynamic'),
        lazy='dynamic'
    )

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        if not self.slug and self.title:
            self.slug = self.generate_slug(self.title)

    @staticmethod
    def generate_slug(title):
        """生成 URL 友好的 slug"""
        from flask import current_app
        import unicodedata

        # 转换为 ASCII
        slug = unicodedata.normalize('NFKD', title)
        slug = slug.encode('ascii', 'ignore').decode('ascii')

        # 替换非字母数字为连字符
        slug = re.sub(r'[^\w\s-]', '', slug.lower())
        slug = re.sub(r'[-\s]+', '-', slug).strip('-_')

        # 确保唯一性
        base_slug = slug
        counter = 1
        while Post.query.filter_by(slug=slug).first():
            slug = f'{base_slug}-{counter}'
            counter += 1

        return slug

    def increment_views(self):
        """增加浏览量"""
        self.views_count += 1
        db.session.commit()

    def add_tag(self, tag_name):
        """添加标签"""
        tag = Tag.get_or_create(name=tag_name.strip().lower())[0]
        if tag not in self.tags.all():
            self.tags.append(tag)
            db.session.commit()

    def remove_tag(self, tag_name):
        """移除标签"""
        tag = Tag.query.filter_by(name=tag_name.strip().lower()).first()
        if tag and tag in self.tags.all():
            self.tags.remove(tag)
            db.session.commit()

    def get_absolute_url(self):
        """获取文章 URL"""
        return url_for('blog.post', slug=self.slug, _external=True)

    def to_dict(self, include_content=True):
        """转换为字典"""
        data = {
            'id': self.id,
            'title': self.title,
            'slug': self.slug,
            'summary': self.summary,
            'author': self.author.to_dict(),
            'category': self.category.name if self.category else None,
            'tags': [tag.name for tag in self.tags.all()],
            'views_count': self.views_count,
            'likes_count': self.likes_count,
            'comments_count': self.comments_count,
            'is_published': self.is_published,
            'published_at': self.published_at.isoformat() if self.published_at else None,
            'created_at': self.created_at.isoformat(),
            'updated_at': self.updated_at.isoformat(),
            'url': self.get_absolute_url()
        }

        if include_content:
            data['content'] = self.content

        return data

    def __repr__(self):
        return f'<Post {self.title}>'

class Category(BaseModel):
    """分类模型"""
    __tablename__ = 'categories'

    name = db.Column(db.String(64), unique=True, nullable=False)
    slug = db.Column(db.String(64), unique=True, index=True)
    description = db.Column(db.String(200))

    # 关系
    posts = db.relationship('Post', backref='category', lazy='dynamic')

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        if not self.slug and self.name:
            self.slug = re.sub(r'[-\s]+', '-', self.name.lower())

    def post_count(self):
        """文章数量"""
        return self.posts.filter_by(is_published=True).count()

    def __repr__(self):
        return f'<Category {self.name}>'

class Tag(BaseModel):
    """标签模型"""
    __tablename__ = 'tags'

    name = db.Column(db.String(64), unique=True, nullable=False)
    slug = db.Column(db.String(64), unique=True, index=True)

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        if not self.slug and self.name:
            self.slug = re.sub(r'[-\s]+', '-', self.name.lower())

    def post_count(self):
        """文章数量"""
        return self.posts.filter_by(is_published=True).count()

    def __repr__(self):
        return f'<Tag {self.name}>'

class Comment(BaseModel):
    """评论模型"""
    __tablename__ = 'comments'

    content = db.Column(db.Text, nullable=False)
    is_approved = db.Column(db.Boolean, default=False)

    # 外键
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False)
    parent_id = db.Column(db.Integer, db.ForeignKey('comments.id'))

    # 关系
    replies = db.relationship(
        'Comment',
        backref=db.backref('parent', remote_side='Comment.id'),
        lazy='dynamic',
        cascade='all, delete-orphan'
    )

    def approve(self):
        """批准评论"""
        self.is_approved = True
        self.post.comments_count += 1
        db.session.commit()

    def to_dict(self):
        """转换为字典"""
        return {
            'id': self.id,
            'content': self.content,
            'author': self.author.to_dict(),
            'post_id': self.post_id,
            'parent_id': self.parent_id,
            'is_approved': self.is_approved,
            'created_at': self.created_at.isoformat()
        }

    def __repr__(self):
        return f'<Comment {self.id}>'

三、模板与静态文件

3.1 Jinja2 模板高级用法

<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="description" content="{% block description %}我的 Flask 博客{% endblock %}">
    <meta name="keywords" content="{% block keywords %}Flask, Python, 博客{% endblock %}">

    <title>{% block title %}Flask Blog{% endblock %}</title>

    <!-- Favicon -->
    <link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">

    <!-- CSS -->
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
    {% block extra_css %}{% endblock %}
</head>
<body>
    <!-- 导航栏 -->
    <nav class="navbar">
        <div class="container">
            <a href="{{ url_for('main.index') }}" class="navbar-brand">Flask Blog</a>

            <ul class="navbar-nav">
                <li class="nav-item">
                    <a href="{{ url_for('main.index') }}" class="nav-link {{ 'active' if request.endpoint == 'main.index' }}">
                        首页
                    </a>
                </li>
                <li class="nav-item">
                    <a href="{{ url_for('blog.posts') }}" class="nav-link {{ 'active' if request.endpoint == 'blog.posts' }}">
                        文章
                    </a>
                </li>
                <li class="nav-item">
                    <a href="{{ url_for('main.about') }}" class="nav-link {{ 'active' if request.endpoint == 'main.about' }}">
                        关于
                    </a>
                </li>
            </ul>

            <ul class="navbar-nav ml-auto">
                {% if current_user.is_authenticated %}
                <li class="nav-item dropdown">
                    <a href="#" class="nav-link dropdown-toggle">
                        <img src="{{ current_user.avatar(30) }}" alt="{{ current_user.username }}" class="avatar-sm">
                        {{ current_user.username }}
                    </a>
                    <div class="dropdown-menu">
                        <a href="{{ url_for('blog.create_post') }}" class="dropdown-item">写文章</a>
                        <a href="{{ url_for('auth.profile') }}" class="dropdown-item">个人资料</a>
                        <div class="dropdown-divider"></div>
                        <a href="{{ url_for('auth.logout') }}" class="dropdown-item">退出</a>
                    </div>
                </li>
                {% else %}
                <li class="nav-item">
                    <a href="{{ url_for('auth.login') }}" class="nav-link">登录</a>
                </li>
                <li class="nav-item">
                    <a href="{{ url_for('auth.register') }}" class="nav-link">注册</a>
                </li>
                {% endif %}
            </ul>
        </div>
    </nav>

    <!-- Flash 消息 -->
    {% with messages = get_flashed_messages(with_categories=true) %}
        {% if messages %}
        <div class="container mt-3">
            {% for category, message in messages %}
            <div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
                {{ message }}
                <button type="button" class="close" data-dismiss="alert">
                    <span>&times;</span>
                </button>
            </div>
            {% endfor %}
        </div>
        {% endif %}
    {% endwith %}

    <!-- 主要内容 -->
    <main class="container my-4">
        {% block content %}{% endblock %}
    </main>

    <!-- 页脚 -->
    <footer class="footer">
        <div class="container">
            <p class="text-muted">
                &copy; {{ now.year }} Flask Blog. Powered by Flask.
            </p>
        </div>
    </footer>

    <!-- JavaScript -->
    <script src="{{ url_for('static', filename='js/main.js') }}"></script>
    {% block extra_js %}{% endblock %}
</body>
</html>

<!-- templates/blog/post.html -->
{% extends "base.html" %}

{% block title %}{{ post.title }} - Flask Blog{% endblock %}

{% block description %}{{ post.summary or post.content[:200] }}{% endblock %}

{% block keywords %}{{ post.tags|map(attribute='name')|join(',') }}{% endblock %}

{% block content %}
<article class="post">
    <header class="post-header">
        <h1 class="post-title">{{ post.title }}</h1>

        <div class="post-meta">
            <span class="post-author">
                <img src="{{ post.author.avatar(30) }}" alt="{{ post.author.username }}" class="avatar-sm">
                <a href="{{ url_for('blog.author', username=post.author.username) }}">
                    {{ post.author.username }}
                </a>
            </span>

            <span class="post-date">
                <i class="far fa-calendar-alt"></i>
                {{ post.published_at.strftime('%Y-%m-%d') }}
            </span>

            {% if post.category %}
            <span class="post-category">
                <i class="far fa-folder"></i>
                <a href="{{ url_for('blog.category', slug=post.category.slug) }}">
                    {{ post.category.name }}
                </a>
            </span>
            {% endif %}

            <span class="post-views">
                <i class="far fa-eye"></i>
                {{ post.views_count }} 阅读
            </span>

            <span class="post-comments">
                <i class="far fa-comments"></i>
                {{ post.comments_count }} 评论
            </span>
        </div>
    </header>

    <div class="post-content">
        {{ post.content|markdown|safe }}
    </div>

    <footer class="post-footer">
        {% if post.tags.count() %}
        <div class="post-tags">
            <i class="fas fa-tags"></i>
            {% for tag in post.tags %}
            <a href="{{ url_for('blog.tag', slug=tag.slug) }}" class="tag">
                {{ tag.name }}
            </a>
            {% endfor %}
        </div>
        {% endif %}

        <div class="post-actions">
            <button class="btn btn-like {{ 'active' if liked else '' }}" 
                    data-post-id="{{ post.id }}"
                    onclick="toggleLike({{ post.id }})">
                <i class="far fa-heart"></i>
                <span class="likes-count">{{ post.likes_count }}</span>
            </button>

            {% if current_user.is_authenticated and current_user.id == post.author_id %}
            <a href="{{ url_for('blog.edit_post', slug=post.slug) }}" class="btn btn-edit">
                <i class="far fa-edit"></i> 编辑
            </a>
            {% endif %}
        </div>
    </footer>
</article>

<!-- 相关文章 -->
{% if related_posts %}
<section class="related-posts">
    <h3>相关推荐</h3>
    <div class="row">
        {% for related in related_posts %}
        <div class="col-md-4">
            <div class="card">
                <div class="card-body">
                    <h5 class="card-title">
                        <a href="{{ url_for('blog.post', slug=related.slug) }}">
                            {{ related.title }}
                        </a>
                    </h5>
                    <p class="card-text">{{ related.summary[:100] }}...</p>
                </div>
            </div>
        </div>
        {% endfor %}
    </div>
</section>
{% endif %}

<!-- 评论区域 -->
<section class="comments" id="comments">
    <h3>评论 ({{ comments.total }})</h3>

    {% if current_user.is_authenticated %}
    <div class="comment-form">
        <form method="post" action="{{ url_for('blog.add_comment', slug=post.slug) }}">
            {{ comment_form.hidden_tag() }}

            <div class="form-group">
                {{ comment_form.content.label }}
                {{ comment_form.content(class="form-control", rows=4, placeholder="写下你的评论...") }}
                {% for error in comment_form.content.errors %}
                <small class="text-danger">{{ error }}</small>
                {% endfor %}
            </div>

            {{ comment_form.submit(class="btn btn-primary") }}
        </form>
    </div>
    {% else %}
    <div class="alert alert-info">
        请 <a href="{{ url_for('auth.login', next=request.path) }}">登录</a> 后发表评论
    </div>
    {% endif %}

    {% if comments.items %}
    <div class="comments-list">
        {% for comment in comments.items %}
        <div class="comment" id="comment-{{ comment.id }}">
            <div class="comment-avatar">
                <img src="{{ comment.author.avatar(50) }}" alt="{{ comment.author.username }}">
            </div>

            <div class="comment-body">
                <div class="comment-meta">
                    <strong>{{ comment.author.username }}</strong>
                    <span class="comment-time">
                        {{ comment.created_at|timeago }}
                    </span>
                </div>

                <div class="comment-content">
                    {{ comment.content }}
                </div>

                <div class="comment-actions">
                    <a href="#" onclick="replyTo({{ comment.id }}, '{{ comment.author.username }}')">
                        回复
                    </a>
                </div>

                {% if comment.replies.count() %}
                <div class="comment-replies">
                    {% for reply in comment.replies %}
                    <div class="comment reply">
                        <div class="comment-avatar">
                            <img src="{{ reply.author.avatar(40) }}" alt="{{ reply.author.username }}">
                        </div>
                        <div class="comment-body">
                            <div class="comment-meta">
                                <strong>{{ reply.author.username }}</strong>
                                <span class="comment-time">
                                    {{ reply.created_at|timeago }}
                                </span>
                            </div>
                            <div class="comment-content">
                                {{ reply.content }}
                            </div>
                        </div>
                    </div>
                    {% endfor %}
                </div>
                {% endif %}
            </div>
        </div>
        {% endfor %}
    </div>

    <!-- 评论分页 -->
    {% if comments.pages > 1 %}
    <nav class="pagination">
        {% for page in comments.iter_pages() %}
            {% if page %}
                <a href="{{ url_for('blog.post', slug=post.slug, page=page) }}#comments" 
                   class="page-link {{ 'active' if page == comments.page }}">
                    {{ page }}
                </a>
            {% else %}
                <span class="page-link">...</span>
            {% endif %}
        {% endfor %}
    </nav>
    {% endif %}
    {% endif %}
</section>
{% endblock %}

{% block extra_js %}
<script>
async function toggleLike(postId) {
    const response = await fetch(`/api/posts/${postId}/like`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-CSRFToken': '{{ csrf_token() }}'
        }
    });

    const data = await response.json();

    if (response.ok) {
        const btn = document.querySelector(`.btn-like[data-post-id="${postId}"]`);
        const countSpan = btn.querySelector('.likes-count');
        countSpan.textContent = data.likes_count;
        btn.classList.toggle('active', data.action === 'liked');
    } else if (response.status === 401) {
        window.location.href = "{{ url_for('auth.login') }}";
    }
}

function replyTo(commentId, username) {
    const form = document.querySelector('.comment-form textarea');
    form.value = `@${username} `;
    form.focus();

    // 添加隐藏的 parent_id 字段
    let parentInput = document.querySelector('input[name="parent_id"]');
    if (!parentInput) {
        parentInput = document.createElement('input');
        parentInput.type = 'hidden';
        parentInput.name = 'parent_id';
        form.form.appendChild(parentInput);
    }
    parentInput.value = commentId;

    // 滚动到表单
    form.scrollIntoView({ behavior: 'smooth' });
}
</script>
{% endblock %}

四、Flask 扩展生态

4.1 常用扩展集成

# app/extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_mail import Mail
from flask_caching import Cache
from flask_cors import CORS
from flask_wtf import CSRFProtect
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_talisman import Talisman
from flask_compress import Compress
from flask_admin import Admin
from flask_admin.contrib.sqla import ModelView

# 数据库
db = SQLAlchemy()
migrate = Migrate()

# 认证
login_manager = LoginManager()
csrf = CSRFProtect()

# 邮件
mail = Mail()

# 缓存
cache = Cache()

# 跨域
cors = CORS()

# 限流
limiter = Limiter(key_func=get_remote_address)

# 安全
talisman = Talisman()

# 压缩
compress = Compress()

# 管理后台
admin = Admin(name='Blog Admin', template_mode='bootstrap4')

def init_extensions(app):
    """初始化所有扩展"""

    # 数据库
    db.init_app(app)
    migrate.init_app(app, db)

    # 认证
    login_manager.init_app(app)
    login_manager.login_view = 'auth.login'
    login_manager.login_message = '请先登录'
    csrf.init_app(app)

    # 邮件
    mail.init_app(app)

    # 缓存
    cache.init_app(app)

    # 跨域
    cors.init_app(app, resources={r"/api/*": {"origins": "*"}})

    # 限流
    limiter.init_app(app)

    # 安全(生产环境启用)
    if not app.debug:
        talisman.init_app(
            app,
            content_security_policy={
                'default-src': "'self'",
                'script-src': "'self' 'unsafe-inline' https://cdn.jsdelivr.net",
                'style-src': "'self' 'unsafe-inline' https://cdn.jsdelivr.net",
            },
            force_https=True
        )

    # 压缩
    compress.init_app(app)

    # 管理后台
    admin.init_app(app)

    # 注册管理视图
    from app.models import User, Post, Category, Tag, Comment

    class SecureModelView(ModelView):
        """需要认证的管理视图"""
        def is_accessible(self):
            from flask_login import current_user
            return current_user.is_authenticated and current_user.is_admin

        def inaccessible_callback(self, name, **kwargs):
            from flask import redirect, url_for, flash
            flash('您没有权限访问此页面', 'danger')
            return redirect(url_for('auth.login'))

    admin.add_view(SecureModelView(User, db.session))
    admin.add_view(SecureModelView(Post, db.session))
    admin.add_view(SecureModelView(Category, db.session))
    admin.add_view(SecureModelView(Tag, db.session))
    admin.add_view(SecureModelView(Comment, db.session))

# 使用示例:限流装饰器
# app/blueprints/api/routes.py
from app.extensions import limiter

@api_bp.route('/login', methods=['POST'])
@limiter.limit("5 per minute")
def login():
    """登录接口(限流)"""
    pass

@api_bp.route('/search')
@cache.cached(timeout=60, query_string=True)
def search():
    """搜索接口(缓存)"""
    pass

4.2 Flask-RESTful API

# app/blueprints/api/resources.py
from flask_restful import Resource, reqparse, fields, marshal_with, abort
from flask import request
from flask_login import current_user
from app import db
from app.models import Post, Category, Tag, Comment
from app.extensions import limiter

# 响应字段定义
post_fields = {
    'id': fields.Integer,
    'title': fields.String,
    'slug': fields.String,
    'summary': fields.String,
    'content': fields.String,
    'author': fields.Nested({
        'id': fields.Integer,
        'username': fields.String,
        'avatar': fields.String(attribute='avatar')
    }),
    'category': fields.Nested({
        'id': fields.Integer,
        'name': fields.String,
        'slug': fields.String
    }, allow_null=True),
    'tags': fields.List(fields.Nested({
        'id': fields.Integer,
        'name': fields.String,
        'slug': fields.String
    })),
    'views_count': fields.Integer,
    'likes_count': fields.Integer,
    'comments_count': fields.Integer,
    'published_at': fields.DateTime(dt_format='iso8601'),
    'created_at': fields.DateTime(dt_format='iso8601'),
    'updated_at': fields.DateTime(dt_format='iso8601'),
    'url': fields.String(attribute='get_absolute_url')
}

post_list_fields = {
    'posts': fields.List(fields.Nested(post_fields)),
    'total': fields.Integer,
    'page': fields.Integer,
    'pages': fields.Integer,
    'per_page': fields.Integer
}

class PostListResource(Resource):
    """文章列表资源"""

    def __init__(self):
        self.parser = reqparse.RequestParser()
        self.parser.add_argument('page', type=int, default=1)
        self.parser.add_argument('per_page', type=int, default=10)
        self.parser.add_argument('category', type=str)
        self.parser.add_argument('tag', type=str)
        self.parser.add_argument('q', type=str, help='搜索关键词')
        self.parser.add_argument('sort', type=str, default='-created_at')

    @marshal_with(post_list_fields)
    def get(self):
        """获取文章列表"""
        args = self.parser.parse_args()

        query = Post.query.filter_by(is_published=True)

        # 分类筛选
        if args.category:
            category = Category.query.filter_by(slug=args.category).first()
            if category:
                query = query.filter_by(category_id=category.id)

        # 标签筛选
        if args.tag:
            tag = Tag.query.filter_by(slug=args.tag).first()
            if tag:
                query = query.filter(Post.tags.contains(tag))

        # 搜索
        if args.q:
            query = query.filter(
                db.or_(
                    Post.title.ilike(f'%{args.q}%'),
                    Post.content.ilike(f'%{args.q}%')
                )
            )

        # 排序
        sort_field = args.sort.lstrip('-')
        sort_order = db.desc if args.sort.startswith('-') else db.asc

        if hasattr(Post, sort_field):
            query = query.order_by(sort_order(getattr(Post, sort_field)))

        # 分页
        pagination = query.paginate(
            page=args.page,
            per_page=args.per_page,
            error_out=False
        )

        return {
            'posts': pagination.items,
            'total': pagination.total,
            'page': pagination.page,
            'pages': pagination.pages,
            'per_page': pagination.per_page
        }

    @limiter.limit("10 per hour")
    def post(self):
        """创建文章"""
        if not current_user.is_authenticated:
            abort(401, message='请先登录')

        parser = reqparse.RequestParser()
        parser.add_argument('title', required=True, help='标题不能为空')
        parser.add_argument('content', required=True, help='内容不能为空')
        parser.add_argument('summary')
        parser.add_argument('category_id', type=int)
        parser.add_argument('tags', type=str, help='标签,用逗号分隔')
        parser.add_argument('is_published', type=bool, default=False)

        args = parser.parse_args()

        post = Post(
            title=args.title,
            content=args.content,
            summary=args.summary,
            author_id=current_user.id,
            category_id=args.category_id,
            is_published=args.is_published
        )

        if args.is_published:
            post.published_at = db.func.now()

        db.session.add(post)
        db.session.flush()

        # 处理标签
        if args.tags:
            for tag_name in args.tags.split(','):
                tag_name = tag_name.strip().lower()
                if tag_name:
                    post.add_tag(tag_name)

        db.session.commit()

        return post.to_dict(), 201

class PostResource(Resource):
    """单个文章资源"""

    @marshal_with(post_fields)
    def get(self, slug):
        """获取文章详情"""
        post = Post.query.filter_by(slug=slug, is_published=True).first_or_404()
        post.increment_views()
        return post

    def put(self, slug):
        """更新文章"""
        if not current_user.is_authenticated:
            abort(401, message='请先登录')

        post = Post.query.filter_by(slug=slug).first_or_404()

        if post.author_id != current_user.id and not current_user.is_admin:
            abort(403, message='没有权限编辑此文章')

        parser = reqparse.RequestParser()
        parser.add_argument('title')
        parser.add_argument('content')
        parser.add_argument('summary')
        parser.add_argument('category_id', type=int)
        parser.add_argument('is_published', type=bool)

        args = parser.parse_args()

        for key, value in args.items():
            if value is not None:
                setattr(post, key, value)

        if args.is_published and not post.published_at:
            post.published_at = db.func.now()

        db.session.commit()

        return post.to_dict()

    def delete(self, slug):
        """删除文章"""
        if not current_user.is_authenticated:
            abort(401, message='请先登录')

        post = Post.query.filter_by(slug=slug).first_or_404()

        if post.author_id != current_user.id and not current_user.is_admin:
            abort(403, message='没有权限删除此文章')

        db.session.delete(post)
        db.session.commit()

        return {'message': '文章已删除'}, 200

class LikeResource(Resource):
    """点赞资源"""

    def post(self, post_id):
        """点赞/取消点赞"""
        if not current_user.is_authenticated:
            abort(401, message='请先登录')

        post = Post.query.get_or_404(post_id)

        # 检查是否已点赞
        liked = current_user.is_liking(post)

        if liked:
            current_user.unlike(post)
            action = 'unliked'
        else:
            current_user.like(post)
            action = 'liked'

        db.session.commit()

        return {
            'action': action,
            'likes_count': post.likes_count
        }

# app/blueprints/api/__init__.py
from flask import Blueprint
from flask_restful import Api
from .resources import PostListResource, PostResource, LikeResource

api_bp = Blueprint('api', __name__)
api = Api(api_bp)

# 注册资源
api.add_resource(PostListResource, '/posts')
api.add_resource(PostResource, '/posts/<string:slug>')
api.add_resource(LikeResource, '/posts/<int:post_id>/like')

五、Flask 项目部署

5.1 生产环境配置

# wsgi.py
import os
from dotenv import load_dotenv

# 加载环境变量
dotenv_path = os.path.join(os.path.dirname(__file__), '.env')
if os.path.exists(dotenv_path):
    load_dotenv(dotenv_path)

from app import create_app

# 根据环境变量选择配置
config_name = os.environ.get('FLASK_CONFIG', 'production')
app = create_app(config_name)

if __name__ == '__main__':
    app.run()

# Dockerfile
"""
FROM python:3.11-slim

# 设置工作目录
WORKDIR /app

# 设置环境变量
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    FLASK_APP=wsgi.py \
    FLASK_CONFIG=production

# 安装系统依赖
RUN apt-get update && apt-get install -y \
    gcc \
    postgresql-client \
    && rm -rf /var/lib/apt/lists/*

# 安装 Python 依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 复制项目文件
COPY . .

# 创建非 root 用户
RUN useradd -m -u 1000 flask && chown -R flask:flask /app
USER flask

# 暴露端口
EXPOSE 8000

# 启动命令
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2", "wsgi:app"]
"""

# docker-compose.yml
"""
version: '3.8'

services:
  web:
    build: .
    ports:
      - "8000:8000"
    environment:
      - FLASK_CONFIG=production
      - DATABASE_URL=postgresql://postgres:password@db:5432/flask_blog
      - REDIS_URL=redis://redis:6379/0
      - SECRET_KEY=${SECRET_KEY}
    depends_on:
      - db
      - redis
    volumes:
      - ./uploads:/app/uploads
      - ./logs:/app/logs
    restart: unless-stopped

  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=flask_blog
    volumes:
      - postgres_data:/var/lib/postgresql/data
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./ssl:/etc/nginx/ssl:ro
      - ./static:/app/static:ro
    depends_on:
      - web
    restart: unless-stopped

volumes:
  postgres_data:
  redis_data:
"""

# nginx.conf
"""
events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    # 日志格式
    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';

    access_log /var/log/nginx/access.log main;
    error_log /var/log/nginx/error.log;

    # 基础配置
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;
    client_max_body_size 16M;

    # Gzip 压缩
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css text/xml application/json 
               application/javascript application/xml+rss text/javascript;

    # 上游服务器
    upstream flask_app {
        server web:8000;
    }

    # HTTP -> HTTPS 重定向
    server {
        listen 80;
        server_name example.com www.example.com;
        return 301 https://$server_name$request_uri;
    }

    # HTTPS 配置
    server {
        listen 443 ssl http2;
        server_name example.com www.example.com;

        # SSL 证书
        ssl_certificate /etc/nginx/ssl/fullchain.pem;
        ssl_certificate_key /etc/nginx/ssl/privkey.pem;

        # SSL 配置
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers on;
        ssl_session_cache shared:SSL:10m;
        ssl_session_timeout 10m;

        # 安全头
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-XSS-Protection "1; mode=block" always;

        # 静态文件
        location /static/ {
            alias /app/static/;
            expires 30d;
            add_header Cache-Control "public, immutable";
        }

        # 上传文件
        location /uploads/ {
            alias /app/uploads/;
            expires 7d;
        }

        # 代理到 Flask
        location / {
            proxy_pass http://flask_app;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Host $host;
            proxy_set_header X-Forwarded-Port $server_port;

            # 超时设置
            proxy_connect_timeout 60s;
            proxy_send_timeout 60s;
            proxy_read_timeout 60s;

            # 缓冲设置
            proxy_buffering off;
            proxy_buffer_size 4k;
            proxy_buffers 8 4k;
            proxy_busy_buffers_size 8k;
        }
    }
}
"""

六、Flask 最佳实践

6.1 应用配置管理

# 使用 Pydantic 进行配置管理
# config/settings.py
from pydantic_settings import BaseSettings
from pydantic import PostgresDsn, RedisDsn, EmailStr
from typing import Optional, List

class Settings(BaseSettings):
    """应用配置"""

    # 基础配置
    PROJECT_NAME: str = "Flask Blog"
    VERSION: str = "1.0.0"
    DEBUG: bool = False
    SECRET_KEY: str

    # 服务器配置
    HOST: str = "0.0.0.0"
    PORT: int = 8000

    # 数据库配置
    DATABASE_URL: PostgresDsn
    SQL_ECHO: bool = False
    SQL_POOL_SIZE: int = 10
    SQL_MAX_OVERFLOW: int = 20

    # Redis 配置
    REDIS_URL: RedisDsn

    # 邮件配置
    MAIL_SERVER: str = "smtp.gmail.com"
    MAIL_PORT: int = 587
    MAIL_USERNAME: Optional[str] = None
    MAIL_PASSWORD: Optional[str] = None
    MAIL_FROM: EmailStr = "noreply@example.com"

    # 安全配置
    CORS_ORIGINS: List[str] = ["http://localhost:3000"]
    RATE_LIMIT: str = "100/hour"

    # 文件上传
    MAX_UPLOAD_SIZE: int = 16 * 1024 * 1024  # 16MB
    ALLOWED_EXTENSIONS: List[str] = ["png", "jpg", "jpeg", "gif", "pdf"]
    UPLOAD_PATH: str = "uploads"

    # 缓存配置
    CACHE_TYPE: str = "redis"
    CACHE_DEFAULT_TIMEOUT: int = 300
    CACHE_KEY_PREFIX: str = "flask_blog_"

    # 会话配置
    SESSION_COOKIE_SECURE: bool = True
    SESSION_COOKIE_HTTPONLY: bool = True
    SESSION_COOKIE_SAMESITE: str = "Lax"
    PERMANENT_SESSION_LIFETIME: int = 86400 * 7  # 7天

    # 日志配置
    LOG_LEVEL: str = "INFO"
    LOG_FILE: str = "logs/app.log"
    LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"

    class Config:
        env_file = ".env"
        env_file_encoding = "utf-8"
        case_sensitive = True

settings = Settings()

# 使用配置
from config.settings import settings

def create_app():
    app = Flask(__name__)

    # 从 Pydantic 设置加载配置
    app.config.update({
        'SECRET_KEY': settings.SECRET_KEY,
        'SQLALCHEMY_DATABASE_URI': str(settings.DATABASE_URL),
        'SQLALCHEMY_ECHO': settings.SQL_ECHO,
        'SQLALCHEMY_POOL_SIZE': settings.SQL_POOL_SIZE,
        'SQLALCHEMY_MAX_OVERFLOW': settings.SQL_MAX_OVERFLOW,
        'CACHE_TYPE': settings.CACHE_TYPE,
        'CACHE_REDIS_URL': str(settings.REDIS_URL),
        'CACHE_DEFAULT_TIMEOUT': settings.CACHE_DEFAULT_TIMEOUT,
        'CACHE_KEY_PREFIX': settings.CACHE_KEY_PREFIX,
        'SESSION_COOKIE_SECURE': settings.SESSION_COOKIE_SECURE,
        'SESSION_COOKIE_HTTPONLY': settings.SESSION_COOKIE_HTTPONLY,
        'SESSION_COOKIE_SAMESITE': settings.SESSION_COOKIE_SAMESITE,
        'PERMANENT_SESSION_LIFETIME': settings.PERMANENT_SESSION_LIFETIME,
        'MAX_CONTENT_LENGTH': settings.MAX_UPLOAD_SIZE,
        'UPLOAD_FOLDER': settings.UPLOAD_PATH,
    })

    return app

6.2 错误处理与日志

# app/utils/error_handlers.py
from flask import jsonify, render_template, request
from werkzeug.exceptions import HTTPException
from app import db

def register_error_handlers(app):
    """注册全局错误处理器"""

    @app.errorhandler(400)
    def bad_request(e):
        if request.is_json:
            return jsonify({
                'error': 'Bad Request',
                'message': str(e)
            }), 400
        return render_template('errors/400.html'), 400

    @app.errorhandler(403)
    def forbidden(e):
        if request.is_json:
            return jsonify({
                'error': 'Forbidden',
                'message': '您没有权限执行此操作'
            }), 403
        return render_template('errors/403.html'), 403

    @app.errorhandler(404)
    def not_found(e):
        if request.is_json:
            return jsonify({
                'error': 'Not Found',
                'message': '请求的资源不存在'
            }), 404
        return render_template('errors/404.html'), 404

    @app.errorhandler(405)
    def method_not_allowed(e):
        if request.is_json:
            return jsonify({
                'error': 'Method Not Allowed',
                'message': str(e)
            }), 405
        return render_template('errors/405.html'), 405

    @app.errorhandler(429)
    def too_many_requests(e):
        if request.is_json:
            return jsonify({
                'error': 'Too Many Requests',
                'message': '请求过于频繁,请稍后再试'
            }), 429
        return render_template('errors/429.html'), 429

    @app.errorhandler(500)
    def internal_server_error(e):
        db.session.rollback()

        # 记录错误
        app.logger.error(f'Server Error: {e}', exc_info=True)

        if request.is_json:
            return jsonify({
                'error': 'Internal Server Error',
                'message': '服务器内部错误'
            }), 500
        return render_template('errors/500.html'), 500

    @app.errorhandler(Exception)
    def handle_exception(e):
        # 处理 HTTP 异常
        if isinstance(e, HTTPException):
            return e

        # 处理其他异常
        db.session.rollback()
        app.logger.error(f'Unhandled Exception: {e}', exc_info=True)

        if request.is_json:
            return jsonify({
                'error': 'Internal Server Error',
                'message': '服务器内部错误'
            }), 500
        return render_template('errors/500.html'), 500

# app/utils/logger.py
import logging
import sys
from logging.handlers import RotatingFileHandler
from flask import has_request_context, request

class RequestFormatter(logging.Formatter):
    """自定义日志格式化器,添加请求信息"""

    def format(self, record):
        if has_request_context():
            record.url = request.url
            record.method = request.method
            record.remote_addr = request.remote_addr
            record.user_agent = request.user_agent.string

            from flask_login import current_user
            if current_user.is_authenticated:
                record.user = current_user.username
            else:
                record.user = 'anonymous'
        else:
            record.url = None
            record.method = None
            record.remote_addr = None
            record.user_agent = None
            record.user = None

        return super().format(record)

def setup_logging(app):
    """配置日志"""

    # 设置日志级别
    app.logger.setLevel(logging.INFO)

    # 创建格式化器
    formatter = RequestFormatter(
        '[%(asctime)s] %(levelname)s in %(module)s: '
        '%(message)s [%(method)s %(url)s - %(remote_addr)s - User:%(user)s]'
    )

    # 文件处理器(带轮转)
    file_handler = RotatingFileHandler(
        'logs/flask.log',
        maxBytes=10 * 1024 * 1024,  # 10MB
        backupCount=10
    )
    file_handler.setLevel(logging.INFO)
    file_handler.setFormatter(formatter)
    app.logger.addHandler(file_handler)

    # 错误文件处理器
    error_handler = RotatingFileHandler(
        'logs/error.log',
        maxBytes=10 * 1024 * 1024,
        backupCount=10
    )
    error_handler.setLevel(logging.ERROR)
    error_handler.setFormatter(formatter)
    app.logger.addHandler(error_handler)

    # 开发环境:同时输出到控制台
    if app.debug:
        console_handler = logging.StreamHandler(sys.stdout)
        console_handler.setLevel(logging.DEBUG)
        console_handler.setFormatter(formatter)
        app.logger.addHandler(console_handler)

    # 记录启动信息
    app.logger.info('Flask application startup')

6.3 测试策略

# tests/conftest.py
import pytest
from app import create_app, db
from app.models import User, Post, Category
from config import config

@pytest.fixture(scope='session')
def app():
    """创建测试应用"""
    app = create_app('testing')

    # 创建测试上下文
    with app.app_context():
        db.create_all()
        yield app
        db.drop_all()

@pytest.fixture
def client(app):
    """创建测试客户端"""
    return app.test_client()

@pytest.fixture
def runner(app):
    """创建 CLI 运行器"""
    return app.test_cli_runner()

@pytest.fixture
def init_database(app):
    """初始化测试数据库"""
    with app.app_context():
        # 创建测试数据
        user = User(
            username='testuser',
            email='test@example.com',
            password='password123'
        )
        db.session.add(user)

        category = Category(name='Test Category', slug='test-category')
        db.session.add(category)

        db.session.commit()

        post = Post(
            title='Test Post',
            content='Test content',
            author_id=user.id,
            category_id=category.id,
            is_published=True
        )
        db.session.add(post)
        db.session.commit()

        return {
            'user': user,
            'category': category,
            'post': post
        }

@pytest.fixture
def auth_client(client, init_database):
    """创建已认证的测试客户端"""
    user = init_database['user']

    # 登录
    client.post('/auth/login', data={
        'email': user.email,
        'password': 'password123'
    })

    return client

# tests/test_models.py
import pytest
from app.models import User, Post, Category, Tag
from app import db

class TestUserModel:
    """用户模型测试"""

    def test_password_hashing(self, app):
        """测试密码哈希"""
        with app.app_context():
            user = User(username='test', email='test@test.com', password='secret')
            assert user.password_hash is not None
            assert user.verify_password('secret') is True
            assert user.verify_password('wrong') is False

    def test_avatar_generation(self, app):
        """测试头像生成"""
        with app.app_context():
            user = User(username='test', email='test@example.com', password='secret')
            avatar_url = user.avatar()
            assert 'gravatar.com' in avatar_url
            assert 'test@example.com' not in avatar_url  # 应该被哈希

    def test_unique_email(self, app):
        """测试邮箱唯一性"""
        with app.app_context():
            user1 = User(username='user1', email='test@example.com', password='secret')
            db.session.add(user1)
            db.session.commit()

            user2 = User(username='user2', email='test@example.com', password='secret')
            db.session.add(user2)

            with pytest.raises(Exception):
                db.session.commit()
            db.session.rollback()

class TestPostModel:
    """文章模型测试"""

    def test_slug_generation(self, app, init_database):
        """测试 slug 生成"""
        with app.app_context():
            post = Post(
                title='Test Slug Generation',
                content='Test content',
                author_id=init_database['user'].id
            )
            db.session.add(post)
            db.session.commit()

            assert post.slug == 'test-slug-generation'

    def test_increment_views(self, app, init_database):
        """测试浏览量增加"""
        with app.app_context():
            post = init_database['post']
            initial_views = post.views_count

            post.increment_views()

            assert post.views_count == initial_views + 1

# tests/test_api.py
import json

class TestPostAPI:
    """文章 API 测试"""

    def test_get_posts(self, client, init_database):
        """测试获取文章列表"""
        response = client.get('/api/posts')
        data = json.loads(response.data)

        assert response.status_code == 200
        assert 'posts' in data
        assert len(data['posts']) > 0
        assert data['total'] > 0

    def test_get_single_post(self, client, init_database):
        """测试获取单篇文章"""
        post = init_database['post']
        response = client.get(f'/api/posts/{post.slug}')
        data = json.loads(response.data)

        assert response.status_code == 200
        assert data['title'] == post.title
        assert data['slug'] == post.slug

    def test_create_post_unauthorized(self, client):
        """测试未认证创建文章"""
        response = client.post('/api/posts', json={
            'title': 'New Post',
            'content': 'New content'
        })

        assert response.status_code == 401

    def test_create_post_authorized(self, auth_client, init_database):
        """测试认证创建文章"""
        response = auth_client.post('/api/posts', json={
            'title': 'Authorized Post',
            'content': 'Authorized content'
        })
        data = json.loads(response.data)

        assert response.status_code == 201
        assert data['title'] == 'Authorized Post'
        assert data['author']['id'] == init_database['user'].id

    def test_like_post(self, auth_client, init_database):
        """测试点赞"""
        post = init_database['post']
        response = auth_client.post(f'/api/posts/{post.id}/like')
        data = json.loads(response.data)

        assert response.status_code == 200
        assert data['action'] == 'liked'
        assert data['likes_count'] == 1

        # 再次请求应该取消点赞
        response = auth_client.post(f'/api/posts/{post.id}/like')
        data = json.loads(response.data)

        assert data['action'] == 'unliked'
        assert data['likes_count'] == 0

七、总结

Flask 核心要点回顾

1. 微框架哲学 - 核心简洁,扩展丰富 - 自由度极高,无强制项目结构 - 适合学习和理解 Web 底层原理

2. 核心技术栈 - 路由系统:基于装饰器的 URL 映射 - 模板引擎:Jinja2 的强大模板继承和宏 - 请求处理:请求钩子、上下文全局变量 - 数据库:SQLAlchemy ORM 或原生 SQL - 表单验证:WTForms 集成

3. 扩展生态 - Flask-SQLAlchemy:数据库 ORM - Flask-Migrate:数据库迁移 - Flask-Login:用户认证 - Flask-Mail:邮件发送 - Flask-Caching:缓存 - Flask-RESTful:REST API 构建 - Flask-Admin:管理后台

4. 适用场景 - 微服务和 API 服务 - 中小型 Web 应用 - 原型开发和 MVP - 需要高度定制化的项目

5. 最佳实践 - 使用应用工厂模式 - 蓝图组织代码模块 - 环境配置分离 - 使用 Flask-SQLAlchemy 管理数据库 - 实现自定义错误处理 - 编写单元测试和集成测试 - 使用 Docker 容器化部署

选择 Flask 还是 Django?

场景 推荐框架
快速开发 CMS/博客 Django
微服务/REST API Flask
大型单体应用 Django
需要高度定制 Flask
学习 Web 原理 Flask
企业级应用 Django

Flask 的精髓在于"少即是多"。它不强加任何限制,让开发者能够按照自己的方式构建应用。这种灵活性既是优势也是挑战——它要求开发者有更强的架构能力和更多的决策。但正是这种"无魔法"的设计,让我们能够深入理解 Web 开发的本质。


本文由 尚先生 原创,转载请注明出处。

📖相关推荐

评论

0
暂无评论,来发表第一条评论吧

发表评论

登录 后发表评论