☰
SpringBoot增删改查实例Demo:从建表到接口测试的完整CRUD脚手架
2026/10/13 3:31:40 网站建设 项目流程

简介:本资源是一份面向Java初学者与SpringBoot入门开发者的增删改查实例Demo,聚焦于使用SpringBoot框架与MySQL数据库交互,解决数据表自动生成及CRUD操作的学习需求。压缩包共103个文件,以79个xml配置文件、8个java源码、8个class编译文件、4个yml配置及properties、jar等为主,涵盖项目依赖、实体类、控制器、服务层与仓库接口等模块,整体约92KB,结构紧凑便于快速导入运行。目前已有8680人学习下载,适合需要动手实践Spring Data JPA的开发者参考。实例中完整演示了通过JpaRepository的save()方法实现新增与更新、findAll()与findById()完成查询、deleteById()执行删除,并对比原生SQL与JdbcTemplate操作方式,同时涉及@Transactional声明式事务管理与ddl-auto自动建表配置,可帮助读者理解SpringBoot项目分层结构与数据库交互流程,为后续构建RESTful接口或微服务应用打下基础。

1. 从一份 SpringBoot 增删改查实例 Demo 说起:它到底能帮你省掉哪些重复劳动

如果你写过几个 SpringBoot 项目,大概率会发现一个规律:不管业务怎么变,底层那套增删改查的骨架几乎一模一样。Controller 接参数、Service 转逻辑、Mapper 落库、实体类映射字段,来来回回就是这几层。真正让人头疼的不是写不出来,而是每次新建一个模块都要重新搭一遍,稍不留神就漏了分页、忘了事务、参数校验没加、返回格式不统一。这份 SpringBoot 增删改查实例 Demo 解决的正是这个问题——它把一套完整的 CRUD 流程从建表到接口测试全部串起来,包括实体定义、Mapper 映射、Service 事务控制、Controller 参数接收与统一响应封装。适合刚接触 SpringBoot 的开发者照着跑通第一遍,也适合有经验的工程师拿来当脚手架模板,改改包名和表名就能直接用在模拟项目里。下面我按实际拆包的顺序,把这份资源的结构、跑法、参数配置和容易翻车的地方逐一讲清楚。

2. 拆开这份 Demo:分层结构、依赖版本与建表脚本

2.1 工程目录与分层逻辑

拿到一份 SpringBoot 增删改查实例,第一件事不是急着跑,而是先看它的包结构。常见做法是按controller、service、service.impl、mapper、entity、common这几个包来分。这份 Demo 也不例外,整体遵循典型的 MVC 三层架构,但有几个细节值得注意。

entity包下放的是与数据库表一一对应的实体类,字段名和表字段通常用驼峰与下划线做映射。mapper包下是 MyBatis 的接口,方法名对应 SQL 的语义,比如selectById、insert、updateById、deleteById。service层负责业务组合和事务边界,controller层只做参数接收和结果返回,不写业务逻辑。common包里一般会有统一返回体Result、分页对象PageResult、全局异常处理GlobalExceptionHandler这几样东西。

我一般会先确认一件事:这个 Demo 用的是 MyBatis 还是 MyBatis-Plus。两者在增删改查的写法上差别很大。如果是原生 MyBatis,XML 文件里会写完整的 SQL;如果是 MyBatis-Plus,很多基础方法直接继承BaseMapper就有了,不需要手写。从这份实例的定位来看,它更偏向原生 MyBatis 的写法,因为这样更能看清 SQL 映射的完整链路,对新手理解底层更有帮助。

2.2 依赖版本与关键配置

打开pom.xml,核心依赖通常包括这几项:

<!-- SpringBoot Web 起步依赖,提供内嵌 Tomcat 和 MVC 支持 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis 与 SpringBoot 整合包 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok,用来省掉 getter/setter --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

这里有几个参数需要留意。mybatis-spring-boot-starter的版本要和 SpringBoot 版本匹配,如果 SpringBoot 用的是 3.x,MyBatis starter 也要用对应的 3.x 版本,否则启动时会报NoSuchMethodError。MySQL 驱动在 8.x 之后包名从mysql-connector-java变成了mysql-connector-j,这个细节很多人会忽略,导致依赖拉不下来。

application.yml里的配置同样关键:

spring: datasource: url: jdbc:mysql://localhost:3306/demo_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true

map-underscore-to-camel-case这个参数建议打开,它能让数据库的user_name自动映射到实体类的userName,省掉大量手动映射。serverTimezone必须指定,否则 MySQL 8 会报时区错误。mapper-locations的路径要和实际 XML 文件存放位置一致,放在resources/mapper下是最常见的做法。

2.3 建表脚本与初始化数据

一份能跑通的增删改查 Demo,必然附带建表 SQL。通常是一张用户表或商品表,字段不会太多,但覆盖了自增主键、字符串、数字、时间这几类常见类型。

CREATE TABLE `t_user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `user_name` varchar(64) NOT NULL COMMENT '用户名', `age` int DEFAULT NULL COMMENT '年龄', `email` varchar(128) DEFAULT NULL COMMENT '邮箱', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; INSERT INTO `t_user` (`user_name`, `age`, `email`) VALUES ('张三', 25, 'zhangsan@example.com'), ('李四', 30, 'lisi@example.com');

建表时用utf8mb4而不是utf8,这是血泪经验——后者在存储某些特殊字符时会出问题。create_time和update_time交给数据库自动维护,比在代码里手动 set 更可靠。初始化两条数据是为了后面测试查询和更新时有现成的记录可用。

3. 跑通增删改查:从 Mapper 到 Controller 的完整链路

3.1 实体类与 Mapper 接口定义

实体类用 Lombok 的@Data注解省掉样板代码,字段与表字段通过驼峰映射对应。

@Data public class User { private Long id; private String userName; private Integer age; private String email; private Date createTime; private Date updateTime; }

Mapper 接口定义四个基础方法,对应增删改查:

@Mapper public interface UserMapper { // 根据 ID 查询 User selectById(Long id); // 查询全部 List<User> selectAll(); // 新增 int insert(User user); // 更新 int updateById(User user); // 删除 int deleteById(Long id); }

@Mapper注解让 MyBatis 在启动时扫描到这个接口并生成代理实现。如果不想在每个接口上都加,可以在启动类上用@MapperScan("com.example.demo.mapper")统一指定。

对应的 XML 映射文件放在resources/mapper/UserMapper.xml:

<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.demo.mapper.UserMapper"> <select id="selectById" resultType="User"> SELECT * FROM t_user WHERE id = #{id} </select> <select id="selectAll" resultType="User"> SELECT * FROM t_user ORDER BY id DESC </select> <insert id="insert" useGeneratedKeys="true" keyProperty="id"> INSERT INTO t_user (user_name, age, email) VALUES (#{userName}, #{age}, #{email}) </insert> <update id="updateById"> UPDATE t_user <set> <if test="userName != null">user_name = #{userName},</if> <if test="age != null">age = #{age},</if> <if test="email != null">email = #{email},</if> </set> WHERE id = #{id} </update> <delete id="deleteById"> DELETE FROM t_user WHERE id = #{id} </delete> </mapper>

useGeneratedKeys="true"和keyProperty="id"配合使用,插入后能把数据库生成的自增 ID 回填到实体对象里,后面如果需要用这个 ID 就不用再查一次。<set>标签配合<if>做动态更新,只更新非空字段,这是实际项目里最常用的写法。#{}是预编译占位符,能防止 SQL 注入,不要用${}拼接字符串。

3.2 Service 层的事务控制

Service 层是事务的边界。增删改查里,单条 insert 或 update 本身是原子操作,但如果业务逻辑涉及多步写操作,就必须加@Transactional。

@Service public class UserService { @Autowired private UserMapper userMapper; public User getById(Long id) { return userMapper.selectById(id); } public List<User> listAll() { return userMapper.selectAll(); } @Transactional(rollbackFor = Exception.class) public void create(User user) { userMapper.insert(user); } @Transactional(rollbackFor = Exception.class) public void update(User user) { userMapper.updateById(user); } @Transactional(rollbackFor = Exception.class) public void delete(Long id) { userMapper.deleteById(id); } }

rollbackFor = Exception.class这个参数建议显式加上。默认情况下 Spring 只对RuntimeException回滚,如果抛的是受检异常,事务不会回滚,这是一个非常隐蔽的坑。加上这个参数后,任何异常都会触发回滚。

3.3 Controller 层与统一返回格式

Controller 层只做参数接收和结果封装,不写业务逻辑。

@RestController @RequestMapping("/api/user") public class UserController { @Autowired private UserService userService; @GetMapping("/{id}") public Result<User> getById(@PathVariable Long id) { return Result.success(userService.getById(id)); } @GetMapping("/list") public Result<List<User>> list() { return Result.success(userService.listAll()); } @PostMapping("/create") public Result<Void> create(@RequestBody @Valid User user) { userService.create(user); return Result.success(); } @PutMapping("/update") public Result<Void> update(@RequestBody User user) { userService.update(user); return Result.success(); } @DeleteMapping("/{id}") public Result<Void> delete(@PathVariable Long id) { userService.delete(id); return Result.success(); } }

Result是一个统一返回体,通常包含code、message、data三个字段。这样前端拿到响应后只需要判断code就能知道成功还是失败,不用去解析 HTTP 状态码。@Valid注解配合实体类上的校验注解(如@NotBlank、@Min)可以在进入业务逻辑之前就拦截非法参数。

启动项目后,用 curl 或 Postman 测试各个接口:

# 查询单条 curl http://localhost:8080/api/user/1 # 查询列表 curl http://localhost:8080/api/user/list # 新增 curl -X POST http://localhost:8080/api/user/create \ -H "Content-Type: application/json" \ -d '{"userName":"王五","age":28,"email":"wangwu@example.com"}' # 更新 curl -X PUT http://localhost:8080/api/user/update \ -H "Content-Type: application/json" \ -d '{"id":1,"userName":"张三丰","age":26}' # 删除 curl -X DELETE http://localhost:8080/api/user/1

每个接口返回的 JSON 结构应该一致,code为 200 表示成功,data里携带具体数据。如果返回的code不是预期值,先看控制台有没有异常堆栈,再去检查 SQL 是否执行成功。

4. 避坑与排查:这份 Demo 跑不起来时先看这几条

4.1 启动报数据源配置错误

现象:项目启动时抛出Failed to configure a DataSource: 'url' attribute is not specified。

原因:application.yml没有被正确加载,或者数据源配置的缩进层级写错了。YAML 对缩进非常敏感,spring.datasource下面的url、username、password必须对齐。

解决:检查application.yml是否在src/main/resources根目录下,确认spring:下面datasource:的缩进是两格,url等字段再缩进两格。如果用的是application.properties,确认 key 的拼写是spring.datasource.url而不是spring.datasource.jdbc-url。

4.2 Mapper 接口找不到 XML 映射

现象:启动时报Invalid bound statement (not found),或者调用接口时提示找不到对应的 SQL。

原因:mapper-locations配置的路径和 XML 实际存放位置不一致,或者 XML 的namespace写错了。

解决:确认application.yml里mybatis.mapper-locations的值是classpath:mapper/*.xml,并且 XML 文件确实放在resources/mapper目录下。再检查 XML 的namespace是否完整对应 Mapper 接口的全限定名,比如com.example.demo.mapper.UserMapper,少一段都会导致绑定失败。

4.3 插入后返回的主键为 null

现象:调用新增接口后,返回的data里id字段是 null,但数据库里确实插入成功了。

原因:<insert>标签上没有配置useGeneratedKeys和keyProperty,或者配置了但keyProperty的值和实体类字段名不一致。

解决:在<insert>标签上加上useGeneratedKeys="true" keyProperty="id",确保keyProperty的值与实体类中主键字段名完全一致。如果主键字段叫userId,那keyProperty就要写userId而不是id。

4.4 更新操作把未传字段覆盖为 null

现象:只想更新用户名,结果年龄和邮箱被更新成了 null。

原因:用的是全量更新 SQL,UPDATE t_user SET user_name=?, age=?, email=? WHERE id=?,没有传的字段就被 set 成了 null。

解决:改用动态 SQL,用<set>配合<if test="field != null">只更新非空字段。或者改用 MyBatis-Plus 的updateById,它默认就是只更新非空字段。如果业务上确实需要把某个字段置空,那就要单独处理,不能依赖动态 SQL。

4.5 事务不生效导致数据不一致

现象:Service 方法里先插入一条记录,再更新另一条记录,更新失败了但插入的记录没有回滚。

原因:@Transactional注解没有生效。常见原因是同类内部方法直接调用,绕过了 Spring 的代理;或者异常类型不在默认回滚范围内。

解决:确认@Transactional加在 public 方法上,且调用方是通过 Spring 注入的 Bean 来调用的,不是new出来的对象。同时显式指定rollbackFor = Exception.class,避免受检异常不回滚。如果确实需要在同类内部调用,可以通过AopContext.currentProxy()获取代理对象再调用。

5. 进阶用法:把这份 Demo 改造成分页查询与批量操作

基础增删改查跑通之后,实际项目里最先遇到的需求就是分页和批量操作。这份 Demo 的骨架可以直接扩展,不需要改动原有结构。

分页查询的核心是在 Mapper 里加两个方法:一个查总数,一个查当前页数据。参数用offset和limit控制。

// Mapper 接口新增 Long countAll(); List<User> selectPage(@Param("offset") int offset, @Param("limit") int limit);
<select id="countAll" resultType="long"> SELECT COUNT(*) FROM t_user </select> <select id="selectPage" resultType="User"> SELECT * FROM t_user ORDER BY id DESC LIMIT #{offset}, #{limit} </select>

Service 层封装分页逻辑,传入页码和每页条数,计算出 offset:

public PageResult<User> page(int pageNum, int pageSize) { long total = userMapper.countAll(); int offset = (pageNum - 1) * pageSize; List<User> list = userMapper.selectPage(offset, pageSize); PageResult<User> result = new PageResult<>(); result.setTotal(total); result.setList(list); result.setPageNum(pageNum); result.setPageSize(pageSize); return result; }

批量插入用<foreach>标签拼接 values:

<insert id="batchInsert"> INSERT INTO t_user (user_name, age, email) VALUES <foreach collection="list" item="item" separator=","> (#{item.userName}, #{item.age}, #{item.email}) </foreach> </insert>

这里有个参数需要留意:批量插入的数据量如果超过几千条,建议分批执行,每批 500 到 1000 条。一次性拼接太长的 SQL 会撞上 MySQL 的max_allowed_packet限制,报Packet for query is too large。我一般会在 Service 层用Lists.partition把大列表切分成小批次,循环调用 Mapper 的批量插入方法。

验证分页是否正确,除了看返回的total和list大小,还要测边界情况:页码为 1、页码为最后一页、页码超出总页数、每页条数为 0 或负数。这些边界不测,上线后迟早会被前端反馈回来。从那以后我每次写完分页接口,都会强制走一遍这四种边界用例,确认返回结构一致、不报错、不返回脏数据。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询