先交代一下背景,这个项目不是我一时兴起写着玩的,而是我前后花了大概三个月业余时间,从零搭建并最终开源的一套个人博客系统。仓库在 GitHub 上同步维护,核心代码 1 万多行,覆盖文章发布、评论、标签聚合、全文搜索、后台管理这些常见功能。之所以把它拿出来开源,一方面是因为市面上的博客框架虽然多,但真正能让人舒服地二改、加功能、跑在自己服务器上的完整开源方案并没有那么多;另一方面是想给准备接触开源项目的同学一个结构简单、文档齐全、可以真正跑起来的参考案例。如果你正在纠结“博客系统到底该用现成的还是自己写”,或者想找一个入门级完整开源项目来读源码、提 issue、练 PR,这篇文章应该能一次性把你的疑问都聊透。
1. 项目定位与技术选型复盘
1.1 为什么自己写一套博客系统,而不是直接用 Hexo 或 WordPress
很多朋友上来就会问这个问题。Hexo 写文章确实快,WordPress 功能也确实全,但我的需求有点特殊:我希望博客系统本身就是一个完整的前后端分离应用,有管理后台、有动态评论、有标签体系和站内搜索,而不仅仅是一个把 Markdown 渲染成静态页面的工具。换句话说,我在意的不是“怎么把文章发出去”,而是“博客系统的代码结构长什么样、改一个功能要动哪些模块、多人一起开发时怎么协作”。
自己做还有一个好处,就是能完全掌控技术栈。每次看到网上有人抱怨“WordPress 插件装多了速度就崩”“Hexo 换台电脑就得重装环境”,我就觉得自建系统的可移植性和可控性是很值钱的。当然,代价也很明显:评论系统要自己做,防垃圾评论要自己写,搜索得自己建索引,部署要自己维护。这些活看起来很杂,但正是这些“杂活”构成了一个好的学习项目。
1.2 技术选型:后端、数据库、前端分别怎么定
这个项目的技术栈是一个很主流的组合,我做选型时没有走偏门路线,原因只有一个:开源项目要想让更多人参与,就必须用大家熟悉的工具。
| 模块 | 选型 | 理由 |
|---|---|---|
| 后端框架 | Spring Boot 3.2 | 生态成熟、做 CRUD 和权限控制都有现成方案,适合结构化展示 |
| 数据库 | MySQL 8.0 | 博客数据是典型的关系型数据,文章、分类、评论、标签都需要关联查询 |
| 缓存 | Redis 7 | 热点文章和标签聚合的响应速度靠它拉起来 |
| 前端 | Vue 3 + Vite + TypeScript | 组件化开发适合博客后台这种表单密集场景,TS 类型约束能让代码更可控 |
| 富文本/Markdown | markdown-it + highlight.js | Markdown 解析生态成熟,配合代码高亮开箱即用 |
| 全文搜索 | Elasticsearch(可选) | 初期用 MySQL LIKE 查询,文章量大后再接 ES,保持入口简单 |
我特别想说明一下为什么没选更轻量的方案比如 Flask 或者 Express。不是说它们不行,而是如果一个博客系统的核心卖点是“帮初学者理解完整应用是怎么分层、怎么组织模块的”,那么一个结构严谨、官方文档丰富且周边资料多的框架,能减少很多沟通成本。Spring Boot 的自动配置和 starter 机制,也让我能把更多精力放在业务逻辑而不是环境搭建上。
1.3 开源许可证怎么选:MIT、Apache 还是 GPL
这个问题的热度非常高,我在开源之前专门研究了一遍。许可证本质上是“你给使用者的权利清单”。MIT 最宽松,基本上拿到源码想怎么用都行;Apache 2.0 比 MIT 多加了对专利的保护条款;GPL 则要求衍生作品也必须开源。做个人博客系统这种工具型项目,我选了 MIT,因为希望它能被最大范围地用到各种场景里,包括商业用途,也希望后人在改我的代码时没有心理负担。如果你做的是某个核心算法库或者希望同道中人必须回馈改进,那 Apache 或者 GPL 更合适。我建议后来的开源项目,在LICENSE文件之外,最好再写一段简单的CONTRIBUTING说明,明确告诉别人“你用我的代码时需要保留版权声明”“提交 PR 前请先跑测试”。这点很多人会漏掉,但真正做起来才发现它比许可证本身更能减少后期沟通麻烦。
2. 整体架构与功能清单拆解
2.1 核心功能设计和优先级排序
博客看起来简单,真正梳理过需求后才发现,这个系统至少可以拆成两块:前台阅读和后台管理。
前台重点是内容展示,包括文章列表、文章详情、标签页、分类页、关于页、站内搜索和评论展示。后台重点则是内容生产,包括登录认证、文章编辑与发布、草稿管理、标签与分类维护、评论审核和基础设置。
我没有在第一版就把所有功能全部做出来,而是先保证主链路通畅:注册管理员账号 → 写文章 → 发布 → 前台可看 → 用户可评论。整个链路跑通之后,再补搜索、标签聚合、评论审核这些增强体验的功能。如果一开始就想着把“定时发布”“文章置顶”“读者排行”这些可有可无的功能都做上,很容易陷入功能蔓延的泥潭,项目迟迟没法收尾。
2.2 后端如何分层:Controller、Service、Repository 到底怎么划分
这个项目里,我把后端代码分成四层:controller、service、mapper/repository、entity。Controller 负责接收 HTTP 请求、参数校验和统一返回结构;Service 负责业务逻辑,比如“发布文章”这个动作除了写数据库,还要更新缓存、刷新搜索索引;Repository 层只用来做数据访问,不写业务判断;Entity 就是数据库表结构的映射。
拿“发布文章”举例,整个链路是这样的:前端提交摘要、Markdown 内容、标签列表和分类 ID 到/api/article,Controller 校验参数后转给 ArticleService;ArticleService 先把内容写入文章表,再刷新 Redis 里的文章列表缓存,同时把标题和正文塞进搜索索引,最后返回文章 ID。分层清晰以后,出了问题能很快定位是参数问题、业务逻辑问题还是数据访问问题,而不是在一个几百行的大方法里来回翻。
2.3 前端路由组织与后台管理界面设计
前端我用 Vue Router 做了两级路由。一级路由是/、/article/:id、/tag/:name、/search和/admin,这些对应页面级的组件。二级路由主要出现在/admin下面,比如/admin/dashboard、/admin/article/list、/admin/article/edit/:id、/admin/comment。
后台管理界面我没有用现成的 admin 模板,而是自己画了一个极简侧边栏布局。原因是开源项目如果套一个厚重的 admin 模板,代码体积会很大,新手理解起来也费劲。侧边栏放“文章管理”“评论管理”“标签管理”“系统设置”四个入口,顶部是当前管理员的信息和退出按钮。前台则重点强化阅读体验,没有多余的元素,文章的排版用 CSS 做了比较精细的优化,比如行高保持 1.8,代码块单独用深色背景,图片统一圆角。
3. 核心模块的实操实现与细节解析
3.1 Markdown 解析与代码高亮
这是整个博客系统最核心的模块,没有之一。我踩了不少 Markdown 解析的坑,先说结论:不要自己手写解析器,直接用markdown-it。
import MarkdownIt from 'markdown-it' import hljs from 'highlight.js' const md = new MarkdownIt({ html: true, linkify: true, typographer: true, highlight(str, lang) { if (lang && hljs.getLanguage(lang)) { try { return hljs.highlight(str, { language: lang }).value } catch (__) {} } return '' // 使用默认转义 } })这段代码里最容易被忽略的是linkify: true。开了它以后,用户粘贴的裸链接https://example.com会自动变成可点击的链接,这点非常影响文章阅读体验。另一个坑是html: true,如果允许文章里写 HTML,就必须在后端做防 XSS 过滤,否则任何能发文章的人都能往页面里注入脚本。我的方案是在后端统一接了一个cleanHtml库,只有从白名单里放行的标签和属性才会保留。
代码高亮这块,我建议把highlight.js的样式文件按需引入,而不是全量引入所有语言。我一开始图省事引了全量包,构建后 CSS 文件直接多出了 200KB,首屏加载肉眼可见地变慢。后来改成只引入常见语言:java、javascript、typescript、python、go、bash、sql、json、xml、markdown,体积瞬间降下来了。
3.2 评论系统的防滥用设计
评论是动态功能的代表,也是垃圾内容的重灾区。我没有用第三方的评论服务,而是自己做了三件事来保证评论区的干净。
第一件事是后端校验频率:同一个 IP 一分钟内最多评论 3 条,超出直接返回“操作太频繁”。这个用 Redis 的INCR+EXPIRE就可以实现,代码也很简单。第二件事是昵称和邮箱的字段校验:昵称不能含有链接特征字符,邮箱必须格式正确,防止有人把联系方式伪装成昵称刷进来。第三件事是人工审核开关:后台可以设置“评论是否需要审核”,默认是关闭,但一旦发现有人乱发,打开这个开关就能让所有新评论先进入待审核队列。
第二点和第三点合在一起,基本能挡住大部分垃圾评论。真正要防的是那些模仿正常用户发的推广内容,那只能靠人工定期后台清理,没有十全十美的自动方案。
3.3 标签聚合与站内搜索的落地方案
标签和分类是两个容易混淆的概念。我做的分类是单级树形结构,一篇文章只能选一个分类,适合做内容的顶层划分;标签则是多对多关系,一篇文章可以打多个标签,强调内容之间的横向关联。数据库里建了article_tag关联表,文章与标签的绑定关系都存这张表里。
站内搜索的第一版用的是 MySQL 的LIKE '%keyword%',文章数量在几百篇时毫无压力。但我提前预留了搜索服务的接口,定义好SearchService,后续想切成 Elasticsearch 时只需要提供一个新实现。这也是我在架构设计里比较得意的一点:先定义接口,再决定实现。当项目真的跑了大半年,文章超过一千篇,中文分词和高亮检索变得越来越需要时,我只要扩展一个基于 IK 分词器的 ES 实现,替换应用里的 SearchService 即可。
3.4 发布全链路的状态机设计
文章状态我用了一个简单的状态机:草稿、已发布、已下线、已删除。这是我在实际编码中反复体会出来的设计。
状态的流转必须受控,不能出现“已删除的文章还能通过接口被编辑”的诡异情况。我做了下面的约定:草稿可以转为已发布;已发布可以转为已下线,也可以直接转为已删除;已下线可以重新转为已发布;已删除是终态,只能通过管理员软删除恢复。这样一个状态机模型让前后端的每个按钮都对应一个明确的动作,后端只暴露少数几个状态转换接口,有效避免了“状态被改乱了”的问题。
4. 部署上线、性能优化与运维经验
4.1 用 Docker Compose 实现一键部署
为了降低部署门槛,我把整个项目的运行环境都容器化了。发布前只需在服务器上执行一条docker-compose up -d,就能把 MySQL、Redis、后端、前端四个服务全部跑起来。
这是我项目里一个非常关键的配置文件,简化后如下:
version: '3.8' services: mysql: image: mysql:8.0 container_name: blog-mysql environment: MYSQL_ROOT_PASSWORD: your_root_password MYSQL_DATABASE: blog volumes: - ./mysql-data:/var/lib/mysql ports: - "3306:3306" restart: always redis: image: redis:7-alpine container_name: blog-redis ports: - "6379:6379" restart: always backend: build: ./backend container_name: blog-backend depends_on: - mysql - redis environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/blog?useUnicode=true&characterEncoding=utf8 SPRING_DATA_REDIS_HOST: redis ports: - "8080:8080" restart: always frontend: build: ./frontend container_name: blog-frontend depends_on: - backend ports: - "80:80" restart: always有几个细节值得注意。restart: always保证了服务挂掉后能自动拉起,我现在的服务器稳定运行了一年多,靠的就是它。depends_on只保证依赖容器创建顺序,不保证依赖服务已经就绪,所以我把后端代码里加了一个简单的重试机制:连接数据库时如果失败,每隔 3 秒重试一次,最多重试 5 次。这个“容器依赖陷阱”很多人第一次部署都会踩,最后发现后端一直连不上数据库,就是因为没做连接重试。
4.2 Nginx 反向代理与 HTTPS 配置
前端构建后的产物是一个纯静态文件包,运行在 Nginx 容器里。我通过 Nginx 把/api路径下的请求代理到后端的 8080 端口,同时开启 gzip 压缩和 HTTP/2 协议。
server { listen 80; server_name your-domain.com; location /api/ { proxy_pass http://backend:8080; 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; } location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } gzip on; gzip_types text/plain text/css application/json application/javascript; }try_files $uri $uri/ /index.html;这行是前端路由能不能正常刷新页面的关键。Vue Router 的 history 模式在用户直接访问/article/123这个地址时,服务端是没有这个文件的,如果没配try_files,刷新就 404。加上这句话之后,所有不存在的路径都会回退到index.html,再由前端路由接管。
4.3 数据备份、日志监控和基本安全加固
数据是无价的,所以我把数据库备份做成了每天的定时任务。用crontab每天凌晨三点执行一次mysqldump,只保留最近 7 天的备份文件,同时把备份文件通过rsync同步到另一台机器上。
日志监控我采用的是最省事的方案:后端使用 Spring Boot Actuator 暴露健康检查端点,用一个外部监控服务每 5 分钟探一次/actuator/health,如果连续三次失败就发告警邮件。日志文件则统一输出到挂载目录,配合 logrotate 按天切割,避免单个日志文件无限膨胀。
安全加固方面我做了五件事:数据库不使用默认端口,Redis 开启密码认证并绑定内网 IP,Nginx 层面对管理后台路径/admin增加 IP 白名单限制,后端对上传接口限制文件类型和大小,所有网站请求强制跳转到 HTTPS。前四件事成本都很低,但对防护效果提升明显。最后一件事用 Certbot 申请免费证书后,在 Nginx 里加一个 80 端口的 301 跳转即可。
5. 开源全过程:从首个 Release 到社区维护
5.1 开源前的准备:文档、Demo、Code of Conduct
我把代码拉到一个公开仓库之前,先花了整整一周的时间做文档。很多开源项目的文档写得像天书,README 里只有一个项目名字和一张截图,让人完全没有参与的欲望。我的 README 里包含这么几个部分:项目介绍、核心功能清单、技术栈说明、目录结构、快速开始(本地启动 + Docker 部署两种方式)、截图预览、常见问题解答、贡献指南和许可证声明。
特别要说的是目录结构这一块,因为很多新手拿到源码最困惑的不是代码逻辑,而是“文件太多了我该从哪里看起”。我在 README 里专门画了一棵树,标清楚backend、frontend、docs、scripts各是什么,然后建议新人从后端controller包开始读起,因为那是用户请求的入口。尽可能降低别人阅读源码的门槛,才更有可能获得真实反馈和贡献。
我还写了一个CONTRIBUTING.md,里面说明了两件事:提交 issue 时请提供完整的环境信息和复现步骤;提交 PR 前请先拉最新的 main 分支并补充对应的单元测试。这两条规定看着简单,实际执行下来确实过滤掉了很多没有价值的 issue 和半成品 PR。
5.2 第一个 Release 与版本号规划
发布开源项目,版本号不是随便写的。我遵循 SemVer 语义化版本规范:主版本号表示不兼容的 API 变更,次版本号表示向后兼容的功能新增,补丁版本号表示向后兼容的问题修复。第一版是1.0.0,然后按功能迭代正常推进。
版本发布前我做了三件事:更新CHANGELOG.md记录新增和修复;在 Git 仓库打上v1.0.0标签;写一份 release notes 重点说明“这个版本能干什么”“和上一版比改了什么”“怎么升级”。一开始我也觉得维护 changelog 很麻烦,但后来有一个用户提了一个 issue,说他从0.9.0升到1.0.0后文章页面变成空白,我很快意识到是因为我改了返回数据结构的字段名,而这个变更在 changelog 里没有写清楚。从那以后,我把 changelog 当作和代码一样重要的交付物。
5.3 维护社区:Issue 模板、PR 合入和 Roadmap 公开
开源项目不是把代码扔上去就结束了,维护社区才是最消耗精力的部分。我给仓库配置了 issue 模板,分“Bug 反馈”和“功能建议”两种,把用户最容易漏掉的信息强制结构化。这样拿到的问题描述普遍很清晰,不会出现“我的博客打不开了”这种让人无从下手的话。
PR 合入方面,我坚持三个原则:必须通过 CI 检查、必须有测试覆盖、必须描述改动动机和影响范围。如果 PR 只是改了几个文件没有任何说明,我会礼貌地打回去,请他补充内容。这看起来有点严格,但经历过一次“合入一个 PR 导致整站打不开”的事故之后,我宁愿多花时间在流程上,也不愿用线上事故换所谓的高效。
Roadmap 这块,我直接在 README 里公开了未来的计划:计划支持的主题系统、计划支持的图床集成、计划支持的多语言国际化。公开 Roadmap 的最大好处是,社区成员不会重复提你已经规划过的需求,也会把你没考虑到但是大家都需要的功能补充进来。整个过程很像是在做一个公共产品,而不仅仅是一份代码。
6. 高频踩坑与问题排查速查
6.1 中文乱码、时区问题、Markdown 异常这几个老大难
中文乱码是博客项目最容易遇到的问题,而且出现的时机总让人抓狂。根因一般是三处没有对齐:数据库连接串没写characterEncoding=utf8、数据库表默认字符集不是utf8mb4、后端返回数据时响应头没声明 UTF-8。只要这三处统一了,乱码问题基本能根除。
时区问题也常见。Spring Boot 默认时区是 UTC,MySQL 默认时区是系统时区,两者不一致会导致文章发布时间和实际时间相差 8 小时。我建议统一采用Asia/Shanghai时区,在数据库连接串里加serverTimezone=Asia/Shanghai,然后在后端全局配置里强制指定时区。这样不管部署在哪台服务器上,时间显示都不会出错。
Markdown 解析出问题大多集中在三种场景:代码块里包含 Markdown 特殊字符、嵌套列表渲染异常、表格语法兼容性差异。我用markdown-it解决了一大半问题,剩下的是靠测试用例来覆盖的。我在项目里放了一个test-markdown.md文件,里面全是最容易出错的语法,每次升级解析库都拿它做回归测试。
6.2 常用问题速查表
我自己整理了一份问题速查表,放到项目文档里,现在已经成了仓库里阅读量最高的文件之一。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 文章列表是空的 | 数据库表没初始化 | 检查schema.sql是否执行成功 |
| 发布文章后前台看不到 | 缓存未刷新 | 清理 Redis 中article:list缓存键 |
| 评论提交报 429 | 频率限制触发 | 等待 1 分钟或调整限流阈值 |
| 图片上传失败 | 上传目录权限不足 | 确认upload目录对容器用户可写 |
| 博客页面刷新 404 | Nginx 未配置try_files | 按示例补全 location 配置 |
| 后台登录无效 | JWT 密钥不一致 | 统一前后端与后端环境变量中的密钥 |
| 搜索无结果 | 搜索索引未构建 | 调用管理端“重建索引”接口 |
这张表的价值在于,它把所有验证过的解决方案集中成一个入口,节省了用户反复提问的时间,也让项目的维护成本下降了一个层级。
6.3 兜底精神:日志、测试、回滚
最后聊一个我在整个开发和开源过程中体会最深的东西,就是“兜底”。写日志是为了能回看现场,写测试是为了敢改代码,做备份是为了能回滚服务。这三件事在博客这种小项目里看起来不是刚需,但只要你想把项目持续做下去,它们就是把不确定性降到最低的筹码。
我现在的习惯是:任何新功能提交前,先把日志加好,把核心接口的单元测试补上,把升级和回滚步骤写进CHANGELOG。这套流程看起来增加了工作量,但长远来看省下的时间绝对物超所值。
开源一个个人博客系统,技术上并不复杂,但它带来的收获远超我的预期。你会慢慢理解一个项目从“能跑”到“好用”再到“别人愿意贡献”分别意味着什么,也会在维护社区的过程中学到很多编程之外的东西。如果这篇文章能让你对自建博客或开源维护少一些焦虑,多一些下场的动力,那它就没有白写。