1. 实训项目复盘:从零搭建一个真正能用的个人博客
先交代一下背景。这是我在某高校软件学院创新实训阶段做的个人项目,主题很朴素——个人博客,但要求是从零开始完整走一遍开发流程:需求分析、技术选型、数据库设计、前后端实现、部署上线。实训的硬性指标是“必须有完整的业务闭环”,不能只写个静态页面交差。所以这个博客最终形态是一个支持用户注册登录、文章发布编辑、评论互动、标签归档、搜索统计的后端完整系统,前端也做了响应式适配,手机和PC都能正常浏览。
如果你也是处于实训阶段或想自己练手做完整项目的人,这篇复盘应该能帮你少踩几个坑。我会把整个项目的设计思路、核心模块的实现细节、实操过程中踩过的典型问题全部拆开讲,每一段都尽量说清楚“为什么这么做”,而不是只给结论。
开头先把几个关键结论放这里:技术栈选的是Spring Boot 3 + MyBatis-Plus + Vue 3 + Element Plus,数据库用的MySQL 8,部署用云服务器加Nginx反代。整体开发周期大约4周,每天投入3到4小时,最终代码量在8000行左右。这个体量对一个单人实训项目来说是合理的,太小了体现不了完整度,太大了容易烂尾。
2. 整体设计思路与方案选型
2.1 功能边界是怎么定下来的
做个人博客最大的坑,是容易把范围想得过大。刚开始我列了二十多项功能,包括私信、站内通知、皮肤商城、文章定时发布、数据分析大屏,后来冷静下来一算,按单人开发的节奏,光这些光界面和交互就要拖掉一大半时间。实训答辩看的不是功能数量,而是逻辑是否闭环、代码是否清晰、数据库设计是否合理。
所以最终我只保留了六个核心模块:
- 用户模块:注册、登录、退出、个人信息修改,密码用加盐哈希存储。
- 文章模块:发布、编辑、删除、分页浏览、关键词搜索。
- 评论模块:文章详情页内发表评论,支持嵌套回复。
- 标签模块:文章可绑定多个标签,标签页聚合展示。
- 统计模块:文章浏览量、评论数、站点访问量。
- 管理后台:普通用户走前台,管理员可进入后台管理用户和文章。
这个边界有两个好处。第一,每个模块都能独立讲清楚业务逻辑,答辩时思路清晰。第二,模块间有天然的数据关联,比如用户-文章-评论-标签之间都是标准的外键关系,数据库设计能体现水平。
2.2 技术栈的选型逻辑
选型这块,我给自己定了几条硬约束:能用成熟框架绝不手写底层、前后端分离、部署成本低、生态文档丰富。
为什么后端选了Spring Boot而不是Node或Python?因为我平时主要写Java,Spring Boot的自动配置和起步依赖能让我把精力集中在业务代码上,而不是纠结环境配置。而且实训答辩中,Spring Boot + MyBatis-Plus这套组合在高校里认可度很高,面试聊起来也有话说。
MyBatis-Plus相比原生MyBatis,最大的价值是内置了通用Mapper和条件构造器。单表查询基本不用写SQL,比如用户按用户名查询、文章按ID查询,一行LambdaQueryWrapper就搞定了。多表统计时再写自定义SQL,进退都有余地。
前端选Vue 3 + Element Plus的理由也很直接:组件生态成熟,表格、表单、弹窗这些管理后台最常见的元素都有现成组件,能省掉大量造轮子的时间。Vite作为构建工具比Webpack快得多,热更新几乎秒级,实在受不了Webpack启动等半分钟的感觉。
数据库选MySQL 8,是因为它的窗口函数、JSON类型支持都很实用,而且云服务器装MySQL 8比较方便,坑也比旧版本少。生产环境数据库版本和本地保持一致,这点很重要,能避免“本地好好的,上服务器就报错”的经典尴尬。
2.3 核心表结构设计思路
数据库我一共设计了5张表,这个规模对一个博客系统来说刚刚好,既不会简单到像课设,也不会复杂到维护困难。
用户表字段包括:id、用户名、密码哈希、昵称、头像地址、邮箱、角色(普通用户/管理员)、创建时间。密码字段必须存哈希值,不能明文存。我这里用的BCrypt,Spring Security自带的加密器,每次加密自动加盐,安全性可靠。
文章表字段包括:id、作者ID、标题、摘要、正文Markdown、浏览量、评论数、发布状态(草稿/已发布)、创建时间、更新时间。摘要字段很多人会忽略,但列表页展示的其实是摘要而不是全文,如果不做摘要字段,每次列表查询都要把Markdown原文全查出来,性能和传输负担都不划算。
评论表字段包括:id、文章ID、用户ID、父评论ID、内容、创建时间。父评论ID这个字段是实现嵌套回复的关键。顶级评论的父ID为0,回复某个评论时填上对应评论的ID,前端配合缩进就能实现层级结构。这样设计比单独建回复表简单得多,查询效率也高。
标签表很简单:id、名称、创建时间。文章和标签是多对多关系,所以还需要一张中间表:id、文章ID、标签ID。关联查询时两次JOIN就能拿到一篇文章的所有标签。
3. 核心模块实现细节与实操要点
3.1 用户认证:从Session到JWT的取舍
用户认证这块我前后改过几版,最开始的方案是标准的Session登录,登录成功后把用户信息放Session里,后续请求通过Cookie自动带SessionID。这套逻辑在单体服务里没有任何问题,实现也简单。
但考虑到项目是前后端分离架构,前端部署在Nginx下,后端是独立的Java服务,存在跨域请求的情况。虽然可以用CORS配置加Credentials解决Session方案,但每次都得带上Cookie,前端还得配合设置withCredentials,总感觉不够干净。
所以最终还是换成了JWT方案。登录成功后后端生成一个带过期时间的Token返回给前端,前端把它存到本地存储里,后续每次请求在请求头里带上Authorization: Bearer 。后端用一个拦截器统一解析Token,解析成功就把用户信息放到请求上下文中。
JWT方案的好处是无状态、可水平扩展,但也带来两个必须处理的坑。第一个是Token失效问题:如果用户修改了密码,旧Token还有效,这就尴尬了。所以我在用户表里加了update_time字段,然后往Token的payload里塞一个密码版本号或者最后修改时间,验签时做比对,一旦密码变了,旧Token就会因版本不匹配而失效。第二个是Token刷新问题:客户端需要定期刷新Token,避免长时间操作时突然过期。我做了个简单的滑动过期策略,只要用户在操作,每次请求都重新签发一个新Token。
提示:JWT密钥一定要放到配置文件里,不能写死在代码里。我用的HMAC-SHA256算法,密钥长度至少256位,部署到服务器时通过环境变量注入,别提交到代码仓库。
3.2 文章编辑:Markdown编辑器选型与存储方案
文章正文用什么格式存储,我纠结了一阵子。最终选了Markdown,而不是纯文本或富文本。原因很实际:博客内容天然适合Markdown这种结构化纯文本,轻量、可读、方便版本管理,存储到数据库里就是一段普通字符串,不涉及富文本那种HTML标签注入风险。
编辑器选的是ByteMD,一个基于字节跳动开源生态的Markdown编辑器。选它而不是别的编辑器,主要是看重几个特点:支持中文文档、自带代码高亮、图片上传支持自定义接口、整体体积比同类编辑器小。前端接入后,左侧是编辑区,右侧是实时预览区,写文章的时候能即时看到效果。
存储时,前端把Markdown原文提交到后端,后端原样存库。展示时有两种方式:第一种是前端解析Markdown,我用的是markdown-it,渲染成HTML后放到容器里。这样做法比较简单,但也有隐患——用户输入的Markdown如果包含恶意HTML标签,直接渲染会被XSS攻击。所以渲染前必须做XSS过滤,用白名单只允许p、a、code、pre、img等安全标签。第二种是后端渲染,用Java的服务端Markdown解析库转好HTML再返回给前端,安全性更好控制,但每次请求都要多做一次解析,性能略差。
我最终用了一个折中方案:文章保存时后端先把Markdown渲染成HTML,存一份到单独的html_content字段,查询时直接返回渲染后的HTML,避免每次访问都重复解析。前端拿到HTML后再做脱敏处理,只保留安全标签。
3.3 评论模块的嵌套设计与防刷策略
评论模块的难点不在数据库设计,而在于嵌套数据的查询组织。
我的评论表用parent_id表示层级关系,查询某篇文章的评论时,一次查回该文章下所有评论,然后按照parent_id在Java内存里组装成树形结构,返回给前端。这个方案的优缺点都很明显。优点是只需要一次数据库查询,响应速度极快。缺点是有上限——如果一篇文章有几万条评论,一次性全查出来内存压力大会变慢,需要做分页或懒加载。但对博客场景来说,一篇文章的评论量一般在几十到几百条,一次查询完全没问题。
防刷策略方面,我做了几点控制,虽然博客不像电商那样面临严重恶意攻击,但基础的防护还是要有:
- 未登录用户可浏览文章,但不能发表评论。
- 同一用户对同一篇文章的评论频率做了限制,两次评论间隔最短30秒,用Redis做计数器。
- 评论内容做了长度限制,最长500字,后台还会对评论内容做HTML转义,防止存储型XSS。
- 管理员可删除违规评论,删除是软删除,数据不会物理消失,方便审计。
Redis那块我原来没打算上,实训项目嘛,想着尽量简单。后来考虑到统计模块也需要计数缓存,才引入的。用Spring Boot的RedisTemplate操作,接入成本很低。如果云服务器内存吃紧,也可以换成ConcurrentHashMap做简单限流,但多实例部署时就失效了,正式项目还是建议直接用Redis。
3.4 标签聚合与搜索功能的实现
标签模块的关联查询是其中一个相对复杂的地方。文章发布成功后,前端提交文章ID和标签名列表,后端逐条插入文章标签关联表。这里要注意一个细节:需要事务控制。文章主表插入、文章内容插入(因为我把正文分了主表大字段和朋友表)、标签关联插入,这三步必须在一个事务里,任何一个失败都要回滚,否则会出现文章保存在了但标签丢失的数据脏情况。
查询端,点击某个标签,前端根据标签名转成标签ID,然后分页查询该标签下的文章列表。SQL写法是:select p.* from post p inner join post_tag pt on p.id = pt.post_id where pt.tag_id = ? order by p.create_time desc。这个关联查询在数据量小的时候性能很好,只要在post_tag表的tag_id上加索引就好。
搜索功能我原本想做全文检索,引入一个重量级搜索引擎,但考虑实训周期和部署成本,还是用MySQL的LIKE来实现。搜索范围限定在标题、摘要和正文,使用关键词前后加百分号模糊匹配。在数据量小于几千条时,这个方案完全够用,但数据上了万就要考虑全文索引或搜索引擎了。
最后我给文章表加了一个复合索引:(create_time, status),这样列表页按时间倒序拉已发布文章时走索引,性能好很多。加索引这种“小动作”平时看代码发现不了,但实际QPS上去后差别是巨大的。
4. 实操过程:关键环节的完整实现记录
4.1 初始化项目与数据库脚本
后端项目我用Spring Initializr生成的,选择Java 17 + Spring Boot 3.2。依赖选了:Spring Web、MyBatis-Plus Starter、MySQL驱动、Lombok、Validation、Redis。前端用Vite创建Vue 3项目,安装Element Plus、Pinia(状态管理)、Vue Router、Axios、markdown-it、ByteMD。
数据库脚本这块,我在项目根目录建了个sql文件夹,把初始化脚本管理起来。建表完成后插入两条初始数据:一个管理员账号和一个测试用户账号。这样做的好处是项目克隆下来启动就能登录,而不是让答辩老师自己注册。
这期间踩了第一个坑:MyBatis-Plus和Spring Boot 3的兼容性问题。MyBatis-Plus在3.5.3之前的版本对Spring Boot 3的支持不完善,启动时会出现循环依赖错误。解决方案是升级到3.5.5及以上版本。所以如果你用Spring Boot 3,MyBatis-Plus一定别用太老的版本。
4.2 后端接口设计与统一响应结构
前后端分离项目里,接口通信的数据格式必须统一。我定义了一个Result类,包含三个字段:code(状态码,200成功,400参数错误,401未登录,500服务器异常)、message(提示信息)、data(响应数据)。所有Controller返回的都是Result对象,前端Axios封装了对Result的拦截处理,code不为200时统一弹错误提示。
接口的命名和设计遵循REST风格:
- POST /api/auth/register 注册
- POST /api/auth/login 登录
- GET /api/post/page 分页查询文章
- GET /api/post/{id} 文章详情
- POST /api/post 发布文章
- PUT /api/post/{id} 修改文章
- DELETE /api/post/{id} 删除文章
- GET /api/post/tag/{tagId} 按标签查询
- GET /api/comment/list/{postId} 评论列表
- POST /api/comment 发表评论
- GET /api/stat/overview 站点统计
自定义了一个注解@RequireAuth,拦截器扫描到这个注解的接口就去解析Token。管理员接口再加上@RequireAdmin,双注解组合控制权限。
这套设计虽然是老套路,但胜在清晰可靠,答辩时能讲出“权限控制是基于自定义注解加拦截器实现的”,比嘴上说“我有权限功能”有说服力得多。
4.3 前端的路由守卫与状态管理
前端这边,路由设计为:
- / 首页,展示文章列表
- /post/:id 文章详情,包含评论区
- /write 写文章页,需登录
- /tags 标签页
- /login 登录页
- /admin 管理后台,需管理员权限
路由守卫用来控制页面访问权限。Vue Router的beforeEach钩子里,判断目标路由的meta字段是否需要登录或管理员。需要登录且没有Token时就重定向到/login页,并带上redirect参数,登录成功后跳回原页面。需要管理员但没有对应角色时就显示无权限页。
Pinia仓库里存了三个全局状态:用户信息、Token、站点统计。用户信息和Token在登录成功后统一写入,页面刷新时再从本地存储恢复。这里我用Pinia的持久化插件自动同步到localStorage,省去手动存取。
管理后台的实现难度不高,就是一个标准的CRUD界面:用户表格可禁用/启用账号、重置密码;文章表格可下架/删除文章;评论表格可删除评论。El-Table表格组件加El-Pagination分页组件十行代码内能搞定一个页面。
4.4 部署上线:从本地到云服务器的完整流程
部署是整个实训项目里周期最长、最容易出意外的一步,我前前后后折腾了两天。
云服务器选了Linux系统(CentOS风格),2核4G内存的入门配置。装好Nginx、MySQL、Redis,Java环境用的是JDK17。
部署流程大概是这样的:
- 本地前端执行npm run build,生成dist静态目录。
- 后端用Maven打成JAR包:mvn package。
- 把前端dist传到服务器上的/var/www/blog目录。
- 后端JAR传到/opt/blog目录。
- 配置Nginx静态资源服务,root指向/var/www/blog。
- 再配置Nginx反向代理,把/api开头的请求转发到本机的8080端口。
- 后端JAR用nohup启动:nohup java -jar blog.jar > /var/log/blog.log 2>&1 &
Nginx这块有两个易错点。第一是history路由模式必须配置try_files: try_files $uri $uri/ /index.html,否则刷新某个子页面时会404。第二是静态资源缓存:js/css这类带hash指纹的资源可以设置长时间缓存,index.html设置no-cache,这样既保证性能又保证每次发布后能拿到最新版本。
后端启动后,记得检查一下日志,确认没有异常。我第一次部署时忽略了Java版本不一致的问题——本地是JDK17编译的包,服务器上装的竟然是JDK8,启动直接报UnsupportedClassVersionError。这个坑很基础,但确实容易犯。
4.5 数据统计模块的缓存设计
统计模块看似简单,但实现时有不少细节。站点访问量和文章浏览量如果每次请求都实时更新数据库,在高并发下会拖慢接口响应。我用的方案是Redis缓存加定时落库:
- 文章浏览量:Redis里用Hash结构存 post:view_count:{postId} -> count,每次浏览只做INCR操作,内存操作极快。
- 站点访问量:独立Key,每次访问+1,同样是内存操作。
- 定时任务每5分钟把Redis里的增量数据同步到MySQL。
定时任务用Spring的@Scheduled注解实现,在同步前先查一遍Redis中所有浏览量Key,然后批量更新到文章表。同步完成后不能直接删除Redis数据,因为可能在这5分钟内又有了新增量,所以要改为先取出当前值再重置为0,或者用缓存值减数据库值的方式来确保不丢数据。
这个方案在博客这种读多写少的场景下完全够用。如果以后访问量大了,还可以引入Kafka之类的消息队列做异步削峰,但现阶段属于过度设计。
5. 常见问题与排查技巧实录
开发过程中踩了不少坑,有些问题能折腾一晚上。挑几个最典型的写出来,给同样在做博客类项目的人一个参考。
5.1 跨域请求被拦截
前端页面部署在Nginx的80端口,后端在8080端口,浏览器直接请求/api地址时会被CORS策略拦截。报错信息通常是一大段红色英文,里面有一个关键词CORS error。
解决方案是两个层面配合。一是后端启用CORS配置,允许来自前端域名的请求,用@CrossOrigin注解或实现WebMvcConfigurer统一配置。二是Nginx做反向代理,把同一个域名下的/api路径转发到后端服务。这个方案是最推荐的,因为用户浏览器看到的只有前端域名,不存在跨域问题,Cookie和Authorization头也都好处理。
踩过的坑:一开始我只配置了后端CORS,前端还是直连后端域名,浏览器默认发送一个OPTIONS预检请求,拦截器直接把它挡掉了,导致POST请求总是失败。后来在拦截器里加了判断:OPTIONS请求放行,再配合Nginx反代,问题才彻底解决。
5.2 文件上传大小超限导致接口报错
博客涉及图片上传,头像和文章配图都要传文件。默认情况下Spring Boot的请求体大小限制是1MB,超过就报错。我在配置里调成了:
spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=10MB
Nginx那边也要改:client_max_body_size 10m。这两个配置忘了任何一个,传大图都会静默失败或报413错误。
图片存储路径我也踩了个坑。一开始存在项目目录下的uploads文件夹,结果重新部署时被覆盖了,所有用户头像全部丢失。后来改成存到服务器固定目录,比如/data/blog_images,数据库只存访问路径,图片文件独立管理,部署时不再受影响。
5.3 刷新页面Total失效
这个坑非常经典。前后端分离开发时,把前端服务跑在Vite的5173端口,浏览器地址栏直接输入一个子路由路径,比如/post/1,返回404或白屏。原因就是前端用的是History模式路由,刷新时浏览器请求的是后端路径,后端返回404,而不是前端index.html。
本地开发时Vite自带history fallback,所以不会暴露这个问题。部署到Nginx后没有配置try_files就立刻暴露了。修复方法就是前面提到的Nginx配置try_files。这个坑我记忆犹新,答辩前一晚发现刷新后白屏,赶紧去看Nginx配置,加了那行try_files才恢复。
5.4 密码加密算法的选择与实现
用户密码安全是底线,绝对不能明文存储。我用的BCrypt算法,虽然是十多年前的算法,但在密码哈希场景下依然可靠。它自带随机盐,每次加密相同密码得到的哈希值都不同,有效防止彩虹表攻击。
实现上,注册时调用BCryptPasswordEncoder的encode方法生成哈希存储,登录时调用matches方法比对。Spring Security有现成的BCryptPasswordEncoder类,单独引出来用也很方便,不需要为了用它启动整个Security过滤器链。
注意:不要自己写MD5加盐方案。MD5本身速度太快,容易被GPU暴力破解,虽然加盐能防彩虹表,但计算效率仍然不够慢。BCrypt设计上就是故意慢的,一次哈希需要几十毫秒,暴力破解成本呈指数级上升。
5.5 定时统计丢数据的竞态问题
刚引入Redis统计时,定时任务落库逻辑写得比较粗糙:先把Redis中的浏览量取出来,更新到数据库,再删除Redis中的Key。结果在高频访问场景下,删除Key后到下一次定时任务执行前,Redis重新从0计数,这期间的访问量就丢了。
修复思路:落库时把Redis键值从A切换到B,新访问写入B,等A的值清零后再删除A,把切换过程中的空窗期覆盖掉。简单做法是,落库前先读取并替换为0,用一个原子操作。这里的核心思想是“取走后计数器要重置而不是删除”,因为删除会让后续INCR重新建Key从1开始,导致重复计数。
6. 经验总结与后续扩展建议
写到最后,我想说几句实在话。
做实训项目,很多时候比的不是你用了多酷炫的技术,而是你能不能把一件标准化的事做完、做好、讲清楚。我这个博客项目,技术上没有一样是新的,但完整走完一遍之后,我对Spring Boot的自动装配、MyBatis-Plus的底层SQL生成、Vue组件通信、Nginx反向代理配置的理解,都在实战中加深了一个层次。这些东西书上都有,但只有自己遇到问题又解决掉,才算真正变成自己的。
具体来说有几点体会:
第一,项目范围宁可小一点,也要把核心链路打通。我的博客从用户注册到发表文章到评论互动,中间所有环节都真实可跑。很多同学功能列了一大堆,结果答辩演示时注册完登录不了,那才是灾难。
第二,数据库设计值得花一天时间好好画。表关系不清楚,到写SQL和联调阶段全是返工。先理顺实体间的关系,后面编码就是水到渠成的事。
第三,部署文档从开发第一天就开始写。把Nginx配置、JAR包启动命令、环境变量说明都记录下来,一是自己复盘方便,二是实训报告有内容可以写,三是以后换机器重新部署时不用从头摸索。
如果后续要继续扩展这个项目,我给自己列了几个方向,按优先级排序:引入全文搜索引擎替代LIKE模糊搜索,把Markdown图片上传改为对象存储方案,增加文章草稿自动保存功能,甚至可以加一个简单的推荐系统,根据用户浏览历史推荐相关文章。这些方向每一个都够再写一个实训项目了。
对我个人来说,这个博客项目最大的收获不是那8000行代码,而是建立了一条完整的工程思维链路:从需求出发,做设计决策,写代码实现,部署运维,再回到需求去验证。这个流程在任何软件开发任务中都是通用的。如果你也正在做类似的实训项目,记住一点:先跑通再优化,先完成再完美,这句话真的是至理名言。