☰
SpringBoot首个Web网站:从零实现CRUD完整流程
2026/10/21 18:17:45 网站建设 项目流程

1. 这不是“Hello World”,而是你真正能上线的Web网站起点

很多人第一次点开SpringBoot文档,看到“快速构建Web应用”几个字,心里想的是:不就是写个页面弹个提示框?结果一上手就卡在启动失败、端口被占、Thymeleaf模板不渲染、数据库连不上、POST请求405、JSON返回空对象这些地方。我带过不少刚转Java后端的新手,他们最常问的一句话是:“为什么我照着教程敲,就是跑不起来?”——问题从来不在代码本身,而在于对SpringBoot“约定大于配置”这一底层逻辑的陌生。这个项目标题里藏着三个关键信号:“SpringBoot”代表现代Java Web开发的工程范式,“第一个Web网站”意味着从零搭建完整闭环,“CRUD”则是检验你是否真正理解MVC分层、HTTP协议、数据持久化与前后端交互的试金石。它不是玩具Demo,而是你未来所有业务系统的基础模型:用户注册要增(Create),订单列表要查(Read),资料修改要改(Update),账号注销要删(Delete)。我们今天做的,是把这四个动作封装进一个可运行、可调试、可扩展的最小可行系统。适合两类人:一类是刚学完Java基础、正站在Web开发门口张望的新人,另一类是写过SSH但没碰过SpringBoot、想快速建立新认知框架的转型者。接下来所有内容,都基于真实开发现场——没有“理论上可以”,只有“我试过,这样最稳”。

2. 整体架构设计与技术选型逻辑拆解

2.1 为什么必须用SpringBoot而不是原生Spring MVC?

十年前写一个Servlet需要手动配置web.xml、DispatcherServlet、ViewResolver、MessageConverter……光是XML文件就能写满一页。现在用SpringBoot,你只需要一个@SpringBootApplication注解和一个main方法,就能启动一个内嵌Tomcat的Web服务。这不是偷懒,而是工程效率的质变。SpringBoot通过自动配置(Auto-configuration)和起步依赖(Starter Dependencies)解决了传统Spring的两大痛点:配置爆炸和依赖冲突。比如你引入spring-boot-starter-web,它会自动拉取Spring MVC、Jackson、Tomcat等一整套兼容版本的依赖;你加spring-boot-starter-data-jpa,它会根据classpath里的H2或MySQL驱动,自动配置DataSource、EntityManagerFactory和TransactionManager。这种“开箱即用”不是黑盒魔法,而是SpringBoot内部预定义了上百个@ConditionalOnClass、@ConditionalOnMissingBean条件判断——只有当某个类存在、某个Bean未定义时,才触发对应配置。所以当你看到application.properties里只写spring.datasource.url=jdbc:h2:mem:testdb,背后其实是H2DatabaseAutoConfiguration类在起作用。如果你跳过这层理解,后面遇到自定义DataSource失效、JPA审计不生效等问题,就会陷入“配置写了但没用”的困惑。

2.2 为什么选择H2内存数据库而非MySQL作为入门首选?

新手常犯的错误是:一上来就装MySQL,配环境变量,建库授权,结果卡在Access denied for user 'root'@'localhost'。这不是你的错,是学习路径错了。H2是一个纯Java实现的嵌入式数据库,无需独立安装,启动时自动创建内存库(重启即清空)或文件库(jdbc:h2:./data/test)。它的优势在于零运维成本和调试友好性:你可以在浏览器直接访问http://localhost:8080/h2-console打开图形化控制台,执行SQL、查看表结构、甚至模拟并发操作。更重要的是,H2完全支持标准SQL语法和JDBC接口,你写的Repository代码,未来迁移到MySQL只需改一行URL和驱动类名。我见过太多人因为MySQL权限问题浪费半天,最后连第一个save()都没调通。记住:CRUD的核心是逻辑流程,不是数据库运维。等你把Controller→Service→Repository→Entity这条链路跑通三次,再切MySQL,效率反而更高。

2.3 为什么用Thymeleaf而不是Vue/React做前端?

有人会质疑:“现在谁还用服务端渲染写Web?”——这个问题问得好,但答案很实在:降低认知负荷,聚焦核心链路。如果你同时学SpringBoot + Vue,就要面对跨域问题(前端端口3000,后端8080)、代理配置、Axios请求封装、Vue Router路由守卫、状态管理Vuex……这些全是额外的学习曲线。而Thymeleaf是服务端模板引擎,HTML文件里直接写<tr th:each="user : ${users}">,后端Model塞进Model.addAttribute("users", userList),页面就能渲染。它不涉及JavaScript打包、热更新、模块化,所有逻辑都在Java里。当你在浏览器F12看到Network标签页里,GET/users返回的是完整的HTML页面(不是JSON),你就真正理解了“服务端渲染”和“前后端分离”的本质区别。等你用Thymeleaf把CRUD全跑通,再用Postman调同样的API接口拿JSON,对比两者差异,那种顿悟感比看十篇原理文章都强。

2.4 为什么放弃XML配置,坚持全注解+YAML?

SpringBoot官方明确声明:不再推荐XML配置。原因很现实:XML冗长、易出错、IDE支持弱(比如属性名拼错,XML不会报错,运行时才崩)。而application.yml是YAML格式,层级清晰,天然支持Profile多环境(spring.profiles.active: dev),且Spring Boot Actuator等生产级功能默认只读取YAML/Properties。更重要的是,所有配置项都有明确的命名规范:server.port、spring.jpa.hibernate.ddl-auto、logging.level.org.springframework.web=DEBUG。这些键值对不是随意定的,而是Spring Boot Configuration Properties机制扫描@ConfigurationProperties类生成的。比如spring.jpa.hibernate.ddl-auto=create-drop,对应的是JpaBaseConfiguration类里的hibernateDdlAuto字段。你改配置时,其实是在覆盖Spring Boot预设的默认值。这种“约定优于配置”的思想,正是SpringBoot工程化的灵魂——它强迫你按统一范式组织代码,而不是让每个项目都成为配置黑洞。

3. 核心模块解析与实操细节深挖

3.1 Entity实体类:不只是数据容器,更是ORM映射契约

很多人把Entity当成简单的JavaBean,只写getter/setter,这是大忌。Entity的本质是Java对象与数据库表的双向映射契约,每个注解都在告诉JPA“你该怎么操作这张表”。以用户管理为例:

@Entity @Table(name = "t_user") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "username", nullable = false, unique = true, length = 50) private String username; @Column(name = "email", nullable = false, length = 100) @Email(message = "邮箱格式不正确") private String email; @Column(name = "age") @Min(value = 0, message = "年龄不能小于0") @Max(value = 150, message = "年龄不能大于150") private Integer age; @Column(name = "created_time", updatable = false) @CreatedDate private LocalDateTime createdTime; @Column(name = "updated_time") @LastModifiedDate private LocalDateTime updatedTime; }

这里的关键点远不止@Entity和@Id:

  • @Table(name = "t_user"):显式指定表名,避免JPA默认的user(关键字冲突)或user_entity(不符合团队命名规范)
  • @Column的nullable = false和unique = true:不仅约束Java层,更会生成DDL语句中的NOT NULL和UNIQUE约束,这是数据一致性的第一道防线
  • @Email和@Min/@Max:属于Bean Validation规范,配合@Valid在Controller层做参数校验,比if-else写十行判断更优雅
  • @CreatedDate和@LastModifiedDate:需要启用@EnableJpaAuditing并配置DateTimeProvider,否则时间戳永远为null——这是新手踩坑率最高的点之一

提示:不要在Entity里写业务逻辑方法!比如getUserFullName()这种getter应该放在DTO或Service里。Entity只负责数据映射,职责必须单一。

3.2 Repository接口:JPA的魔法黑盒,但你要知道它怎么施法

UserRepository extends JpaRepository<User, Long>这行代码看似简单,背后是JPA Criteria API和Hibernate HQL的深度封装。JpaRepository提供了20+个开箱即用的方法,比如findAll()、findById()、save()、deleteById(),但它们的实现逻辑完全不同:

  • findById(id):生成SELECT * FROM t_user WHERE id = ?,一级缓存命中直接返回,不查库
  • save(entity):如果entity有id且数据库存在该id,则执行UPDATE;否则执行INSERT(这就是@GeneratedValue的作用)
  • findAll():默认无分页,大数据量时可能OOM,生产环境必须用Pageable分页

更强大的是方法名查询能力。你不需要写SQL,只要按规则命名方法,JPA自动解析:

// 自动生成 SELECT * FROM t_user WHERE username LIKE '%john%' AND age > 18 List<User> findByUsernameContainingAndAgeGreaterThan(String username, Integer age); // 自动生成 SELECT COUNT(*) FROM t_user WHERE email IS NOT NULL long countByEmailIsNotNull(); // 支持排序:findByUsernameOrderByAgeDesc(String username)

这种能力基于Spring Data JPA的QueryLookupStrategy,它会将方法名拆解为PropertyPath(username、age)、Predicate(Containing、GreaterThan)、Sort(OrderByAgeDesc)三部分,再转换成JPA Criteria查询。但要注意陷阱:findByUsername(String username)默认是精确匹配,而findByUsernameContaining(String username)才是模糊查询——少个Containing,页面搜索就永远查不到数据。

3.3 Service层:事务边界与业务逻辑的黄金分割线

新手常把所有代码塞进Controller,导致Controller臃肿、事务失控、测试困难。Service层的核心价值是定义事务边界和封装业务规则。看这个典型反例:

// ❌ 错误:事务控制在Controller,违反分层原则 @PostMapping("/users") public String createUser(@Valid User user, Model model) { // 事务在这里开启,但Controller不该管事务 userRepository.save(user); model.addAttribute("message", "创建成功"); return "redirect:/users"; }

正确做法是:

// ✅ 正确:@Transactional标注在Service方法上 @Service public class UserService { @Autowired private UserRepository userRepository; @Transactional(rollbackFor = Exception.class) public User createUser(User user) { // 业务校验:用户名不能重复(数据库unique约束是最后一道防线,这里要提前拦截) if (userRepository.findByUsername(user.getUsername()).isPresent()) { throw new RuntimeException("用户名已存在"); } // 业务逻辑:创建时间自动填充 user.setCreatedTime(LocalDateTime.now()); return userRepository.save(user); } }

@Transactional的rollbackFor参数至关重要。默认只对RuntimeException回滚,如果你的业务异常继承自Exception(如IOException),就必须显式声明,否则事务不会回滚。另外,@Transactional只能作用于public方法,且必须通过Spring代理调用(即不能在同一个类里this.createUser(),否则事务失效)。这是Spring AOP代理机制决定的硬约束。

3.4 Controller层:HTTP协议与MVC模式的落地接口

Controller是HTTP请求进入Java世界的“海关”,它的职责非常明确:接收请求、校验参数、调用Service、组装响应、返回视图或数据。Spring MVC通过@RequestMapping系列注解完成路由绑定:

@Controller @RequestMapping("/users") public class UserController { @Autowired private UserService userService; // GET /users → 查询所有用户 @GetMapping public String listUsers(Model model) { model.addAttribute("users", userService.findAll()); return "user/list"; // 返回templates/user/list.html } // GET /users/1 → 查询单个用户 @GetMapping("/{id}") public String getUser(@PathVariable Long id, Model model) { model.addAttribute("user", userService.findById(id)); return "user/detail"; } // POST /users → 创建用户 @PostMapping public String createUser(@Valid @ModelAttribute User user, BindingResult result, Model model) { if (result.hasErrors()) { model.addAttribute("errors", result.getAllErrors()); return "user/form"; // 表单页,显示校验错误 } userService.createUser(user); return "redirect:/users"; // 重定向防止重复提交 } }

这里有几个关键细节:

  • @ModelAttribute:将表单字段自动绑定到User对象,比手动request.getParameter()优雅得多
  • BindingResult:必须紧跟@Valid参数之后,否则校验不生效
  • return "redirect:/users":使用重定向而非转发,避免用户刷新页面时重复提交表单(PRG模式)
  • @PathVariable:从URL路径提取参数,/{id}对应@GetMapping("/{id}"),不是?id=1

注意:Controller里绝对不要出现SQL、数据库操作、复杂计算。它只是协调者,不是执行者。

4. 完整实操流程与关键环节实现

4.1 环境准备与项目初始化(5分钟搞定)

别再用IDEA手动新建Maven项目——Spring Initializr是官方认证的最快路径。访问https://start.spring.io/(国内可直连),按以下配置勾选:

  • Project:Maven Project
  • Language:Java
  • Spring Boot:3.2.0(当前最新稳定版)
  • Packaging:Jar
  • Java:17(LTS版本,兼容性最好)
  • Dependencies:
    • Spring Web(必备,提供MVC支持)
    • Spring Data JPA(ORM框架)
    • H2 Database(内存数据库)
    • Thymeleaf(模板引擎)
    • Validation(参数校验)
    • Lombok(减少样板代码,@Data/@AllArgsConstructor等)

点击“Generate”下载zip包,解压后用IDEA打开(File → Open → 选择pom.xml)。此时项目结构已自动生成,pom.xml里包含所有起步依赖。重点检查<parent>标签是否指向spring-boot-starter-parent,这是自动配置生效的前提。

4.2 application.yml配置详解(每一行都是经验之谈)

在src/main/resources下创建application.yml,粘贴以下内容并逐行理解:

# 服务器配置 server: port: 8080 servlet: context-path: /demo # 所有接口前缀,避免根路径冲突 # 日志配置:开发期必须开DEBUG,否则看不到SQL logging: level: org.springframework.web: DEBUG org.hibernate.SQL: DEBUG org.hibernate.type.descriptor.sql.BasicBinder: TRACE # 显示SQL参数值 # 数据源配置(H2内存库) spring: datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: # JPA/Hibernate配置 jpa: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop # 开发期用,每次启动重建表;生产环境必须改为validate show-sql: true # 控制台打印SQL(仅开发期) properties: hibernate: format_sql: true # SQL美化输出 # H2控制台(开发神器) h2: console: enabled: true path: /h2-console # 访问 http://localhost:8080/demo/h2-console # Thymeleaf配置 thymeleaf: cache: false # 开发期禁用缓存,改HTML立即生效 prefix: classpath:/templates/ suffix: .html encoding: UTF-8 servlet: content-type: text/html

关键参数说明:

  • DB_CLOSE_DELAY=-1:H2内存库关闭延迟设为-1,表示永不关闭,避免多线程操作时库被意外关闭
  • ddl-auto: create-drop:启动时建表,退出时删表。这是开发期的“安全网”,确保每次启动都是干净环境。但注意:如果ddl-auto设为update,JPA只会新增字段,不会删除或修改字段类型——这会导致数据库脏数据
  • cache: false:Thymeleaf默认开启模板缓存,开发时必须关掉,否则改了HTML要重启服务才能看到效果

4.3 实体类与Repository编码(手把手实现)

创建包结构:com.example.demo.entity(放User.java)、com.example.demo.repository(放UserRepository.java)。代码如下:

User.java

package com.example.demo.entity; import jakarta.persistence.*; import jakarta.validation.constraints.*; import java.time.LocalDateTime; @Entity @Table(name = "t_user") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotBlank(message = "用户名不能为空") @Size(min = 2, max = 20, message = "用户名长度2-20位") @Column(name = "username", nullable = false, unique = true, length = 50) private String username; @Email(message = "邮箱格式不正确") @Column(name = "email", nullable = false, length = 100) private String email; @Min(value = 0, message = "年龄不能小于0") @Max(value = 150, message = "年龄不能大于150") @Column(name = "age") private Integer age; @Column(name = "created_time", updatable = false) private LocalDateTime createdTime; @Column(name = "updated_time") private LocalDateTime updatedTime; // Lombok会自动生成getter/setter/toString/构造器 // 如果不用Lombok,需手动补全 }

UserRepository.java

package com.example.demo.repository; import com.example.demo.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface UserRepository extends JpaRepository<User, Long> { // JPA自动实现:SELECT * FROM t_user WHERE username = ? User findByUsername(String username); // JPA自动实现:SELECT COUNT(*) FROM t_user WHERE email = ? long countByEmail(String email); }

实操心得:@Repository注解不是必须的(JpaRepository已自带),但加上更清晰表明这是DAO层。方法命名必须严格遵循JPA规则,比如findByEmail会生成WHERE email = ?,而findByEmailLike才是WHERE email LIKE ?。

4.4 Service与Controller实现(打通CRUD全链路)

创建com.example.demo.service和com.example.demo.controller包。代码如下:

UserService.java

package com.example.demo.service; import com.example.demo.entity.User; import com.example.demo.repository.UserRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.Optional; @Service public class UserService { @Autowired private UserRepository userRepository; @Transactional(rollbackFor = Exception.class) public User createUser(User user) { // 业务校验:用户名唯一性(数据库unique是兜底,这里提前拦截提升体验) if (userRepository.findByUsername(user.getUsername()) != null) { throw new RuntimeException("用户名已存在"); } user.setCreatedTime(LocalDateTime.now()); user.setUpdatedTime(LocalDateTime.now()); return userRepository.save(user); } public Page<User> findAllUsers(int page, int size) { return userRepository.findAll(PageRequest.of(page, size, Sort.by("id").descending())); } public Optional<User> findById(Long id) { return userRepository.findById(id); } @Transactional(rollbackFor = Exception.class) public User updateUser(Long id, User userDetails) { User user = userRepository.findById(id) .orElseThrow(() -> new RuntimeException("用户不存在")); user.setUsername(userDetails.getUsername()); user.setEmail(userDetails.getEmail()); user.setAge(userDetails.getAge()); user.setUpdatedTime(LocalDateTime.now()); return userRepository.save(user); } @Transactional(rollbackFor = Exception.class) public void deleteUser(Long id) { userRepository.deleteById(id); } }

UserController.java

package com.example.demo.controller; import com.example.demo.entity.User; import com.example.demo.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; import jakarta.validation.Valid; import java.util.Optional; @Controller @RequestMapping("/users") public class UserController { @Autowired private UserService userService; // 列表页:GET /users @GetMapping public String listUsers(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size, Model model) { Page<User> userPage = userService.findAllUsers(page, size); model.addAttribute("users", userPage.getContent()); model.addAttribute("currentPage", page); model.addAttribute("totalPages", userPage.getTotalPages()); model.addAttribute("pageSize", size); return "user/list"; } // 新增页:GET /users/new @GetMapping("/new") public String showCreateForm(Model model) { model.addAttribute("user", new User()); return "user/form"; } // 新增提交:POST /users @PostMapping public String createUser(@Valid @ModelAttribute User user, BindingResult result, Model model) { if (result.hasErrors()) { return "user/form"; // 校验失败,返回表单页 } userService.createUser(user); return "redirect:/users"; } // 编辑页:GET /users/1/edit @GetMapping("/{id}/edit") public String showEditForm(@PathVariable Long id, Model model) { Optional<User> user = userService.findById(id); if (user.isPresent()) { model.addAttribute("user", user.get()); return "user/form"; } else { return "redirect:/users"; } } // 更新提交:POST /users/1 @PostMapping("/{id}") public String updateUser(@PathVariable Long id, @Valid @ModelAttribute User userDetails, BindingResult result, Model model) { if (result.hasErrors()) { model.addAttribute("user", userDetails); return "user/form"; } userService.updateUser(id, userDetails); return "redirect:/users"; } // 删除:GET /users/1/delete @GetMapping("/{id}/delete") public String deleteUser(@PathVariable Long id) { userService.deleteUser(id); return "redirect:/users"; } }

4.5 Thymeleaf模板编写(HTML即应用)

在src/main/resources/templates下创建目录user,放入三个HTML文件:

list.html(用户列表页)

<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>用户列表</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> </head> <body> <div class="container mt-4"> <h1>用户管理</h1> <a href="/users/new" class="btn btn-primary mb-3">新增用户</a> <table class="table table-striped"> <thead> <tr> <th>ID</th> <th>用户名</th> <th>邮箱</th> <th>年龄</th> <th>创建时间</th> <th>操作</th> </tr> </thead> <tbody> <tr th:each="user : ${users}"> <td th:text="${user.id}"></td> <td th:text="${user.username}"></td> <td th:text="${user.email}"></td> <td th:text="${user.age} ?: '未知'"></td> <td th:text="${#temporals.format(user.createdTime, 'yyyy-MM-dd HH:mm')}"></td> <td> <a th:href="@{/users/{id}/edit(id=${user.id})}" class="btn btn-sm btn-outline-primary">编辑</a> <a th:href="@{/users/{id}/delete(id=${user.id})}" class="btn btn-sm btn-outline-danger" onclick="return confirm('确定删除?')">删除</a> </td> </tr> </tbody> </table> <!-- 分页 --> <nav aria-label="用户分页"> <ul class="pagination"> <li class="page-item" th:classappend="${currentPage == 0} ? 'disabled'"> <a class="page-link" th:href="@{/users(page=${currentPage - 1}, size=${pageSize})}" tabindex="-1">上一页</a> </li> <li class="page-item" th:classappend="${currentPage == totalPages - 1} ? 'disabled'"> <a class="page-link" th:href="@{/users(page=${currentPage + 1}, size=${pageSize})}">下一页</a> </li> </ul> </nav> </div> </body> </html>

form.html(新增/编辑表单页)

<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title th:text="${user.id} ? '编辑用户' : '新增用户'">新增用户</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> </head> <body> <div class="container mt-4"> <h1 th:text="${user.id} ? '编辑用户' : '新增用户'">新增用户</h1> <!-- 表单提交地址:新增时POST /users,编辑时POST /users/{id} --> <form th:action="@{|/users${user.id} ? '/' + ${user.id} : ''|}" th:object="${user}" method="post" class="needs-validation" novalidate> <div class="mb-3"> <label for="username" class="form-label">用户名</label> <input type="text" class="form-control" id="username" th:field="*{username}" required> <div class="invalid-feedback" th:if="${#fields.hasErrors('*{username}')}" th:errors="*{username}">用户名错误</div> </div> <div class="mb-3"> <label for="email" class="form-label">邮箱</label> <input type="email" class="form-control" id="email" th:field="*{email}" required> <div class="invalid-feedback" th:if="${#fields.hasErrors('*{email}')}" th:errors="*{email}">邮箱错误</div> </div> <div class="mb-3"> <label for="age" class="form-label">年龄</label> <input type="number" class="form-control" id="age" th:field="*{age}" min="0" max="150"> <div class="invalid-feedback" th:if="${#fields.hasErrors('*{age}')}" th:errors="*{age}">年龄错误</div> </div> <button type="submit" class="btn btn-primary"> <span th:text="${user.id} ? '更新' : '保存'">保存</span> </button> <a href="/users" class="btn btn-secondary">取消</a> </form> </div> <!-- Bootstrap JS(可选) --> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> </body> </html>

detail.html(详情页,留作扩展)

<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>用户详情</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> </head> <body> <div class="container mt-4"> <h1>用户详情</h1> <div class="card"> <div class="card-body"> <h5 class="card-title" th:text="${user.username}">用户名</h5> <p class="card-text"><strong>邮箱:</strong><span th:text="${user.email}">邮箱</span></p> <p class="card-text"><strong>年龄:</strong><span th:text="${user.age} ?: '未知'">年龄</span></p> <p class="card-text"><strong>创建时间:</strong> <span th:text="${#temporals.format(user.createdTime, 'yyyy-MM-dd HH:mm:ss')}">时间</span> </p> </div> </div> <a href="/users" class="btn btn-secondary mt-3">返回列表</a> </div> </body> </html>

实操心得:Thymeleaf的th:field会自动绑定表单字段,*{username}表示当前对象的username属性。th:if="${#fields.hasErrors('*{username}')}"是校验错误提示的关键,必须配合BindingResult使用。Bootstrap CDN链接可直接复制,无需本地下载。

5. 常见问题与排查技巧实录

5.1 启动失败:端口被占用或依赖冲突

现象:IDEA控制台报错Address already in use: bind或Caused by: java.lang.NoClassDefFoundError
排查步骤:

  1. 检查application.yml中server.port是否被其他程序占用(如另一个SpringBoot实例、Tomcat、Skype)
  2. 执行命令netstat -ano | findstr :8080(Windows)或lsof -i :8080(Mac/Linux)查PID,再taskkill /PID <PID> /F结束进程
  3. 若报NoClassDefFoundError,大概率是依赖版本冲突。在IDEA右侧Maven面板,点击Reload project,然后展开Dependencies,搜索重复的jar(如两个不同版本的spring-core)
  4. 强制指定版本:在pom.xml中添加<properties>块,统一管理版本号
<properties> <spring-boot.version>3.2.0</spring-boot.version> </properties>

5.2 页面404:模板路径或控制器映射错误

现象:访问http://localhost:8080/demo/users显示Whitelabel Error Page
排查清单:

  • ✅ 检查Controller类是否有@Controller和@RequestMapping("/users")
  • ✅ 检查方法是否有@GetMapping且返回字符串(如"user/list")
  • ✅ 检查HTML文件路径是否为src/main/resources/templates/user/list.html(注意是resources不是java)
  • ✅ 检查application.yml中spring.thymeleaf.prefix是否为classpath:/templates/
  • ✅ 在application.yml中临时添加logging.level.org.springframework.web.servlet.DispatcherServlet=DEBUG,启动时看日志是否打印Mapped to com.example.demo.controller.UserController#listUsers

5.3 数据库操作失败:H2连接或JPA配置问题

现象:userRepository.save()后数据库无数据,或H2控制台打不开
高频原因:

  • application.yml中spring.datasource.url写错:必须是jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1,少分号或参数名错误都会失败
  • spring.jpa.hibernate.ddl-auto设为none:表根本不会创建,需改为create-drop
  • @EnableJpaAuditing未启用:@CreatedDate字段始终为null,需在主类添加@EnableJpaAuditing并配置@Bean
@SpringBootApplication @EnableJpaAuditing public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } @Bean public DateTimeProvider dateTimeProvider() { return () -> LocalDateTime.now(); } }

5.4 表单提交失败:参数校验不生效或400错误

现象:输入非法邮箱,页面没提示错误,直接跳转空白页
根源分析:

  • @Valid注解后必须紧跟BindingResult参数,且顺序不能错(@Valid @ModelAttribute User user, BindingResult result)
  • th:field="*{email}"必须与Entity字段名完全一致(大小写敏感)
  • application.yml中spring.thymeleaf.cache=false必须为false,否则HTML修改不生效
  • 浏览器F12看Network,确认请求Method是POST,Content-Type是application/x-www-form-urlencoded

5.5 SQL不打印:日志配置失效

现象:控制台看不到Hibernate: insert into t_user ...
解决方案:

  • application.yml中logging.level.org.hibernate.SQL=DEBUG必须顶格写,不能缩进错误
  • logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE用于显示参数值(如binding parameter [1] as [VARCHAR] - [john])
  • 如果用Logback,需确认logback-spring.xml未覆盖yml配置

6. 进阶扩展与生产化改造建议

6.1 从H2切换到MySQL的三步迁移法

当项目需要上线时,H2必须替换为MySQL。这不是简单改URL,而是系统性迁移:

  1. 数据库初始化:在MySQL中执行H2生成的DDL(启动项目时加spring.jpa.hibernate.ddl-auto=create,控制台会打印SQL,复制到MySQL执行)
  2. 配置替换:application.yml中修改
spring: datasource: url: jdbc:mysql://localhost:3306/demo_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root

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

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

立即咨询