ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot2+Vue3+MyBatis-Plus个人博客系统全栈实践

SpringBoot2+Vue3+MyBatis-Plus个人博客系统全栈实践 折腾个人博客的方案我试过不少最早用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快很多。组合式APIComposition 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张核心表用户表、分类表、标签表、文章表、文章标签关联表、评论表。下面是我设计时的核心字段说明。用户表字段名类型说明idbigint主键自增usernamevarchar(50)登录用户名唯一passwordvarchar(100)加密后的密码BCrypt格式nicknamevarchar(50)显示昵称avatarvarchar(255)头像地址rolevarchar(20)角色比如admin/usercreate_timedatetime创建时间update_timedatetime更新时间deletedtinyint逻辑删除标记分类表和标签表结构类似都是id、名称、别名、创建时间其中别名用于URL展示比如/category/java。多看一点标签表不设deleted因为标签删掉会影响历史文章的关联通常只是标记不再使用。文章表是最核心的一张表字段名类型说明idbigint主键titlevarchar(200)标题summaryvarchar(500)摘要contentlongtextMarkdown原文内容content_htmllongtext渲染后的HTML内容cover_imagevarchar(255)封面图category_idbigint分类IDstatustinyint草稿/已发布/已下线view_countint浏览量create_time / update_timedatetime时间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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.2.1/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency配置文件application.yml里大部分就是把数据源、日志、MyBatis-Plus配置写好。我有一次差点忘了设置mybatis-plus.configuration.map-underscore-to-camel-casetrue导致数据库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里直接查PageArticle page articleMapper.selectPage( new Page(current, size), new LambdaQueryWrapperArticle() .eq(Article::getStatus, 1) .orderByDesc(Article::getCreateTime) );分页接口返回时除了列表数据还需要把总条数、总页数一起返回给前端这样才能渲染好分页控件。第二个是条件构造器。LambdaQueryWrapper可以避免字符串拼接不用手写一堆if判断。比如文章查询需要按分类、关键词、状态筛选代码可以这样写LambdaQueryWrapperArticle 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 ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.message 操作成功; r.data data; return r; } public static T ResultT error(String message) { ResultT 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方法里校验TokenOverride 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); // 保存标签 ListLong tagIds handleTags(dto.getTags()); // 删除旧关联编辑场景 articleTagMapper.delete(new LambdaQueryWrapperArticleTag() .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 vitelatest blog-frontend -- --template vue进入项目后安装axios、vue-router、pinia等依赖。目录结构按功能划分src/api接口请求封装src/components公共组件比如文章卡片、分页组件src/router路由配置src/storePinia状态管理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结构解包页面上直接拿到dataaxios.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?serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrueallowPublicKeyRetrievaltrue这个参数是在MySQL8.0下连接时容易遇到的问题不加上有时候会报公钥检索错误。5.2 前后端打包与Nginx部署后端打包mvn clean package -DskipTests生成的可执行jar包放到服务器用java -jar blog.jar --spring.profiles.activeprod启动把生产环境的数据库配置放到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配置里加proxyserver: { 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之后又直接解构或重新赋值导致响应式丢失。比如文章详情接口返回PromiseArticle如果在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。前者加allowPublicKeyRetrievaltrue解决后者在连接串里加serverTimezoneAsia/Shanghai解决。另外MySQL8.0默认的认证插件是caching_sha2_password如果连接驱动版本太旧也会出问题用新版本的mysql-connector-j就行。6.5 这套系统的可扩展方向与我的个人体会做完整套系统我的体会是个人博客写起来不难但要做到“整洁、易扩展、文档清楚”工作量比想象中大。这套系统后续可以扩展的方向也很多比如用Redis缓存热点文章、接入OSS保存图片、增加全文搜索、做文章定时发布等等。数据库表结构为了这些扩展已经预留了一些余地比如文章表有status字段评论表有parent_id字段想加功能不用动大手术。最后再分享一个小技巧写这种全栈项目时每完成一个模块马上用自定义的测试数据验一遍完整流程不要等全部写完再统一调试。比如做完登录就去Cookie、Token、拦截器一层层验证。这样看似慢了实际能省下最后联调时大把找Bug的时间。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进