iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
Software Development

Spring 冒險日記:六邊形架構、DDD 與微服務整合系列 第 7

Day 7|貓咪的持久化 🐾 (中):Spring Data JPA、 DataSource、HikariCP

  • 分享至 

  • xImage
  •  

前言

今天我們會用 Docker 建立 Postgres,然後讓 Java Spring Data JPA 去 access 這個 DB。這篇會同時紀錄 Docker 的配置、DataSource、HikariCP(連線池),並實作一個最小可用的 Entity-Repository-Service 然後再用一個簡單的 Controller 來用 Postman 檢查有沒有成功。

Maven Dependency and Scopes

以下是以 JPA 來使用 PostgreSQL 所需要的 dependencies

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

為什麼 Postgres driver 的 scope 是 runtime ?

程式在 compile 時我們不需要 connect to db,但程式在跑的時候,會執行連線,而這時一定要有 driver。就像餐廳開店的廚師(driver),你自己寫菜單(code)時不需要真的請廚師到場,但上菜(runtime)時少不了。

4 Common maven scopes:

  • compile:預設,程式編譯、測試、打包都會用到。
  • provided:只在編譯/測試時要,但 runtime 由容器提供(e.g. Servlet API)。
  • runtime:只在執行時需要(e.g. Postgres driver)。
  • test:測試專用。

JPA

  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        jdbc:
          batch_size: 50
        order_updates: true
        format_sql: true
  • open-in-view: false:不要把 Hibernate Session 維持到 Web View 層,避免在 Controller/JSON 序列化時不小心觸發 lazy loading。這樣更安全,也能貼近六邊形架構/DDD 的分層界線。
  • ddl-auto: none:不要自動建表或改表(update/create 等),而是交給 Flyway 管理 schema,確保版本可控且可回溯(下一篇會介紹)。
  • hibernate.jdbc.batch_size: 50:批次寫入,讓 INSERT/UPDATE 累積到 50 筆再送出,提升效能。
  • order_updates: trueUPDATE 語句排序,搭配上面的批次送出更有效率。
  • format_sql: true:log 時把 SQL 排版漂亮,方便 debug。

就像平常去郵局寄信是一封封寄(效率差),批次處理就像把 50 封信先收集起來,一次交給櫃檯 ~

Open Session in View & Lazy Loading

我覺得這個必須看原文才能理解:
https://www.baeldung.com/spring-open-session-in-view

org.hibernate.LazyInitializationException :
https://stackoverflow.com/questions/74546974/what-can-cause-a-lazyinitatializationexception-whereas-spring-open-in-view-is-en

Java Spring OSIV:
https://ithelp.ithome.com.tw/upload/images/20250922/20178775VFQQesVjy7.png
https://blog.devgenius.io/osiv-lazyinitializationexception-46a0b630b421

Datasource and HikariCP

What is Datasource?

Datasource 是一個中間層,如果沒有這一層,我們對 data 有任何的操作都會要 "重新連線 "。Datasource 讓多個請求可以共用連線,不用每次都 DriverManager.getConnection(...) 重新開 socket,這樣效率差很多。

https://ithelp.ithome.com.tw/upload/images/20250922/201787755dmcnnU0nT.png

What is HikariCP?

https://github.com/brettwooldridge/HikariCP

Fast, simple, reliable. HikariCP is a "zero-overhead" production ready JDBC connection pool. At roughly 165Kb, the library is very light.

  • 實作 DataSource interface ,幫助我們管理與重複利用與 DB 的連線。
  • HikariCP 是 Spring Boot 預設的連線池(connection pool)實作,當 classpath 上有 Hikari 時會自動採用,如果沒有 Hikari,Spring Boot 才會 fallback 用 Tomcat JDBC Pool 或其他。(註: Spring Boot 的 auto-configuration 機制 會去偵測 classpath 上有什麼,而 classpath 就是 JVM 啟動時會去找「class 與 library」的搜尋路徑。)
  hikari:
    maximum-pool-size: 10 
    minimum-idle: 2       
    connection-timeout: 20000 

maximum-pool-size: 最大連線數
minimum-idle : 最少保留的閒置連線。當前閒置連線數低於這個值時,連線池會主動建立新連線來補到這個數字(但永遠不會超過 maximum-pool-size)。
connection-timeout : 取連線最多等 20s。

Docker

docker-compose.yml

version: "3.9"
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 10

環境變數怎麼注入 ?

  • 來源可以是:.env 檔、docker-compose.yml 的 environment,或 Docker secrets 搭配 Spring 的 configtree。
  • Spring Boot 啟動時,會把這些值整合成 PropertySource,讓你在 application.yml${...} access 環境 /secret 的值。

What is PropertySource ?

在 Spring Boot 裡,PropertySource 就是「configuration properties 的來源 (a source of key-value pairs)」。
config 值 有很多所以會有優先順序把它們整合起來,例如:

  • application.yml / application.properties
  • .env 或作業系統的環境變數
  • command-line arguments
  • Docker secrets / Kubernetes ConfigMap

這些來源都會被統一收進 Environment 裡,${POSTGRES_USER} 這種 placeholder 的時候,Spring 就會去一個一個 PropertySource 找,找到就替換。

  • Environment: an interface, org.springframework.core.env.Environment
    • 每一個 PropertySource 代表一份「設定來源」(source of key-value pairs)。
    • 有許多不同的實作 e.g. SystemEnvironmentPropertySource 代表來自 OS 的環境變數
  • PropertySource: an abstract class, org.springframework.core.env.PropertySource<T>
    • 管理多個 PropertySource
    • 提供 API 讓我們用 key 查 value

可以想像 Environment 像是一間圖書館 PropertySource 的相關 instance 則是一本本不同的字典,有些放 .env 的值,有些放 application.yml 的值。

What is ConfigTree ?

ConfigTree 是 Spring Boot 2.4+ 引進的一個功能,用來讀 Docker/K8s 的 secrets 或 config。

  • Spring Boot 就會把 /run/secrets/ 目錄下的檔案當成一個 PropertySource
  • 每個檔名就是「key」,檔案內容就是「value」。
/run/secrets/
  ├─ POSTGRES_USER  
  ├─ POSTGRES_PASSWORD 

然後你就可以 access in application.yml

spring.datasource.username: ${POSTGRES_USER}
spring.datasource.password: ${POSTGRES_PASSWORD}

Entity-Repository-Service 實作

@Entity
public class ArticleEntity {

    @Id
    @GeneratedValue
    @Column(columnDefinition = "uuid")
    private UUID id;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 40)
    private SourceType source;

    @Column(name = "external_id", length = 128)
    private String externalId;

    @Column(nullable = false, columnDefinition = "text")
    private String title;

    @Column(nullable = false, columnDefinition = "text")
    private String url;

    //find digest content for given url
    @Column(name = "url_hash_hex", length = 64, nullable = false)
    private String urlHashHex;

    @Column(name = "author")
    private String author;

    @Column(name = "published_at", columnDefinition = "timestamptz")
    private Instant publishedAt;

    @Column(name = "first_seen_at", nullable = false, columnDefinition = "timestamptz")
    private Instant firstSeenAt = Instant.now();

    // Ommited getter and setter
}

我們先暫時用個簡單的 JPA 例子,注意我們只要繼承 JpaRepository 就好。
Spring Data repository 常常「interface 繼承 interface」。下圖是 Spring Data Repository 的繼承關係,越往下功能越多,根據要操做的需求選合適的介面。(註 Repository 只是 marker interface, no method)

https://ithelp.ithome.com.tw/upload/images/20250922/20178775POccoyy786.png

@Repository
public interface ArticleJpaRepository extends JpaRepository<ArticleEntity, UUID> {
    // 繼承 JpaRepository,已經自動有 save(), saveAll(), findAll() ...
}

Day2 我們有提到 Domain 層面收集各個 news api 的 data 統一轉成 FeedItem 處理。所以需要一個 Mapper 把 FeedItem 轉成 ArticleEntity

//map FeedItem to ArticleEntity
public class ArticleMapper {
    public static ArticleEntity toNewEntity(FeedItem f) {
        var a = new ArticleEntity();
        a.setSource(f.source());
        a.setExternalId(f.id());
        a.setTitle(f.title());
        a.setUrl(f.url());
        a.setUrlHashHex(Hashes.sha256Hex(Urls.normalize(f.url())));
        a.setAuthor(f.author());
        a.setPublishedAt(f.publishedAt());
        a.setFirstSeenAt(Instant.now());
        return a;
    }

    public static List<ArticleEntity> toNewEntityList(List<FeedItem> fs) {
        return fs.stream()
                 .map(ArticleMapper::toNewEntity)
                 .toList();
    }

    private ArticleMapper() {}
}
@Service
public class ArticleService {

    private final ArticleJpaRepository articleJpaRepository;

    public ArticleService(ArticleJpaRepository articleJpaRepository) {
        this.articleJpaRepository = articleJpaRepository;
    }

    public void saveAllFeedItem(List<FeedItem> feedItems) {
        articleJpaRepository.saveAll(ArticleMapper.toNewEntityList(feedItems));
    }

    public List<ArticleEntity> getAllArticles() {
        return articleJpaRepository.findAll();
    }
}

Test Connectivity


1 下一篇才提到用 flyway 管理 schema 所以先讓 hibernate 根據 entity 自動建立 table

spring:
  jpa:
    hibernate:
      ddl-auto: update  
    show-sql: true
    properties:
      hibernate.format_sql: true

2 把 DB container build 好然後再啟動 Spring Boot 的 app

docker compose -f docker-compose.yml up -d db

3 寫一個簡單的 Controller and test via Postman

@RestController
public class WebService {

    final private ArticleService articleService;

    public WebService(ArticleService articleService) {
        this.articleService = articleService;
    }

    @GetMapping("/articles")
    public ResponseEntity<?> getAllArticles() {
        return ResponseEntity
                .status(HttpStatus.ACCEPTED)
                .body(articleService.getAllArticles());
    }
    
    @PostMapping(path = "/import", consumes = "application/json")
    public ResponseEntity<Void> saveAllFeedItem(@RequestBody List<FeedItem> items) {
        articleService.saveAllFeedItem(items);
        return ResponseEntity
                .status(HttpStatus.CREATED)
                .build();
    }
}

@RequestBody 告訴 Spring 用 JSON 反序列化 (deserialize) 到 List<FeedItem> 。Spring 會透過 HttpMessageConverter 把 HTTP request 的 body 根據 Content-Type(例如 application/json),JSON array -> Java List。

79.4 Customize the @ResponseBody Rendering
Spring uses HttpMessageConverters to render @ResponseBody (or responses from @RestController). ....

官方文件:
https://docs.spring.io/spring-boot/docs/2.1.1.RELEASE/reference/html/howto-spring-mvc.html

consumes = "application/json":讓 API 規格更明確。如果 client 用了 Content-Type: text/plain 就會 415 Unsupported Media Type。

註: consumes and produces :

It specifies the supported media type of the request (consumes), and the media type of the response (produces).
https://stackoverflow.com/questions/33591574/what-is-produce-and-consume-in-request-mapping

確認在 Day2 提到的 FeedItem

public record FeedItem(
        String id,
        SourceType source,
        String title,
        String url,
        String author,
        Integer score,
        java.time.Instant publishedAt
) {}

用 Postman 測試: 在 Post Request 的 Body 用 JSON Array

[
  {
    "id": "424242",
    "source": "HN",
    "title": "Hello Hacker News",
    "url": "https://news.ycombinator.com/item?id=424242",
    "author": "alice",
    "score": 123,
    "publishedAt": "2025-09-19T08:00:00Z"
  },
  {
    "id": "world/2025/sep/20",
    "source": "GUARDIAN",
    "title": "Guardian World",
    "url": "https://www.theguardian.com/world/2025/sep/20/abc",
    "author": "bob",
    "score": 77,
    "publishedAt": "2025-09-20T12:34:56Z"
  }
]

用 curl 測試:

curl -i -X POST http://localhost:8080/articles/import \
  -H 'Content-Type: application/json' \
  -d '[
    {
      "id":"424242",
      "source":"HN",
      "title":"Hello Hacker News",
      "url":"https://news.ycombinator.com/item?id=424242",
      "author":"alice",
      "score":123,
      "publishedAt":"2025-09-19T08:00:00Z"
    }
  ]'
curl -i http://localhost:8080/articles

Reference

LAURENŢIU SPILCĂ, Spring Start Here: Learn what You Need and Learn it Well
https://docs.spring.io/spring-boot/docs


上一篇
Day 6|貓咪的持久化 🐾 (上):認識 JPA、Hibernate、Spring Data
系列文
Spring 冒險日記:六邊形架構、DDD 與微服務整合7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言