昨天的 Day 14,我們終於開始接觸資料庫!
除了認識 H2 Database,也成功透過 H2 Console 建立飲水紀錄資料表,並使用 SQL 新增與查詢資料。
不過目前還有一個問題。
雖然資料庫已經建立好了,但我們的 Java 程式其實還沒有真正使用它!
在 Day 13,我們使用 Java 的 List 儲存飲水紀錄:
private final List<Integer> waterRecords = new ArrayList<>();
這樣雖然可以記錄飲水量,但只要重新啟動 Spring Boot,資料就會消失。
所以今天決定正式升級!
我們要讓使用者透過 API 新增飲水紀錄,再由 Java 自動將資料寫入 H2 Database。
今天的目標是:
學會使用 Spring JDBC 的 JdbcTemplate,讓智慧健康助理可以真正儲存與查詢飲水紀錄。
在 Day 13,我們完成了兩個 API。
| HTTP Method | API | 功能 |
|---|---|---|
| POST | /health/water |
新增飲水紀錄 |
| GET | /health/water |
查詢當日累積飲水量 |
例如使用者傳送:
{
"amount": 300
}
系統就會把資料放進 List。
接著再傳送:
{
"amount": 500
}
就可以計算:
300 + 500 = 800 ml
但這些資料只存在記憶體裡。
今天我們希望將流程改成:
使用者傳送 JSON
↓
WaterController
↓
WaterService
↓
JdbcTemplate
↓
H2 Database
↓
儲存飲水紀錄
查詢時則變成:
使用者發送 GET
↓
WaterController
↓
WaterService
↓
JdbcTemplate
↓
H2 Database
↓
查詢當日飲水總量
↓
回傳 JSON
這樣就不需要再依靠 List 保存紀錄了。
今天會接觸到一個新的名詞:
JDBC(Java Database Connectivity)
簡單來說,JDBC 是 Java 用來連接和操作關聯式資料庫的 API。
透過 JDBC,Java 程式可以執行 SQL。
例如:
INSERT INTO water_records (amount, record_date)
VALUES (300, CURRENT_DATE);
或是:
SELECT * FROM water_records;
如果使用傳統 JDBC,我們通常需要自己處理資料庫連線、SQL 執行及資源關閉等工作。
但 Spring Boot 提供了更方便的方式。
那就是今天要使用的:
JdbcTemplate
JdbcTemplate 是 Spring JDBC 提供的工具類別。
它可以幫助我們簡化資料庫操作。
例如原本在 H2 Console 輸入:
INSERT INTO water_records (amount, record_date)
VALUES (300, CURRENT_DATE);
現在可以改成在 Java 裡面寫:
jdbcTemplate.update(
"INSERT INTO water_records (amount, record_date) VALUES (?, CURRENT_DATE)",
300
);
這樣就能透過 Java 執行 SQL。
其中:
update()
通常用來執行:
如果要查詢資料,就可以使用:
query()
或是:
queryForObject()
例如:
Integer total = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(amount), 0) FROM water_records",
Integer.class
);
這樣就可以取得飲水總量。
對目前的專案來說,JdbcTemplate 已經足夠完成基本的資料庫操作。
昨天已經在 pom.xml 加入了:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
以及:
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
所以今天不需要再安裝新的套件。
只要確認 Maven 已經成功載入即可。
另外,也要確認 application.properties 還保留昨天的設定:
spring.application.name=health-assistant
spring.datasource.url=jdbc:h2:file:./data/healthdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
這裡最重要的是:
spring.datasource.url=jdbc:h2:file:./data/healthdb
因為我們使用的是 H2 File Mode。
這樣資料才可以儲存在檔案中,而不是只存在記憶體裡。
提醒一下:這些設定適合本機開發練習,正式部署時不應開放 H2 Console,也不應直接使用空密碼。
昨天我們已經在 H2 Console 手動建立資料表。
不過我想讓專案更方便使用,所以今天改成透過 SQL 初始化檔案建立資料表。
首先在:
src/main/resources
新增:
schema.sql
內容如下:
CREATE TABLE IF NOT EXISTS water_records (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
amount INT NOT NULL CHECK (amount > 0),
record_date DATE NOT NULL
);
這裡建立的資料表包含三個欄位:
| 欄位 | 型別 | 用途 |
|---|---|---|
| id | BIGINT | 每筆紀錄的編號 |
| amount | INT | 飲水量,單位 ml |
| record_date | DATE | 紀錄日期 |
其中:
PRIMARY KEY
代表主鍵。
而:
CHECK (amount > 0)
可以讓資料庫額外檢查飲水量必須大於 0。
雖然之前已經使用 @Positive 驗證資料,但資料庫也保留基本限制,會更完整。
接著在 application.properties 加入:
spring.sql.init.mode=always
這樣 Spring Boot 啟動時,就會執行 schema.sql。
因為我們使用:
CREATE TABLE IF NOT EXISTS
所以資料表已經存在時,不會因為重複建立而直接失敗。
不過如果未來需要修改資料表結構,就不能只依靠這個方法,還需要考慮資料庫版本管理工具。
目前先用這種方式練習即可。
在 Day 10,我們學過 Controller 和 Service 分層。
今天要再多認識一個新的分層:
Repository
Repository 主要負責與資料庫溝通。
所以現在的系統架構會變成:
Controller
↓
Service
↓
Repository
↓
Database
每一層負責的工作不同。
| 分層 | 主要工作 |
|---|---|
| Controller | 接收 HTTP Request、回傳 Response |
| Service | 處理業務邏輯 |
| Repository | 執行資料庫操作 |
| Database | 儲存資料 |
這樣就不用把 SQL 全部寫在 Controller 或 Service 裡面。
首先建立新的 Package:
repository
接著建立:
WaterRepository.java
先來完成第一個功能:新增飲水紀錄。
WaterRepository.java:
package com.example.healthassistant.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
@Repository
public class WaterRepository {
private final JdbcTemplate jdbcTemplate;
public WaterRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public void save(int amount, LocalDate date) {
String sql = """
INSERT INTO water_records (amount, record_date)
VALUES (?, ?)
""";
jdbcTemplate.update(sql, amount, date);
}
}
這裡出現了新的 Annotation:
@Repository
它用來標記負責資料存取的類別。
和之前使用的:
@Service
有點類似,都是讓 Spring 管理元件。
但用途不同。
@Service 主要表示業務邏輯元件。
@Repository 則表示資料存取元件。
接著看到:
private final JdbcTemplate jdbcTemplate;
這裡使用了之前學過的建構子注入。
讓 Spring 提供 JdbcTemplate 物件。
最重要的是:
jdbcTemplate.update(sql, amount, date);
它會將飲水量和日期寫入資料庫。
剛剛的 SQL:
INSERT INTO water_records (amount, record_date)
VALUES (?, ?)
裡面有兩個:
?
它們代表 SQL 參數的位置。
例如:
jdbcTemplate.update(sql, 300, LocalDate.now());
第一個問號會對應:
300
第二個問號會對應:
今天的日期
這種方式稱為參數化查詢。
它不需要我們自己把使用者輸入的資料直接拼接到 SQL 字串裡。
例如不建議這樣寫:
String sql = "INSERT INTO water_records (amount) VALUES (" + amount + ")";
雖然某些簡單情況可以運作,但當資料來自使用者輸入時,直接拼接 SQL 字串可能造成安全問題。
所以使用:
?
搭配參數,是比較適合的做法。
接下來新增第二個功能。
我們希望查詢指定日期的累積飲水量。
在 WaterRepository 裡面加入:
public int getTotalWater(LocalDate date) {
String sql = """
SELECT COALESCE(SUM(amount), 0)
FROM water_records
WHERE record_date = ?
""";
Integer total = jdbcTemplate.queryForObject(
sql,
Integer.class,
date
);
return total != null ? total : 0;
}
這裡使用:
queryForObject()
取得查詢結果。
因為我們只需要一個數字:
當日飲水總量
所以不需要把所有紀錄都查出來。
另外,SQL 裡面使用了:
SUM(amount)
代表將飲水量加總。
而:
COALESCE(SUM(amount), 0)
代表如果查詢結果沒有資料,就回傳 0。
例如今天沒有任何飲水紀錄,結果就會是:
0
而不是:
NULL
這樣比較方便 Java 程式處理。
整理之後,今天的 WaterRepository.java 如下:
package com.example.healthassistant.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
@Repository
public class WaterRepository {
private final JdbcTemplate jdbcTemplate;
public WaterRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public void save(int amount, LocalDate date) {
String sql = """
INSERT INTO water_records (amount, record_date)
VALUES (?, ?)
""";
jdbcTemplate.update(sql, amount, date);
}
public int getTotalWater(LocalDate date) {
String sql = """
SELECT COALESCE(SUM(amount), 0)
FROM water_records
WHERE record_date = ?
""";
Integer total = jdbcTemplate.queryForObject(
sql,
Integer.class,
date
);
return total != null ? total : 0;
}
}
現在 Repository 已經可以完成:
接下來就要讓 Service 使用 Repository。
在 Day 13,我們的 WaterService 使用:
List<Integer>
儲存資料。
但今天已經有資料庫了,所以不需要再使用 List。
我們要把原本的程式改成:
package com.example.healthassistant.service;
import com.example.healthassistant.repository.WaterRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
@Service
public class WaterService {
private final WaterRepository waterRepository;
public WaterService(WaterRepository waterRepository) {
this.waterRepository = waterRepository;
}
public int addWater(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException(
"Water amount must be greater than 0."
);
}
LocalDate today = LocalDate.now();
waterRepository.save(amount, today);
return waterRepository.getTotalWater(today);
}
public int getTotalWater() {
return waterRepository.getTotalWater(LocalDate.now());
}
}
這次最大的改變,就是原本的:
waterRecords.add(amount);
變成:
waterRepository.save(amount, today);
也就是:
不再把資料加入 List,而是直接寫入資料庫。
另外,之前使用 LocalDate 判斷跨日後清空 List。
今天也不需要這樣做了。
因為每一筆紀錄都有:
record_date
所以查詢時只要指定今天的日期,就可以取得今天的飲水量。
昨天的資料也不需要刪除。
這樣未來就能保留歷史紀錄!
接下來修改 Controller。
原本的 API 路徑不需要改變。
我們仍然使用:
POST /health/water
以及:
GET /health/water
完整程式如下:
package com.example.healthassistant.controller;
import com.example.healthassistant.dto.WaterRequest;
import com.example.healthassistant.service.WaterService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/health/water")
public class WaterController {
private final WaterService waterService;
public WaterController(WaterService waterService) {
this.waterService = waterService;
}
@PostMapping
public ResponseEntity<Map<String, Object>> addWater(
@Valid @RequestBody WaterRequest request) {
int total = waterService.addWater(request.getAmount());
Map<String, Object> result = new HashMap<>();
result.put("message", "Water record saved successfully.");
result.put("amount", request.getAmount());
result.put("totalWater", total);
result.put("unit", "ml");
return ResponseEntity.ok(result);
}
@GetMapping
public ResponseEntity<Map<String, Object>> getWater() {
Map<String, Object> result = new HashMap<>();
result.put("totalWater", waterService.getTotalWater());
result.put("unit", "ml");
return ResponseEntity.ok(result);
}
}
這次 Controller 其實沒有太大的變化。
因為我們之前已經將不同工作分層。
Controller 不需要知道資料到底是放在 List,還是存進 H2 Database。
它只需要呼叫:
waterService.addWater()
以及:
waterService.getTotalWater()
這也是分層架構的好處!
接下來重新啟動 Spring Boot。
先測試新增飲水紀錄。
HTTP Method:
POST
URL:
http://localhost:8080/health/water
Body:
{
"amount": 300
}
如果資料庫原本沒有今天的紀錄,預期回傳:
{
"message": "Water record saved successfully.",
"amount": 300,
"totalWater": 300,
"unit": "ml"
}
接著再新增一筆:
{
"amount": 500
}
預期回傳:
{
"message": "Water record saved successfully.",
"amount": 500,
"totalWater": 800,
"unit": "ml"
}
這次和 Day 13 最大的不同是:
飲水紀錄已經不是存在 List 裡面,而是寫入 H2 Database。
如果之前在 H2 Console 手動新增過今天的資料,累積總量也會包含那些紀錄,因此實際數字可能不同。
光看到 API 回傳成功還不夠。
我們還要確認資料真的進入資料庫。
打開:
http://localhost:8080/h2-console
使用之前設定的 JDBC URL 連線:
jdbc:h2:file:./data/healthdb
接著執行:
SELECT * FROM water_records;
預期可以看到剛剛透過 API 新增的資料。
例如:
| ID | AMOUNT | RECORD_DATE |
|---|---|---|
| 1 | 300 | 2026-10-17 |
| 2 | 500 | 2026-10-17 |
實際 ID 和日期會依照資料庫現有內容及執行日期而不同。
這樣就代表:
Postman
↓
Spring Boot
↓
WaterController
↓
WaterService
↓
WaterRepository
↓
JdbcTemplate
↓
H2 Database
整個流程已經成功串接起來!
今天最重要的測試來了。
先確認今天已經新增了飲水紀錄。
接著:
HTTP Method:
GET
URL:
http://localhost:8080/health/water
如果資料庫裡有今天的 800 ml 紀錄,預期回傳:
{
"totalWater": 800,
"unit": "ml"
}
這次重新啟動後,資料應該仍然存在!
因為我們已經將資料寫入 H2 的檔案模式資料庫。
不過如果跨日測試,GET API 只會查詢當天的紀錄,因此總量可能變成 0。
這不代表舊資料消失,而是因為我們使用日期條件查詢。
經過今天的更新,專案結構又多了一個 Repository。
health-assistant
│
├── src
│ └── main
│ ├── java
│ │ └── com/example/healthassistant
│ │ │
│ │ ├── HealthAssistantApplication.java
│ │ │
│ │ ├── controller
│ │ │ ├── HealthController.java
│ │ │ └── WaterController.java
│ │ │
│ │ ├── service
│ │ │ ├── BmiService.java
│ │ │ └── WaterService.java
│ │ │
│ │ ├── repository
│ │ │ └── WaterRepository.java
│ │ │
│ │ ├── dto
│ │ │ ├── HealthRequest.java
│ │ │ └── WaterRequest.java
│ │ │
│ │ └── exception
│ │ └── GlobalExceptionHandler.java
│ │
│ └── resources
│ ├── application.properties
│ └── schema.sql
│
├── data
│ └── healthdb.mv.db
│
└── pom.xml
現在的系統已經具備比較完整的分層:
Controller
↓
Service
↓
Repository
↓
Database
雖然程式檔案變多了,但每個 Class 的工作也更明確。
雖然今天已經成功將飲水紀錄存進資料庫,但還有幾個地方需要改進。
所有使用者的飲水紀錄都會存在同一張資料表。
而 GET API 也會將當天所有紀錄加總。
所以目前仍然只適合單一使用者的本機練習。
目前使用:
LocalDate.now()
取得日期。
但如果未來使用者分布在不同國家,就需要處理時區問題。
雖然資料庫已經保存不同日期的資料,但目前 GET API 只會查詢今天。
未來可以增加:
GET /health/water/history
讓使用者查看過去的飲水紀錄。
如果使用者不小心輸入錯誤的飲水量,目前還不能修改。
之後也可以考慮加入:
PUT
DELETE
讓健康紀錄管理更完整。
今天雖然沒有直接使用 AI,但我們已經完成一個很重要的基礎。
原本智慧健康助理只能處理當下輸入的資料。
現在則可以把紀錄保存下來。
未來就可以讓系統查詢:
最近七天的飲水量
或是:
本週平均飲水量
再將必要的統計結果整理成文字摘要。
例如:
{
"date": "2026-10-17",
"totalWater": 1500,
"unit": "ml"
}
之後如果加入睡眠和運動紀錄,就能讓智慧健康助理處理更多種類的資料。
不過在真正串接 AI 之前,還需要考慮使用者隱私、資料安全,以及哪些資料適合傳送給外部服務。
所以目前先把 Java 和資料庫的基礎建立好。
今天終於成功讓 Java 程式直接操作資料庫!
我們不再使用 List 儲存飲水紀錄,而是透過 Spring JDBC 的 JdbcTemplate,將資料寫入 H2 Database。
今天學到的重點包括:
update() 執行 INSERTqueryForObject() 查詢資料? 進行參數化查詢現在的飲水紀錄流程已經變成:
使用者輸入飲水量
↓
WaterRequest DTO
↓
@Valid 驗證
↓
WaterController
↓
WaterService
↓
WaterRepository
↓
JdbcTemplate
↓
H2 Database
↓
儲存飲水紀錄
今天最大的收穫是:
從使用 List 暫存資料,正式進入使用資料庫保存健康紀錄的階段。
目前已經完成 30 天挑戰的一半!
雖然還沒真正串接 AI,但 Java、Spring Boot、REST API、DTO、資料驗證和資料庫,都已經逐漸串起來了。
今天已經成功將飲水紀錄存進 H2 Database。
但目前我們只能透過 API 查詢當日的飲水總量。
如果想知道:
昨天喝了多少水?
或是:
最近七天的飲水量是多少?
目前還做不到。
所以下一篇準備加入歷史查詢功能!
預計會學習:
@RequestParam 的日期參數LocalDate 處理日期讓智慧健康助理不只可以保存資料,也能開始查看過去的健康紀錄!