先聊点实在的。Spring Boot 的增删改查可以说是每一个 Java 后端开发绕不开的“基本功”。很多朋友在 B 站、博客上看了大量的视频和文章真到自己动手用 IDEA 2024 新建一个项目时却总是卡在第一步版本选不对、依赖拉不下来、好不容易跑起来又报数据库连接错误。这篇文章我不打算给你复制粘贴一段官方文档而是基于我自己的实际踩坑经历手把手带你把 IDEA 2024 Spring Boot 3.x 的 CRUD 从零到一完整跑通。不管你是刚学完 Java 基础准备进阶的后端新人还是需要在内部项目里快速搭一个管理后台的“全干工程师”这套流程都适用。我尽量写得直白每一步都告诉你为什么这么做以及我曾经在这里摔过的跟头。1. 项目初始化与工具准备要点1.1 IDEA 2024 与 JDK 版本的选择逻辑工欲善其事必先利其器。我用的是 IDEA 2024.1 旗舰版社区版其实也能干这活儿只要你愿意手动引入 Spring Boot 的依赖。但既然谈“高效”旗舰版内置的 Spring Initializr 向导能帮你省掉不少事。这里先敲一个重点Spring Boot 3.x 强制要求 JDK 17 及以上。我见过不少朋友还在用 JDK 8然后跑去创建 Spring Boot 3.2 的项目结果连项目都编译不过。如果你未来想用那些新语法比如 record、sealed class、文本块JDK 17 是底线。IDEA 2024 对 JDK 17 的支持非常丝滑自动补全和热部署都比老版本舒服很多。我本机装的是 JDK 17具体小版本号不重要LTS 版本够用就行。下载完 JDK 后记得在 IDEA 的 Project Structure 里把 Project SDK 指到对应路径千万别图省事直接用它默认的”。有时候 IDEA 识别不到你新装的 JDK你就手动点 “Add SDK” 选到 JDK 安装目录即可。注意IDEA 2024 默认会把你新建项目的编译级别跟 SDK 绑定但你最好再检查一下 Settings - Build Tools - Compiler - Java Compiler 里的 Target bytecode version 是否也对应 17。这里经常出现“SDK 是 17编译目标却是 1.8”的诡异问题查半天才发现是配置没同步。1.2 通过 Spring Initializr 快速生成基础骨架打开 IDEA选择 New Project左侧列表直接选 “Spring Initializr”。这一步的关键是选择 Server URL。默认的 start.spring.io 在国内网络环境下时好时坏我个人的经验是如果连不上或者下载超时就把 URL 换成阿里云的镜像地址https://start.aliyun.com。虽然它的初始化模板版本偶尔会滞后小半拍但胜在稳定生成完你再去 pom.xml 里把版本号改成自己想要的即可。接下来填项目坐标Group、Artifact建议 Group 用公司或个人的域名倒写Artifact 用项目名比如springboot-crud-demo。Type 选 MavenPackaging 选 JarJava 版本选 17。到这里先别急着一路点“Finish”右侧的 Dependencies 区域要提前勾选依赖Spring Web提供 RestController、Tomcat 等没有它你连接口都暴露不出去。Spring Data JPA或MyBatis二选一。如果你定位是快速开发后台管理系统MyBatis-Plus 会顺手很多如果只是学习 ORM 思想JPA 会更“正统”。我下面会以 MyBatis-Plus 为例展开因为这个库真正用起来代码量最少。MySQL Driver连接 MySQL 的必要驱动Java 17 对应的驱动版本最好用 8.0.33 以上。Lombok简化实体类 getter/setter 的样板代码。勾选完成后填好项目名点击 Finish。如果你是首次创建 Spring Boot 项目IDEA 会花几分钟下载 Maven 依赖这个过程需要耐心。实操心得阿里云镜像源生成的初始项目里pom.xml 有时会带着一个 version 占位符拉下来后记得检查一下有没有 ${revision} 这类参数。另外Maven 的 settings.xml 里要用阿里云镜像就统一用别混用否则会出现“依赖下载一半失败导致本地仓库脏数据”的闹心事那时候就 clean 也没用得手动去本地仓库删掉 .lastUpdated 后缀文件。1.3 配置 application.yml 的数据库连接项含时区避坑初始项目默认给你的是application.properties我通常直接改成application.yml缩进结构看起来更清晰。最核心的数据源配置如下server: port: 8080 spring: application: name: springboot-crud-demo datasource: url: jdbc:mysql://localhost:3306/crud_demo?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 你自己的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto这里有两个让我记忆深刻的坑。第一个是时区问题。如果 URL 里不加上serverTimezoneAsia/Shanghai运行时插入带时间的字段数据库里的值会比本地时间少了 8 小时。useSSLfalse很关键因为本地开发环境根本没有 SSL 证书不关掉会直接在控制台持续刷红字警告。第二个是驱动类名。老项目里常见的是com.mysql.jdbc.Driver但 MySQL 8.0 必须写成com.mysql.cj.jdbc.Driver。一旦写错启动时会有提示但报错日志藏在一大堆 INFO 里不太容易被发现。 提示如果你用的是 Spring Boot 3.2数据源这块的配置项基本没变。但如果你编程式地写 Bean 配置 DataSource记得确认连接池 HikariCP 的 maximum-pool-size 默认值 10 是否满足你的压测需求反正开发阶段不用管。2. 数据库表设计与分层架构拆解2.1 从“用户管理”场景出发设计数据表CRUD 听起来简单但如果只是对着一张只有主键的表做增删改查那其实学不到什么。我这里以一个典型的“用户信息管理”作为模拟项目 X 的场景。这个需求足够常见后续你套到员工管理、商品管理里思路完全一致。我们要建的表叫sys_user字段设计如下字段名类型说明idbigint/自增主键usernamevarchar(50)登录名唯一索引nicknamevarchar(50)显示昵称emailvarchar(100)邮箱可空phonevarchar(20)手机号可空statustinyint1有效 0禁用create_timedatetime创建时间update_timedatetime最后更新时间建表 SQL 我这里直接贴一份可执行的CREATE TABLE sys_user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, nickname varchar(50) DEFAULT NULL, email varchar(100) DEFAULT NULL, phone varchar(20) DEFAULT NULL, status tinyint DEFAULT 1, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;utf8mb4 而不是 utf8是为了兼容 Emoji 字符以及特殊生僻字。这条属于基础共识不用多解释。2.2 三层架构与“责任边界”的思考我把后端分成三层Controller 层、Service 层、Mapper 层。这是最常用的分层法责任边界清晰Controller 层的作用是承接 HTTP 请求做参数初步校验然后把参数打包交给 Service最后把 Service 返回的数据转成 JSON 响应给前端。它不应该写任何 SQL 或业务判断。Service 层负责真正的业务逻辑像是用户名是否重复、状态能否禁用这类业务规则都封装在这里。Mapper 层负责跟数据库打交道它最理想的状态是一个接口文件外加一个 XML 文件或者像 MyBatis-Plus 这样直接调用封装好的方法。我见过很多新人把业务判断塞进 Controller 里最后导致 Controller 类动辄几百行测试也不好写。分层不是为了炫技是为了以后需求变更时你不需要把整个项目翻个底朝天。2.3 实体类与 MyBatis-Plus 的字段映射坑数据库表建好了接下来在项目中创建实体类。我用 Lombok 注解简化代码Data TableName(sys_user) public class SysUser { TableId(type IdType.AUTO) private Long id; private String username; private String nickname; private String email; private String phone; private Integer status; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }这里有几个注解需要掰扯清楚TableName如果实体类名跟表名不一致比如类叫SysUser表叫t_user必须用这个注解指定不然 MyBatis-Plus 会按驼峰转下划线的规则去找表。TableId设置主键策略。IdType.AUTO就是数据库自增。如果你用了ASSIGN_ID那还得保证有雪花算法生成器反而多了一层配置。TableField(fill FieldFill.INSERT)配合自定义 MetaObjectHandler 可以自动填充创建时间。如果你不想搞这么复杂直接在插入时手动setCreateTime(LocalDateTime.now())也行。但既然用了 MyBatis-Plus自动填充是高效做法。 注意在改字段名时千万别忽略一个老坑——实体类里用了 isDeleted 这种 boolean 字段MyBatis-Plus 的默认逻辑删除是找 deleted 字段且类型要是 Integer。名字对不上你的“删除”就会变成“真删”到时候找谁说理去3. Controller、Service 与 DTO 的接口规范设计3.1 RESTful API 的统一返回格式设计前后端分离模式下接口返回的数据结构必须一致。我最常见的是下面这种Result类Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String msg) { ResultT result new Result(); result.setCode(500); result.setMessage(msg); return result; } }这样设计保证了前端拿到 JSON 后无论请求哪个接口都可以用一个统一类型来解析。code表示业务状态码message表示提示信息data才是真正要展示的数据体。3.2 编写 Mapper 接口与 Service 实现Mapper 接口极其简单public interface SysUserMapper extends BaseMapperSysUser { }没错就这一行。MyBatis-Plus 的BaseMapper已经帮我们内置了insert、deleteById、updateById、selectById、selectPage这些最常用的方法。你不需要再去写insert、update标签了。Service 层建议先定义接口再写实现public interface SysUserService extends IServiceSysUser { PageSysUser pageQuery(Integer pageNum, Integer pageSize, SysUser query); }然后是实现类Service public class SysUserServiceImpl extends ServiceImplSysUserMapper, SysUser implements SysUserService { Override public PageSysUser pageQuery(Integer pageNum, Integer pageSize, SysUser query) { LambdaQueryWrapperSysUser wrapper new LambdaQueryWrapper(); if (StrUtil.isNotBlank(query.getUsername())) { wrapper.like(SysUser::getUsername, query.getUsername()); } if (query.getStatus() ! null) { wrapper.eq(SysUser::getStatus, query.getStatus()); } wrapper.orderByDesc(SysUser::getId); return this.page(new Page(pageNum, pageSize), wrapper); } }LambdaQueryWrapper比老的QueryWrapper更安全因为它是基于方法引用而不是字符串硬编码编译期就能发现字段名拼写错误。这个是我极力推荐养成的编码习惯。3.3 Controller 层的接口命名与参数校验Controller 提供五个标准方法新增、删除、修改、详情查询、分页列表。我直接贴核心代码RestController RequestMapping(/api/user) public class SysUserController { Resource private SysUserService sysUserService; PostMapping public ResultBoolean add(RequestBody Valid SysUser user) { return Result.success(sysUserService.save(user)); } DeleteMapping(/{id}) public ResultBoolean delete(PathVariable Long id) { return Result.success(sysUserService.removeById(id)); } PutMapping public ResultBoolean update(RequestBody SysUser user) { return Result.success(sysUserService.updateById(user)); } GetMapping(/{id}) public ResultSysUser detail(PathVariable Long id) { return Result.success(sysUserService.getById(id)); } GetMapping(/page) public ResultPageSysUser page(RequestParam Integer pageNum, RequestParam Integer pageSize, SysUser query) { return Result.success(sysUserService.pageQuery(pageNum, pageSize, query)); } }注意 REST 风格新增用 POST、删除用 DELETE、修改用 PUT、查询用 GET。路径设计尽量简洁不要出现/addUser、/deleteUserById这种动词式接口规范起来后前端对接也会很舒服。 实操心得这里有个看起来很隐蔽的坑修改接口如果用 PUT前端有时候会用 POST 提交 JSON导致后端 RequestBody 解析失败或路由 405。你自己写测试时就把它统一了但跟前端联调时一定要在接口文档里写清楚。我以前就因为“前端把 PUT 发成了 POST”排查了老半天一度怀疑是自己框架配置出了毛病。4. 分页查询与条件检索的改造升级4.1 MyBatis-Plus 分页插件的配置如果你直接在前面代码里调用this.page()在 MyBatis-Plus 3.x 的某些版本里分页是不会生效的。因为你需要先注册一个PaginationInnerInterceptorConfiguration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这里的原理是MyBatis-Plus 通过拦截器在 SQL 执行前拦截掉原来的查询语句自动拼接LIMIT ? , ?。你没配这个拦截器分页结果只会返回全量数据然后它自己在内存里“假分页”给你看。数据量少时看不出问题一旦表里数据破万内存压力就上来了。4.2 利用 LambdaQueryWrapper 实现多条件动态查询动态查询是管理后台最头痛也最出彩的一环。用户可能只输入了用户名也可能只选了状态还可能是两个条件同时存在。如果为每个可能性都写一条 SQL代码会爆炸。上面的pageQuery写法已经展示了动态拼接的妙处if判断条件是否为空空就不拼接where条件MyBatis-Plus 自动帮我们动态生成 SQL。这比手工拼接字符串靠谱得多不用考虑 SQL 注入问题。参数query作为查询对象它会从前端 GET 请求的 query string 里自动绑定。例如前端请求/api/user/page?pageNum1pageSize10usernameabcstatus1后端SysUser对应字段就会自动被填充。4.3 分页结果的返回细节分页接口返回的数据结构直接用了 MyBatis-Plus 的Page对象JSON 序列化时会包含records、total、size、current等字段。很多前端框架默认要的就是这些字段名比如 Element UI 的 Table 组件。如果你不想暴露records这个字段名有些前端团队会统一包装成list可以用Page对象转 Map 的方式重新组装。不过一般情况下直接返回Page对象就够用没必要过度设计。 小技巧条件查询里关于时间的范围筛选如果你在 SysUser 里加了 createTimeBegin 和 createTimeEnd 两个额外字段记得加 TableField(exist false) 注解否则 MyBatis-Plus 会把它当成真实表字段生成 SQL 时直接报“字段不存在”。5. 实际联调过程中的常见报错与排查实录5.1 启动时报 “Field sysUserMapper in ... required a bean of type ...”这个报错是我被问过最多的问题之一。根因基本是 Mapper 接口没有被 Spring 容器扫描到。解决办法在启动类上加上MapperScan(com.example.demo.mapper)或者你在每个 Mapper 接口上加Mapper注解。我推荐用MapperScan它是批量注册写一次就完事了不用每个接口都加注解。5.2 插入数据后主键 ID 没有自动回填如果你在测试时发现调用insert后拿到的实体对象的id是null大概率是TableId策略没配。检查IdType.AUTO是否设置以及数据库表主键是否真的是自增。如果表的主键不是自增而是应用层生成那你得改成IdType.ASSIGN_ID或手动 set。5.3 时区相关诡异问题数据库时间比本地早 8 小时这个问题我前面提过一次但在联调阶段会再次出现。排查方法有两个方向JDBC 连接串里检查serverTimezone确认是Asia/Shanghai。MySQL 数据库时区本身查看命令SHOW VARIABLES LIKE %time_zone%;如果数据库是容器化部署的有些镜像默认时区是 UTC建议启动时挂载-e TZAsia/Shanghai。5.4 接口能通但数据返回为空日志却不报错这是典型的“查询条件没拼对”或者“逻辑删除字段没配上”。如果是逻辑删除字段你查询时它自动拼上deleted0但你表里没有这个字段就查询不到数据。解决方案是调整实体字段与表字段完全对齐。5.5 依赖冲突NoClassDefFoundError 与版本管理Spring Boot 3.x 里如果你引用了旧版的 MyBatis-Plus例如 3.4 之前的版本启动时大概率报 NoClassDefFoundError。这是因为 Spring Boot 3 把底层从 javax 迁移到了 jakarta旧版库不兼容。解决办法是统一到我上面推荐的mybatis-plus-spring-boot3-starter最新版本任何 xml 配置依赖的包都要一起升级。 避坑指南遇到这类依赖问题最快的排查方式是在 IDEA 的 Maven 面板右键点击 Show Diagram 查看依赖树看有没有重复引入不同版本的包。你不想一个个手动翻 pom 文件的话这个可视化面板是很趁手的工具。6. 我的实战体会与日常开发补充建议写到这里回到这套 CRUD 本身的一些细节再唠叨几句。首先很多人觉得 CRUD 太简单不值得写文章。但恰恰是 CRUD 的编码规范性决定了一个项目后续的维护成本。从我经手的几个项目来看越是底层的 Controller 和 Service 写得乱后面加需求就越痛苦。因为所谓业务逻辑八成都是散落在各种 CRUD 之上的条件分支、状态流转和数据校验。其次关于测试习惯。我建议你每写完一个接口先不急着用 Postman 测而是先在浏览器里直接访问最简单的 GET 接口比如分页列表确认项目在正常运行再去测 POST、PUT、DELETE。这样能快速区分是“接口写错了”还是“网络/端口问题”。最后想分享一下自我检查的经验。每次调整数据库表结构我都会习惯性去实体类、Service 层和前端那侧的类型定义里进行“三方对齐”。数据库字段类型是 bigintJava 这边别用 int前端定义序列化时也别当成 number超过安全整数范围的数值类型会丢精度。这些细节是平时不容易被测试出来的但一旦出现处理起来费神费力。如果你按着上文一路操作下来记忆最深的应该不是哪个代码片段而是“先理清表结构再想清楚接口边界最后落代码”这个顺序。编码本身是水到渠成的事情而思路和排查问题的路径才是真正拉开效率差距的地方。
阅读完成 · 觉得有帮助?