在这一步,我们谈谈 Spring Boot。接口、业务用例与持久化各归其位。这篇把可访问接口、实体与持久化建模、事务与错误响应梳理一遍,先分清三者边界,再查问题。
目前对象创建由我们管理,创建与装配代码也由我们编写。实验是让 Spring 框架创建对象并装配。第一步从简单对象开始,例如姓名、年龄、人物及其地址,让 Spring 管理这些对象。
提示:下面
DemoApplication、User、UserRepository需项目环境(Spring Boot 3.5、Spring Data JPA 与数据库驱动,构建工具和依赖版本按工程固定),不能用javac直接运行;可运行的边界演示见本篇纯 Java 版本BoundaryDemo。
一、从一个可访问接口开始
创建 Spring Boot 项目推荐用 Spring Initializr 网站。选择 Maven、Java 与最新发布版本,不选快照版或里程碑版。输入 Group、Artifact,例如 com.in28minutes 与 learn-spring-boot,添加 Spring Web 依赖后生成 zip,解压后按已有 Maven 项目导入。src/main/java 写代码,src/main/resources 放配置,src/test/java 写测试。pom.xml 中的 spring-boot-starter-web 带来 Web 开发所需依赖。
创建含 Spring Web 的 Maven 项目,将下列文件放入 src/main/java/demo/DemoApplication.java(需项目环境,用 ./mvnw spring-boot:run 启动):
package demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@RestController
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@GetMapping("/hello")
public String hello() {
return "Hello";
}
}使用项目生成的 Maven Wrapper 运行 ./mvnw spring-boot:run,再请求 curl --fail-with-body http://127.0.0.1:8080/hello。启动类所在包影响默认组件扫描范围,业务类通常放在其子包。
本文以 Spring Boot 3.5 与 Java 21 为版本基线。当前系统要求列出 Java 17 为最低要求;项目应固定构建工具和依赖版本,避免示例与本地环境不一致。
Starter 的目标是帮助快速启动项目。spring-boot-starter-web 包含 Spring Web MVC、Tomcat、JSON 支持,返回 List<Course> 时由 Starter 带来的 JSON 支持转为 JSON 响应。与数据库通信用 spring-boot-starter-data-jpa 或 spring-boot-starter-jdbc,保护应用用 spring-boot-starter-security。每个 Starter 管理一系列依赖与版本,pom.xml 中配置一个 Starter 即可获得对应能力。
Actuator 帮助构建生产就绪应用,提供健康、指标、Bean 列表、请求映射等端点。添加 spring-boot-starter-actuator 后访问 /actuator,默认只启用少数端点。在 application.properties 配置 management.endpoints.web.exposure.include 后可启用更多端点,例如 beans 查看 CourseController 对应的 Bean,health 查看健康,env 查看端口与 Java 版本,metrics/http.server.requests 查看请求数。生产配置只启用需要用到的端点。
下面是纯 Java 版本,用函数调用模拟 Controller、Service、Repository 三层。Service 管用例,Repository 只管存,接口只返回 DTO:
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.ArrayList;
public class BoundaryDemo {
record UserResponse(Long id, String name) {}
static class UserRepository {
private final Map<Long, String> db = new HashMap<>();
private long seq = 0;
synchronized Long save(String name) {
db.put(++seq, name);
return seq;
}
List<String> findByName(String name) {
var out = new ArrayList<String>();
db.values().forEach(v -> {
if (v.equals(name)) {
out.add(v);
}
});
return out;
}
int count() {
return db.size();
}
}
static class UserService {
private final UserRepository repo = new UserRepository();
UserResponse create(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name required");
}
Long id = repo.save(name);
return new UserResponse(id, name);
}
}
public static void main(String[] args) {
var service = new UserService();
System.out.println(service.create("tom")); // 输出:UserResponse[id=1, name=tom]
System.out.println(service.create("tom")); // 输出:UserResponse[id=2, name=tom]
try {
service.create("");
} catch (IllegalArgumentException e) {
System.out.println("400 " + e.getMessage()); // 输出:400 name required
}
}
}二、依赖注入与职责
| 层 | 负责 | 说明 |
|---|---|---|
| Controller | HTTP 参数和响应 | 处理协议细节,不写业务规则 |
| Service | 用例组织和事务 | 完成一个业务用例的方法定事务边界 |
| Repository | 存储访问 | 只做持久化,不定业务语义 |
Controller 处理 HTTP 参数和响应,Service 组织用例和事务,Repository 访问存储。构造器注入能明确依赖,也方便在测试中替换。字段加上 final 只固定引用,Bean 内部可变状态仍需考虑并发。
依赖注入有三种类型:基于构造器、基于 Setter、基于字段。构造器注入在创建时传入依赖,Setter 注入通过 Setter 方法传入,字段注入通过反射直接装配字段。之前游戏例子把 GamingConsole 通过构造函数传入 GameRunner,就是基于构造器的注入。
查询使用 GET;创建订单等有副作用的操作使用相应的写入接口,并在服务端验证权限和业务约束。框架层参数校验与业务规则校验各有用途。
三、Entity 与 Record 分别使用
H2 是内存数据库。启动日志打印 JDBC URL,把 spring.h2.console.enabled=true 写入 application.properties 后访问 /h2-console。默认 URL 动态生成,每次重启需要从日志复制,配置 spring.datasource.url=jdbc:h2:mem:testdb 后可固定。src/main/resources/schema.sql 在启动时建表,例如 create table course (id bigint not null, name varchar(255) not null, author varchar(255) not null, primary key (id)),long 对应 bigint,文本对应 varchar,id 作主键。
JPA 实体使用符合持久化规范的类。下面是实体片段,使用前需要 Spring Data JPA 和数据库驱动依赖(需项目环境):
package demo;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "app_user")
public class User {
@Id
@GeneratedValue
private Long id;
private String name;
protected User() {}
public User(String name) { this.name = name; }
public Long getId() { return id; }
public String getName() { return name; }
}管理实体用 Repository。创建 CourseRepository 接口,声明 extends JpaRepository<Course, Long>,第一个参数是管理的实体,第二个参数是主键类型。在 Controller 中声明 private CourseRepository repository 并自动装配,调用 repository.findAll() 返回所有课程,调用 repository.findById(id) 返回 Optional<Course>。实体需要默认构造函数,否则查询时报错。命名惯例是实体名加 Repository,类放在启动类子包下以便组件扫描拾取:
package demo;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByName(String name);
}| 类型 | 用途 | 约束 |
|---|---|---|
| Entity 类 | 持久化建模 | 需满足无参构造、非 final 等规范要求 |
| Record | 返回 DTO 或查询投影 | 不直接标记为 JPA Entity |
实体需要满足无参构造和非 final 等规范要求,Record 适合返回 DTO 或查询投影。将 Record 直接标记为 JPA Entity 不符合这里的实体建模约束,详见 Jakarta Persistence 实体规则。
注意:Record 不能直接当 Entity:实体要满足无参构造、非 final 等规范要求,Record 只适合返回 DTO 或查询投影。
package demo;
public record UserResponse(Long id, String name) {}API 显式返回允许公开的字段,避免把数据库字段、懒加载关系或以后新增的敏感属性全部暴露出去。
GET 返回课程详情时,把路径变量映射为 long id,调用 repository.findById(id)。返回是 Optional,为空时抛运行时异常提示未找到对应 ID,否则 get 后返回。POST 创建课程时加 @PostMapping("/courses"),方法参数加 @RequestBody 把 JSON 映射为实体,调用 repository.save(course)。浏览器默认只能发 GET,POST、PUT、DELETE 需要用 REST 客户端发送,并按 {"name":"...","author":"..."} 组织 JSON。
四、事务与错误响应
flowchart LR
A[HTTP 请求] --> B[Controller 参数与响应]
B --> C[Service 用例与事务]
C --> D{经过代理吗}
D -->|自调用绕过| E[事务不生效]
D -->|经代理| F[Repository 存取]
F --> G[数据库]
C --> H[返回 DTO 非实体]事务边界放在完成一个业务用例的 Service 方法。基于代理的声明式事务需要核对调用是否经过代理、自调用情况和异常回滚规则。一次数据库事务无法自动包含远程 HTTP 请求,跨服务写入还需要幂等与失败补偿。
警告:自调用会绕过代理:基于代理的声明式事务要核对调用是否经过代理,类内自调用会让事务不生效,同时核对异常回滚规则。
接口至少区分参数错误、资源不存在、权限不足和服务异常。写入测试要验证数据库最终状态;只检查 HTTP 200 会遗漏事务回滚失败或重复写入。
提示:只看 HTTP 200 不够:写入测试要验证数据库最终状态,否则会遗漏事务回滚失败或重复写入。
请求到数据库的完整调用路径,可以继续看后端分层示例。这里的代码片段聚焦接口与持久化职责,落地到项目时,还需要结合具体依赖和数据库配置调整。
练习
- 跑
BoundaryDemo,确认正常创建返回 DTO,空名字返回 400;再想想为什么 Repository 里不写业务规则。 - 对照
User和UserResponse,说出哪一个能加@Entity,哪一个只能当 DTO;试着解释无参构造的作用。 - 画一下自调用绕过代理的路径,确认事务边界为什么要放在 Service 用例方法上;写入测试时,除了看 HTTP 200,还要看什么。
小结
- 三者边界先分清;Spring Boot 管组装与自动配置,Spring MVC 管 HTTP,JPA 管持久化;
- 版本基线先固定;构建工具、依赖与本地环境对齐,避免示例跑不起来;
- 构造器注入明确依赖;方便测试替换;
final只固定引用,可变状态仍要考虑并发; - Entity 与 Record 分开用;实体守持久化规范,Record 做 DTO 与投影,接口只暴露允许公开的字段;
- 事务边界放在 Service 用例方法;核对代理、自调用与回滚规则;跨服务写入加幂等与失败补偿;
- 错误响应至少分四类;参数错误、资源不存在、权限不足、服务异常;写入测试看数据库最终状态。
边界清楚了,接口与持久化职责各归其位,问题定位从调用链查起。