首页 / 资讯中心 / 文章详情

用Flask搭建个人博客:CMS全流程实战与避坑指南

用Flask搭建个人博客:CMS全流程实战与避坑指南 ★ FEATURED ARTICLE
简介一份基于Flask框架搭建个人博客的入门级项目源码面向正在学习Python Web开发、希望快速上手轻量级CMS的初学者可作为课程设计或个人练手的完整参考既能巩固基础知识也能作为小型项目的起点。项目围绕博客文章展示、发布与用户登录等典型功能展开完整展示了Flask路由、模板继承、SQLite数据库操作以及登录认证机制的协作方式后端以文章模型为核心前端使用Jinja2模板与少量CSS/JavaScript完成页面交互。压缩包为zip格式共19个文件以12个HTML模板、2个Python脚本和2个JavaScript文件为主体另含CSS、Markdown说明与依赖清单包体仅71KB目录涵盖应用包、静态资源与模板文件结构清晰包含启动入口与环境配置信息便于本地直接运行调试减少环境搭建障碍。资源已有5267人浏览学习适合用于快速理解Flask项目组织方式和CMS基本实现路径。通过研读源码可掌握应用初始化、数据模型定义、路由视图编写、表单处理和页面模板继承等关键环节为后续扩展评论、分类、搜索等功能打下扎实基础整体麻雀虽小但五脏俱全适合仿写与二次开发。1. 用Flask搭建个人博客它比想象中更能打用Flask搭个人博客网站是我带过的多数Python开发新手第一个真正落地的CMS项目。它不复杂但足够完整路由、模板渲染、数据库模型、表单提交、登录态、部署全链路都会过一遍。很多人觉得博客太“初级”但真动手后才发现博客是把CMS内容管理的核心逻辑讲得最清楚的载体——没有复杂业务规则干扰你只需要想清楚“文章怎么进来、怎么存、怎么出去”。这篇文章拆的正是这样一个项目不做资讯站级的复杂权限不碰微服务就做“写文章、存文章、展示文章”这条主链路。适合刚学完Python基础、想用Web框架串起知识的人也适合接CMS类小项目、急着避开路由与安全坑的从业者。下面从骨架开始一点一点把坑填平。2. 骨架先行Flask博客的模块化设计2.1 蓝图拆分为什么博客前台与后台必须分离刚开始做Flask博客的人最常犯的毛病是把所有路由写在一个文件里。文章列表、详情、分类页、登录、发布全堆在 app.py五千行代码往那一个文件里怼。我当时也这么干过跑到后面想改一个URL规则不敢动怕碰坏其他逻辑。后来我学到的第一个经验是按职责拆Blueprint。这个博客项目我把路由拆成三块main前台展示、admin后台管理、auth登录认证。每个蓝图一个包包里放自己的路由文件和模板目录。这样做的直接好处是前台出了问题不会影响后台后台增加功能也不会误伤前台。拆蓝图还有一个隐藏收益中间件和装饰器可以按蓝图挂载。比如只有 admin 蓝图需要检测登录态我只需要在 admin 蓝图里统一加 before_request 处理器前台完全不受影响。如果全堆在一个文件里想做这种隔离就得在每个路由函数上手动加装饰器漏一个就是安全漏洞。2.2 三张表一个库文章、分类、标签的模型设计博客的数据库设计这个项目用的是三张核心表加一张关联表Article文章、Category分类、Tag标签加上 ArticleTag 做多对多关联。分类和文章是一对多标签和文章是多对多这是内容管理系统里最常见的关系模型。# models.py from datetime import datetime from . import db class Category(db.Model): __tablename__ category id db.Column(db.Integer, primary_keyTrue) name db.Column(db.String(50), uniqueTrue, nullableFalse) articles db.relationship(Article, backrefcategory, lazydynamic) class Tag(db.Model): __tablename__ tag id db.Column(db.Integer, primary_keyTrue) name db.Column(db.String(50), uniqueTrue, nullableFalse) articles db.relationship(Article, secondaryarticle_tag, backrefdb.backref(tags, lazydynamic), lazydynamic) class ArticleTag(db.Model): __tablename__ article_tag id db.Column(db.Integer, primary_keyTrue) article_id db.Column(db.Integer, db.ForeignKey(article.id)) tag_id db.Column(db.Integer, db.ForeignKey(tag.id)) class Article(db.Model): __tablename__ article id db.Column(db.Integer, primary_keyTrue) title db.Column(db.String(200), nullableFalse) slug db.Column(db.String(200), uniqueTrue, indexTrue) content db.Column(db.Text, nullableFalse) category_id db.Column(db.Integer, db.ForeignKey(category.id)) created_at db.Column(db.DateTime, defaultdatetime.utcnow) updated_at db.Column(db.DateTime, defaultdatetime.utcnow, onupdatedatetime.utcnow)这段代码里有几个决策点值得说。slug 字段必须加唯一索引它决定了文章URL是/post/1还是/post/python-flask-blog做SEO和分享链接时后者更友好。created_at 用 UTC 时间是Flask社区常见做法展示时再转本地时区避免服务器时区不一致导致发布时间错乱。ArticleTag 中间表自己带 id是为了以后如果要扩展“打标签的人”“打标签的时间”不必改表结构。Category 和 Tag 的 name 都加了 unique这是为了防止脏数据。CMS内容管理最怕分类名重复同一个分类出现两种写法后期统计文章数量会算错。我当时没加唯一约束上线两周后发现两个叫“Python学习”的分类合数据时费了半天劲。2.3 路由与视图函数把列表、详情、分类页串起来路由设计是这个博客项目的另一条主线。前台路由我保留了两个动态参数一个接文章的 slug一个接分类的 id。这里要重点说明为什么分类用 id 而不用分类名的拼音分类名可能改动一旦改名老链接就全部失效用 id 则永远指向同一条数据。# routes/main.py from flask import Blueprint, render_template, abort from models import Article, Category main Blueprint(main, __name__) main.route(/) def index(): page request.args.get(page, 1, typeint) pagination Article.query.order_by(Article.created_at.desc()).paginate( pagepage, per_page10, error_outFalse) return render_template(index.html, paginationpagination) main.route(/post/string:slug) def post_detail(slug): article Article.query.filter_by(slugslug).first_or_404() return render_template(post.html, articlearticle) main.route(/category/int:category_id) def category_page(category_id): category Category.query.get_or_404(category_id) page request.args.get(page, 1, typeint) pagination category.articles.paginate(pagepage, per_page10, error_outFalse) return render_template(category.html, categorycategory, paginationpagination)路由参数的类型转换器值得注意slug 用的是stringcategory_id 用的是int。如果你把 id 写成 string那么/category/abc这种非法请求就会直接进数据库查查不到再返回404多了一次无谓的数据库查询。用 Flask 内置的 int 转换器Flask 在路由匹配阶段就会拒绝非数字请求直接404性能和安全都好。分页用了 Flask-SQLAlchemy 自带的 paginate 方法这里error_outFalse很关键。不设置这个参数当你访问/post?page999这种超出范围的页码时Flask-SQLAlchemy 默认会抛 404。但实际情况里搜索引擎爬虫可能会顺着错误的页码链接爬404 会让站点被搜索引擎降权。error_outFalse会让它返回空列表页面正常渲染只是没数据而已。3. 零后台的CMS内容管理这套操作链怎么设计3.1 用不用 Flask-Admin两种路线的取舍到了内容管理环节很多人第一反应是上 Flask-Admin。我拆这个项目时也纠结过装上 Flask-Admin 只需要几行代码就能得到一个带增删改查的管理界面。但我最后选择了自写管理页面原因有三。第一Flask-Admin 的默认界面长得像内部工具样式陈旧每次发布文章都要盯着那个表格界面编辑体验并不好。第二Flask-Admin 对 Markdown 内容的支持偏弱默认是textarea如果你要实时预览得自己做一套自定义控件改造量等于重写。第三这个博客项目只有一两个管理账号权限模型极度简单用不上 Flask-Admin 的整套 RBAC 体系。如果你想要一个“能用的后台”自写三个页面就够了文章列表页、编辑页、分类/标签管理页。核心就是一个表单提交加一个列表展示。这也是CMS内容管理的本质——管理操作无非是CRUD复杂的是角色权限和审批流个人博客不需要。3.2 表单、校验与发布一篇文章要过三道门自写后台的文章发布流程我总结成三道门表单字段校验、CSRF 防护、入库前的数据处理。这三道门缺一个后面都会补坑。# routes/admin.py from flask_wtf import FlaskForm from wtforms import StringField, TextAreaField, SelectField from wtforms.validators import DataRequired, Length class PostForm(FlaskForm): title StringField(标题, validators[DataRequired(), Length(max200)]) slug StringField(URL别名, validators[DataRequired(), Length(max200)]) category_id SelectField(分类, coerceint, validators[DataRequired()]) tags StringField(标签英文逗号分隔) content TextAreaField(正文, validators[DataRequired()]) admin.route(/post/new, methods[GET, POST]) login_required def new_post(): form PostForm() form.category_id.choices [(c.id, c.name) for c in Category.query.all()] if form.validate_on_submit(): article Article() form.populate_obj(article) db.session.add(article) db.session.commit() return redirect(url_for(admin.post_list)) return render_template(admin_edit.html, formform)coerceint是 SelectField 最容易忽略的参数。分类下拉框返回的文本默认是字符串如果数据库里 category_id 是整数直接存字符串虽然 SQLite 会帮你隐式转换但换到 MySQL 严格模式就会直接报错。加上 coerceint 让 WTForms 在验证阶段就把字符串转成整数入库前类型就是对的。标签这个字段我用的是一串文本前端用逗号分隔后端在 commit 之前拆分并建立关联。这一步不能省很多新手直接把字符串塞进 ArticleTag 表最后关联表里全是脏数据。3.3 Markdown还是富文本渲染方案的取舍博客正文的存储格式这个项目我选了 Markdown。理由是富文本编辑器存的是 HTML数据库里全是p和span一旦前端样式改版旧文章的排版就乱了。Markdown 存的是纯文本样式由渲染模板统一控制改版只影响展现层不污染数据层。# utils/markdown.py import markdown from markdown.extensions.codehilite import CodeHiliteExtension from markdown.extensions.toc import TocExtension def render_markdown(text): return markdown.markdown( text, extensions[ CodeHiliteExtension(guess_langFalse, use_pygmentsTrue), TocExtension(permalinkTrue), extra ], output_formathtml5 )如果不想在每次请求时重新渲染Markdown可以存文章时就把 HTML 渲染好存进另一个字段只读列表和详情都用已渲染的 HTML。代价是数据库体积稍微变大换来的是每次请求省去一次渲染耗时。文章量到了一万篇以上这个优化就能感觉到区别。用 Pygments 做代码高亮模板里要引对应的 CSS 文件。这里有个容易被忽略的点Pygments 默认生成的样式类名带有 hash 后缀如果你换了 Pygments 版本类名会变旧文章里的代码块样式会突然失效。解决方法是固定 Pygments 版本或者在 Pygments 源码里指定 CSS 类前缀。4. 博客避坑指南三个高频故障的排查记录4.1 中文乱码改成UTF-8还是乱到底问题出在哪现象文章标题和正文在后台录入时显示正常在前台页面显示成’这样的乱码。很多人第一反应是把 HTML 的 meta charset 改成 UTF-8但改完没用。原因这个问题的根源不在模板而在数据库连接层。SQLite 对编码宽容但如果你用的是 MySQLFlask-SQLAlchemy 连接串里没指定 charset默认连接字符集可能是 latin1中文存进去就是乱码。另一个可能原因是 HTTP 响应头里没带Content-Type: text/html; charsetutf-8。解决在数据库连接串里显式加?charsetutf8mb4同时用响应头强制 UTF-8。# config.py 中数据库配置示例 SQLALCHEMY_DATABASE_URI mysqlpymysql://user:password127.0.0.1:3306/blog?charsetutf8mb4如果你已经把乱码数据写进库里单纯改连接串救不回旧数据需要导出数据、转码再导入。所以任何 CMS 项目数据库连接串必须从第一天就写好字符集后面再加就是血泪教训。4.2 登录状态反复失效secret_key 与 session 的坑现象后台登录后隔几分钟再点就被踢回登录页浏览器里 cookie 还在但会话失效。原因Flask 的 session 是把数据签名后放在浏览器 cookie 里的签名密钥就是 secret_key。这个 key 如果每次重启进程时变化比如写了secret_key str(random.random())或者干脆用的默认值用户一刷新 session 就失效。另一个原因是 session 的PERMANENT_SESSION_LIFETIME默认只有 31 天但这里表现不会这么极端。解决把 secret_key 写进环境变量或单独的配置文件固定下来不要放在代码里。# 生成固定密钥 python -c import secrets; print(secrets.token_hex(32))生成一次的字符串写死在 .env 文件应用启动时读取。从那以后我每次排查登录问题第一件事永远是检查当前进程的 secret_key 和线上配置是否一致。提示开发环境用固定 secret_key 还有个好处就是调试时改了代码重启服务登录态不会丢省掉反复登录的时间。4.3 上传图片路径错乱前端能预览但刷新后图片裂掉现象后台提交的封面图当时能显示页面一刷新图片就变成裂图。检查文件路径发现图片确实上传成功了但 URL 不对。原因最典型的是用了相对路径。上传文件时保存到了upload/目录但文章详情页的 URL 是/post/python-flask-blog/浏览器解析相对路径时图片的请求会拼成/post/python-flask-blog/upload/xxx.jpg实际文件在根目录的upload/下自然404。解决所有上传后的图片 URL 必须以/开头存储时把相对路径拼成绝对路径或者用 Flask 提供的url_for(static, filenameupload/xxx.jpg)生成。另外一个隐患是同名文件覆盖问题文件名加时间戳或 uuid 前缀这个项目我用的是uuid.uuid4().hex拼上原始扩展名。import os import uuid from flask import current_app def save_upload(file): ext os.path.splitext(file.filename)[1].lower() if ext not in { .jpg, .jpeg, .png, .gif}: raise ValueError(不支持的图片格式) filename uuid.uuid4().hex ext file.save(os.path.join(current_app.config[UPLOAD_FOLDER], filename)) return url_for(static, filenameupload/ filename)扩展名白名单校验是必须的否则用户传一个shell.php上来如果服务器没做执行权限隔离等于给了攻击者一个后门。CMS系统的第一个安全原则永远不要信任用户传入的文件名。5. 性能与安全博客虽小这几道防线不能省5.1 列表页全量查询文章多了就差一秒现象博客文章不到两百篇的时候首页秒开。文章到一千篇左右首页响应时间明显变长尤其是有多个标签关联的文章。原因文章列表页没分页之前一次性查出所有文章再在模板里循环渲染。Flask-SQLAlchemy 默认是懒加载模板里遍历每篇文章的标签列表时会为每篇文章额外发起一次查询N1 查询问题就出现了。解决分页加预加载联合查询。# 使用 joinedload 提前加载关联数据 from sqlalchemy.orm import joinedload articles Article.query.options( joinedload(Article.category), joinedload(Article.tags) ).order_by(Article.created_at.desc()).paginate(pagepage, per_page10)分页本身也好理解每页 10 条数据库只需查 10 条文章记录再统计总数总耗时降一个数量级。索引也要建好created_at字段建索引slug本身已经加了 unique 索引分类外键建普通索引。SQLite 默认索引太少这几个索引手工加上能明显提升大数据量下的查询速度。5.2 XSS与CSRF写博客容易被忽略的两个漏洞博客正文如果用 remarkdown 渲染还相对安全但如果有用户输入板块比如文章评论功能XSS 漏洞就很常见。危险做法是用户提交 HTML 直接存库渲染时原样输出攻击者可以提交一段带script标签的内容在别人浏览器里执行任意脚本。Flask 的转义函数escape()治标不治本正确做法是渲染时对不可信内容做HTML转义或者用 Markdown 库时关掉原始 HTML 支持。CSRF 则是表单攻击。Flask-WTF 默认给所有表单生成 token模板里在 form 标签内加{{ form.hidden_tag() }}就可以但这个 token 的校验有个前提表单的请求必须带 session。如果你用了login_required登录后才能访问问题不大但如果后台登录页没加 CSRF 防护攻击者在其他站点构造一个伪装表单诱导你提交就可能被跨站发文章甚至改密码。5.3 SQLite还是MySQL什么时候必须换这个博客项目默认用 SQLite开发阶段完全够用。但我觉得有必要明确边界SQLite 适合单机读写、并发量低的应用如果有多进程写入并发SQLite 的锁机制会出现database is locked错误。部署到云服务器只要后台只有你一个人用SQLite 完全能撑住。换 MySQL 的信号有两个一是你要开评论或用户注册并发写明显变多二是文章量过万SQLite 在做复杂分组查询时性能下降明显。迁移方案不用自己写Flask-Migrate 可以自动生成迁移脚本字段可以平滑迁移。我见过很多项目在几千篇文章时还在 SQLite 上跑突然某天接口超时一查是 SQLite 锁竞争迁移时又因为数据量大停机了。所以如果你预期流量会涨不如项目刚开始就上 MySQL少走一步迁移的坑。6. 部署与维护让博客稳定运行的几个具体技巧6.1 用 Gunicorn Nginx 把 Flask 跑成正式服务开发用的app.run()自带服务器只适合调试。正式环境我习惯用 Gunicorn 做 WSGI 服务器Nginx 做反向代理和静态文件服务。部署步骤其实只有三块装 Gunicorn、配置启动参数、写 Nginx 配置。# 启动命令4个worker 超时时间 gunicorn -w 4 -b 127.0.0.1:8000 --timeout 60 wsgi:appworker 数量不是越多越好一般CPU核心数 × 2 1就够了。我吃过 worker 开太多的亏一台2核服务器开了8个worker内存直接吃满进程互相争CPU导致响应时间反而变慢。timeout 设 60 是因为有些文章渲染图片时有外部请求超时太短会误杀正常请求。Nginx 配置里需要注意两个点一是静态文件的 location 要写在最前避免 Flask 处理静态文件二是 proxy_pass 的地址要和 Gunicorn 的 socket 完全对应。server { listen 80; server_name example.com; location /static { alias /home/blog/static/; expires 30d; } location / { proxy_pass http://127.0.0.1:8000; 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; } }X-Forwarded-Proto这行不能少。如果用户是 HTTPS 访问Nginx 把请求转发给 Flask 的时候是 HTTPFlask 里的request.is_secure会判为 False导致url_for生成的全是 http 链接跳出混合内容警告。6.2 备份与回滚一条命令的最稳起步方案博客数据量不大备份用 SQLite 的在线备份命令就够。这条命令不用停服务SQLite 会在内部做一致性快照。# 在线备份SQLite数据库 sqlite3 /path/blog.db .backup /backup/blog_$(date %F).db如果哪天文章改坏了想回滚直接把备份文件拷回去就行。数据和代码分开备份代码用 Git 管数据库用上面的命令每天定时执行。我习惯把备份命令写进 crontab每天凌晨3点执行一次保留最近30天的备份文件旧文件用find加-mtime 30 -delete清理避免服务器磁盘被备份占满。6.3 验证部署部署完到底怎么确认是真的跑通了部署结束后不要只看页面打开就完事。我一般会做三件事第一用curl -I看响应头确认 Nginx 和 Gunicorn 都正常第二故意访问一个不存在的文章 slug看是否返回 404 而不是 500第三看 Gunicorn 的日志确认没有隐性报错。curl -I http://example.com/ curl -I http://example.com/post/not-exist-slug响应头里Server: nginx说明 Nginx 活着HTTP/1.1 404说明 Flask 应用正常处理了异常路径。如果 404 返回成了 500大概率是模板渲染抛了未捕获异常日志里一定有 traceback。这套验证做完一个 Flask 博客的部署链路才算闭环。从那以后我每次部署任何项目都会坚持把“查响应头、访问错误路径、看日志”这个三步走完整走一遍。这三步看似简单却帮我拦截过多次“页面能打开但后台登录Acctually崩了”的隐性故障。如果你准备拿这份博客项目练手或落地把上面这些检查点从头到尾过一遍而不是复制完代码就跑你会少踩很多我当年踩过的坑。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站