知識庫 / Spring RSS 訂閱

Spring Data JPA Projections

Spring
HongKong
10
01:23 PM · Dec 06 ,2025

1. 概述

當使用 Spring Data JPA 實現持久化層時,倉庫通常返回一個或多個根類的一個實例。然而,在大多數情況下,我們並不需要返回對象的所有屬性。

在這些情況下,我們可能希望以自定義類型的對象來檢索數據。 這些類型反映了根類中的部分視圖,僅包含我們關心的屬性。 這就是投影發揮作用的地方。

2. 初始設置

第一步是設置項目並填充數據庫。

2.1. Maven 依賴

請查閲本教程的第 2 章以瞭解更多關於依賴的信息。

2.2. 實體類

讓我們定義兩個實體類:

@Entity
public class Address {
 
    @Id
    private Long id;
 
    @OneToOne
    private Person person;
 
    private String state;
 
    private String city;
 
    private String street;
 
    private String zipCode;

    // getters and setters
}

並且:

@Entity
public class Person {
 
    @Id
    private Long id;
 
    private String firstName;
 
    private String lastName;
 
    @OneToOne(mappedBy = "person")
    private Address address;

    // getters and setters
}

PersonAddress 實體之間存在一對一的雙向關係;Address 是主實體,Person 是反向實體。

在本教程中,我們使用嵌入式數據庫 H2。

當配置嵌入式數據庫時,Spring Boot 會自動生成我們定義的實體的底層表。

2.3. SQL 腳本

我們將使用 projection-insert-data.sql 腳本來填充兩個後台表:

INSERT INTO person(id,first_name,last_name) VALUES (1,'John','Doe');
INSERT INTO address(id,person_id,state,city,street,zip_code) 
  VALUES (1,1,'CA', 'Los Angeles', 'Standford Ave', '90001');

為了在每次測試運行後清理數據庫,我們可以使用另一個腳本:projection-clean-up-data.sql

DELETE FROM address;
DELETE FROM person;

2.4. 測試類

然後,為了確認投影產生正確的數據,我們需要一個測試類:

@DataJpaTest
@RunWith(SpringRunner.class)
@Sql(scripts = "/projection-insert-data.sql")
@Sql(scripts = "/projection-clean-up-data.sql", executionPhase = AFTER_TEST_METHOD)
public class JpaProjectionIntegrationTest {
    // injected fields and test methods
}

通過提供的註釋,Spring Boot 在每個測試方法執行前後,創建數據庫、注入依賴項以及清理表

3. 基於接口的投影

在投影實體時,依賴接口是很自然的,因為我們不需要提供實現。

3.1. 閉包投影

回顧一下 Address 類,我們可以看到 它擁有許多屬性,但並非所有屬性都有效。例如,有時郵政編碼就足以指示地址。

讓我們聲明一個用於 Address 類的投影接口:

public interface AddressView {
    String getZipCode();
}

然後我們將它在存儲接口中使用:

public interface AddressRepository extends Repository<Address, Long> {
    List<AddressView> getAddressByState(String state);
}

很容易看出,使用投影接口定義倉庫方法與使用實體類幾乎完全相同。

唯一的區別在於,投影接口,而不是實體類,被用作返回集合的元素類型。

我們來快速測試一下 Address 投影:

@Autowired
private AddressRepository addressRepository;

@Test
public void whenUsingClosedProjections_thenViewWithRequiredPropertiesIsReturned() {
    AddressView addressView = addressRepository.getAddressByState("CA").get(0);
    assertThat(addressView.getZipCode()).isEqualTo("90001");
    // ...
}

在幕後,Spring 會為每個實體對象創建投影接口的代理實例,並將所有對代理的調用轉發到該對象。

我們可以遞歸地使用投影。例如,這是用於 Person 類的投影接口:

public interface PersonView {
    String getFirstName();

    String getLastName();
}

現在我們將添加一個具有PersonView返回類型的靜態方法,該方法是Address投影中的嵌套投影:

public interface AddressView {
    // ...
    PersonView getPerson();
}

請注意,返回嵌套投影的方法名必須與根類中返回相關實體的同名方法相同。

我們將通過在剛剛編寫的測試方法中添加幾條語句來驗證嵌套投影:

// ...
PersonView personView = addressView.getPerson();
assertThat(personView.getFirstName()).isEqualTo("John");
assertThat(personView.getLastName()).isEqualTo("Doe");

請注意,遞歸投影僅在從擁有者側向逆向側進行遍歷時才有效。 如果反過來進行遍歷,則嵌套投影將設置為 null

3.2. 使用接口和自定義查詢實現嵌套 JPA 投影

在上一節中,我們探討了使用接口和派生查詢實現嵌套 JPA 投影。 此外,我們也可以通過自定義查詢來實現這一點:

@Query("SELECT c.zipCode as zipCode, c.person as person FROM Address c WHERE c.state = :state")
List<AddressView> getViewAddressByState(@Param("state") String state);

在上述代碼中,我們編寫了一個自定義查詢,用於根據郵政編碼檢索地址幷包含相關的人員信息。Spring 將查詢結果根據匹配的字段名稱映射到 AddressViewPersonView 接口。

讓我們為嵌套的 JPA 投影編寫一個單元測試:

@Test
void whenUsingCustomQueryForNestedProjection_thenViewWithRequiredPropertiesIsReturned() {
    AddressView addressView = addressRepository.getViewAddressByState("CA").get(0);
    assertThat(addressView.getZipCode()).isEqualTo("90001");

    PersonView personView = addressView.getPerson();
    assertThat(personView.getFirstName()).isEqualTo("John");
    assertThat(personView.getLastName()).isEqualTo("Doe");
}

在上述測試中,我們調用了 AddressRepository 上的自定義倉庫方法,以獲取地址和對應的個人信息。然後我們斷言返回值與預期結果相等。

此外,自定義查詢允許我們指定連接操作的類型,從而可能提高數據庫查找效率並防止 N+1 問題。

3.3. 開放投影 (Open Projections)

在此之前,我們已經探討了封閉投影,它指示投影接口的方法名與實體屬性名完全匹配。

還有一種基於接口的投影方式,即開放投影。 這些投影允許我們定義具有不匹配名稱和在運行時計算返回值的接口方法。

讓我們回到 Person 投影接口,並添加一個新的方法:

public interface PersonView {
    // ...

    @Value("#{target.firstName + ' ' + target.lastName}")
    String getFullName();
}

傳遞給 @Value 註解的參數是一個 SpEL 表達式,其中 target 指示器指示後置實體對象。

現在,我們將定義另一個存儲接口:

public interface PersonRepository extends Repository<Person, Long> {
    PersonView findByLastName(String lastName);
}

為了簡化操作,我們只返回一個投影對象,而不是一個集合。

此測試確認開放投影按預期工作:

@Autowired
private PersonRepository personRepository;

@Test 
public void whenUsingOpenProjections_thenViewWithRequiredPropertiesIsReturned() {
    PersonView personView = personRepository.findByLastName("Doe");
 
    assertThat(personView.getFullName()).isEqualTo("John Doe");
}

開放投影確實存在一些缺點;Spring Data 無法優化查詢執行,因為它事先不知道哪些屬性將被使用。因此,我們應該只在閉合投影無法滿足我們的需求時才使用開放投影。

4. 基於類的投影

相比於使用 Spring Data 創建的代理,我們可以定義自己的投影類。

例如,以下是一個用於 Person 實體類型的投影類:

public class PersonDto {
    private String firstName;
    private String lastName;

    public PersonDto(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }

    // getters, equals and hashCode
}

為了使投影類與存儲接口協同工作,其構造函數的參數名稱必須與根實體類的屬性相匹配。

我們還需要定義 equalshashCode 實現,這允許 Spring Data 處理投影對象在集合中的過程。

上述要求可以通過 Java 的 records 實現,從而使我們的代碼更精確和表達力更強:

public record PersonDto(String firstName, String lastName) {

}

現在讓我們為 Person 倉庫添加一個方法:

public interface PersonRepository extends Repository<Person, Long> {
    // ...

    PersonDto findByFirstName(String firstName);
}

此測試驗證了我們的基於類的投影。

@Test
public void whenUsingClassBasedProjections_thenDtoWithRequiredPropertiesIsReturned() {
    PersonDto personDto = personRepository.findByFirstName("John");
 
    assertThat(personDto.getFirstName()).isEqualTo("John");
    assertThat(personDto.getLastName()).isEqualTo("Doe");
}

採用基於類的方案,我們無法使用嵌套投影。

4.1. 使用類進行嵌套投影

此外,我們還可以使用嵌套的DTO類將相關的實體一起映射:

class AddressDto {
    private final String zipCode;
    private final PersonDto person;

    public AddressDto(String zipCode, PersonDto person) {
        this.zipCode = zipCode;
        this.person = person;
    }
}

在這裏,我們聲明 PersonDto 作為 AddressDto 中的一個字段,並將其傳遞給構造函數。 值得注意的是,構造函數參數的名稱必須與 DTO 類字段相匹配。

然後,我們可以使用派生查詢或自定義查詢來執行數據庫查找。

@Query("SELECT new com.baeldung.jpa.projection.view.AddressDto(a.zipCode," +
  "new com.baeldung.jpa.projection.view.PersonDto(p.firstName, p.lastName)) " +
  "FROM Address a JOIN a.person p WHERE a.state = :state")
List<AddressDto> findAddressByState(@Param("state") String state);

在我們的查詢中,我們使用 new 關鍵字,並指定 DTO 的全限定類名來調用它們的構造函數。這對於在數據庫查找期間創建 DTO 實例至關重要。

對於派生查詢,我們只需要確保查詢方法名稱以有效的實體字段結尾:

List<AddressDto> findAddressByState(String state);

然而,自定義查詢提供靈活性,允許執行復雜的連接操作以及更精細的數據檢索。

4.2. 使用 JPA 原生查詢

JPA 原生查詢允許我們直接編寫 SQL 查詢,而不是使用 JPQL。 此外,我們還可以將原生查詢的結果映射到 DTO 類。

@NamedNativeQuery用於定義 SQL 查詢,並使用 resultSetMapping 參數,將其設置為映射的 DTO 註解。 @SqlResultSetMapping 註解用於定義查詢結果的映射到 DTO 類。

@Entity
@NamedNativeQuery(
  name = "person_native_query_dto",
  query = "SELECT p.first_name, p.last_name From Person p where p.first_name LIKE :firstNameLike",
  resultSetMapping = "person_query_dto"
)
@SqlResultSetMapping(
  name = "person_query_dto", 
  classes = @ConstructorResult(
    targetClass = PersonDto.class, 
    columns = { 
        @ColumnResult(name = "first_name", type = String.class), 
        @ColumnResult(name = "last_name", type = String.class), 
    }
  )
)
public class Person {
   // properties, getters and setters
}

在上述邏輯中,我們將本地查詢的結果映射到 PersonDto 類,其中定義了 columns,這些 columns 被映射到 PersonDto 類中的屬性上。

一旦我們對實體進行了註解,我們就可以定義存儲方法:

public interface PersonRepository extends Repository<Person, Long> {
    @Query(name = "person_native_query_dto", nativeQuery = true)
    List<PersonDto> findByFirstNameLike(@Param("firstNameLike") String firstNameLike);
}

findByFirstNameLike() 方法中,我們使用 @Query 註解,該註解接收我們在實體上定義的原生查詢名稱。

我們可以編寫一個簡單的單元測試來驗證結果:

@Test
void whenUsingClassBasedProjectionsAndJPANativeQuery_thenDtoWithRequiredPropertiesIsReturned() {
    List<PersonDto> personDtos = personRepository.findByFirstNameLike("Jo%");
    assertThat(personDtos.size()).isEqualTo(2);
    assertThat(personDtos).isEqualTo(Arrays.asList(new PersonDto("John", "Doe"), new PersonDto("Job", "Doe")));
}

5. 動態投影

一個實體類可以有多個投影。在某些情況下,我們可能使用一種類型,但在其他情況下,我們可能需要另一種類型。有時,我們還需要使用實體類本身。

僅僅為了支持多種返回類型,定義單獨的倉庫接口或方法是繁瑣的。為了解決這個問題,Spring Data 提供了一種更好的解決方案:動態投影。

只需聲明一個帶有 Class 參數的倉庫方法,即可應用動態投影:

public interface PersonRepository extends Repository<Person, Long> {
    // ...

    <T> T findByLastName(String lastName, Class<T> type);
}

通過將投影類型或實體類傳遞給此類方法,我們可以檢索到所需類型的對象:

@Test
public void whenUsingDynamicProjections_thenObjectWithRequiredPropertiesIsReturned() {
    Person person = personRepository.findByLastName("Doe", Person.class);
    PersonView personView = personRepository.findByLastName("Doe", PersonView.class);
    PersonDto personDto = personRepository.findByLastName("Doe", PersonDto.class);

    assertThat(person.getFirstName()).isEqualTo("John");
    assertThat(personView.getFirstName()).isEqualTo("John");
    assertThat(personDto.getFirstName()).isEqualTo("John");
}

6. 結論

在本文中,我們探討了各種 Spring Data JPA 投影類型。

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.