折腾个人博客的方案我试过不少:最早用WordPress,插件多但维护烦;后来切到Hexo,轻量但每次发文章要敲命令;再后来用过在线文档,分享是方便,可总感觉那是别人的工具。最后还是决定自己写一套——正好手头有个机会,要做一个基于SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0的Java Web个人博客系统,还能把项目文档一并沉淀下来。这套系统做完之后,文章的发布、分类、标签、评论、后台管理全都能自己掌控,前后端分离,部署也不复杂。如果你正琢磨做个人站点,或者想找一个全栈练手项目,这套东西从设计到实现都挺有参考价值。
1. 这套博客系统解决了什么问题
1.1 为什么还要自己写博客系统
很多人第一反应是:博客系统不早被WordPress、Hugo这类工具包办了吗?确实,如果只是写文章,现成方案更省事。但一旦你不是“只写文章”,事情就复杂了。比如想要一个完全自定义的归档页面、想按自己的方式管理标签和分类、想给文章做权限控制,或者想学一套完整的项目架构,就得自己动手。
自己做博客系统,最大的好处是“每一行代码都在自己手里”。数据表怎么设计、接口怎么暴露、权限怎么控制,完全可控。遇到问题随时可以改,不用担心某个主题更新之后样式崩了。另一个实际收益是,这个项目本身就是一套极好的全栈练习:后端处理业务逻辑、数据分页、事务管理,前端做交互、路由、状态管理,两边还要对接API。做完这套,再去面试聊项目经验,能聊的东西就多了。
1.2 核心技术栈定位:SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0
这套系统的技术栈不是随便拍的,每一层都有明确考虑。
后端选了SpringBoot2而不是3.x,一是因为2.x的生态信息量大,网上资料多,遇到问题基本上都能搜到解法;二是在当前企业里,2.x依然占大头,用它做项目更容易迁移到业务开发中。搭配MyBatis-Plus,等于把最烦躁的CRUD代码省了一半,单表操作不用写XML,条件构造器可以动态拼SQL,写起来非常顺手。
前端用Vue3,配合Vite构建,开发启动速度比Webpack快很多。组合式API(Composition API)让逻辑复用变得自然,不像Options API那样一堆代码挤在method里。MySQL8.0是现在的默认选择,支持窗口函数、CTE,字符集用utf8mb4,中文和表情符号都能存得住。这套组合在实际开发中非常稳,既没有太新的坑,也没有老旧的性能瓶颈。
还有一点要专门说:这套系统“含文档”。项目交付时不光是代码,还包括数据库脚本、环境配置说明、部署步骤和接口文档。对个人项目来说,文档的意义不只是给别人看,更是给三个月后的自己看的。我见过太多项目代码写完了,回头要加功能时想不起来表结构是干嘛的,所以这次特意把文档沉淀了下来。
2. 整体架构与数据库设计
2.1 前后端分离的架构思路
整套系统采用前后端分离架构:前端是一个独立的Vue3单页应用,负责页面渲染和用户交互;后端是SpringBoot2工程,只提供RESTful API接口,不碰页面逻辑。两者通过JSON格式的数据进行通信。
这个架构的好处是职责清楚。前端只管“长什么样”,后端只管“数据怎么来”。部署的时候可以用Nginx托管前端静态文件,并把API请求反向代理到后端端口,这样同域访问就不会产生跨域问题。同时在开发阶段,前端也能通过Vite的proxy配置把请求转发到后端,两边独立启动、独立调试。
需要说明的是,虽然是前后端分离,但用户的登录状态依然要通过Token来维持。前端登录成功后把Token存到浏览器,之后的每一次请求都在头里带上它。后端通过拦截器校验Token,并从中读取当前用户信息。这个流程做一次之后,很多系统都能套用。
2.2 核心数据表设计与字段解析
博客系统看起来简单,但表结构还是需要仔细推敲的。这套系统的数据库一共6张核心表:用户表、分类表、标签表、文章表、文章标签关联表、评论表。下面是我设计时的核心字段说明。
用户表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| username | varchar(50) | 登录用户名,唯一 |
| password | varchar(100) | 加密后的密码,BCrypt格式 |
| nickname | varchar(50) | 显示昵称 |
| avatar | varchar(255) | 头像地址 |
| role | varchar(20) | 角色,比如admin/user |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
| deleted | tinyint | 逻辑删除标记 |
分类表和标签表结构类似,都是id、名称、别名、创建时间,其中别名用于URL展示,比如/category/java。多看一点:标签表不设deleted,因为标签删掉会影响历史文章的关联,通常只是标记不再使用。
文章表是最核心的一张表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| title | varchar(200) | 标题 |
| summary | varchar(500) | 摘要 |
| content | longtext | Markdown原文内容 |
| content_html | longtext | 渲染后的HTML内容 |
| cover_image | varchar(255) | 封面图 |
| category_id | bigint | 分类ID |
| status | tinyint | 草稿/已发布/已下线 |
| view_count | int | 浏览量 |
| create_time / update_time | datetime | 时间 |
content和content_html分开存的原因是:展示时直接返回HTML,提高访问速度;编辑时用Markdown原文,方便修改。这也是很多博客系统常见的双字段设计。文章和标签是多对多关系,通过文章标签关联表解决。
评论表设计时要注意:不仅要存评论内容,还要存评论人的昵称、邮箱、头像,以及被评论文章的ID和父评论ID。父评论ID用来做楼中楼回复,这是一个很容易被忽略的点。如果一开始不做,后面想加回复功能就要改表,麻烦。
2.3 设计细节背后的考量
这几个设计细节对开发体验影响很大:
逻辑删除:很多表都有deleted字段,通过MyBatis-Plus的@TableLogic注解实现。它的价值在于保留历史数据。比如文章被删了,但评论还在,之后想恢复文章,数据都还在。当然,对于个人博客来说,数据量不大,逻辑删除的代价可以忽略不计。
自动填充:create_time和update_time这两个字段通过MyBatis-Plus的自动填充功能处理,定义MetaObjectHandler类,在insert和update时自动写入时间。这样代码里就不用手动set时间了,省了很多重复劳动。
文章状态字段:status设计成三个状态,草稿、已发布、已下线。这个状态可以让前台只展示已发布的内容,后台可以管理所有状态。如果没有这个字段,想临时隐藏某篇文章就只能删掉,很不灵活。
3. 后端核心模块实现
3.1 SpringBoot2项目结构与依赖配置
后端工程用了标准的Maven结构,包名按功能分层:controller、service、mapper、entity、config、common、utils。
在pom.xml里,核心依赖如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>4.2.1</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>配置文件application.yml里大部分就是把数据源、日志、MyBatis-Plus配置写好。我有一次差点忘了设置mybatis-plus.configuration.map-underscore-to-camel-case=true,导致数据库create_time字段映射到Java属性createTime失败,所以建议在配置文件里显式声明。
3.2 MyBatis-Plus的高效玩法:分页插件、代码生成器、条件构造器
MyBatis-Plus真正省时间的是这三个东西。
第一个是分页插件。不配置分页插件的话,Page对象传进去也不会生效。需要先建一个配置类:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }然后Service里直接查:
Page<Article> page = articleMapper.selectPage( new Page<>(current, size), new LambdaQueryWrapper<Article>() .eq(Article::getStatus, 1) .orderByDesc(Article::getCreateTime) );分页接口返回时,除了列表数据,还需要把总条数、总页数一起返回给前端,这样才能渲染好分页控件。
第二个是条件构造器。LambdaQueryWrapper可以避免字符串拼接,不用手写一堆if判断。比如文章查询需要按分类、关键词、状态筛选,代码可以这样写:
LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(categoryId != null, Article::getCategoryId, categoryId) .like(StringUtils.hasText(keyword), Article::getTitle, keyword) .eq(Article::getStatus, 1);这种写法在业务条件不确定时简直是无敌的。
第三个是代码生成器。虽然很多人觉得它鸡肋,但当一个项目的表超过5张时,手工建Entity、Mapper接口太烦了。用生成器生成基础代码然后再改,能省半小时。不过生成器的模板需要调整,默认生成的可能不够符合自己的代码习惯。
3.3 统一返回体与全局异常处理
前后端接口交互,最忌讳的是后端每个接口返回格式都不一样。前端处理起来就得写一堆if分支。所以我做了一个统一的返回体Result类:
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 200; r.message = "操作成功"; r.data = data; return r; } public static <T> Result<T> error(String message) { Result<T> r = new Result<>(); r.code = 500; r.message = message; return r; } }与之配套的是一个全局异常处理器,用@RestControllerAdvice + @ExceptionHandler捕获异常,把业务异常、参数校验异常统一转换成Result格式。这样控制器里就不用写try-catch了,代码很干净。
3.4 基于JWT的登录认证与权限控制
博客后台不能允许所有人都能访问。这里用JWT做身份认证。登录成功后,后端生成一个Token,里面包含用户ID和用户名,设置一个过期时间(一般是2小时或7天),返回给前端。前端在请求头里带上Authorization: Bearer <token>。
后端加一个拦截器,在HandlerInterceptor的preHandle方法里校验Token:
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BusinessException("未登录"); } Long userId = JwtUtil.parseToken(token.replace("Bearer ", "")); if (userId == null) { throw new BusinessException("登录已过期"); } request.setAttribute("userId", userId); return true; }后台管理的所有接口统一走这个拦截器,而前台公开接口则放行。这里有一个经验:拦截器里不要把业务查询放进去太重,否则会拖慢响应。仅仅校验Token就可以,拿到用户信息可以在控制器里再利用。
3.5 文章发布的业务流程拆解
写文章这个功能是最能体现“事务处理”的。保存一篇文章,不只是往article表插入一条记录,还要处理文章与标签的关联关系。如果标签是新增的,还得先往标签表里插入,再绑定关联关系。这涉及到多张表的操作,必须加@Transactional注解,否则中间出错会导致数据不一致。
大致的Service代码结构:
@Transactional public void saveArticle(ArticleDTO dto) { Article article = new Article(); BeanUtils.copyProperties(dto, article); // 处理内容渲染等 articleMapper.insert(article); // 保存标签 List<Long> tagIds = handleTags(dto.getTags()); // 删除旧关联(编辑场景) articleTagMapper.delete(new LambdaQueryWrapper<ArticleTag>() .eq(ArticleTag::getArticleId, article.getId())); // 插入新关联 for (Long tagId : tagIds) { articleTagMapper.insert(new ArticleTag(article.getId(), tagId)); } }这里有个小技巧:更新文章时不要先delete再insert全表,而是先delete关联记录再insert,但要注意放在和insert article同一个事务里,否则并发时会有脏数据。
4. 前端Vue3实现与联调
4.1 Vue3 + Vite项目搭建与目录结构
前端工程我用的Vite来初始化,命令就一条:
npm create vite@latest blog-frontend -- --template vue进入项目后安装axios、vue-router、pinia等依赖。目录结构按功能划分:
- src/api:接口请求封装
- src/components:公共组件,比如文章卡片、分页组件
- src/router:路由配置
- src/store:Pinia状态管理
- src/views:页面组件,按前台/后台再分一层
Vue3的组合式API写法确实让逻辑更清晰。以前Options API要在data、methods、computed里来回跳,现在一个函数里就能把相关逻辑写完。比如一个文章列表的onMounted加载函数,直接写在setup里,可维护性高很多。
4.2 路由设计:前台展示与后台管理的权限控制
前台路由和后台路由需要分开。前台包括首页、文章详情、分类归档、标签归档、关于页。后台包括仪表盘、文章管理、标签管理、评论管理等。
后台部分不能直接访问,要通过路由守卫检查登录状态。我给路由表添加了一个meta属性,标记哪些路由需要登录,然后在全局前置守卫里判断:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login' }) } else { next() } })懒加载也别忘了,后台模块代码量多,一次性加载会影响首屏速度。用() => import('../views/admin/ArticleManage.vue')能按需加载。
4.3 Axios封装与Token处理
前端请求一定要封装统一的Axios实例,否则每个页面写一遍全凭运气。我的request.js里做几件事:
- 设置baseURL
- 请求拦截器里从localStorage取Token放到header里
- 响应拦截器里统一处理错误码,比如401跳转登录页
- 统一把后端返回的Result结构解包,页面上直接拿到data
axios.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) axios.interceptors.response.use(res => { const { code, message, data } = res.data if (code === 200) { return data } else { ElMessage.error(message) return Promise.reject(message) } })这样封装之后,页面里调用api函数可以稍微省点心。
4.4 Markdown编辑方案选型
写博客最理想的是Markdown编辑器。前端我用的是v-md-editor预览插件,它支持GitHub风格样式和代码高亮,不需要自己从零写一个Markdown解析器。编辑时左边写Markdown,右边直接预览效果,非常符合写作习惯。
需要注意一点:后端存储的是Markdown原文,但展示给前台读者的是HTML。所以前端编辑器只需要提交原文给后端,而后端负责渲染成HTML并存储。不过如果前端需要预览草稿,可以直接用编辑器自带的实时预览能力。这样做还有一个好处:就算以后换前端框架,数据还是Markdown原文,迁移成本低。
5. 部署配置与文档沉淀
5.1 MySQL8.0配置与初始化
数据库初始化这一步经常有人踩坑,我单独补充几个关键点。
创建数据库时一定要指定字符集:
CREATE DATABASE blog_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;如果建库时忘了,后面写入中文乱码会查半天。同时连接串里也要带上时区信息:
url: jdbc:mysql://localhost:3306/blog_db?serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=trueallowPublicKeyRetrieval=true这个参数是在MySQL8.0下连接时容易遇到的问题,不加上有时候会报公钥检索错误。
5.2 前后端打包与Nginx部署
后端打包:
mvn clean package -DskipTests生成的可执行jar包放到服务器,用java -jar blog.jar --spring.profiles.active=prod启动,把生产环境的数据库配置放到application-prod.yml里。
前端打包:
npm run build生成dist目录,上传到服务器。Nginx配置大致如下:
server { listen 80; server_name blog.example.com; # 前端静态资源 root /opt/blog/dist; index index.html; # 前端history路由 location / { try_files $uri $uri/ /index.html; } # 后端API反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这套配置既解决了history路由刷新404的问题,也避免了跨域,是我比较推荐的单机部署方式。
5.3 项目文档应该包含什么
之前提到这套系统含文档,我在整理文档时总结了四个必备部分:
- 环境准备与初始化:JDK版本、Node版本、MySQL8.0安装步骤、数据库脚本执行方法,这部分对新手尤其重要。
- 部署指南:本地开发如何跑前后端、服务器上如何打包和发布、Nginx配置示例。
- 接口文档:每个接口的请求方式、路径、参数、返回示例。可以用Swagger自动生成,也可以手动整理一份缩略版。
- 项目说明:包括目录结构说明、核心业务流程图(文字版)、常见问题FAQ。
文档的价值在于“可复现”。别人拿到代码后能不能跑起来,全看文档写得够不够细。
6. 常见问题排查与经验总结
6.1 前后端联调时的跨域问题
开发阶段最常见的是跨域。前端跑在5173端口,后端跑在8080端口,直接访问肯定跨域。解决方案可以在Vite配置里加proxy:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }也可以在SpringBoot里加全局CORS配置。我个人的经验是:开发环境用Vite proxy,生产环境用Nginx反向代理,不要在后端乱开CORS,这样既安全又省心。
6.2 MyBatis-Plus分页失效的坑
分页失效的现象是:Page对象返回的记录数是对的,但total一直是0,或者SQL里没有limit。这通常是因为没有加MybatisPlusInterceptor,或者加了但拦截器的顺序不对。还要注意:如果自己手写了分页SQL,插件可能不生效,这时需要判断是走插件还是手写SQL。最简单的方法就是在配置类里加上之前那段代码,其余交给插件处理。
6.3 Vue3响应式数据丢失与异步渲染
Vue3里最容易踩的坑是把普通对象交给ref/reactive之后,又直接解构或重新赋值,导致响应式丢失。比如文章详情接口返回Promise<Article>,如果在onMounted之前先定义const article = ref({}),然后在异步回调里article.value = data是正常的;但如果用reactive定义,并且直接Object.assign(article, data)也可以。千万别做const { title, content } = article这种解构,一旦解构出来就不会响应式了。
异步渲染另一个问题是组件拿到数据后DOM可能还没更新,如果需要在数据渲染后获取高度等属性,记得用nextTick包一下。
6.4 MySQL8.0的时区与认证插件问题
连接MySQL8.0时有两个高频报错:Public Key Retrieval is not allowed和The server time zone value 'Öйú' is unrecognized。前者加allowPublicKeyRetrieval=true解决,后者在连接串里加serverTimezone=Asia/Shanghai解决。另外MySQL8.0默认的认证插件是caching_sha2_password,如果连接驱动版本太旧也会出问题,用新版本的mysql-connector-j就行。
6.5 这套系统的可扩展方向与我的个人体会
做完整套系统,我的体会是:个人博客写起来不难,但要做到“整洁、易扩展、文档清楚”,工作量比想象中大。这套系统后续可以扩展的方向也很多,比如用Redis缓存热点文章、接入OSS保存图片、增加全文搜索、做文章定时发布等等。数据库表结构为了这些扩展已经预留了一些余地,比如文章表有status字段,评论表有parent_id字段,想加功能不用动大手术。
最后再分享一个小技巧:写这种全栈项目时,每完成一个模块,马上用自定义的测试数据验一遍完整流程,不要等全部写完再统一调试。比如做完登录就去Cookie、Token、拦截器一层层验证。这样看似慢了,实际能省下最后联调时大把找Bug的时间。