Projection + record

Java Record

Java Record(Java 16 引入,Java 17+ 正式版)是一种特殊的类,专门用于不可变数据载体;它自动生成 构造器getterequals()hashCode()toString(),极大减少了样板代码:

// 传统 POJO —— 需要大量样板代码
public class UserDTO {
    private final Long id;
    private final String username;
    private final String email;
    // 构造器、getter、equals、hashCode、toString...(50+ 行)
}

// Record —— 一行搞定
public record UserDTO(Long id, String username, String email) {}

Projection(投影) 的核心思想是:只查询和返回需要的字段,而不是加载整个实体的所有列

  • 在 Spring Data JPA 中,Projection 是框架内置的概念(接口投影、类投影)
  • 在 MyBatis 中,Projection 是通过 SQL 的 SELECT 子句 + 自定义结果映射类型来实现的;( Spring Data JPA 和 MyBatis )

Projection + Record:是将两者结合,用 Java Record 作为投影的目标类型,只查询需要的字段,映射到一个不可变的 Record 中。

Spring Data JPA 和 MyBatis 是两种主流的持久层框架,两者的设计理念和工作方式截然不同;因为 MyBatis 面向 SQL,极其灵活,能够应对各种复杂场景,因此使用更加广泛。


由于 Record 没有 Setter 方法,MyBatis 会通过全参构造函数进行实例化;

在 DTO 和 Projection 场景中,应该全面废弃 Lombok 的 @Data

2026年的最佳实践是结合 JSpecify 来定义 Record,如下:

public record UserProjection( 
    Long id, 
    String username, 
    @Nullable String nickname // 明确标记可能为空的字段 
) {}


在 Spring Boot 4.0.5 + MyBatis 中的使用

方式一:注解方式(推荐用于简单查询)

java
// 1. 定义 Projection Record
public record UserSummary(Long id, String username, String email) {}

// 2. Mapper 接口
@Mapper
public interface UserMapper {

    // 简单情况:列名与 Record 组件名完全一致,MyBatis 可自动通过构造器映射
    @Select("SELECT id, username, email FROM users WHERE active = true")
    List<UserSummary> findActiveUserSummaries();

    // 复杂情况:列名不一致时,使用 @ConstructorArgs 显式映射
    @Select("SELECT u.id, u.user_name, u.email_addr FROM users u WHERE u.id = #{id}")
    @ConstructorArgs({
        @Arg(column = "id", javaType = Long.class),
        @Arg(column = "user_name", javaType = String.class),
        @Arg(column = "email_addr", javaType = String.class)
    })
    UserSummary findUserById(@Param("id") Long id);
}

方式二:XML 方式(推荐用于复杂查询)

xml
<!-- UserMapper.xml -->
<resultMap id="userSummaryMap" type="com.example.dto.UserSummary">
    <constructor>
        <arg column="id" javaType="java.lang.Long"/>
        <arg column="user_name" javaType="java.lang.String"/>
        <arg column="email_addr" javaType="java.lang.String"/>
    </constructor>
</resultMap>

<select id="findActiveUsers" resultMap="userSummaryMap">
    SELECT id, user_name, email_addr
    FROM users
    WHERE active = 1
</select>

方式三:自动映射(MyBatis 3.5.10+,最简洁)

当 SQL 列名/别名与 Record 组件名完全匹配时,MyBatis 可以自动通过构造器注入,无需额外配置

java
public record UserSummary(Long id, String username, String email) {}

@Mapper
public interface UserMapper {
    // 列名与 Record 组件名一致,自动映射
    @Select("SELECT id, username, email FROM users WHERE id = #{id}")
    UserSummary findById(@Param("id") Long id);
}


2026 年最佳实践(Spring Boot 4.0.5 + MyBatis)

1>. 分层架构中的 Record 使用

┌─────────────────────────────────────────────────────────┐
│  Controller 层    ←  使用 Response Record(API 响应)     │
│  Service 层       ←  业务逻辑、Record 之间的转换          │
│  Mapper 层        ←  使用 Projection Record(数据库投影)  │
│  数据库           ←  只查询需要的列                       │
└─────────────────────────────────────────────────────────┘


// ✅ Mapper 层:投影 Record(只包含数据库字段)
public record UserProjection(Long id, String username, String email, LocalDateTime createdAt) {}

// ✅ Controller 层:响应 Record(面向前端,可与投影不同)
public record UserResponse(Long id, String username, String email, String createdAtFormatted) {}

// ✅ Service 层:转换
@Service
public class UserService {
    
    private final UserMapper userMapper;
    
    public UserResponse getUserById(Long id) {
        UserProjection projection = userMapper.findById(id);
        return new UserResponse(
            projection.id(),
            projection.username(),
            projection.email(),
            projection.createdAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
        );
    }
}


2. Record 命名规范


3. 复杂查询的处理策略

// ✅ 对于一对多关系,使用嵌套查询 + 在 Service 层组装
public record OrderProjection(Long orderId, String orderNo, BigDecimal totalAmount) {}
public record OrderItemProjection(Long orderId, String productName, Integer quantity) {}

public record OrderDetailResponse(
    Long orderId,
    String orderNo,
    BigDecimal totalAmount,
    List<OrderItemProjection> items  // 在 Service 层组装
) {}

@Service
public class OrderService {
    public OrderDetailResponse getOrderDetail(Long orderId) {
        OrderProjection order = orderMapper.findOrderById(orderId);
        List<OrderItemProjection> items = orderMapper.findItemsByOrderId(orderId);
        return new OrderDetailResponse(
            order.orderId(), order.orderNo(), order.totalAmount(), items
        );
    }
}

4. 与 Spring Boot 4.0.5 新特性结合

// ✅ 用 Record 做不可变配置(Spring Boot 4.0.5 完全支持)
@ConfigurationProperties(prefix = "app.mail")
public record MailProperties(String host, int port, String from) {}

// ✅ 用 Record 做 Controller 请求体绑定
@RestController
@RequestMapping("/api/users")
public class UserController {
    
    public record CreateUserRequest(
        @NotBlank String username,
        @Email String email,
        @Size(min = 6) String password
    ) {}  // Record + Jakarta Validation 注解
    
    @PostMapping
    public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
        return userService.createUser(request);
    }
}

5. 避免的反模式

// ❌ 不要给 Record 添加业务逻辑
public record UserProjection(Long id, String username) {
    public boolean isAdmin() { return "admin".equals(username); }  // 不推荐
}

// ❌ 不要用 Record 做可变实体(Record 天然不可变)
// 如果需要更新数据库,使用 Map 或专门的 Command/Request 对象

// ❌ 不要用一个 Record 满足所有场景
// 应该为不同的查询场景创建不同的 Projection Record

// ✅ 正确做法:按场景拆分
public record UserListItem(Long id, String username) {}        // 列表页
public record UserDetail(Long id, String username, String email, String phone) {}  // 详情页
public record UserBrief(String username) {}                     // 下拉选择器

核心原则:所有只读数据传输对象都应该优先使用 Record。只有在需要可变对象时,才使用传统 POJO。

通常 1 个 Entity 对应 1~3 个 Projection,因此 Record 不会太多;如果 Record 超过 5 个了,说明可能过度设计了。


可变用 Record,不可变用 Entity; 两者职责不同,不是替代关系。

Record 不可变 和它能不能用于写操作是两个不同的概念;只要写入时不需要改变实体,那 Record 完全可以使用,也推荐使用;如下:

// 前端传来 JSON → Spring 反序列化成 Record → 一次性构造完毕,之后不再修改
public record UserCreateRequest(@NotBlank String username, @Email String email) {}

Record 本身是不可变的(没有 setter),它的用途是承载写操作的入参。流程是:

即,entity 是"要改的",record 是"不用改的";Request 虽然服务于写流程,但它自己不需要被改,所以应该使用 record。


Entity(实体):代表数据库中的一条记录,是与表结构 1:1 映射的基石。

Record(投影/DTO):代表某个业务场景下的数据视图,是不可变的快照。


安全性:如果把 User Entity 返回给前端,可能会无意中泄露 password_hashinternal_remark 等敏感字段。

性能:Entity 往往包含很多大字段(如 long_text 类型的备注),但在列表展示时只需要 idname;用 Record 做投影可以显著减少内存占用和 SQL 查询开销。


内部 Record(Scoped Records):如果一个 Record 只用于某个特定的 Service 方法,可以将其定义在接口内部或类内部,避免污染全局命名空间。

按需聚合:不要为每个微小的差异都建 Record。如果两个接口需要的字段 90% 重合,可以复用。

管理 50 个清晰的 Record,远比维护一个带有 100 个字段、充满 if(null) 判断、且不知道哪些字段在哪个接口被使用的“超级实体”要容易得多。



举报

© 著作权归作者所有


0