2026/10/18 3:00:04

SpringBoot+Thymeleaf后台管理CRUD实战:从数据流转到表单校验

SpringBoot+Thymeleaf后台管理CRUD实战:从数据流转到表单校验 1. 动手前先想清楚的事这套方案到底解决什么问题“增删改查”这四个字在简历上出现频率很高但真正能用SpringBootThymeleaf把它做成一个可交付、可扩展、页面好看的后台管理模块很多人其实是卡住的。问题往往不在“会不会写代码”而在“不知道整个数据是怎么从浏览器流到数据库再流回页面的”。我见过太多学习者照着教程把代码抄完换了一张表、加了一个字段就不知道怎么改了。先明确本文要做什么用SpringBoot构建后端服务用Thymeleaf渲染服务端页面实现一个完整且可直接落地的增删改查功能。我会拿一个典型的“员工信息管理”场景为主线覆盖列表查询、新增、编辑、删除、表单校验、分页、异常处理这些环节。这不是一篇只贴代码的教程每一步我都会解释“为什么这么设计”因为只有理解了来龙去脉换个需求你才接得住。先说结论性的东西SpringBootThymeleaf这套组合最适合的是内部管理系统、运营后台、CMS这类“服务端渲染优先”的场景。为什么不用前后端分离因为这类页面的核心诉求是快速交付、可直接刷新、便于SEO如果对外、开发链路短。你不需要专门维护一套接口文档不需要处理跨域不需要前端工程化环境一个SpringBoot应用全部搞定。把复杂的东西留给后端把渲染交给模板引擎这对中小团队来说是性价比最高的方案。1.1 为什么这个场景不适合一上来就前后端分离很多同学一听到“管理系统”就默认要上VueElementUI然后后端写接口再部署个Nginx。这套东西本身不复杂但它有个隐性成本你需要同时维护两套工程处理跨域、权限、环境变量联调成本比服务端渲染高一个量级。而Thymeleaf这套方案模板页面直接放在SpringBoot的src/main/resources/templates目录里Controller返回一个视图名SpringMVC自动帮你去渲染HTML。用户刷新浏览器他看到的就是最新数据没有接口联调这回事。你甚至可以理解为Thymeleaf让“页面”成为Controller的输出结果而不是一个独立的前端App。特别是当你的团队成员以Java为主、前端功力一般时Thymeleaf的学习成本和维护成本远低于维护一个完整前后端分离体系。这也是大量的后台管理系统至今仍在使用服务端渲染的原因——不是技术落后是成本可控。1.2 先把数据流转拆成五步再动手写代码在写任何一个CRUD功能之前我建议你先在纸上把这五步写出来浏览器发起HTTP请求GET或POST。Controller接收请求解析路径参数、表单参数。Service层处理业务逻辑比如校验、默认值填充。Repository层与数据库交互执行查询或持久化。Controller把数据模型放到Model里Thymeleaf模板渲染返回HTML。明确了这一步后续每一个功能你都能定位到“我到底要改哪一层”。列表页烂了去查Controller和模板保存数据不对去查Service和实体映射数据库没建表去查JPA配置——问题边界立刻清晰。这是整个项目最基本的认知框架后面所有代码都会落在链条的某一段上。2. 工程初始化与目录规划三分钟跑起骨架我的建议是直接用Spring Initializr创建项目不要手动拼目录。访问start.spring.io或者用IDEA自带的新建SpringBoot项目入口都可以。版本方面我用的是Spring Boot 3.3.xJDK 17。如果你还在用JDK 8和Spring Boot 2.x代码里的javax.*包要换回javax.persistence而不是jakarta.persistence这个坑后面讲实体类时再强调。核心依赖就五个Web、Thymeleaf、Spring Data JPA、MySQL Driver、Validation。Lombok也加上能少写很多getter/setter。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里特别说一句spring-boot-starter-validation经常被漏掉。写校验注解的时候你发现Valid找不到类就是因为没引这个依赖。Spring Boot 2.3之后它不再默认包含在Web启动器里必须手动声明。然后写application.yml。本地开发关键配置如下spring: datasource: url: jdbc:mysql://localhost:3306/staff_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true thymeleaf: cache: falseddl-auto: update的意思是实体类改了字段会自动帮你在数据库里同步更新表结构。开发期非常方便但上线前必须改成validate或none否则生产环境一旦实体变化表结构被自动改掉后果很严重。这一点我真的是踩过坑在线上的库里跑出一条自动加的字段半夜被叫起来排查。thymeleaf.cache: false是本地开发的救命配置。不开缓存的话你改完HTML需要重启应用才生效开了缓存false页面模板每次请求都会重新加载刷新浏览器就能看到改动。2.1 目录结构你可以再简单一点但不要更简单标准的包路径结构大概是这样的src/main/java/com/example/demo ├── DemoApplication.java ├── controller │ └── StaffController.java ├── service │ └── StaffService.java ├── repository │ └── StaffRepository.java └── entity └── Staff.java src/main/resources ├── static │ ├── css │ └── js └── templates ├── staff │ ├── list.html │ └── form.html └── error.html很多人为了图省事把Repository省略直接在Service里用EntityManager或者把Service省略Controller里直接注入Repository。在Demo阶段确实能跑但一旦业务规则复杂比如“删除前要检查关联数据”“新增时自动设置创建时间”你就知道Service层夹在中间的价值了。我的原则很简单Controller只做参数接收和视图跳转Repository只做持久层操作业务规则全部放Service层。项目再小这个分层都不要省。2.2 数据源配置里最容易忽略的细节URL里那三个参数useUnicodetruecharacterEncodingutf8保证中文不乱码serverTimezoneAsia/Shanghai保证时间类型映射正确。少一个轻则中文乱码重则LocalDateTime存入数据库时差8小时。driver-class-name在Spring Boot 3里是com.mysql.cj.jdbc.Driver老的com.mysql.jdbc.Driver已经废弃了。很多老教程还在用旧驱动启动时虽然会告警但能跑建议还是新写法干净。3. 实体、仓库与服务层别把数据库表直接暴露给页面实体类是一个Java对象和数据库表之间的映射。我们以员工表为例核心字段设计如下ID、姓名、邮箱、部门、入职日期、创建时间。Entity Table(name staff) Getter Setter NoArgsConstructor public class Staff { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; NotBlank(message 姓名不能为空) Column(nullable false, length 50) private String name; Email(message 邮箱格式不正确) NotBlank(message 邮箱不能为空) Column(nullable false, unique true, length 100) private String email; NotBlank(message 部门不能为空) Column(length 50) private String department; Column(name hire_date) private LocalDate hireDate; Column(name create_time, updatable false) private LocalDateTime createTime; PrePersist public void prePersist() { this.createTime LocalDateTime.now(); } }PrePersist这个注解很实用在实体第一次保存前自动执行方法体给字段填默认值。这样你就不用在Service层手动set创建时间了代码少一点是一点。updatable false防止更新时覆盖创建时间从根源上避开数据错误。数据库表主键生成策略选IDENTITY就是数据库自增。MySQL下这是最常规的选择性能也好。别用序列或者UUID当主键去做业务表长字符串主键会引起聚簇索引性能下降这个在数据量大了之后特别明显。3.1 Repository层的代码可以少到什么程度Spring Data JPA最爽的一点在于只要继承接口你就能零实现地拿到一堆现成方法。public interface StaffRepository extends JpaRepositoryStaff, Long { // 按姓名模糊查询 ListStaff findByNameContaining(String name); // 邮箱唯一性检查 boolean existsByEmail(String email); // 按部门查询 ListStaff findByDepartment(String department); }方法名按照“字段名关键词”的命名规则写框架会自动生成对应的SQL。这种机制叫方法名派生查询。你不需要写任何实现代码findByNameContaining会被自动翻译成WHERE name LIKE %?%。我个人建议命名规则不超过两个字段组合再多就老实写Query注解吧可读性反而更好。3.2 Service层事务与业务规则的落脚点Service层的存在不是为了“多写一层”而是为了守住业务规则。举个最常见的例子新增员工前要检查邮箱不能重复这个检查放Controller里就是污染控制层放Repository层又写不出来就只能放Service层。Service RequiredArgsConstructor public class StaffService { private final StaffRepository staffRepository; Transactional(readOnly true) public PageStaff pageStaff(Pageable pageable) { return staffRepository.findAll(pageable); } Transactional(readOnly true) public Staff getStaff(Long id) { return staffRepository.findById(id) .orElseThrow(() - new IllegalArgumentException(员工数据不存在: id)); } Transactional public void saveStaff(Staff staff) { // 新增时检查邮箱唯一性编辑时排除自身 Staff existing staffRepository.findByEmail(staff.getEmail()); if (existing ! null !existing.getId().equals(staff.getId())) { throw new IllegalArgumentException(邮箱已被使用: staff.getEmail()); } staffRepository.save(staff); } Transactional public void deleteStaff(Long id) { staffRepository.deleteById(id); } }Transactional(readOnly true)放在查询方法上告诉数据库“我这个方法只需要读不需要改”。MySQL的InnoDB在这种模式下可以走快照读减少锁竞争。写操作的方法上用Transactional保证原子性。这里的Transactional一定要导入org.springframework.transaction.annotation.Transactional别导成javax.transaction。更新操作有个经典错误直接创建一个新对象塞上id然后save()。这样做的后果是——没被set的字段全部变成null然后把数据库里原来的值覆盖掉。正确做法是先从数据库里查出原实体再逐个更新需要修改的字段最后调用save()。由于目标对象是持久态对象JPA自己会做脏检查事务提交时自动更新。你甚至可以不写save()但为了代码意图明确我还是建议显式调用。4. Controller与数据校验把入口管住Controller是HTTP请求的入口也是整个数据流转的枢纽。我的习惯是每个资源一个Controller页面级路由统一收归一处。先说路由设计。列表页挂在GET /staffs新增表单页在GET /staffs/new提交新增用POST /staffs编辑表单页在GET /staffs/{id}/edit提交更新用POST /staffs/{id}删除用POST /staffs/{id}/delete。这里几个细节很多人看不出来编辑我用POST /staffs/{id}而不是PUT /staffs/{id}。HTML表单只支持GET和POST两种方法虽然可以伪造_method参数配合HiddenHttpMethodFilter模拟PUT但纯Thymeleaf场景没必要POST本身就是通用的。删除必须用POST不能用GET。如果你用GET /staffs/{id}/delete搜索引擎爬虫和浏览器预加载可能提前触发这个操作数据被删了你都不知道。用POST可以规避这个风险。完整Controller如下Controller RequestMapping(/staffs) RequiredArgsConstructor public class StaffController { private final StaffService staffService; GetMapping public String list(RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size, Model model) { Pageable pageable PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, createTime)); PageStaff staffPage staffService.pageStaff(pageable); model.addAttribute(staffPage, staffPage); model.addAttribute(currentPage, page); return staff/list; } GetMapping(/new) public String createForm(Model model) { model.addAttribute(staff, new Staff()); return staff/form; } PostMapping public String create(Valid ModelAttribute(staff) Staff staff, BindingResult bindingResult, Model model) { if (bindingResult.hasErrors()) { return staff/form; } try { staffService.saveStaff(staff); } catch (IllegalArgumentException e) { model.addAttribute(errorMsg, e.getMessage()); return staff/form; } return redirect:/staffs; } GetMapping(/{id}/edit) public String editForm(PathVariable Long id, Model model) { model.addAttribute(staff, staffService.getStaff(id)); return staff/form; } PostMapping(/{id}) public String update(PathVariable Long id, Valid ModelAttribute(staff) Staff staff, BindingResult bindingResult, Model model) { if (bindingResult.hasErrors()) { return staff/form; } staff.setId(id); try { staffService.saveStaff(staff); } catch (IllegalArgumentException e) { model.addAttribute(errorMsg, e.getMessage()); return staff/form; } return redirect:/staffs; } PostMapping(/{id}/delete) public String delete(PathVariable Long id) { staffService.deleteStaff(id); return redirect:/staffs; } }4.1 校验失败时怎么把错误信息带回表单Valid和BindingResult必须成对出现且BindingResult必须紧跟在被校验对象后面否则Spring会直接抛BindException表单错误信息根本拿不到。校验失败时我直接返回staff/form字符串此时model里的staff对象是用户填过的原值Thymeleaf用th:field可以自动回显。这就是表单校验里最核心的一句话校验失败不是跳转而是返回当前视图重新渲染。关于业务异常的提示我在Service层用了IllegalArgumentException。Controller捕获后放到model属性里页面上根据errorMsg是否存在来判断是否显示。这个方案简单直接是中小项目里比较实用的模式。4.2 PRG模式成功提交后为什么要重定向看代码你会发现新建和更新成功后都是返回redirect:/staffs。这用的是Post/Redirect/Get模式。原因很简单如果成功提交后直接返回模板页面用户按F5刷新浏览器的当前动作是重复提交上次的表单请求也就是会再插入一条记录。改成重定向浏览器会GET一下列表页刷新也只是重新加载列表不会重复写库。这个设计本身就是对不对的问题不是可不可选的问题。凡是表单提交成功的页面一律重定向跳转。5. Thymeleaf模板实战表单回显、分页与代码碎片Thymeleaf的语法核心就八个属性th:text、th:if、th:each、th:field、th:href、th:action、th:object、th:replace。掌握了这套基本可以覆盖日常所有页面的编写。先说列表页list.html的结构。表格里每一行是数据操作列放编辑和删除按钮底部放分页导航。table classtable thead tr thID/th th姓名/th th邮箱/th th部门/th th入职日期/th th操作/th /tr /thead tbody tr th:eachstaff : ${staffPage.content} td th:text${staff.id}1/td td th:text${staff.name}张三/td td th:text${staff.email}zhangsanexample.com/td td th:text${staff.department}研发部/td td th:text${#temporals.format(staff.hireDate, yyyy-MM-dd)}2024-01-01/td td a th:href{/staffs/{id}/edit(id${staff.id})}编辑/a form th:action{/staffs/{id}/delete(id${staff.id})} methodpost onsubmitreturn confirm(确认删除该员工吗?); styledisplay:inline; button typesubmit删除/button /form /td /tr /tbody /tableth:eachstaff : ${staffPage.content}用来遍历分页结果集。分页对象里真正放数据的是content属性这块容易写错写成staffPage是拿不到数据的。{/staffs/{id}/edit(id${staff.id})}是Thymeleaf的URL表达式作用就是拼接URL并做参数编码。你千万别用字符串拼接去写链接特别是带中文参数的时候编码问题会让你抓狂。删除操作我直接在表格里放了一个内联表单好处是不用为每个删除按钮配备JavaScript只要一个confirm就完成了交互。表单的action表达式和POST方法配合安全又简单。5.1 时间格式化的坑Thymeleaf对LocalDate和LocalDateTime不能直接调用toString会输出2024-01-01T00:00:00这种带字母T的格式。正确写法是依赖模板内置的#temporals工具类th:text${#temporals.format(staff.hireDate, yyyy-MM-dd)}如果你嫌哪都写日期格式化太啰嗦可以给实体类加一个getHireDateText()方法返回格式化后的字符串。模板里直接${staff.hireDateText}。这个做法有点违背“实体不要放展示逻辑”的原则但在模板引擎场景下我个人认为是可接受的取舍省得满屏都是格式化表达式。5.2 新增和编辑共用一个form.html的思路表单页是CRUD里最能体现Thymeleaf功底的地方。新增和编辑其实共用同一个页面th:object指向的staff对象不同而已。编辑时staff来自数据库新增时staff是空的。form th:action${staff.id null} ? {/staffs} : {/staffs/{id}(id${staff.id})} th:object${staff} methodpost input typehidden th:field*{id} / div label姓名/label input typetext th:field*{name} / p classerror th:if${#fields.hasErrors(name)} th:errors*{name}姓名错误/p /div div label邮箱/label input typeemail th:field*{email} / p classerror th:if${#fields.hasErrors(email)} th:errors*{email}邮箱错误/p /div div label部门/label input typetext th:field*{department} / p classerror th:if${#fields.hasErrors(department)} th:errors*{department}部门错误/p /div div label入职日期/label input typedate th:field*{hireDate} / /div p classerror th:if${errorMsg} th:text${errorMsg}邮箱占用/p button typesubmit保存/button a th:href{/staffs}返回列表/a /formth:action根据staff.id是否为null自动切换提交路径th:object${staff}帮后面的th:field*{name}自动补齐了字段前缀。也就是说*{name}自动等价于${staff.name}这就是表单回显能生效的核心机制。错误展示有两类一类是BindingResult的字段校验错误用#fields.hasErrors(name)判断另一类是Service层抛出的业务异常放在errorMsg属性里。两种都是“渲染当前页面并显示错误”不会丢失用户输入。5.3 把分页做成分段代码分页逻辑我也直接写在列表页底部没有抽公共组件因为业务简单但写法有个小技巧页码范围用#numbers.sequence生成。nav ul li th:if${staffPage.hasPrevious()} a th:href{/staffs(page${currentPage - 1})}上一页/a /li li th:eachi : ${#numbers.sequence(1, staffPage.totalPages)} th:classappend${i currentPage} ? active a th:href{/staffs(page${i})} th:text${i}1/a /li li th:if${staffPage.hasNext()} a th:href{/staffs(page${currentPage 1})}下一页/a /li /ul /navstaffPage.hasPrevious()和hasNext()是Spring Data Page对象自带的方法currentPage是Controller传进来的页码。注意数据库分页的页码是0开始页面展示的页码是1开始Controller里PageRequest.of(page - 1, size)已经做了转换模板里直接拿页面页码做加减就行。静态资源这块CSS和JS统一放static/css、static/jsHTML里用th:href{/css/style.css}引用。注意static目录下的文件不能直接访问templates目录下的页面templates下的HTML只能由Controller视图渲染后返回这个界限新手经常搞混。6. 完整跑通后的避坑复盘四个高频问题代码写完了跑起来总会碰到几个拦路虎。我把实际教学和开发过程中最常遇到的几个问题梳理成排查链路每一个都给出根因和验证思路做项目时对照着看能省大把时间。6.1 列表页可以访问但提交表单后一直报405或404这个现象的根子通常在表单的th:action写错了路径。Controller里定义的是PostMapping(/staffs)表单却提交到了/staffs/或者/staffs/xxx匹配不上自然报错。排查思路很简单打开浏览器开发者工具在Network面板看提交的Request URL和Method再对照Controller的RequestMapping。别盯着代码看半天看请求一眼就能定位。另外一个容易忽略的场景Thymeleaf模板里的{...}表达式如果写错了比如漏了/生成的URL会带上当前路径前缀变成类似/staffs/staffs的鬼路径。这个只能用浏览器Network里的实际URL判断肉眼很难看出来。6.2 表单校验不生效空值照样保存成功这个坑我几乎每周都能看到。如果你确认写了Valid和NotBlank但提交空值照样进数据库大概率是这么几种情况校验注解对应的JSR 380实现没引入也就是少了spring-boot-starter-validation。BindingResult没有紧跟在校验对象后面Spring直接抛异常看到500而不是友好的错误提示页。表单里的字段名与实体属性名不一致比如页面写th:field*{names}实体叫name绑定直接失败。第三点排查起来比较隐蔽建议在Controller参数上加一个ModelAttribute Staff staff在进入方法体后打印一下这个对象看字段有没有被填充三两分钟就能确定。6.3 编辑保存之后某几个字段变成null这是一个原理性的坑。前面逻辑里写得很清楚更新时要先查出原实体再修改字段。很多人图省事直接staffRepository.save(new Staff(...fields..., id))然后发现没set的字段全被清空。根因就是JPA帮你把传入的对象当作全新实体处理null字段直接覆盖原值。这里可以给一个经验性判断如果表单里只有一个隐藏的id字段和几个输入框那么新增和更新走同一个Service方法没问题但只要你的实体里有“后端默认值”字段比如创建时间、状态、版本号那就必须在Service里区分新增和更新或者用DTO组装配后再落到实体上。早期项目先老老实实分两个方法。6.4 中文乱码和日期回显格式不一致中文乱码优先检查两点数据库连接的characterEncodingutf8、页面本身编码UTF-8。Spring Boot默认的CharacterEncodingFilter已经帮你设置了UTF-8所以通常问题出在数据库连接串或MySQL表本身的CHARSET。日期回显问题也常见。实体里定义的是LocalDate页面用input typedate绑定理论上格式一致不会出问题。但如果浏览器显示成2024-01-01T00:00:00这种值说明实体字段用了LocalDateTime。如果你只是想存日期就用LocalDate想存时间就用LocalDateTime不要含糊混用否则模板里格式化逻辑会变成一团乱麻。我把这几个高频问题汇总成一张表方便你排查时快速对照。现象根因排查方向列表页能访问表单提交405/404路由路径或方法不匹配Network面板核对URL与Method空值照样保存成功校验依赖缺失或参数顺序错误检查依赖与BindingResult位置编辑后部分字段变NULL更新时未先查原实体Service层更新逻辑拆开处理中文乱码连接串或表字符集不对检查URL参数和数据库CHARSET日期出现T时间戳格式实体字段类型与输入框不一致统一LocalDate或LocalDateTime6.5 ddl-auto: update带来的连带问题开发期用update省事但它的副作用也大。比如你修改了字段长度定义Hibernate会尝试改表数据库表里如果已经有数据字段类型转换失败启动直接报错。更麻烦的是当你改了字段名老字段不会自动删除表结构变得越来越脏。我的习惯是本地开发阶段表结构不重要的直接用create-drop每次重启干净重建一旦本地已经有测试数据了再切回update部署到测试环境时使用validate保证实体和表结构一致不一致就启动失败。生产环境直接none或validate任何自动改表的操作都是灾难。7. 这一版做完之后项目还能往哪走基本的增删改查跑通之后很多人会觉得“项目做完了”但实际上这才刚跨过门槛。同一套骨架你可以顺手做三件事让项目直接上一个台阶。第一给列表页加搜索和过滤。Repository里多写一个方法比如findByDepartmentAndNameContainingController接收查询参数拼装Pageable模板里加一个查询表单。这不需要改任何其他层十几分钟就能完成。第二处理关联表数据。比如员工属于某个部门部门本身是另一张表。这时候实体关系注解ManyToOne、表单下拉选「部门」查询时用fetch避免N1问题。这是CRUD到真实系统之间最重要的一步。第三做权限控制。引入Spring Security后要求未登录用户不能访问/staffs只有管理员能删除普通用户只能查看和编辑。这个需求本质是给现有代码加过滤器CRUD的核心逻辑不需要改。我自己在实际项目里常把这些功能留作下一步的迭代项而不是在第一版就全部做进去。原因很简单第一版的目标是“跑得动、看得懂、可扩展”结构清晰比功能多更重要。你把CRUD的骨架打扎实后续加搜索、加权限、加报表都是在稳定地基上添砖加瓦不太会出现推倒重来的情况。如果你后续确实要做搜索分页这类查询增强建议先去掌握Spring Data JPA的Specification或QueryByExample。这两个机制能帮你用动态条件组合查询而不是在每个方法名里堆字段条件。但前面那个前提依然成立——先把基础CRUD吃透再去碰动态查询不然你会被一堆抽象概念绕晕。顺便给一个我个人的使用习惯做完这个项目后我一般会把它保留为一个模板工程后续新项目需要后台管理模块就直接从它复制改。因为它的分层清晰、表单复用逻辑完整、分页和校验都已跑通改表名、改字段、调整页面文案半小时就能交付一个新的管理模块。这种“拿来即用”的积累价值比再写十个Demo都有用。