文档站点
Skip to content

JPA 序列化与 DTO ​

Eloquent 有「$hidden + Resource + toArray()」控制输出;JPA 实体直接 JSON 化会踩一堆坑(懒加载 N+1、密码泄露、字段泄露)。这篇讲清楚为什么别直接返回实体,以及怎么用 DTO 优雅地控制输出。

为什么不要直接返回实体 ​

java
@GetMapping("/users/{id}")
public User show(@PathVariable Long id) {
    return userRepository.findById(id).orElseThrow();
}

三个问题:

  1. 密码/敏感字段泄露:User 里的 password 会直接进 JSON
  2. 懒加载 N+1:User 带 posts(懒加载),序列化时逐个访问 → N+1 + 可能 LazyInitializationException
  3. 字段不可控:给接口的字段和内部实体强耦合,改实体就改接口

TIP

Laravel 里你一般用 Resource 或 toArray() 定制输出;Java 里对应的最佳实践就是 DTO。

DTO:出参专用对象 ​

java
@Data
public class UserResponse {
    private Long id;
    private String username;
    private String email;
    private String role;
    private LocalDateTime createdAt;
}

转换(手动 set 或 BeanUtils):

java
public UserResponse toResponse(User user) {
    UserResponse r = new UserResponse();
    r.setId(user.getId());
    r.setUsername(user.getUsername());
    r.setEmail(user.getEmail());
    r.setRole(user.getRole());
    r.setCreatedAt(user.getCreatedAt());
    return r;
}

或更省事:

java
UserResponse r = new UserResponse();
BeanUtils.copyProperties(user, r);   // 同名属性自动拷贝(见 [常用工具](/deeper/helpers))

为什么不用 Map?

Laravel 里你可以 return $user->only(['id','name']) 返回数组;Java 里可以用 Map,但:

  • 字段名、类型全靠手写字符串,无编译期检查
  • IDE 重构、接口文档、前端类型推导全废 定 DTO 类是 Java 的推荐姿势,代价是多写几个类,换来类型安全和可维护性。

入参 DTO:FormRequest 对应物 ​

请求体也别直接绑实体(用户传什么字段就改什么字段,有字段填充漏洞):

java
@Data
public class CreateUserRequest {
    @NotBlank
    private String username;
    @Email
    private String email;
    @NotBlank
    @Size(min = 6)
    private String password;
}
java
@PostMapping("/users")
public UserResponse store(@Valid @RequestBody CreateUserRequest req) {
    User user = new User();
    user.setUsername(req.getUsername());
    user.setEmail(req.getEmail());
    user.setPassword(passwordEncoder.encode(req.getPassword()));  // 只存哈希
    return toResponse(userRepository.save(user));
}

对应 Laravel

LaravelSpring
StoreUserRequest(入参校验)CreateUserRequest(入参 DTO + 校验注解)
UserResource(出参结构)UserResponse(出参 DTO)
$request->validated()@Valid @RequestBody CreateUserRequest
User::query()->where(...) 防字段注入手动 set 需要的字段(没有 $fillable 魔法)

Jackson 注解:出参微调 ​

不方便建 DTO 时,实体上的 @JsonIgnore / @JsonProperty 能救急:

java
@Data
@Entity
@Table(name = "users")
public class User {
    @Id
    private Long id;

    @JsonIgnore                          // 响应里永远不出现
    private String password;

    @JsonProperty("created_at")          // 改名输出
    private LocalDateTime createdAt;
}

WARNING

@JsonIgnore 在实体会同时影响反序列化(请求体绑定),要小心。正规做法还是 DTO:实体保持纯净,DTO 控制进出。

懒加载字段序列化的坑 ​

实体带懒加载集合,直接序列化会 N+1 或报错:

java
@Data
@Entity
public class User {
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Post> posts = new ArrayList<>();
}

如果确实要输出 posts:

java
// 1. Service 里显式初始化(事务内)
@Transactional
public UserResponse getUserWithPosts(Long id) {
    User user = userRepository.findById(id).orElseThrow();
    user.getPosts().size();                    // 触发加载
    return toResponse(user);
}

// 2. 或查询时 EntityGraph 预加载
@Query("SELECT u FROM User u JOIN FETCH u.posts WHERE u.id = :id")
Optional<User> findWithPosts(@Param("id") Long id);

TIP

序列化 DTO 时只挑需要的字段(UserResponse 里放 List<PostResponse>),不要去碰实体的懒加载集合。这是最稳的做法。

通用响应包装 + DTO ​

结合 响应 里的 ApiResponse:

java
@GetMapping("/users/{id}")
public ApiResponse<UserResponse> show(@PathVariable Long id) {
    UserResponse r = toResponse(userRepository.findById(id).orElseThrow());
    return ApiResponse.ok(r);
}

对照速查 ​

需求EloquentSpring JPA
出参结构定制PostResourcePostResponse DTO
入参校验结构FormRequest@Valid @RequestBody XxxRequest
隐藏字段$hidden@JsonIgnore / DTO 不包含
字段改名$appends / asArray@JsonProperty
防字段注入$fillable手动 set(无魔法赋值)

结论 ​

记住三条
响应体不直接返回实体,用出参 DTO
请求体不直接绑实体,用入参 DTO + 校验
实在用实体序列化,@JsonIgnore 掉敏感字段 + 防懒加载

接下来看国内企业主流的另一个选择:MyBatis 入门。

面向 PHP / Laravel 开发者的 Spring Boot 中文文档