Java 后端

后端分层:从 HTTP 请求到数据库查询

用一个查询接口,串起 Controller、Service、Mapper 与显式响应。

在这一步,我们谈谈后端分层。一个查询接口由 Controller、Service 与 Mapper 协作完成。这篇把各层职责、数据与映射、错误反查边界梳理一遍,用一个查询接口串起调用链。

耦合是衡量改变某事物需要多少工作的指标。GameRunner 直接依赖 MarioGame 时,切换到 SuperContraGame 需要修改 GameRunner 的代码,这就是紧耦合。引入 GamingConsole 接口后,GameRunner 只依赖接口,切换游戏时不需要修改 GameRunner。让我们看一个例子:

java40 行
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。

一、每个部分负责什么

一次查询经过四道处理:

mermaid14 行
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 对应空结果:

java51 行
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 使用):

java13 行
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 使用):

java11 行
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 使用):

java15 行
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 使用):

java22 行
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 等方法按资源语义设计。请求体与查询参数属于协议设计细节,不能只按有没有请求体判断业务是否允许重试。

练习

  1. 跑 LayeringDemo,分别传入存在 ID、不存在 ID、负数,确认 200 / 404 / 400 三条路径都走通。
  2. 把 UserResponse 改成直接返回 UserRow,看看手机号会不会漏出去;再改回来,体会 DTO 的作用。
  3. 把 @Select 里的 #{id} 换成 ${id} 想一想会发生什么;记住参数绑定一律用 #{}。

小结

  • 协议、业务与持久化各有修改位置;参数绑定归 Controller,业务约束归 Service,存储访问归 Mapper;
  • 接口只返回允许公开的字段;手机号留在内部模型,成功响应返回显式 DTO;
  • 参数绑定一律用 #{};${...} 是文本替换,不能接收未经约束的用户输入;
  • Service 约束随复用保留;多个入口复用 Service 时,其业务输入约束仍然有效;
  • 用错误反查边界;400 管参数范围、404 管空结果、403 管授权、服务错误不向客户端暴露内部凭据;
  • 跨域与认证分开处理;CORS 按实际来源配置,不拿它当身份认证;重试语义按资源与业务判断,不只看有没有请求体。

把一次查询的调用链走通,分层边界自然清楚。

相关阅读

← 返回文章