ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Spring Boot + Vue 博客系统开发实战:从技术选型到部署填坑

Spring Boot + Vue 博客系统开发实战:从技术选型到部署填坑

去年年初,我接手过一个半成品博客项目。前端是网上扒下来的模板改的,后端还是传统的Spring MVC加JSP跑在Tomcat里,发布一篇文章要做三件事:把HTML丢进数据库、把图片手动传到某个目录、再把Tomcat重启一遍。坚持半个月后我决定推倒重来。这里记录的就是我用Spring Boot和Vue重写博客管理系统的全过程,包括技术选型、表结构设计、核心接口和前后端联调的坑。

这个项目做完之后,我最大的感受是:博客系统看起来简单,但真正动手做的时候,会遇到大量“文档里不会告诉你”的问题。文章编辑器传上来的HTML怎么保留格式?用户上传的图片路径怎么和页面访问地址对得上?Vue的history路由刷新后为什么404?这些才是让一个项目从demo变成可用系统的关键。所以这篇文章不准备面面俱到讲每个API怎么拼,而是按我重写项目的顺序,把做决策和填坑的思路讲清楚。

如果你正准备做一个博客类项目,或者在学Spring Boot和Vue但不知道怎么把前后端串起来,这篇文章应该能帮你少走不少弯路。

1. 从传统Web到前后端分离:我为什么选择这套组合

1.1 旧项目的痛点让我下决心重写

旧的博客系统其实打通了基本功能,文章能发、评论能看,但每一次小改动都要付出大代价。举个例子,我想在文章详情页加一个“上一篇/下一篇”按钮,后端接口需要返回相邻文章的ID。在原项目里,这个逻辑被塞在ArticleController的一个方法里,页面直接在JSP中用ModelAndView传递数据。前端要改样式,后端要改控制层和数据查询,两个人同时改一个文件,天天冲突。

更麻烦的是,旧项目里所有页面都是服务端渲染,编辑器和展示页共用一套模板,导致文章正文里的HTML经常被JSP标签和JavaScript脚本干扰。那时候我意识到,博客管理系统这种以内容展示为主的系统,最适合的做法是前后端完全分离:后端只提供JSON接口,前端只关心页面渲染,两者通过API通信。这也是我最终选择Spring Boot + Vue的根本原因。

1.2 技术选型不是越新越好,要看团队和维护成本

前后端分离技术方案很多,我当时的取舍是这样的:

层级候选方案我为什么没有选
后端框架Spring Boot最终选择,Java生态成熟,资料多,部署方便
后端框架Node.js + Express我没选它是因为团队更熟悉Java,而且博客系统涉及数据关联和权限,Java写起来更稳
后端框架PHP + Laravel启动快,但后续想扩展接口做App后端会麻烦一点
前端框架Vue 3最终选择,上手快,组件化清晰,生态完善
前端框架React学习曲线相对高一点,做这种CMS有点杀鸡用牛刀
前端框架原生JS + jQuery不现实,维护性太差

我最后敲定的是Spring Boot 2.7 + Vue 3 + Element Plus组合。Spring Boot负责提供RESTful接口和数据处理,Vue 3负责构建前台展示和后台管理两个端,UI组件用Element Plus可以节约大量时间。整个过程不追求花哨,只求稳定、可扩展,后续接App端或者小程序,这套后端接口也能继续用。

1.3 这套组合适合什么类型的博客系统

如果你的目标是做高并发大流量平台,比如类似信息流网站,Spring Boot + Vue也可以,但需要加缓存、消息队列、分库分表一堆东西。对于个人博客、企业官网的文章板块、知识库系统,这套组合已经足够。我在项目中主要实现了文章管理、分类管理、标签管理、评论管理、用户登录授权五个核心模块,这基本覆盖了一个内容型系统的80%需求。

2. 数据层设计:五张核心表还是三张?我最终这样拆

2.1 先把功能清单列清楚再建表

我习惯在写代码之前先画一张功能脑图,把需要的数据摸清楚。这个博客系统我拆成了两个端:

  • 前台:文章列表、分类查看文章、标签查看文章、文章详情、评论列表、发表评论、用户登录
  • 后台:文章发布/编辑/删除、分类管理、标签管理、评论审核、用户信息修改

顺着功能清单,我一开始想偷懒只用三张表:user、article、comment,分类和标签都直接用字符串存到文章表里。后来一想这样不行,分类一旦改名,所有文章都要更新,标签也无法做聚合统计,于是老老实实拆成五张核心表加一张关联表。

2.2 五张表的结构和字段设计

以article表为例,最核心的字段是这样的:

CREATE TABLE `article` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '作者ID', `category_id` bigint DEFAULT NULL COMMENT '分类ID', `title` varchar(200) NOT NULL COMMENT '标题', `summary` varchar(500) DEFAULT NULL COMMENT '摘要', `content` longtext COMMENT '正文HTML', `cover_image` varchar(500) DEFAULT NULL COMMENT '封面图', `status` tinyint DEFAULT '1' COMMENT '1发布 0草稿', `view_count` int DEFAULT '0' COMMENT '浏览量', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

content字段我用的是longtext,因为富文本编辑生成的HTML体积很大。编码统一用utf8mb4,否则emoji表情存不进去。我见过有人用utf8,结果评论区的emoji全部变成问号,这个细节一定要留意。

user表不需要多复杂,最基本的是用户名、密码、昵称、头像、角色。密码不存明文,我用BCrypt加密存储。comment表我设计成了自关联结构,加了一个parent_id字段,这样可以实现楼中楼回复,评论列表做递归查询也不会太吃力。category和article是一对多关系,article和tag是多对多关系,所以额外加了一张article_tag关联表。

2.3 设计数据库时我做的一个关键取舍

标签到底要不要独立成表?这个我想了很久。如果只做个人博客,用article表里加一个逗号分隔的tags字段最简单,查列表时用FIND_IN_SET也能过滤。但后续我想做标签云和标签聚合页,独立表加关联表才能支持高效的统计查询。所以最后选择多对多建模,代价是写文章时多两条SQL,换取统计时的灵活度和可维护性。对于博客系统这种读多写少的场景,这个取舍非常值得。

2.4 ORM我用MyBatis-Plus而不是Spring Data JPA

很多Spring Boot教程会用JPA,我这次选了MyBatis-Plus。原因很简单:博客系统的查询经常要拼接条件,比如按标题模糊搜索、按分类筛选、按状态过滤,用MyBatis-Plus的LambdaQueryWrapper写起来非常直观,而且它内置分页插件,拿回来就能用。JPA也能做,但要写JPQL或Specification,团队里有人不熟悉时改查询会难受。如果你习惯JPA也没关系,但下面讲的分页和关联查询思路是一样的。

3. 后端接口实战:JWT鉴权不止是加一个注解

3.1 项目分层结构先固定好

我包结构是这样的:

com.example.blog ├── config ├── controller ├── service ├── mapper ├── entity ├── dto ├── vo ├── common └── utils

控制器只接收参数和返回结果,业务逻辑全部放service,数据访问放mapper。dto放接收前端的请求对象,vo放返回前端的响应对象。这个分层不高级,但在多人协作时能减少冲突,接口文档也容易对起来。

3.2 登录和JWT签发的完整流程

后台管理接口不能裸奔,我用JWT做无状态鉴权。流程是这样的:

  1. 用户提交用户名和密码
  2. 后端校验通过后,生成一个JWT返回前端
  3. 前端把token存到localStorage
  4. 每次请求在请求头带上Authorization: Bearer <token>
  5. 后端拦截器解析token并放入用户上下文

生成token的代码核心部分:

public class JwtUtil { private static final String SECRET = "your-secret-key-change-in-production"; private static final long EXPIRE_TIME = 24 * 60 * 60 * 1000; // 24小时 public static String createToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } }

这里的SECRET一定不能写在代码里写死,我项目里是放到application.yml的配置项里,部署时用环境变量覆盖。JWT的优势是服务端不用存session,横向扩展方便;缺点是一旦签发,在过期前无法主动撤销。对于博客系统,24小时过期时间足够。

3.3 用拦截器校验token,而不是到处加注解

Spring Security也能做,但配置复杂度偏高。对于博客后台只有一种角色的场景,我用HandlerInterceptor实现了校验逻辑:

public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String auth = request.getHeader("Authorization"); if (StringUtils.hasText(auth) && auth.startsWith("Bearer ")) { String token = auth.substring(7); if (JwtUtil.validateToken(token)) { return true; } } response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } }

然后在WebMvcConfigurer中注册拦截器,只拦截/api/admin/**路径,前台的/api/article/list这些公开接口完全放开。这样比在Controller方法上逐个加注解干净得多,也不容易漏。

这里有一个我踩过的坑:前端在请求拦截器里给请求头加token时,有些字段名会和CORS预请求冲突。如果你用了Authorization,后端跨域配置里必须把Authorization暴露到allowedHeaders里,否则浏览器会先拦截预检请求。

3.4 文章和评论的核心接口:分页、条件查询、状态控制

文章列表接口是博客系统的核心,我设计成GET请求加参数:

@GetMapping("/api/article/page") public Result<PageResult<ArticleVO>> page(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) Long categoryId, @RequestParam(required = false) String keyword) { Page<Article> p = new Page<>(page, size); LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(categoryId != null, Article::getCategoryId, categoryId) .like(StringUtils.hasText(keyword), Article::getTitle, keyword) .eq(Article::getStatus, 1) .orderByDesc(Article::getCreateTime); Page<Article> result = articleMapper.selectPage(p, wrapper); // 转成VO,填充作者名称和分类名称 return Result.success(buildPageResult(result)); }

这个接口同时服务前台和后台。前台传status=1隐藏草稿,后台传status=-1可以看到所有草稿。一个接口两用,减少重复代码,但要注意权限边界。

评论接口我做了两级数据校验。第一级是参数校验,前端传上来的articleId和content必填;第二级是业务校验,比如文章是否存在、评论内容长度是否超过500字。文章可能被软删除,所以查询评论前先查文章状态,避免直接拼SQL查出一个已下架文章下的评论列表。

4. 前端页面实现:从零搭建Vue管理端和展示端

4.1 项目初始化和路由设计

前端我用Vite创建工程,目录结构按功能拆分:

src ├── api ├── assets ├── components ├── router ├── stores ├── views │ ├── admin │ └── web

路由用Vue Router的history模式,但有个大坑后面会讲。我设计了两种布局:WebLayout管前台,AdminLayout管后台。路由元信息中用meta.requiresAuth标记后台页面是否要登录,配合前面说的后端拦截器,前端也做一层路由守卫:

router.beforeEach((to, from, next) => { if (to.meta.requiresAuth && !authStore.isLoggedIn) { next({ path: '/login' }) } else { next() } })

这套双保险结构虽然简单,但能避免用户白屏访问后台时才被后端拒绝。

4.2 前台页面:列表、详情、评论互动

前台首页就是文章列表卡片,每张卡片显示封面、标题、摘要、分类、发布时间和浏览量。数据来源是后端的分页接口,我用一个articleApi.getPage()函数封装axios调用。为了减少首屏加载时间,列表页用了Vue的onMounted只加载第一页数据,滚动到底部再加载下一页。

文章详情页最麻烦的是正文展示。富文本编辑器存的是HTML,所以我用v-html渲染。但这里必须做干净:后端在保存正文时过滤掉危险标签,前端再对v-html内容做一次白名单校验,双保险。我实际开发中被验收方要求不能出现可执行的<script>标签,所以后端用Jsoup做了内容清洗,这个后面会细说。

评论区我做了最基础的楼中楼,发表评论后通过emit事件刷新评论列表。这里没有用WebSocket,而是评论成功后立即调一次评论列表接口。博客系统评论频率不高,用轮询反而简单稳定。

4.3 后台管理:富文本编辑和图片上传

后台最核心的是文章编辑页。我选了一款开源的富文本编辑器,配置了图片上传地址:

const editorConfig = { uploadUrl: '/api/admin/upload', uploadFileName: 'file' }

上传接口后端接收MultipartFile,保存到本地目录后返回可访问的URL。这里我后来统一改成了返回相对路径,比如/uploads/2024/05/xxx.png,而不是http://localhost:8080/uploads/xxx.png。原因很直接:本地开发和生产环境的域名不一样,如果返回完整地址,部署时图片就全裂了。相对路径加Nginx映射是最省心的方案。

图片上传还有一个细节:编辑文章时的图片地址,要能回显到编辑器中。我上传接口返回的URL直接给前端存到了文章内容的<img>标签的src属性里,这样编辑和展示共用一条数据链路,没有额外做图片和文章的关系表。

4.4 状态管理:登录态和用户信息

前端我用Pinia做状态管理,只存了三个东西:token、用户昵称、用户角色。本来想顺手存文章列表的缓存,后来发现得不偿失,数据一致性太难维护。状态管理这层应该克制,只放全局需要的用户态和配置信息。axios请求拦截器统一从store里取token并注入请求头,响应拦截器遇到401就清掉本地登录态并跳转到登录页。

axios.interceptors.request.use(config => { const authStore = useAuthStore() if (authStore.token) { config.headers.Authorization = `Bearer ${authStore.token}` } return config })

这个写起来不多,但一定要和路由守卫配合好,否则会出现提示框还没跳走,接口已经连续发好几个401请求的情况。

5. 联调与部署:那些文档里没写但一定会踩的坑

5.1 跨域问题:预检请求把所有人卡住了

前后端分离项目,本地联调第一个坑必然是跨域。前端跑在5173端口,后端跑在8080端口,浏览器默认不允许跨站请求。我当时在后端写了一个还算通用的CORS配置:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

注意allowCredentials(true)时不能直接使用allowedOrigins("*"),否则浏览器会拒绝。我一开始用了allowedOrigins("*")加allowCredentials(true),结果OPTIONS请求一直不通过,查了很久才发现规范不允许这个组合。

5.2 图片上传后前端访问不到

部署时我把前端打包成dist目录扔到Nginx,后端jar包单独跑在8080端口。图片上传到了后端所在的机器目录,比如/home/admin/blog/upload/,但图片URL访问的是http://域名/upload/xxx。Nginx根本不认识这个路径,导致上传成功后编辑器里的图全部裂开。

我的解决办法是让Nginx把/upload/开头的请求反向代理到后端服务,或者更直接一点,让Nginx将/upload/映射到后端的静态资源目录:

location /upload/ { alias /home/admin/blog/upload/; }

同时后端在Spring配置里加了静态资源映射,方便本地直接预览:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath + "/"); } }

这是整个项目里最不起眼但最容易出问题的环节。图片路径方案最好一开始定好,代码里不要出现任何硬编码的域名。统一按/upload/相对路径处理,部署时只需配好Nginx就能到处跑。

5.3 分页查询的参数坑和返回格式统一

用MyBatis-Plus分页需要注册分页插件:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

如果没有这段配置,selectPage会查全量数据再内存分页,数据一多就卡死。这个问题不少新手遇到,因为框架没有任何报错,只是分页结果莫名其妙。另外,前端传来的page和size我不会直接用,会做一次边界约束:

int finalSize = Math.min(size, 30);

这样防止有人恶意传size=10000把接口拖垮。

5.4 富文本内容里的XSS安全处理

文章内容是富文本HTML,如果用v-html直接渲染,等于把前端的安全完全交给了后端。所以我在后端保存文章时增加了一步Jsoup清洗:

Whitelist whitelist = Whitelist.relaxed(); whitelist.addTags("img").addAttributes("img", "src", "width", "height"); String safeContent = Jsoup.clean(content, whitelist);

这段代码会把<script>、<iframe>、onerror事件等全部过滤掉。我试过用编辑器直接粘贴一段带onerror的图片标签,不清洗的话,只要页面一打开就会不断触发恶意请求。做完清洗后,文章基本可以安全展示。但要注意,白名单配置别一刀切,比如你希望文章里支持视频嵌入,就得把<iframe>加到白名单里并限定只允许来自视频平台的src。

5.5 Vue history路由刷新后404的解决

前端路由用history模式后,刷新一个深层链接,比如/article/12,Nginx会直接找该路径对应的文件,找不到就404。解决办法是Nginx配置中把所有路径都回退到index.html:

location / { try_files $uri $uri/ /index.html; }

如果是后台管理端有独立域名,还可以按路径前缀配置。这个坑我在部署当天踩的,改完Nginx配置后记得nginx -s reload。

6. 项目复盘:博客系统需要的不是复杂,而是克制

6.1 上线后我发现浏览量统计数据有问题

最初我设计的浏览量是每次访问详情页就update item set view_count = view_count + 1,结果上线第二天就发现同一篇文章刷几遍浏览量破万。后来我加了IP访问限制,同一个IP在10分钟内只统计一次。实现上不需要额外表,直接在Redis里存一个key,过期时间10分钟。但如果你没有上Redis,也可以先用一个简单的内存Map加过期清空,只是多机部署时会失效。这个功能如果一开始没有想好方案,会越做到后面越难受。

6.2 评论系统和鉴权模块的解耦

原本我想把评论设计成必须登录才能发,后来试了试发现体验太差。个人博客就应该是低门槛互动,不登录也能评论,但我需要处理昵称、邮箱、内容这三个字段。于是我把评论发接口改成公开接口,后端限制发送频率,防止恶意刷屏。这个决定对代码影响不大,却让我更清楚一个道理:权限控制不是所有地方越严格越好,业务场景决定了安全策略。

6.3 从单机到容器化的下一步计划

当前项目是单体应用,Nginx加一个Java进程,数据库用本地MySQL,日常维护并不复杂。如果以后要迁移服务器,我会考虑用Docker Compose把MySQL、后端、Nginx三个服务编排起来,数据目录用volume挂载,备份也容易。但我不建议博客系统一开始就上K8s或Service Mesh,复杂度过高,收益却非常有限。

我复盘整轮开发,最值得借鉴的并不只是某个框架怎么用,而是如何把项目控制在可维护的复杂度内。Spring Boot和Vue极大降低了前后端开发的门槛,但项目的成功仍然取决于对业务的理解、对数据的建模和对边界情况的处理。回头再看当初那个旧项目,我最大的收获其实不是代码,而是明白了“做成什么样子才算真的能用”。

如果让我再给后来者一个建议,我会说:动手之前,先把文章正文怎么存、图片路径怎么回显、刷新后路由怎么不404这三个问题想清楚。这三个问题解决了,你的博客系统就成功了一大半。

返回列表