找回密码
立即注册
搜索
发回帖 发新帖

6501

积分

0

好友

796

主题
发表于 12 小时前 | 查看: 1| 回复: 0

我在不同团队里反复看到同一种模式:一个 Spring Boot 应用最初直接从 Controller 返回 JPA Entity。在 Postman 里调试时一切正常,第一个 UI 版本也顺利上线。可随着真实需求不断涌入——隐藏字段、调整响应结构、校验输入、避免 JSON 循环引用、绕过懒加载崩溃——代码虽然还能跑,但设计已经变得相当脆弱。

这篇文章主要面向初级和中级开发者,尤其是正在构建 Angular + Spring Boot 应用的读者。先看看最简单直接的做法,再分析它为什么在真实项目中会出问题,最后介绍如何用 DTO 完成一次切实可行的改造。

DTO 与 Entity 对比插画

为什么 Spring Boot API 必须区分 DTO 与 Entity

Entity 是持久化模型,它的职责是映射数据库,包括表结构、关系、懒加载和级联操作。DTO(Data Transfer Object,数据传输对象)则是 API 契约,负责跨越边界传递数据,例如 Controller 与客户端之间、Service 与外部集成之间,有时甚至包括 Service 与 Service 之间。

如果直接返回 Entity,就等于向每一个客户端宣告:数据库长什么样,API 就长什么样。今天这个客户端可能是 Angular,明天可能就是移动端。正是这种耦合,埋下了后续一系列麻烦的种子。

在大多数真实应用中,你总会需要自由调整以下内容:

  • 数据库 schema,最好不破坏任何客户端;
  • 对外暴露哪些字段,以保障安全与隐私;
  • 如何加载关联数据,以控制性能;
  • 如何校验数据,以保证正确性。

所以,“DTO 与 Entity”的区分,常常是代码从教程示例迈向生产级设计时,第一件需要认真对待的事。

如果想进一步了解 Spring 应用通常如何划分职责,不妨同步阅读《为什么 Spring Boot 项目中会有 Controller、Service 和 Repository》。

错误模式:直接从 Controller 返回 Entity

下面这种写法,很多人刚开始都写过:

@RestController
@RequestMapping("/api/users")
class UserController {

    private final UserRepository userRepository;

    UserController(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @GetMapping("/{id}")
    User getById(@PathVariable Long id) {
        return userRepository.findById(id)
                .orElseThrow();
    }
}

@Entity
class User {

    @Id @GeneratedValue
    private Long id;

    private String email;

    private String passwordHash;

    @OneToMany(mappedBy = "user")
    private List<Order> orders;
}

乍一看没什么问题,但麻烦很快就会出现。

真实项目里会暴露哪些问题

敏感数据泄露。 passwordHash 字段绝不应该离开服务器,可一旦直接返回 Entity,它就可能被序列化出去。

JSON 序列化问题。 双向关联很容易引发无限递归,或者产生体积巨大的响应体。

懒加载异常。 Controller 返回的是 User,Jackson 尝试序列化 orders;如果 Session 已经关闭,Hibernate 会直接抛出 LazyInitializationException。

API 契约在无意间改变。 重命名字段、调整关系或新增一列,都可能迫使 Angular 应用跟着改动——哪怕这些改动和 UI 的实际需求毫无关系。

这正是为什么“DTO 与 Entity”会被反复讨论。它从来不只是代码风格问题,而是在控制损害范围。

解决方案:用 DTO 定义 API 契约,把 Entity 留在内部

一种切实可行的做法,是分别为请求和响应定义 DTO。不需要堆砌几十层架构,只需要建立一条清晰的边界。

第 1 步:创建响应 DTO 与请求 DTO

让它们保持简单、明确:

// 客户端接收的内容
public record UserResponseDto(
        Long id,
        String email,
        int orderCount
) {}

// 允许客户端发送的内容
public record CreateUserRequestDto(
        @jakarta.validation.constraints.Email String email,
        @jakarta.validation.constraints.Size(min = 8, max = 100) String password
) {}

注意这里缺失了什么:没有 passwordHash,也没有 List<Order>。这是刻意为之。DTO 的结构应该服务于 API,而不是数据库。

Spring 的验证支持可参考官方文档《Spring 中的 Bean Validation》。

第 2 步:在专门的位置完成 Entity 与 DTO 的映射

映射可以放在 Service 层,也可以单独写一个 Mapper 类。关键是让 Controller 保持精简。

@Component
class UserMapper {

    UserResponseDto toDto(User user) {
        int orderCount = user.getOrders() == null ? 0 : user.getOrders().size();
        return new UserResponseDto(user.getId(), user.getEmail(), orderCount);
    }

    User toEntity(CreateUserRequestDto request, String passwordHash) {
        User user = new User();
        user.setEmail(request.email());
        user.setPasswordHash(passwordHash);
        return user;
    }
}

手动映射算不算“额外工作”?算。但这里也是你明确表达设计决策的地方,这笔投入很值得。

第 3 步:从 Controller 返回 DTO

Controller 应该处理 HTTP 和 DTO,而不是持久化模型。这个原则同样体现在《Spring Boot Controller:应该做什么,不应该做什么》中。

@RestController
@RequestMapping("/api/users")
class UserController {

    private final UserService userService;

    UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    UserResponseDto getById(@PathVariable Long id) {
        return userService.getUser(id);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    UserResponseDto create(@Valid @RequestBody CreateUserRequestDto request) {
        return userService.createUser(request);
    }
}

第 4 步:在 Service 中有意识地控制 Entity 加载

如果 DTO 需要派生数据,比如 orderCount,就明确地加载所需内容,而不是在无意间序列化半个数据库。

@Service
class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final UserMapper userMapper;

    UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, UserMapper userMapper) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.userMapper = userMapper;
    }

    public UserResponseDto getUser(Long id) {
        User user = userRepository.findById(id).orElseThrow();
        return userMapper.toDto(user);
    }

    public UserResponseDto createUser(CreateUserRequestDto request) {
        String hash = passwordEncoder.encode(request.password());
        User user = userMapper.toEntity(request, hash);
        User saved = userRepository.save(user);
        return userMapper.toDto(saved);
    }
}

如果读取逻辑开始变得复杂,可以考虑编写专门的 Repository 方法,只获取真正需要的数据。对于 JPA 相关实践,官方《Spring Data JPA 参考文档》值得收藏。

这样做如何终结前端的痛苦

Angular 并不需要你的 Entity。Angular 需要的是一个稳定的 JSON 结构,用于类型定义、校验和渲染。

一旦开始返回 DTO,Angular 模型就会变得可预测:

// user-response.dto.ts
export interface UserResponseDto {
  id: number;
  email: string;
  orderCount: number;
}

// create-user-request.dto.ts
export interface CreateUserRequestDto {
  email: string;
  password: string;
}

Service 同样会变得清爽:

@Injectable({ providedIn: 'root' })
export class UserApi {

  constructor(private http: HttpClient) {}

  getUser(id: number) {
    return this.http.get<UserResponseDto>(`/api/users/${id}`);
  }

  createUser(payload: CreateUserRequestDto) {
    return this.http.post<UserResponseDto>('/api/users', payload);
  }
}

如果想了解这层 HTTP 边界背后“不靠魔法”的原理,可以阅读《Angular 如何与 Spring Boot 通信:没有魔法的 HTTP》。

实际收益很直接:Angular 应用不再与后端的持久化决策绑定。你的 API 成为真正的产品,而不是 ORM 的副作用。

团队反复踩过的 DTO 坑

为所有场景使用一个“超级 DTO”

如果同一个 UserDto 同时处理创建、更新和响应,它迟早会变成什么都往里塞的大杂烩。当不同场景允许的字段不同时,就应当拆分请求 DTO 和响应 DTO。

任由 DTO 随意演变

DTO 是契约的一部分,任何修改都应该是刻意的。把 DTO 变更当作 API 变更来管理,而不是普通重构。

“只为内部客户端”返回 Entity

内部客户端迟早会变成外部客户端。也许会有第二个前端,也许会有合作伙伴接入。只要数据跨越 HTTP 边界,就应该使用 DTO。

用注解掩盖数据泄露

@JsonIgnore 有时确实有用,但它替代不了一套完整的 DTO 策略。它会把 JSON 相关问题与持久化代码耦合在一起,日后很容易漏掉某个字段。

明天就能用上的思维模型

  • Entity 回答:“我该如何存储这些数据?”
  • DTO 回答:“我通过网络暴露和接收什么?”
  • Mapper 回答:“我如何在两者之间安全地转换?”
  • Service 回答:“需要应用哪些业务规则?”
  • Controller 回答:“我如何处理 HTTP?”

如果始终遵循这个模型,“DTO 与 Entity”就不再是一个需要纠结的难题。代码会保持易于修改,改起来也不再让人心惊胆战。

快速检查清单:什么时候该引入 DTO

  • 你正在为 Angular 或其他客户端构建 REST API;
  • 你需要隐藏字段,比如涉及安全、隐私或内部 ID 的字段;
  • 数据存在一对多或多对多关系,且你希望控制响应体大小;
  • 创建、更新和响应需要不同的数据结构;
  • 你已经遇到过序列化问题或懒加载异常。

只要命中其中任何一条,就应该停止返回 Entity,认真定义 DTO。

如果你目前还在从 Controller 返回 Entity,不妨这周就试着改造一个端点:挑一个使用频率最高的接口,引入请求 DTO 和响应 DTO,把映射放到 Service 层,然后观察后续修改是不是变得从容很多。




上一篇:树莓派Wi-Fi速度慢排查:8个常见原因与解决方法
下一篇:RK3576车载环视卡在300ms?DMA-BUF零拷贝方案:不是算力不够,是数据搬得太勤
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-28 13:09 , Processed in 0.085502 second(s), 43 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表