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>×</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">
© {{ 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