Post

JdbcTemplate and Spring Data JPA Repository

JdbcTemplate and Spring Data JPA Repository

最近在整理幾個 Spring Boot 專案的 package 命名時,又遇到 daoreporepository 要怎麼放的問題。

如果只看「是不是 Web 專案」,很容易下錯結論。比較關鍵的差異其實是資料存取方式:

  • 單 DB + JdbcTemplate + 手寫 SQL
  • 單 DB + Spring Data JPA repository
  • 多 DB + 每個 DB 各自切 JPA repository / SQL repository

JdbcTemplate

JdbcTemplate 比較像是把 JDBC 的連線、statement、result set、exception 這些樣板流程收起來,但 SQL 還是由程式自己寫。

Spring Framework JDBC Core 這邊有講到:

1
JdbcTemplate is the central class in the JDBC core package.

同一段也提到它會處理資源建立與釋放,避免忘記關 connection 這類常見錯誤。

所以原本要寫很複雜的 JDBC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement("""
    SELECT UYEAR, TEACHER_RECEIPT_DUE_DATE
      FROM NP_TS_REMINDER_SETTING
     WHERE UYEAR = ?
""");
statement.setString(1, uyear);
ResultSet rs = statement.executeQuery();
try {
    if (!rs.next()) {
        return Optional.empty();
    }
    return Optional.of(new ReminderSetting(
        rs.getString("UYEAR"),
        rs.getDate("TEACHER_RECEIPT_DUE_DATE").toLocalDate()
    ));
} finally {
    rs.close();
    statement.close();
    connection.close();
}

用了 JdbcTemplate 後,可以簡化成:

1
2
3
4
5
6
7
8
9
return jdbcTemplate.query(sql, rs -> {
    if (!rs.next()) {
        return Optional.empty();
    }
    return Optional.of(new ReminderSetting(
        rs.getString("UYEAR"),
        toLocalDate(rs.getTimestamp("TEACHER_RECEIPT_DUE_DATE"))
    ));
}, uyear);

這種寫法適合:

  • 查詢以既有資料表為主,欄位命名不一定完全符合 Java model
  • SQL 條件需要精準控制
  • 不想為了少數查詢建立完整 JPA Entity 關聯
  • batch 或報表程式需要直接看懂實際 SQL

因此這類 package 叫 dao 可以接受;若團隊想統一叫 repo,也應該再分出 repo.xxx.sql,避免和 Spring Data JPA repository 混在一起。

Spring Data JPA Repository

Spring Data JPA repository 的重點不是「不用寫 SQL」而已,而是把資料表映射成 Entity,讓 repository 介面代表某個 aggregate 或資料模型的存取入口。

Spring Data JPA 這邊有講到:

1
Spring Data JPA provides repository support for the Jakarta Persistence API (JPA).

所以很多 Spring Data JPA 專案會把這類介面放在 repository 或縮寫成 repo package。這不是 Java 或 Spring Boot 強制規定的 package 名稱,而是從 Spring Data 的 Repository 抽象、掃描規則與範例命名延伸出來的慣例。

Spring Guide: Accessing Data with JPA 這邊有講到:

1
Spring Data JPA focuses on using JPA to store data in a relational database.

同一份 guide 接著建立的介面就叫 CustomerRepository

1
2
3
4
5
6
public interface CustomerRepository extends CrudRepository<Customer, Long> {

    List<Customer> findByLastName(String lastName);

    Customer findById(long id);
}

也就是說,正統 Spring Data JPA 寫法的核心是「Repository interface」,而不是「一定要有一個叫 repo 的 package」。只是實務上為了讓掃描邊界與職責清楚,常會把這些 interface 集中放在 repositoryrepo package。

EnableJpaRepositories API 這邊有講到:

1
Will scan the package of the annotated configuration class for Spring Data repositories by default.

這也是多 DB 專案常把 package 切成 repo.ntnupmb.jparepo.ntnusalary.jpa 的原因:不是為了好看,而是讓每個 EntityManagerFactory 只掃到自己那一組 repository。

如果是一般 CRUD,原本可能要自己寫 insert、update、select by id、delete 這些固定程式碼;用了 JpaRepository 後,可以簡化成:

1
2
public interface NpTsRecordRepository extends JpaRepository<NpTsRecord, Long> {
}

Service 就可以直接使用:

1
2
3
4
NpTsRecord record = repository.findById(recordSn)
    .orElseThrow(() -> new IllegalArgumentException("查無資料"));
record.setTeacherRecvDate(LocalDateTime.now());
repository.saveAndFlush(record);

查詢條件不複雜時,也可以用 method name 表達。

Spring Data JPA Query Methods 這邊有講到:

1
Spring Data tries to resolve a call to these methods to a named query

同份文件也說明 repository 可以直接用 @Query 把查詢綁在方法上:

1
2
3
4
5
6
7
8
9
public interface PersonBasicRepository extends JpaRepository<PersonBasic, Long> {

    @Query("""
        select p
          from PersonBasic p
         where p.loginId = :loginId
    """)
    Optional<PersonBasic> findByLoginId(String loginId);
}

這種寫法適合:

  • 專案已經有明確 Entity
  • 資料存取常常圍繞同一個 domain model
  • CRUD 與簡單查詢很多
  • 想讓 transaction、dirty checking、entity mapping 交給 JPA

單 DB 時怎麼取名

如果專案是單 DB,通常不用把 DB 名稱塞進 package。可以簡單分成:

1
2
3
tw.edu.ntnu.itc.project.dao
tw.edu.ntnu.itc.project.service
tw.edu.ntnu.itc.project.model

或如果是 JPA 專案:

1
2
3
tw.edu.ntnu.itc.project.dao
tw.edu.ntnu.itc.project.repo
tw.edu.ntnu.itc.project.service

但要注意:daorepo 不只是換名字。若 repo 裡面全是 Spring Data JPA interface,讀程式的人會預期它是 JPA repository;若裡面其實都是 JdbcTemplate 手寫 SQL,名稱就會造成誤解。

多 DB 時怎麼取名

多 DB 專案要再清楚一點,因為每個 DB 可能有自己的 DataSourceEntityManagerFactoryTransactionManagerJdbcTemplate

比較清楚的切法是:

1
2
3
4
5
6
7
tw.edu.ntnu.itc.project.dao.ntnupmb
tw.edu.ntnu.itc.project.repo.ntnupmb.jpa
tw.edu.ntnu.itc.project.repo.ntnupmb.sql

tw.edu.ntnu.itc.project.dao.ntnusalary
tw.edu.ntnu.itc.project.repo.ntnusalary.jpa
tw.edu.ntnu.itc.project.repo.ntnusalary.sql

這樣一看就知道:

  • dao.ntnupmb 是 ntnupmb 的 Entity
  • repo.ntnupmb.jpa 是 ntnupmb 的 Spring Data JPA repository
  • repo.ntnupmb.sql 是 ntnupmb 的 JdbcTemplate / native SQL repository

對幾個專案的對照

TsNoticeDownload 是單 DB Web 專案,主要使用 Spring Data JPA repository,所以 package 叫 repo 很合理。

TsNoticeApproval 是多 DB 範本,因此拆成 repo.ntnupmb.jparepo.ntnupmb.sql;這是為了讓不同 DB、不同資料存取技術的邊界更清楚。

TsNoticeReminder 是 CLI batch,但 CLI 不是它使用 dao 的真正理由。真正原因是它目前使用單 DB JdbcTemplate 手寫 SQL,而且這些查詢只是把設定、收件人與寄信紀錄整理出來,沒有要把查到的資料當成長生命週期 Entity 繼續交給 JPA 做 dirty checking、關聯載入或 cascade。

換句話說,這裡用 JdbcTemplate 而不是 Spring Data JPA,是因為查詢目的很直接:查出資料、組成 batch 所需的 DTO 或 record,接著寄信與寫 log。若為了這種流程硬補 Entity 與 JPA repository,反而會讓資料存取層變重。

如果想和 TsNoticeDownload 對齊命名,可以改叫 repo;但比較精確的作法應該是先決定它是不是要走 Spring Data JPA。

若仍維持 JdbcTemplate,我會傾向:

1
tw.edu.ntnu.itc.tsnoticereminder.dao

如果團隊想統一所有資料存取 package 都叫 repo,則可以用:

1
tw.edu.ntnu.itc.tsnoticereminder.repo

但不要讓人誤以為它是 Spring Data JPA repository。若未來真的要擴成多 DB,再改成:

1
tw.edu.ntnu.itc.tsnoticereminder.repo.ntnupmb.sql

結論

命名不是看 Web 或 CLI,而是看資料存取抽象層。

  • JdbcTemplate:SQL 是核心,Java 程式負責 mapping,daorepo.xxx.sql 比較清楚。
  • Spring Data JPA:Entity 與 repository 是核心,reporepo.xxx.jpa 比較自然。
  • 多 DB:package 名稱最好帶 DB 名稱與技術類型,避免 service 注入錯資料來源。

回到專案命名,如果只是想讓 TsNoticeReminder 對齊 TsNoticeDownload,那應先問:要對齊的是「package 字面名稱」,還是「Spring Data JPA repository 風格」。前者只是搬 package;後者會牽涉 Entity、Repository interface、transaction 與測試策略,影響範圍完全不同。

This post is licensed under CC BY 4.0 by the author.