ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue+MyBatis+MySQL实战:从零搭建BB平台内容管理系统

SpringBoot+Vue+MyBatis+MySQL实战:从零搭建BB平台内容管理系统 1. 为什么是SpringBootVueMyBatisMySQL这套组合做BB平台管理系统这个项目最常被问到的就是你为什么要用这套技术栈。我先说结论这套组合不是最炫的但绝对是最适合做内容社区类系统的方案之一。BB平台本质上是一个集博客写作与论坛互动BlogBBS于一体的内容管理系统核心业务是用户管理、内容发布、评论互动、权限控制这些经典模块。这类系统的特点是业务逻辑清晰、并发压力集中在读操作、权限体系有一定复杂度选SpringBootVueMyBatisMySQL这套成熟方案图的就是稳定、好招人、遇坑能搜到答案。先拆一下各层的选型理由。后端用SpringBoot核心原因不是它新而是它的自动配置机制极大减少了集成成本。举个例子SpringBoot整合MyBatis时只需要引入mybatis-spring-boot-starter配置数据源和Mapper扫描路径就能跑起来对比早期SpringSpringMVCMyBatis时代那一堆XML配置省下的时间不是一星半点。持久层用MyBatis而不是JPA是因为BB平台的帖子列表、评论分页、多表关联统计这类查询逻辑复杂MyBatis的SQL可控性更强优化起来也更直接——你完全知道自己执行了什么SQL。前端选Vue是因为Vue对中小型团队的学习曲线友好双向绑定和组件化开发能极大提升页面交互的开发效率社区生态也足够丰富。数据库选MySQL则是考虑到内容社区系统的数据量级和事务需求MySQL在中小规模场景下性能足够运维成本低。这里必须多说一句技术选型最忌讳的是为了用而用。我见过不少项目为了秀技术强行上微服务、上NoSQL结果团队维护成本暴涨连个用户注册功能都要跨三个服务调用。BB平台这种体量单体应用加合理的模块划分完全够用。如果哪天用户量真的大到需要拆分那也是后话先把业务跑通、把代码写干净才是正道。2. SpringBoot后端骨架分层设计、MyBatis集成与配置踩坑2.1 项目结构与包分层的取舍很多新手拿到SpringBoot就开始写Controller写到最后所有的业务逻辑全堆在Controller里代码根本没法维护。我做BB平台时的分层思路是这样的controller层只做参数接收和结果封装service层管业务逻辑和事务mapper层管数据库操作domain或entity层放数据库实体dto层放接口出入参对象。com.example.bbplatform ├── controller ├── service │ └── impl ├── mapper ├── domain ├── dto ├── config ├── common │ ├── result │ ├── exception │ └── utils └── BbPlatformApplication.java这里有个很实用的经验domain和dto一定要分开不要图省事直接用实体类接收前端参数。原因很简单前端传来的字段往往和数据库表字段不完全一致比如注册接口需要confirmPassword但user表里根本没有这个字段如果你直接用实体类接收就得在实体里加一堆多余字段时间长了实体类会变成一个大杂烩。我在项目里就是User实体只管数据库字段UserRegisterDTO负责接收注册参数用BeanUtils.copyProperties做属性拷贝干净又清爽。2.2 MyBatis的XML映射与Configuration定制MyBatis这块是BB平台后端的关键。我采用的是XML映射文件加注解混用的方式简单的单表查询用注解复杂SQL用XML。这里要强调一个基本原则——多表关联或者动态SQL必须用XML因为注解里写动态SQL需要拼接字符串可读性差而且条件多了之后维护起来就是灾难。select idselectPostList resultTypecom.example.bbplatform.dto.PostDTO SELECT p.id, p.title, u.username AS authorName, COUNT(c.id) AS commentCount FROM post p LEFT JOIN user u ON p.user_id u.id LEFT JOIN comment c ON c.post_id p.id where if testkeyword ! null and keyword ! AND (p.title LIKE CONCAT(%, #{keyword}, %) OR p.content LIKE CONCAT(%, #{keyword}, %)) /if if testcategoryId ! null AND p.category_id #{categoryId} /if /where GROUP BY p.id, p.title, u.username ORDER BY p.create_time DESC /select再说说MyBatis的Configuration定制。现在的mybatis-spring-boot-starter已经帮我们做了大量自动配置但有些场景还是要手动干预。比如需要在XMLConfigBuilder加载配置后追加自定义逻辑或者想自定义TypeHandler处理特殊类型。我这次在BB平台里就踩了一个典型需求数据库里存的是JSON类型字段用户扩展信息Java实体里是ListString如果不写TypeHandlerMyBatis只能把JSON字符串原样映射给你你还得自己手动解析。MappedJdbcTypes(JdbcType.VARCHAR) public class StringListTypeHandler extends BaseTypeHandlerListString { private static final ObjectMapper MAPPER new ObjectMapper(); Override public void setNonNullParameter(PreparedStatement ps, int i, ListString parameter, JdbcType jdbcType) throws SQLException { try { ps.setString(i, MAPPER.writeValueAsString(parameter)); } catch (JsonProcessingException e) { throw new RuntimeException(e); } } Override public ListString getNullableResult(ResultSet rs, String columnName) throws SQLException { String value rs.getString(columnName); return parseJsonList(value); } // getNullableResult 的其他重载方法省略 }在实体字段上标注TypeHandler注解后读写就能自动完成JSON和List的互转。这个经验对做内容类系统特别有用——用户的标签、帖子的扩展属性用JSON字段存储非常灵活配合TypeHandler就完全无感了。2.3 缓存、分页和SQL日志打印的正确姿势BB平台的帖子列表是典型的高频读场景MyBatis的缓存策略需要认真设计。一级缓存默认开启作用范围是同一个SqlSession在Spring整合后几乎可以忽略因为每个请求都新建SqlSession。真正有用的是二级缓存它的作用范围是Namespace级别。但这里有个大坑如果开启了二级缓存又涉及到多表关联查询很容易出现脏数据。比如你缓存了帖子列表结果用户改了头像帖子里关联的authorName还是缓存的旧值——因为关联查询的缓存失效条件很难精确控制。我的建议是BB平台这类系统中帖子列表、评论列表这种实时性要求高的查询不要开二级缓存直接用Redis做业务级缓存后面章节会讲可控性更强。MyBatis自带的二级缓存适合那些几乎不变的数据比如文章分类列表。分类表本身很少更新查询又频繁开二级缓存就很合适mybatis: configuration: cache-enabled: true分页是另一个必踩的坑。网上有很多人大手写LIMIT分页但每页的参数校验、总数统计、排序逻辑都要自己处理很繁琐。推荐直接用PageHelper插件用法很简单PageHelper.startPage(pageNum, pageSize); ListPostDTO list postMapper.selectPostList(queryDTO); PageInfoPostDTO pageInfo new PageInfo(list);PageInfo里直接封装了总数、总页数、当前页、是否有下一页等信息返回给前端非常方便。不过要注意一点PageHelper是线程绑定的必须紧跟第一条Mapper查询语句中间不能有别的查询否则分页会作用到错误的SQL上。还有SQL日志打印。开发阶段一定要把SQL和执行参数打出来不然SQL写错了你根本不知道问题在哪。配置很简单logging: level: com.example.bbplatform.mapper: debug这样配置后MyBatis会打印每个Mapper方法的完整SQL、参数和返回值。但上线前一定要把级别调回info否则高并发下日志本身就是性能瓶颈。另外如果你用了mybatis-config.xml自定义配置注意configuration标签和Spring Boot的mybatis.configuration配置项不要重复设置否则会互相覆盖排查起来非常痛苦。3. MySQL表结构与数据层的几个关键设计3.1 核心表设计用户、帖子、评论、互动BB平台的核心业务表至少有这几张user用户表、post帖子表、comment评论表、category分类表以及点赞、收藏这类互动关系表。我的设计原则是尽量符合第三范式但允许冗余高频查询字段。以post表为例CREATE TABLE post ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 发帖人, category_id BIGINT DEFAULT NULL COMMENT 分类ID, title VARCHAR(200) NOT NULL COMMENT 标题, content LONGTEXT NOT NULL COMMENT 正文, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态0草稿 1发布 2删除, view_count INT NOT NULL DEFAULT 0 COMMENT 浏览量, like_count INT NOT NULL DEFAULT 0 COMMENT 点赞数, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_category_status (category_id, status), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci COMMENT帖子表;这里有两个设计上的思考。第一status字段用TINYINT而不是VARCHAR是因为状态值在代码里用枚举管理更可靠数据库层面只存数字避免脏数据。第二索引设计要结合查询场景——列表页常见查询是按分类查已发布帖子并按时间倒序所以建了(category_id, status)联合索引再配合create_time排序。但create_time的索引排序在数据量大时并不一定有效后续可以优化为按id倒序因为InnoDB的主键本身就是自增且和创建时间强相关。3.2 字符集与排序规则utf8mb4和排序问题MySQL这边第一个容易翻车的坑是字符集。很多人建库时图省事直接建utf8结果存用户昵称里的emoji表情时报错Incorrect string value。原因很简单MySQL的utf8字符集最多支持3个字节而emoji是4个字节必须要用utf8mb4。这不是什么高深问题但遇到就是一下午。CREATE DATABASE bb_platform DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;再说排序规则。utf8mb4_unicode_ci和utf8mb4_general_ci的区别在于排序比较算法unicode_ci基于Unicode标准排序算法对多语言支持更全面general_ci是MySQL早期实现排序速度快一些但准确度略差。做中文内容平台我推荐utf8mb4_unicode_ci特别是做帖子标题搜索排序时中文字符的排序结果更符合直觉。但即便是unicode_ci中文排序在某些场景下仍然不是按拼音排的。如果产品非要按拼音首字母排用户昵称最常见靠谱的方案是用CONVERT(name USING gbk)来辅助排序SELECT * FROM user ORDER BY CONVERT(nickname USING gbk) ASC;GBK编码的字节序恰好和拼音顺序一致这是一种经典的取巧方式。当然如果数据量大这种做法会很慢更合理的方案是在用户表冗余一个拼音首字母字段写入时生成好查询直接排序。3.3 连接串与SSLmysql ssl连接错误排查开发环境连接MySQL报SSL错误应该是Java后端新手最常碰到的问题之一。报错信息一般是SSL connection error: SSL certificate problem或者The server time zone value Öйú±ê׼ʱ¼ä is unrecognized。其实这两类问题根源都在连接串参数上。我现在的统一做法是开发环境的JDBC连接串直接写成这样jdbc:mysql://localhost:3306/bb_platform?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue逐项解释一下useSSLfalse开发环境本地连接不需要加密如果你本机MySQL没配SSL证书强制加密必然报错serverTimezoneAsia/Shanghai解决时区问题allowPublicKeyRetrievaltrue解决MySQL 8.0以上用caching_sha2_password认证时可能出现的Public Key Retrieval is not allowed错误。生产环境如果数据链路需要加密再单独配置SSL证书而不是开着useSSLtrue却没有任何证书配置——那样一定连接失败。4. Vue前端环境安装、路由守卫和动态路由4.1 Vue环境安装与工程初始化Vue这边的第一步就有些人会卡住。前置依赖是Node.js和npm建议直接装Node 16以上的LTS版本。然后npm install -g vue/cli安装脚手架工具。不过现在官方更推荐用Vite创建Vue 3项目推荐的方式是npm create vitelatest bb-frontend -- --template vue或者用Vue CLIvue create bb-frontend我这次用的是Vue 3加Vite的组合相比Vue CLI的Webpack方案Vite的冷启动速度和热更新体验好太多开发时几乎是秒开。多说一句如果你下载依赖的速度很慢把npm镜像源切到国内镜像npm config set registry https://registry.npmmirror.com安装完基础依赖后还需要装Vue Router和状态管理库npm install vue-router4 pinia4.2 路由守卫与动态权限路由BB平台的前端路由有一个核心需求不同角色的用户看到不同的菜单和页面普通用户不能进入管理员后台。这就有两种实现方式前端硬编码所有的路由然后靠权限判断控制显隐或者后端返回路由数据前端动态注册。我采用的是后者——动态路由方案配合路由守卫做访问控制。首先在router/index.js里只定义公共路由const router createRouter({ history: createWebHashHistory(), routes: [ { path: /login, component: Login }, { path: /, component: Layout, children: [] } ] })登录成功后前端调用/user/routes接口拿到该用户有权限的菜单和路由信息然后用router.addRoute动态注册// 动态添加路由 function addDynamicRoutes(menuList) { menuList.forEach(menu { const route { path: menu.path, name: menu.name, component: loadView(menu.component), meta: { title: menu.title, icon: menu.icon } } router.addRoute(Layout, route) }) }路由守卫负责拦访问题router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token to.path ! /login) { next(/login) } else if (token to.path /login) { next(/) } else { next() } })这里有一个非常容易踩的坑动态添加的路由刷新页面后会丢失。刷新时Vue重新执行main.jsrouter恢复成初始状态动态路由没了如果用户当前正在一个动态路由页面刷新后就会白屏。解决办法是在路由守卫里判断当前路由是否匹配如果不匹配就重新请求路由数据再next({ ...to, replace: true })。这个逻辑必须在守卫里同步处理否则会出现循环跳转。4.3 视频模块m3u8播放的免安装方案BB平台如果要做视频板块最常见的格式就是HLSHTTP Live Streaming协议的.m3u8文件。传统做法是引入video.js加videojs-contrib-hls插件但这类库比较大且配置繁琐。更轻量的方式是直接用hls.js库几行代码搞定播放。npm install hls.jstemplate video idplayer controls autoplay/video /template script setup import Hls from hls.js import { onMounted } from vue onMounted(() { const video document.getElementById(player) if (Hls.isSupported()) { const hls new Hls() hls.loadSource(/video/stream.m3u8) hls.attachMedia(video) } }) /script这个方案的好处是HLS的兼容性问题交给hls.js处理Safari浏览器原生支持HLS直接播放其他浏览器走hls.js的MSEMedia Source Extensions转码播放用户完全无感知也不需要安装任何插件。如果你的视频是加密的HLS还能用hls.js的decryption相关配置对接鉴权信息这里先不展开。5. 前后端联调与生产部署从跨域到Vue打包进SpringBoot5.1 开发环境的代理与跨域处理前后端分离开发时最烦的问题就是跨域。前端的axios请求后端接口端口不同Vite默认5173SpringBoot默认8080按浏览器同源策略必然跨域。开发阶段我推荐用Vite的代理来解决而不是在后端开启CORS。原因很简单后端开CORS相当于对所有来源放开接口访问虽然有配置项可以限制但总归是增加暴露面开发环境能省则省。// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } })这样前端请求/api/user/loginVite会代理到http://localhost:8080/user/login浏览器看到的还是同源请求不存在跨域问题。如果某些场景必须跨域调用比如第三方对接再在后端配CORS也不迟Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowCredentials(true) .maxAge(3600); } }5.2 生产环境Vue构建产物如何放进SpringBoot生产部署有两种思路。第一种是前后端完全分离部署前端静态文件扔Nginx后端跑Java通过Nginx反向代理/api到后端。第二种是把npm run build出来的静态文件直接复制到SpringBoot的src/main/resources/static目录下由一个应用提供所有服务。BB平台这种中小体量系统第二种其实更省事——只需要部署一个Jar包不用单独维护Nginx。直接复制文件的做法很简单npm run build后把dist目录里的index.html和assets等内容复制到static下。但有一个很关键的坑前端路由用的如果是HTML5 History模式后端必须配置路径转发否则刷新页面就会404。比如你直接访问/post/123SpringBoot找不到对应的Controller返回404。解决办法有两个。一是前端改用createWebHashHistory()URL变成/#/post/123刷新永远不会404因为#后面的内容不会被发到服务器。很多管理后台都这么干简单省事。二是保持History模式然后在SpringBoot里加一个路由转发配置把非/api开头的路径全部转发到index.htmlConfiguration public class WebMvcConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController(/{path:[^\\.]*}).setViewName(forward:/index.html); } }我这次实际用的是History模式加转发配置因为URL更干净也方便将来如果拆出独立的Nginx层时无缝切换。两种方案没有绝对优劣关键是你得知道有这两个坑。5.3 部署过程中的资源路径和接口前缀问题还有一个部署时容易踩的坑是Vite的base配置。默认情况下npm run build生成的静态资源路径是/assets/xxx.js如果SpringBoot的server.servlet.context-path不为空比如你设置了server.servlet.context-path/bb那么页面加载/assets/xxx.js就会404。因为/assets在context/bb之外。解决办法是在vite.config.js里设置base: /bb/或者干脆不要设置context-path让应用跑在根路径。如果项目规模不大我建议不设置context-path减少一堆路径拼接的麻烦。6. 值得扩展的实战能力MinIO文件存储与消息异步6.1 SpringBoot集成MinIO做文件服务BB平台的用户头像、帖子图片、视频文件这些总不能都存到数据库里。最开始我图省事把文件存到本地磁盘用/upload/**映射静态资源但这样有个问题——文件和应用耦合在同一个磁盘上将来部署多实例或者迁移服务器时文件还得手动搬。后来我引入了MinIO做对象存储。MinIO是开源的对象存储服务兼容Amazon S3接口部署超级简单一条Docker命令就起一个服务docker run -p 9000:9000 -p 9001:9001 \ -e MINIO_ROOT_USERminioadmin \ -e MINIO_ROOT_PASSWORDminioadmin \ minio/minio server /data --console-address :9001SpringBoot这边引入依赖dependency groupIdio.minio/groupId artifactIdminio/artifactId version8.5.7/version /dependency封装一个MinioService核心操作就三件事创建Bucket、上传文件、生成访问链接。Service public class MinioService { Value(${minio.endpoint}) private String endpoint; Value(${minio.access-key}) private String accessKey; Value(${minio.secret-key}) private String secretKey; private MinioClient client; PostConstruct public void init() { client MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); } public String upload(MultipartFile file, String bucketName) throws Exception { boolean exists client.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build()); if (!exists) { client.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build()); } String fileName UUID.randomUUID() - file.getOriginalFilename(); client.putObject(PutObjectArgs.builder() .bucket(bucketName) .object(fileName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return fileName; } }这里有一个安全方面的建议上传文件接口一定要做类型和大小校验不能信前端传来的文件名。图片只允许jpg/jpeg/png/webp大小限制在2MB以内否则别人传一个带脚本的文件到对象存储上就是给自己埋雷。6.2 缓存策略与异步处理的扩展思路做内容平台读多写少是常态。帖子列表页被频繁访问每次都查MySQL、再做MyBatis映射数据库压力很大。我引入Spring Cache做了一层应用级缓存核心配置是一个Redis作为缓存存储的CacheManager。Configuration public class RedisCacheConfig { Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); return RedisCacheManager.builder(factory) .cacheDefaults(config) .build(); } }然后在查询帖子详情的Service方法上加Cacheable(cacheNames postDetail, key #id)帖子被更新时调用CacheEvict清掉对应缓存。这套方案对BB平台来说性价比极高不需要引入复杂的分布式缓存设计几行注解就能把热点查询挡在缓存层。另外一个实用扩展是消息异步化。比如用户发帖成功后要通知所有关注者这个操作如果同步执行发帖接口的响应会变慢。我的实践是引入Spring自带的Async注解配合线程池来处理这类非核心操作先不急着上ActiveMQ或RocketMQ这种重量级消息中间件。你想系统用户量没上来就上MQ运维成本和复杂度都在涨收益却很小。等业务真的需要削峰填谷、需要跨系统解耦了再引入消息队列也不迟——到时候把Async替换成消息发送代码就行改动量也不大。6.3 大数据量下的性能优化方向如果你的BB平台真的做起来了帖子表数据量过百万有几个方向需要提前规划。第一是分页查询的性能深翻页问题OFFSET很大会越来越明显经典的优化方案是书签分页用上一次查询的最后一条ID作为下一次查询的起点-- 不推荐 SELECT * FROM post ORDER BY id DESC LIMIT 100000, 20; -- 推荐 SELECT * FROM post WHERE id #{lastId} ORDER BY id DESC LIMIT 20;第二是热帖统计view_count这种高频更新的字段每次都直接UPDATE会带来行锁竞争。常用的优化是先用Redis的INCR累加定时批量同步回MySQL。第三是全文搜索LIKE %keyword%在数据量大时即使有索引也不肯走最多支持左前缀匹配。真到了这个阶段就该引入Elasticsearch了不过这是一套单独的玩法这里就不展开了。另外一个SpringBoot版本相关的提醒如果你用的是SpringBoot 3.x要注意MyBatis的mybatis-spring-boot-starter要用2.3.1以上版本否则兼容性会有问题。SpringBoot 3.x把javax包换成jakarta很多老代码里的import javax.annotation.PostConstruct要批量替换成jakarta.annotation.PostConstruct。这是很多人在升级版本时踩得最狠的坑之一编译不报错但启动时报NoClassDefFoundError。7. 做这套BB平台系统我踩过的坑和最终建议项目做完回看最值得说的其实不是某个技术点本身而是我在这过程中的几个顿悟时刻。第一不要过度设计。最开始我甚至想过引入微服务架构把用户、帖子、评论拆成三个服务最后冷静下来发现单体应用完全够用反而因为少了很多网络开销开发和调试都更快。像BB平台这种刚起步的内容系统业务变化快单体架构的灵活性反而是优势。第二配置和代码要分开。数据库连接、MinIO地址、缓存过期时间这些配置一定要外置到application.yml里不要硬编码到代码中。发布测试环境、生产环境总共只需要三个配置文件和一套打包命令用--spring.profiles.activeprod切换省心省力。第三开发阶段一定要把日志打好。我自己的习惯是Controller入口打INFO日志Service层关键操作打INFO异常统一由全局异常处理器捕获后打ERROR日志并返回统一格式的错误信息。日志不只是给自己看的将来线上问题排查全靠它。特别是Java的堆栈信息太长了不全打出来根本没法定位问题。最后说一句实在话源码拿到手只是开始自己跟着敲一遍才是真正学到东西。这套SpringBootVueMyBatisMySQL的组合你完整跑通一遍前后端联调的思路、数据库设计的感觉、部署上线的流程就全都有了。很多人买了源码看一眼就丢一边过几个月面试问到还是答不上来其实不是源码质量不行是缺乏动手的过程。我个人实操后的建议是先跑通Demo再挑一个你认为最薄弱的地方重写一遍——比如自己实现一遍动态路由或者自己写一遍文件上传。重写的过程就是你真正理解的过程。这套系统后续还能加很多功能比如私信、关注、消息通知、数据统计大屏它完全可以当做一个内容平台的地基来用。
返回列表