在这一步,我们谈谈后端分层。一个查询接口由 Controller、Service 与 Mapper 协作完成。这篇把各层职责、数据与映射、错误反查边界梳理一遍,用一个查询接口串起调用链。
耦合是衡量改变某事物需要多少工作的指标。GameRunner 直接依赖 MarioGame 时,切换到 SuperContraGame 需要修改 GameRunner 的代码,这就是紧耦合。引入 GamingConsole 接口后,GameRunner 只依赖接口,切换游戏时不需要修改 GameRunner。让我们看一个例子:
public class GameLooseDemo {
interface GamingConsole {
void up();
void down();
}
static class MarioGame implements GamingConsole {
@Override public void up() {
System.out.println("跳起");
}
@Override public void down() {
System.out.println("进入管道");
}
}
static class SuperContraGame implements GamingConsole {
@Override public void up() {
System.out.println("向上");
}
@Override public void down() {
System.out.println("坐下");
}
}
static class GameRunner {
private final GamingConsole game;
GameRunner(GamingConsole game) {
this.game = game;
}
void run() {
game.up();
game.down();
}
}
public static void main(String[] args) {
new GameRunner(new MarioGame()).run();
// 输出:跳起
// 输出:进入管道
new GameRunner(new SuperContraGame()).run();
// 输出:向上
// 输出:坐下
}
}分层也是同样的思路。Controller 只处理协议,Service 组织用例,Mapper 只做存储访问,各层通过接口协作,变更收敛在各自内部。
提示:下面
demo/UserMapper.java、demo/UserService.java、demo/UserController.java需项目环境(Java 21、Spring Boot 3.5 与 MyBatis,配套依赖、数据库初始化与启动配置按具体工程组织),不能用javac直接运行;可运行的核心链路见本篇纯 Java 演示LayeringDemo与GameLooseDemo。
一、每个部分负责什么
一次查询经过四道处理:
sequenceDiagram
participant B as 浏览器
participant C as Controller
participant S as Service
participant M as 仓储 Mapper
participant D as 数据库
B->>C: GET /users/{id}
C->>S: find id
S->>M: findById 参数绑定
M->>D: SELECT 允许列
D-->>M: 返回行
M-->>S: Optional UserRow
S-->>C: 业务结果
C-->>B: 200/400/404 DTO| 部分 | 负责 | 一次查询中的动作 |
|---|---|---|
| Controller | HTTP 参数绑定、响应和协议错误 | 接收用户 ID |
| Service | 业务用例、权限约束和事务组织 | 确认查询范围与处理不存在情况 |
| Mapper/Repository | 参数化存储访问 | 按 ID 查询允许读取的列 |
| Entity/DTO | 持久化模型与接口数据 | 保存内部字段,输出允许公开的字段 |
参数格式在入口验证,业务约束在用例中维护。Service 是否需要独立接口,由替换需求和模块边界决定;Mapper 可使用注解或 XML,两种方式均需匹配方法和结果映射。
下面是纯 Java 版本,用函数调用模拟整条链。Controller 管协议,Service 管用例,Mapper 管存取。200 对应成功,400 对应参数,404 对应空结果:
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class LayeringDemo {
static class UserRow {
Long id;
String name;
String phone;
UserRow(Long id, String name, String phone) {
this.id = id;
this.name = name;
this.phone = phone;
}
}
record UserResponse(Long id, String name) {}
static class UserMapper {
private final Map<Long, UserRow> db = new HashMap<>();
UserMapper() {
db.put(1L, new UserRow(1L, "tom", "138****0001"));
}
UserRow findById(long id) {
return db.get(id);
}
}
static class UserService {
private final UserMapper mapper = new UserMapper();
Optional<UserRow> find(long id) {
if (id <= 0) {
throw new IllegalArgumentException("id must be positive");
}
return Optional.ofNullable(mapper.findById(id));
}
}
static class UserController {
private final UserService service = new UserService();
String get(long id) {
if (id <= 0) {
return "400 bad request";
}
return service.find(id)
.map(row -> "200 " + new UserResponse(row.id, row.name))
.orElse("404 not found");
}
}
public static void main(String[] args) {
var c = new UserController();
System.out.println(c.get(1)); // 输出:200 UserResponse[id=1, name=tom]
System.out.println(c.get(2)); // 输出:404 not found
System.out.println(c.get(-1)); // 输出:400 bad request
}
}二、数据与映射
假设表名是 app_user,字段为 id、name、phone。手机号留在内部模型,接口只返回 ID 与姓名。类分别位于所注明的文件中。
demo/UserRow.java(需项目环境,配合 MyBatis 使用):
package demo;
public class UserRow {
private Long id;
private String name;
private String phone;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
}demo/UserMapper.java(需项目环境,配合 MyBatis 使用):
package demo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface UserMapper {
@Select("SELECT id, name, phone FROM app_user WHERE id = #{id}")
UserRow findById(@Param("id") long id);
}#{id} 将值作为查询参数绑定。${...} 执行文本替换,不能接收未经约束的用户输入。使用 XML 时,namespace 对应接口全限定名,statement id 对应方法,文件必须被配置扫描。MyBatis 映射文档说明了参数与结果映射。
警告:
${...}是文本替换:不能接收未经约束的用户输入,参数绑定一律用#{id}。
本文采用 Java 21、Spring Boot 3.5 与 MyBatis 的讲解范围。示例聚焦类之间的协作,配套依赖、数据库初始化与启动配置按具体工程组织。
Spring 管理对象时,被管理的对象叫 Bean。配置类加 @Configuration,方法加 @Bean,Spring 容器启动后用 getBean 获取。例如定义 name 方法返回字符串并加 @Bean,启动 AnnotationConfigApplicationContext 后按名称取出。依赖通过构造器、Setter 或字段注入,由 Spring 在需要处装配,类内部不再直接 new 依赖。
三、组织查询用例
demo/UserService.java(需项目环境,配合 Spring 使用):
package demo;
import java.util.Optional;
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final UserMapper mapper;
public UserService(UserMapper mapper) { this.mapper = mapper; }
public Optional<UserRow> find(long id) {
if (id <= 0) throw new IllegalArgumentException("id must be positive");
return Optional.ofNullable(mapper.findById(id));
}
}示例没有用户身份和多租户上下文,只适合说明查询调用链。实际用户资料接口还要校验调用者权限,并将租户或资源范围加入查询条件。仅检查 ID 为正数不能防止越权读取。
注意:只校验 ID 为正数防不住越权:实际用户资料接口还要校验调用者权限,并将租户或资源范围加入查询条件。
四、返回明确的 HTTP 语义
demo/UserController.java(需项目环境,配合 Spring MVC 使用):
package demo;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserController {
private final UserService service;
public UserController(UserService service) { this.service = service; }
public record UserResponse(Long id, String name) {}
@GetMapping("/users/{id}")
public ResponseEntity<UserResponse> get(@PathVariable("id") long id) {
if (id <= 0) return ResponseEntity.badRequest().build();
return service.find(id)
.map(row -> ResponseEntity.ok(new UserResponse(row.getId(), row.getName())))
.orElseGet(() -> ResponseEntity.notFound().build());
}
}非数字路径参数由框架处理绑定失败;非法范围返回 400,不存在返回 404,成功返回显式 DTO。多个入口复用 Service 时,其业务输入约束仍然有效。正式项目通常使用统一异常映射,避免每个 Controller 重复响应规则。
五、从错误反查边界
| 用例 | 预期 | 重点检查 |
|---|---|---|
| 已存在 ID | 200,仅含公开字段 | 映射、DTO 与字段暴露 |
| 不存在 ID | 404 | 空结果处理 |
| 0、负数、非数字 | 400 | 参数绑定与范围约束 |
| 无权读取的 ID | 403 或按产品策略隐藏资源 | 身份、租户和资源授权 |
| 数据库不可达 | 服务错误,客户端不见内部凭据 | 异常映射、日志与请求关联 |
前端跨域访问需要按实际来源配置 CORS。同源反向代理可避免一部分跨域配置;使用跨域凭据时需明确允许来源,开放 CORS 本身不提供身份认证。Spring CORS 文档给出了配置约束。
提示:开放 CORS 不提供身份认证:使用跨域凭据时需明确允许来源,同源反向代理可避免一部分跨域配置。
GET 用于读取资源,POST、PUT、PATCH 等方法按资源语义设计。请求体与查询参数属于协议设计细节,不能只按有没有请求体判断业务是否允许重试。
练习
- 跑
LayeringDemo,分别传入存在 ID、不存在 ID、负数,确认 200 / 404 / 400 三条路径都走通。 - 把
UserResponse改成直接返回UserRow,看看手机号会不会漏出去;再改回来,体会 DTO 的作用。 - 把
@Select里的#{id}换成${id}想一想会发生什么;记住参数绑定一律用#{}。
小结
- 协议、业务与持久化各有修改位置;参数绑定归 Controller,业务约束归 Service,存储访问归 Mapper;
- 接口只返回允许公开的字段;手机号留在内部模型,成功响应返回显式 DTO;
- 参数绑定一律用
#{};${...}是文本替换,不能接收未经约束的用户输入; - Service 约束随复用保留;多个入口复用 Service 时,其业务输入约束仍然有效;
- 用错误反查边界;400 管参数范围、404 管空结果、403 管授权、服务错误不向客户端暴露内部凭据;
- 跨域与认证分开处理;CORS 按实际来源配置,不拿它当身份认证;重试语义按资源与业务判断,不只看有没有请求体。
把一次查询的调用链走通,分层边界自然清楚。