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();
}三个问题:
- 密码/敏感字段泄露:
User里的password会直接进 JSON - 懒加载 N+1:
User带posts(懒加载),序列化时逐个访问 → N+1 + 可能LazyInitializationException - 字段不可控:给接口的字段和内部实体强耦合,改实体就改接口
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
| Laravel | Spring |
|---|---|
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);
}对照速查
| 需求 | Eloquent | Spring JPA |
|---|---|---|
| 出参结构定制 | PostResource | PostResponse DTO |
| 入参校验结构 | FormRequest | @Valid @RequestBody XxxRequest |
| 隐藏字段 | $hidden | @JsonIgnore / DTO 不包含 |
| 字段改名 | $appends / asArray | @JsonProperty |
| 防字段注入 | $fillable | 手动 set(无魔法赋值) |
结论
| 记住三条 |
|---|
| 响应体不直接返回实体,用出参 DTO |
| 请求体不直接绑实体,用入参 DTO + 校验 |
实在用实体序列化,@JsonIgnore 掉敏感字段 + 防懒加载 |
接下来看国内企业主流的另一个选择:MyBatis 入门。