Java投影完全指南-从开发痛点到实际应用

52 阅读24分钟

Java投影完全指南-从开发痛点到实际应用

前置知识点

为了更好地理解我写的内容,建议你先掌握以下核心知识点:

必需知识点

  • Java 基础语法:熟悉类、接口、方法、字段等基本概念

  • 面向对象编程:理解封装、继承、多态,以及接口的使用

  • 反射机制:了解 ClassFieldMethod 等反射 API 的基本用法

  • Lambda 表达式:掌握方法引用(如 User::getName)和函数式接口(Function<T, R>

推荐知识点

  • Spring Data JPA:了解 Repository 接口和查询方法的基本使用

  • MyBatis:熟悉 resultMapresultType 的配置

  • DTO/VO 模式:理解数据传输对象和视图对象的概念

  • 注解机制:了解 @Entity@Table@JsonIgnore 等常用注解

可选知识点

  • Spring Framework:了解依赖注入和 AOP 的基本概念

  • 泛型编程:理解泛型在集合和接口中的应用

  • RESTful API:熟悉 HTTP 方法和 JSON 数据格式

💡 提示:如果你对某些知识点不熟悉,可以在阅读过程中随时查阅相关资料。我会尽量提供详细的代码示例和注释,帮助你理解投影技术的应用。


00 开发痛点与避坑策略

你是否遇到过这些问题?

问题一:DTO/VO 类爆炸,维护成本居高不下

你是否遇到过这样的场景:为了满足不同 API 接口的需求,需要创建 UserDTOUserVOUserSummaryDTOUserDetailVO 等大量相似的数据传输对象?每次实体类字段变更,都要同步修改多个 DTO/VO 类,稍有不慎就会遗漏,导致字段不一致的 Bug。更糟糕的是,这些类中充斥着大量重复的 getter/setter 方法,代码冗长且难以维护。

问题二:查询性能瓶颈,返回了不需要的数据

你是否遇到过这样的性能问题:从数据库查询用户信息时,只需要返回 idnameemail 三个字段,但 JPA 或 MyBatis 却返回了完整的实体对象,包含 passwordcreateTimeupdateTimedeleted 等几十个字段?这不仅浪费了网络带宽和内存,还可能导致敏感信息泄露。在高并发场景下,这种不必要的字段传输会显著影响系统性能。

问题三:手动映射代码繁琐,容易出错

你是否遇到过这样的开发困境:需要在 User 实体和 UserDTO 之间进行字段映射,不得不编写大量类似 dto.setName(user.getName()) 的样板代码?当字段数量达到几十个时,这种手动映射不仅枯燥乏味,还容易出错。更麻烦的是,当实体类字段类型发生变化时,映射代码可能因为类型不匹配而编译失败,需要逐个检查和修改。

问题四:API 响应臃肿,包含敏感或冗余信息

你是否遇到过这样的安全问题:API 返回的 JSON 响应中包含了 passwordsaltinternalId 等敏感字段,或者包含了前端根本不需要的 versiondeletedcreateTime 等字段?虽然可以通过 @JsonIgnore 注解来排除,但这种方式不够灵活,无法根据不同场景动态控制返回字段。而且,当需要为不同客户端返回不同字段时,这种静态配置方式就显得力不从心。

问题五:多层映射导致性能回退和调试困难

你是否遇到过这样的架构问题:在分层架构中,数据从 EntityDTOVO 经过多层转换,每一层都要进行字段映射和对象复制?这不仅增加了 CPU 开销和 GC 压力,还让问题排查变得困难。当某个字段在最终输出中丢失或错误时,需要在多个映射层之间追踪,定位问题耗时耗力。

为了解决这些问题,强烈建议你学习投影

如果你正在被上述问题困扰,强烈建议你深入学习 Java 投影(Projection)技术。投影是一种优雅的解决方案,它允许你只选择需要的字段,而不是返回完整的对象。通过投影,你可以:

  • 减少 DTO/VO 类的数量:使用接口投影或动态投影,无需为每个场景创建专门的类

  • 提升查询性能:只查询和返回需要的字段,减少数据传输和内存占用

  • 简化映射代码:框架自动处理字段映射,无需手动编写样板代码

  • 灵活控制输出:根据不同场景动态选择返回字段,支持字段脱敏和裁剪

  • 降低维护成本:字段变更时,投影自动适配,减少人工维护工作

通过本文,你可以获得什么

通过深入学习我写的内容,你将全面掌握 Java 投影的核心知识:

  1. 理解投影的本质:从基础概念出发,理解投影与复制、映射、序列化的区别,掌握静态投影和动态投影的适用场景

  2. 掌握运行机制:深入理解投影在 JVM 层面的实现原理,包括反射、字节码增强、注解处理器等三种实现策略的底层机制

  3. 学会实际应用:通过 Spring Data JPA、MapStruct 等框架的实际案例,掌握接口投影、类投影、动态投影的具体用法

  4. 规避常见陷阱:了解类型擦除、ClassLoader 边界、性能瓶颈等常见问题,学会如何避坑和优化

  5. 设计优雅架构:学会在分层架构中合理规划投影层,结合响应式和云原生场景,设计高性能、可维护的投影策略

无论你是刚接触投影的新手,还是希望深入理解其运行机制的资深开发者,我将为你提供系统性的指导和实践建议。让我带你一起探索 Java 投影的奥秘,解决实际开发中的痛点问题!

01 基础概念与术语澄清

投影的定义

投影(Projection) 在 Java 开发中,是指从数据源(如数据库实体、对象实例)中选择特定字段或属性,以构建仅包含所需数据的对象或数据结构的过程。投影的核心思想是"按需选择",而不是"全量复制"。

投影的概念来源于数学和数据库理论。在关系数据库中,投影操作(Projection Operation)是指从关系表中选择特定的列,生成一个新的关系表。在 Java 中,投影将这一概念扩展到了对象层面,允许我们从复杂的对象结构中提取部分字段,形成轻量级的视图。

投影的本质特征包括:

  1. 选择性提取:只选择需要的字段,忽略不需要的字段

  2. 结构转换:可以将源对象的结构转换为目标结构(字段重命名、类型转换等)

  3. 性能优化:减少数据传输量和内存占用

  4. 安全性增强:避免暴露敏感字段

投影与复制、映射、序列化的区别

为了更好地理解投影,让我为你区分几个容易混淆的概念:

投影 vs 复制(Copy)

复制是指创建一个与源对象完全相同的新对象,包含所有字段的完整副本。


// 复制:完整复制所有字段

User user = new User(1L, "张三", "zhangsan@example.com", "password123", ...);

User copiedUser = new User(user.getId(), user.getName(), user.getEmail(),

                          user.getPassword(), ...); // 所有字段都复制

投影则是只选择部分字段,创建一个轻量级的视图对象。


// 投影:只选择需要的字段

UserProjection projection = new UserProjection(user.getId(), user.getName(),

                                               user.getEmail()); // 只选择3个字段

关键区别

  • 复制:包含所有字段,数据完整但可能冗余

  • 投影:只包含需要的字段,数据精简但可能不完整

投影 vs 映射(Mapping)

映射是指将一个对象的所有字段按照某种规则转换到另一个对象,通常是一对一的字段对应关系。


// 映射:字段一一对应转换

UserDTO dto = new UserDTO();

dto.setUserId(user.getId());

dto.setUserName(user.getName());

dto.setUserEmail(user.getEmail());

// ... 所有字段都要映射

投影则更灵活,可以选择性地映射字段,甚至可以改变字段名称和结构。


// 投影:选择性映射,可以重命名

interface UserSummary {

    Long getId();

    String getDisplayName(); // 字段名可以不同

    // 不需要的字段可以不包含

}

关键区别

  • 映射:通常是全量映射,字段一一对应

  • 投影:选择性映射,可以灵活调整字段

投影 vs 序列化(Serialization)

序列化是指将对象转换为字节流或字符串(如 JSON、XML),用于网络传输或持久化存储。


// 序列化:将对象转为 JSON

ObjectMapper mapper = new ObjectMapper();

String json = mapper.writeValueAsString(user);

// 结果:{"id":1,"name":"张三","email":"...","password":"..."}

// 包含所有字段

投影可以在序列化之前先进行字段选择,只序列化需要的字段。


// 投影 + 序列化:先投影再序列化

UserProjection projection = project(user, UserProjection.class);

String json = mapper.writeValueAsString(projection);

// 结果:{"id":1,"name":"张三","email":"..."}

// 只包含投影选择的字段

关键区别

  • 序列化:关注数据格式转换,通常包含所有字段

  • 投影:关注字段选择,可以与序列化结合使用

投影的常见类型

根据投影的实现时机和方式,可以将投影分为两大类:

静态投影(编译期投影)

静态投影是指在编译期就确定投影的字段和结构,投影规则在编译时就已经固定。这种投影方式通常通过接口定义或注解来声明。

特点

  • ✅ 类型安全:编译期检查,避免运行时错误

  • ✅ 性能优秀:编译期生成代码,运行时无额外开销

  • ✅ IDE 支持好:代码补全、重构等功能完善

  • ❌ 灵活性较低:投影规则固定,无法动态调整

实现方式

  • 接口投影(Interface Projection):定义接口,框架自动实现

  • 类投影(Class Projection):定义 DTO 类,使用注解或工具生成映射代码

  • 注解处理器:编译期生成投影代码

示例


// 接口投影(Spring Data JPA)

interface UserSummary {

    Long getId();

    String getName();

    String getEmail();

}

  


// 使用

List<UserSummary> users = userRepository.findAll();

动态投影(运行期投影)

动态投影是指在运行时根据配置或参数动态决定投影的字段和结构。这种投影方式更加灵活,但需要运行时处理。

特点

  • ✅ 灵活性高:可以根据不同场景动态调整投影字段

  • ✅ 配置驱动:可以通过配置文件或参数控制投影行为

  • ❌ 性能开销:运行时反射或字节码生成,有一定性能成本

  • ❌ 类型安全较弱:运行时才能发现错误

实现方式

  • 反射投影:使用反射 API 动态访问字段

  • 字节码生成:运行时动态生成投影类

  • 表达式投影:使用 SpEL、OGNL 等表达式语言

示例


// 动态投影(使用反射)

Map<String, Object> projection = projectFields(user,

    Arrays.asList("id", "name", "email"));

相关概念

为了更好地理解投影,让我为你介绍一些相关概念:

数据传输对象(DTO - Data Transfer Object)

DTO 是一种设计模式,用于在不同层之间传输数据。DTO 通常只包含数据字段和简单的 getter/setter 方法,不包含业务逻辑。

DTO 与投影的关系

  • DTO 可以作为投影的目标类型

  • 投影可以用于将实体对象转换为 DTO

  • 但投影不一定要使用 DTO,也可以使用接口或其他结构


// DTO 示例

public class UserDTO {

    private Long id;

    private String name;

    private String email;

    // getters and setters

}

  


// 使用投影将 Entity 转为 DTO

UserDTO dto = project(user, UserDTO.class);

视图对象(VO - View Object)

VO 是专门为视图层设计的数据对象,通常包含展示所需的所有字段,可能包含格式化后的数据。

VO 与投影的关系

  • VO 是投影的典型应用场景

  • 投影可以将多个实体对象的数据组合成一个 VO

  • 投影可以用于字段格式化、计算字段等


// VO 示例

public class UserVO {

    private Long id;

    private String displayName; // 格式化后的名称

    private String email;

    private String statusText; // 计算字段

}

反射(Reflection)

反射是 Java 提供的一种机制,允许程序在运行时检查和操作类、方法、字段等元信息。

反射在投影中的作用

  • 动态获取类的字段信息

  • 动态访问和设置字段值

  • 实现动态投影的核心技术


// 反射在投影中的应用

// 通过反射机制,我可以动态地获取对象的字段信息,并提取字段值用于投影

  


// 1. 获取源对象的 Class 对象

// getClass() 方法返回对象的运行时类型(Runtime Type)

// 例如:如果 user 是 User 类的实例,则 clazz 就是 User.class

Class<?> clazz = user.getClass();

  


// 2. 获取类中声明的所有字段(不包括继承的字段)

// getDeclaredFields() 返回一个 Field 数组,包含当前类中声明的所有字段

// 包括 private、protected、public 和 package-private 字段

// 注意:不包括从父类继承的字段,如果需要父类字段,需要使用 getFields() 或递归查找

Field[] fields = clazz.getDeclaredFields();

  


// 3. 遍历所有字段,提取字段值

for (Field field : fields) {

    // 3.1 设置字段可访问性

    // 默认情况下,private 和 protected 字段无法通过反射直接访问

    // setAccessible(true) 会绕过 Java 的访问控制检查,允许访问私有字段

    // 注意:这可能会触发 SecurityManager 的安全检查,在某些安全环境中可能失败

    field.setAccessible(true);

   

    // 3.2 获取字段的值

    // field.get(user) 会返回该字段在 user 对象中的实际值

    // 返回类型是 Object,因为字段类型可能不同,需要后续进行类型转换

    // 如果字段是基本类型(如 int、long),会自动装箱为对应的包装类型(Integer、Long)

    Object value = field.get(user);

   

    // 3.3 使用字段值进行投影

    // 这里可以根据投影规则,将字段值复制到目标对象中

    // 例如:检查字段名是否在投影字段列表中,如果是则复制到投影对象

    // 也可以进行字段重命名、类型转换、格式化等操作

    // 实际实现中,通常会结合字段名、字段类型等信息来决定如何处理该字段

}

Lambda 元模型(Lambda Metamodel)

Lambda 元模型是指通过 Lambda 表达式来引用类的属性,提供类型安全的属性访问方式。

Lambda 元模型在投影中的应用

  • 类型安全的字段选择

  • 编译期检查字段是否存在

  • 支持 IDE 重构


// Lambda 元模型示例:使用函数式接口实现类型安全的属性投影

// Lambda 元模型的核心思想是使用 Function<T, R> 等函数式接口来引用类的属性

// 这样可以避免使用字符串常量(如 "name"、"email"),提供编译期类型检查

  


import java.util.HashMap;

import java.util.Map;

import java.util.function.Function;

  


// 源对象类

public class User {

    private Long id;

    private String name;

    private String email;

    private String password; // 敏感字段,不应投影

   

    // 构造函数和 getter 方法

    public User(Long id, String name, String email, String password) {

        this.id = id;

        this.name = name;

        this.email = email;

        this.password = password;

    }

   

    public Long getId() { return id; }

    public String getName() { return name; }

    public String getEmail() { return email; }

    public String getPassword() { return password; }

}

  


// 投影工具类:使用 Lambda 元模型实现类型安全的字段投影

public class ProjectionUtil {

   

    // 使用 Lambda 元模型进行投影的核心方法

    // 参数:source 是源对象,extractors 是属性提取器数组(Lambda 表达式)

    // 返回值:包含投影字段的 Map

    // 优势:类型安全,编译期检查,支持 IDE 重构

    public static <T> Map<String, Object> project(T source, Function<T, ?>... extractors) {

        Map<String, Object> result = new HashMap<>();

       

        // 遍历每个属性提取器(Lambda 函数)

        for (Function<T, ?> extractor : extractors) {

            // 使用 Lambda 函数提取属性值

            // extractor.apply(source) 会调用对应的方法引用(如 User::getName)

            Object value = extractor.apply(source);

           

            // 获取属性名称(通过 Lambda 的 toString() 方法,实际项目中可以使用更复杂的方式)

            // 注意:这里简化了属性名的获取,实际可以使用字节码分析或缓存机制

            String fieldName = extractor.toString(); // 简化示例

           

            result.put(fieldName, value);

        }

       

        return result;

    }

   

    // 更完善的投影方法:支持指定字段名

    // 使用 Map.Entry 来同时保存属性提取器和字段名

    public static <T> Map<String, Object> projectWithNames(

            T source,

            Map.Entry<String, Function<T, ?>>... fieldExtractors) {

        Map<String, Object> result = new HashMap<>();

       

        for (Map.Entry<String, Function<T, ?>> entry : fieldExtractors) {

            String fieldName = entry.getKey(); // 获取字段名

            Function<T, ?> extractor = entry.getValue(); // 获取属性提取器

            Object value = extractor.apply(source); // 提取属性值

            result.put(fieldName, value);

        }

       

        return result;

    }

}

  


// 使用示例

public class LambdaMetamodelExample {

    public static void main(String[] args) {

        // 创建源对象

        User user = new User(1L, "张三", "zhangsan@example.com", "secret123");

       

        // 方式一:使用 Lambda 元模型进行投影

        // User::getId 是方法引用,相当于 (User u) -> u.getId()

        // 这种方式是类型安全的:如果 User 类没有 getId() 方法,编译时就会报错

        // 如果重命名 getId() 方法,IDE 会自动更新所有引用

        Map<String, Object> projection1 = ProjectionUtil.project(

            user,

            User::getId,      // 提取 id 字段

            User::getName,    // 提取 name 字段

            User::getEmail    // 提取 email 字段

            // 注意:没有包含 User::getPassword,实现了字段过滤

        );

       

        // 方式二:使用带字段名的投影方法

        // 使用 Map.entry() 创建字段名和提取器的映射

        Map<String, Object> projection2 = ProjectionUtil.projectWithNames(

            user,

            Map.entry("id", User::getId),

            Map.entry("name", User::getName),

            Map.entry("email", User::getEmail)

        );

       

        // Lambda 元模型的优势:

        // 1. 类型安全:编译期检查,如果方法不存在会编译错误

        // 2. IDE 支持:重命名方法时,所有引用会自动更新

        // 3. 避免字符串硬编码:不需要使用 "name" 这样的字符串常量

        // 4. 性能优秀:方法引用在运行时性能接近直接方法调用

        // 5. 代码简洁:使用 :: 语法,代码更简洁易读

       

        // 对比:使用字符串的投影方式(不推荐)

        // Map<String, Object> badProjection = projectByFieldNames(user, "id", "name", "email");

        // 缺点:

        // - 如果字段名拼写错误,运行时才会发现

        // - 重命名字段时,字符串不会自动更新

        // - 没有类型检查,可能返回错误类型的值

    }

}

小结

投影是 Java 开发中一种重要的数据转换技术,它通过选择性提取字段来优化性能、增强安全性、简化代码。理解投影与复制、映射、序列化的区别,掌握静态投影和动态投影的特点,了解 DTO、VO、反射等相关概念,是深入学习投影技术的基础。在后续章节中,我会深入探讨投影的运行机制和实际应用。

02 Java投影的主要应用场景

投影技术在 Java 开发中有着广泛的应用场景。理解这些场景有助于你在实际项目中正确选择和使用投影技术,提升系统性能、安全性和可维护性。

场景一:持久化框架中的列裁剪(JPA、MyBatis)

在数据库查询中,投影最常见的应用就是列裁剪(Column Pruning),即只查询需要的列,而不是查询整个表的所有列。这对于包含大量字段的表或需要频繁查询的场景尤为重要。

Spring Data JPA 中的投影

Spring Data JPA 提供了两种主要的投影方式:接口投影类投影

1. 接口投影(Interface Projection)

接口投影是最常用且最优雅的方式,通过定义接口来声明需要投影的字段:


// 定义投影接口

public interface UserSummary {

    Long getId();

    String getName();

    String getEmail();

    // 只包含需要的字段,不需要的字段(如 password)不包含

}

  


// 在 Repository 中使用

public interface UserRepository extends JpaRepository<User, Long> {

    // 方法返回类型使用投影接口

    List<UserSummary> findAll();

   

    // 也可以用于单个查询

    UserSummary findById(Long id);

   

    // 支持条件查询

    List<UserSummary> findByNameContaining(String name);

}

  


// 使用示例

@Service

public class UserService {

    @Autowired

    private UserRepository userRepository;

   

    public List<UserSummary> getAllUserSummaries() {

        // 查询时只返回 id、name、email 三个字段

        // 不会查询 password、createTime 等其他字段

        return userRepository.findAll();

    }

}

接口投影的优势

  • ✅ 类型安全:编译期检查,IDE 支持良好

  • ✅ 性能优秀:JPA 会生成只查询指定列的 SQL

  • ✅ 代码简洁:无需创建额外的 DTO 类

  • ✅ 自动映射:框架自动处理字段映射

2. 类投影(Class-based Projection)

类投影使用 DTO 类来定义投影结构:


// 定义投影类(DTO)

public class UserProjection {

    private Long id;

    private String name;

    private String email;

   

    // 必须包含构造函数,参数顺序和类型必须与查询结果匹配

    public UserProjection(Long id, String name, String email) {

        this.id = id;

        this.name = name;

        this.email = email;

    }

   

    // getter 方法

    public Long getId() { return id; }

    public String getName() { return name; }

    public String getEmail() { return email; }

}

  


// 在 Repository 中使用

public interface UserRepository extends JpaRepository<User, Long> {

    // 使用 @Query 注解配合构造函数投影

    @Query("SELECT new com.example.dto.UserProjection(u.id, u.name, u.email) " +

           "FROM User u WHERE u.status = 'ACTIVE'")

    List<UserProjection> findActiveUsers();

}

类投影的适用场景

  • 需要包含计算字段或聚合结果

  • 需要多个实体对象的字段组合

  • 需要更复杂的字段转换逻辑

MyBatis 中的投影

MyBatis 通过 resultMapresultType 来实现投影:


// 方式一:使用 resultType(自动映射)

// Mapper 接口

public interface UserMapper {

    // 返回简化的 DTO,只包含需要的字段

    List<UserDTO> selectUserSummaries();

}

  


// Mapper XML

<mapper namespace="com.example.mapper.UserMapper">

    <!-- 只查询需要的列 -->

    <select id="selectUserSummaries" resultType="com.example.dto.UserDTO">

        SELECT

            id,

            name,

            email

        FROM users

        <!-- 不查询 password、create_time 等字段 -->

    </select>

</mapper>

  


// 方式二:使用 resultMap(显式映射)

<resultMap id="UserSummaryMap" type="com.example.dto.UserDTO">

    <id property="id" column="id"/>

    <result property="name" column="name"/>

    <result property="email" column="email"/>

    <!-- 只映射需要的字段 -->

</resultMap>

  


<select id="selectUserSummaries" resultMap="UserSummaryMap">

    SELECT id, name, email FROM users

</select>

MyBatis 投影的优势

  • ✅ SQL 控制精确:可以精确控制查询的列

  • ✅ 性能可控:可以优化 SQL 查询语句

  • ✅ 灵活性高:支持复杂的 SQL 查询和字段映射

性能对比示例

// 不使用投影:查询所有字段

// SQL: SELECT * FROM users WHERE id = 1

// 返回:id, name, email, password, salt, create_time, update_time, deleted, version...

User user = userRepository.findById(1L);

// 内存占用:假设 User 对象占用 500 字节

  


// 使用投影:只查询需要的字段

// SQL: SELECT id, name, email FROM users WHERE id = 1

// 返回:id, name, email

UserSummary summary = userRepository.findById(1L);

// 内存占用:假设 UserSummary 对象占用 100 字节

// 性能提升:内存占用减少 80%,查询速度提升 30-50%

场景二:API 层的响应瘦身与脱敏

在 RESTful API 开发中,投影技术可以用于响应瘦身(减少响应数据量)和数据脱敏(隐藏敏感信息),这是投影技术最重要的应用场景之一。

响应瘦身:减少数据传输量

问题场景

  • 前端只需要用户的基本信息,但后端返回了完整的用户对象

  • 移动端网络带宽有限,需要减少数据传输量

  • 列表接口返回大量数据,需要优化响应大小

解决方案


// 定义不同场景的投影接口

public interface UserBasicInfo {

    Long getId();

    String getName();

    String getAvatar();

}

  


public interface UserDetailInfo {

    Long getId();

    String getName();

    String getEmail();

    String getPhone();

    LocalDateTime getCreateTime();

    // 不包含 password、salt 等敏感字段

}

  


// Controller 层使用投影

@RestController

@RequestMapping("/api/users")

public class UserController {

   

    @Autowired

    private UserService userService;

   

    // 列表接口:返回基本信息

    @GetMapping

    public ResponseEntity<List<UserBasicInfo>> getUsers() {

        // 只返回 id、name、avatar 三个字段

        List<UserBasicInfo> users = userService.findAllBasicInfo();

        return ResponseEntity.ok(users);

    }

   

    // 详情接口:返回详细信息(但不包含敏感信息)

    @GetMapping("/{id}")

    public ResponseEntity<UserDetailInfo> getUserDetail(@PathVariable Long id) {

        UserDetailInfo user = userService.findDetailInfoById(id);

        return ResponseEntity.ok(user);

    }

}

数据脱敏:保护敏感信息

问题场景

  • API 响应中可能包含密码、身份证号、手机号等敏感信息

  • 不同用户角色需要看到不同级别的信息

  • 日志记录时需要脱敏处理

解决方案


// 定义脱敏投影接口

public interface UserPublicInfo {

    Long getId();

    String getName();

   

    // 邮箱脱敏:zhangsan@example.com -> z****n@example.com

    @JsonSerialize(using = EmailMaskingSerializer.class)

    String getEmail();

   

    // 手机号脱敏:13812345678 -> 138****5678

    @JsonSerialize(using = PhoneMaskingSerializer.class)

    String getPhone();

   

    // 不包含 password、salt、idCard 等敏感字段

}

  


// 自定义序列化器实现脱敏

public class EmailMaskingSerializer extends JsonSerializer<String> {

    @Override

    public void serialize(String email, JsonGenerator gen, SerializerProvider serializers)

            throws IOException {

        if (email == null || !email.contains("@")) {

            gen.writeString(email);

            return;

        }

        String[] parts = email.split("@");

        String username = parts[0];

        String domain = parts[1];

       

        // 脱敏规则:保留首尾字符,中间用 * 替代

        String masked = username.charAt(0) +

                       "*".repeat(Math.max(0, username.length() - 2)) +

                       username.charAt(username.length() - 1) +

                       "@" + domain;

        gen.writeString(masked);

    }

}

  


// 使用示例

@GetMapping("/public/{id}")

public ResponseEntity<UserPublicInfo> getPublicUserInfo(@PathVariable Long id) {

    // 返回脱敏后的用户信息

    UserPublicInfo user = userService.findPublicInfoById(id);

    return ResponseEntity.ok(user);

}

GraphQL 风格的字段选择

对于需要客户端动态选择字段的场景,可以使用类似 GraphQL 的字段选择机制:


// 支持字段选择的投影工具

public class FieldSelector {

    public static <T> Map<String, Object> selectFields(T source, String... fields) {

        Map<String, Object> result = new HashMap<>();

        // 使用反射或字节码技术动态提取指定字段

        // ...

        return result;

    }

}

  


// Controller 支持 fields 参数

@GetMapping("/{id}")

public ResponseEntity<Map<String, Object>> getUser(

        @PathVariable Long id,

        @RequestParam(required = false) String fields) {

   

    User user = userService.findById(id);

   

    if (fields != null && !fields.isEmpty()) {

        // 客户端指定需要的字段:?fields=id,name,email

        String[] fieldArray = fields.split(",");

        return ResponseEntity.ok(FieldSelector.selectFields(user, fieldArray));

    } else {

        // 默认返回所有字段

        return ResponseEntity.ok(convertToMap(user));

    }

}

场景三:事件/消息中的结构降维

在事件驱动架构和消息队列场景中,投影技术可以用于结构降维,即只传递事件处理所需的最小数据集,减少消息大小和网络传输开销。

事件发布中的投影

// 完整的事件对象(可能包含大量字段)

public class UserCreatedEvent {

    private Long userId;

    private String name;

    private String email;

    private String password; // 敏感信息,不应在事件中传递

    private String phone;

    private Address address; // 复杂对象

    private List<Role> roles; // 集合对象

    private Map<String, Object> metadata; // 元数据

    // ... 更多字段

}

  


// 使用投影创建轻量级事件

public interface UserCreatedEventProjection {

    Long getUserId();

    String getName();

    String getEmail();

    // 只包含事件处理所需的最小字段集

}

  


// 事件发布服务

@Service

public class EventPublisher {

    @Autowired

    private RabbitTemplate rabbitTemplate;

   

    public void publishUserCreatedEvent(User user) {

        // 使用投影创建轻量级事件对象

        UserCreatedEventProjection event = createEventProjection(user);

       

        // 发布事件(消息大小减少 70%)

        rabbitTemplate.convertAndSend("user.exchange", "user.created", event);

    }

   

    private UserCreatedEventProjection createEventProjection(User user) {

        // 使用投影工具将 User 转换为投影对象

        return ProjectionUtil.project(user, UserCreatedEventProjection.class);

    }

}

消息队列中的字段选择

// Kafka 消息投影

@Component

public class OrderMessageProducer {

   

    @Autowired

    private KafkaTemplate<String, Object> kafkaTemplate;

   

    public void sendOrderCreatedMessage(Order order) {

        // 订单对象可能包含大量字段,但下游服务可能只需要部分字段

        OrderMessageProjection message = OrderMessageProjection.builder()

            .orderId(order.getId())

            .userId(order.getUserId())

            .totalAmount(order.getTotalAmount())

            .status(order.getStatus())

            .createTime(order.getCreateTime())

            // 不包含订单详情、支付信息等下游不需要的字段

            .build();

       

        kafkaTemplate.send("order-created-topic", message);

    }

}

  


// 消息投影接口

public interface OrderMessageProjection {

    Long getOrderId();

    Long getUserId();

    BigDecimal getTotalAmount();

    String getStatus();

    LocalDateTime getCreateTime();

}

事件/消息投影的优势

  • ✅ 减少消息大小:降低网络传输开销和存储成本

  • ✅ 提高处理速度:下游服务处理更轻量级的数据

  • ✅ 降低耦合度:只传递必要的字段,减少服务间依赖

  • ✅ 保护隐私:敏感信息不会在消息中传递

场景四:动态插件、规则引擎的上下文透传

在插件系统和规则引擎中,投影技术可以用于上下文透传,即只传递插件或规则执行所需的数据,避免传递完整的业务对象。

插件系统中的上下文投影

// 完整的业务上下文(可能包含大量数据)

public class BusinessContext {

    private User currentUser;

    private Order currentOrder;

    private List<Product> products;

    private PaymentInfo paymentInfo;

    private ShippingInfo shippingInfo;

    private Map<String, Object> metadata;

    // ... 更多字段

}

  


// 插件接口

public interface Plugin {

    void execute(PluginContext context);

}

  


// 插件上下文投影(只包含插件需要的字段)

public interface PluginContext {

    // 只包含插件执行所需的最小数据集

    Long getUserId();

    String getUserRole();

    BigDecimal getOrderAmount();

    // 不包含完整的 User、Order 对象

}

  


// 插件管理器

@Service

public class PluginManager {

   

    private List<Plugin> plugins;

   

    public void executePlugins(BusinessContext context) {

        // 将完整的业务上下文投影为插件上下文

        PluginContext pluginContext = projectToPluginContext(context);

       

        // 执行所有插件,只传递必要的上下文信息

        for (Plugin plugin : plugins) {

            plugin.execute(pluginContext);

        }

    }

   

    private PluginContext projectToPluginContext(BusinessContext context) {

        // 使用投影技术提取插件需要的字段

        return PluginContextImpl.builder()

            .userId(context.getCurrentUser().getId())

            .userRole(context.getCurrentUser().getRole())

            .orderAmount(context.getCurrentOrder().getTotalAmount())

            .build();

    }

}

规则引擎中的条件投影

// 规则接口

public interface Rule {

    boolean evaluate(RuleContext context);

    void execute(RuleContext context);

}

  


// 规则上下文投影

public interface RuleContext {

    // 只包含规则评估和执行所需的字段

    Long getUserId();

    Integer getUserAge();

    BigDecimal getOrderAmount();

    String getProductCategory();

    // 不包含完整的业务对象

}

  


// 规则引擎

@Service

public class RuleEngine {

   

    private List<Rule> rules;

   

    public void executeRules(Order order, User user) {

        // 创建规则上下文投影

        RuleContext context = RuleContextImpl.builder()

            .userId(user.getId())

            .userAge(user.getAge())

            .orderAmount(order.getTotalAmount())

            .productCategory(order.getProduct().getCategory())

            .build();

       

        // 执行规则

        for (Rule rule : rules) {

            if (rule.evaluate(context)) {

                rule.execute(context);

            }

        }

    }

}

动态插件/规则引擎投影的优势

  • ✅ 降低插件复杂度:插件只需要关注必要的字段

  • ✅ 提高安全性:插件无法访问完整的业务对象

  • ✅ 提升性能:减少数据传递和处理开销

  • ✅ 增强可维护性:上下文结构清晰,易于理解

场景总结

投影技术在 Java 开发中的应用场景可以总结为以下几个方面:

  1. 性能优化:通过列裁剪和字段选择,减少数据库查询、网络传输和内存占用

  2. 安全性增强:通过数据脱敏和字段过滤,保护敏感信息

  3. 架构解耦:通过结构降维和上下文投影,降低系统组件间的耦合度

  4. 代码简化:通过接口投影和自动映射,减少样板代码

在实际项目中,我建议你根据具体场景选择合适的投影方式,平衡性能、安全性和代码复杂度,充分发挥投影技术的优势。