โหมดมืด
บทที่ 1 — First Spring Boot App: REST API ที่อธิบายทุก annotation
บทนี้เราจะ:
- สร้าง Spring Boot project ผ่าน Spring Initializr
- เขียน REST endpoint แรก
- อธิบายทุก annotation ที่เจอ — อย่าเป็นเวทมนตร์
- ทดสอบ API ด้วย curl + browser
- เพิ่ม service + repository + ทำ in-memory CRUD
📋 ก่อนเริ่ม — ต้องผ่านอะไรมาก่อน?
- ✅ Java พื้นฐาน (Java บทที่ 1–8: class, object, interface, generics, exception)
- ✅ Spring Boot บทที่ 0 (IoC, DI, Bean) — บทนี้เอาแนวคิดพวกนั้นมาลงมือทำจริง
- 🔑 ศัพท์ที่จะเจอบ่อยในบทนี้ (อธิบายเพิ่มระหว่างทาง):
- endpoint (เอนด์-พอยต์) = "ปลายทาง" 1 URL ที่ API เปิดให้เรียก เช่น
GET /users- REST API (เรสต์ เอ-พี-ไอ) = แบบแผนการออกแบบ API ผ่าน HTTP ที่นิยมที่สุด
- CRUD (ครัด) = 4 งานพื้นฐานกับข้อมูล: Create/Read/Update/Delete (สร้าง/อ่าน/แก้/ลบ)
- payload / body = เนื้อข้อมูลที่ส่งไป-กลับใน request/response มักเป็น JSON
- curl (เคิร์ล) = คำสั่งบน terminal ไว้ยิง HTTP request ทดสอบ API (มีบนทุก OS ที่ใหม่หน่อย — macOS / Linux / Windows 10+ ไม่ต้องลงเพิ่ม) ⚠️ Windows PowerShell:
curlใน PowerShell 5.1 เป็น alias ของInvoke-WebRequest— ถ้าคำสั่งในหนังสือ error ให้ใช้curl.exeแทน (หรืออัปเป็น PowerShell 7+ ที่ไม่มี alias นี้)
1. สร้าง project ผ่าน Spring Initializr
ไปที่ https://start.spring.io
💡 ถ้าเปิด start.spring.io ไม่ได้ (เช่น firewall บริษัทกั้น หรือไม่มีอินเตอร์เน็ต): ใช้ IntelliJ แทนได้เลย — File → New → Project → Spring Initializr มีฟีเจอร์เดียวกันทุกอย่างและไม่ต้องการอินเตอร์เน็ต
ตั้งค่า:
| ช่อง | ค่า |
|---|---|
| Project | Maven |
| Language | Java |
| Spring Boot | เวอร์ชันล่าสุด (3.x — ปี 2026 ราว 3.4-3.5) |
| Project Metadata: Group | com.example |
| Artifact | firstapp |
| Name | firstapp |
| Package name | com.example.firstapp |
| Packaging | Jar |
| Java | 21 (LTS — แนะนำสำหรับมือใหม่) หรือ 25 ถ้ามีติดตั้งแล้ว |
💡 เลือก Java เวอร์ชันไหนดี? มือใหม่แนะนำ Java 21 — เป็น LTS (Long-Term Support — เวอร์ชันที่ได้รับการดูแล/แก้บั๊กยาวหลายปี ใช้กันแพร่หลายที่สุดในงานจริง) ถ้าเครื่องคุณยังไม่มี Java 25 ก็ไม่ต้องไปลงเพิ่ม ใช้ 21 ได้สบาย โค้ดในหนังสือนี้รันได้ทั้งสองเวอร์ชัน
⚠️ ยังไม่มี Java? ต้องลงก่อน:
- โหลด Eclipse Temurin 21 (ฟรี, open-source) จาก adoptium.net → เลือก Temurin 21 (LTS)
- ติดตั้งตามปกติ
- เปิด terminal/cmd แล้วเช็ค:
java -version— ควรเห็นopenjdk 21.x.xถ้าเห็น
java -versionแล้วได้เลข version ถูกต้อง = พร้อมไปต่อ
Dependencies (กด ADD DEPENDENCIES):
💡 dependency (ดี-เพน-เดนซี) มี 2 ความหมายตามบริบท:
- ระดับ project — library พร้อมใช้ที่ Maven จะดึงเข้า project ให้อัตโนมัติ (ความหมายที่ใช้ตรงนี้)
- ระดับ object — สิ่งที่ class ต้องใช้ เช่น
UserRepositoryคือ dependency ของUserService(บทที่ 0)อย่าสับสนกัน §2.2 จะลงรายละเอียดเรื่อง Maven
Spring Web— สำหรับ REST APILombok— ลด boilerplate (บอยเลอร์-เพลต — โค้ดซ้ำ ๆ ที่ต้องเขียนทุกครั้ง เช่น getter/setter) Lombok เขียนให้แทน (optional — บทนี้ยังไม่ได้ใช้ เพราะเราใช้recordแทน จะเริ่มใช้จริงในบทที่ใช้ entity ที่ต้องการ mutable setter)
🔴 ทำเลย (ถ้าเลือก Lombok): Settings → Build → Compiler → Annotation Processors → เปิด Enable annotation processing — ถ้าไม่เปิดจะเจอ error
cannot find symbolที่ไม่รู้สาเหตุ
Spring Boot DevTools— auto reload (โหลดโค้ดใหม่อัตโนมัติตอนแก้ไฟล์ — ไม่ต้อง stop+start เอง)
กด GENERATE → ได้ไฟล์ zip → unzip → เปิดใน IntelliJ
💡 ยังไม่มี IntelliJ? — โหลด IntelliJ IDEA Community Edition (ฟรี) จาก jetbrains.com/idea/download เวอร์ชัน Community พอ ไม่ต้อง Ultimate ทางเลือก: VS Code + Extension "Extension Pack for Java" ก็เขียน Spring Boot ได้
💡 ทางลัด IntelliJ: File → New → Project → Spring Initializr — ทำตรงนี้ได้เลย
2. ดู project structure
text
firstapp/
├── pom.xml
├── src/
│ ├── main/
│ │ ├── java/com/example/firstapp/
│ │ │ └── FirstappApplication.java
│ │ └── resources/
│ │ ├── application.properties
│ │ └── static/
│ └── test/
│ └── java/com/example/firstapp/
│ └── FirstappApplicationTests.java
└── target/ (สร้างตอน build)2.1 ดูเนื้อ FirstappApplication.java
💡 ไฟล์นี้ Spring Initializr สร้างให้แล้ว — เราดูเฉย ๆ ก่อน ไม่ต้องพิมพ์เอง
java
package com.example.firstapp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class FirstappApplication {
public static void main(String[] args) {
SpringApplication.run(FirstappApplication.class, args);
}
}แค่นี้ — มี main + 1 annotation
อธิบาย @SpringBootApplication
นี่คือ annotation รวม 3 ตัว:
@SpringBootConfiguration— บอกว่า class นี้เก็บการตั้งค่า (configuration) ของ Spring (คือ specialization ของ@Configurationที่ Spring Boot ใช้สำหรับ main class โดยเฉพาะ — ในการสร้าง config class ทั่วไปให้ใช้@Configurationปกติ ไม่ใช่@SpringBootConfiguration)@EnableAutoConfiguration— เปิด auto-configure (การตั้งค่าอัตโนมัติ — Spring detect (ตรวจหา) dependency ใน classpath (รายการ library ที่ project มี) แล้ว configure (ตั้งค่า) ให้โดยอัตโนมัติ)@ComponentScan— scan annotation (@Component,@Serviceฯลฯ) ใน package นี้ + ลูก
SpringApplication.run(class, args) ทำอะไร
- สร้าง Spring ApplicationContext (container)
- Scan + load bean ทั้งหมด
- Start embedded server (Tomcat) — default port 8080
- รอ HTTP request
2.2 ดู pom.xml
pom.xml = ไฟล์ที่ Maven (เครื่องมือ build ของ Java) ใช้บอกว่า project นี้ใช้ library อะไรบ้าง ทุก dependency เขียนในแท็ก <dependency> มี <groupId> (เจ้าของ library — มักเป็น domain name กลับด้าน เช่น บริษัท example.com → groupId คือ com.example) + <artifactId> (ชื่อ library)
xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
...
</dependencies>spring-boot-starter-web = "ของรวม" — ดึง Tomcat + Jackson + Spring MVC + ฯลฯ
2.3 ลอง run ครั้งแรก
ใน IntelliJ:
- คลิกขวาที่
FirstappApplication→ Run
หรือใน terminal:
bash
# macOS / Linux
./mvnw spring-boot:run
# Windows PowerShell (ต้องมี .\ นำหน้า ไม่งั้น error "not recognized")
.\mvnw.cmd spring-boot:run
# Windows cmd (Command Prompt)
mvnw.cmd spring-boot:run(mvnw = Maven wrapper — ถูกสร้างมาพร้อม project โดย Initializr ไม่ต้องติดตั้ง Maven แยก ใช้ Maven version ที่ project กำหนดไว้ (pin — "ตรึง" เวอร์ชัน))
💡 macOS/Linux: ถ้าได้ error
Permission deniedให้รันchmod +x mvnwก่อน แล้วค่อยรัน./mvnw spring-boot:runอีกครั้ง
ดู log:
text
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.x.x) ← เลข version จะต่างกันตาม version ที่เลือกใน Initializr
... Started FirstappApplication in 1.234 seconds ...
... Tomcat started on port 8080 (http) ...เปิด browser → http://localhost:8080
🔤 localhost = เครื่องตัวเอง · port 8080 = ช่องสื่อสารที่ Spring Boot ใช้เป็น default (เปลี่ยนได้ใน §10 ผ่าน
application.properties)💡 ถ้าเจอ error "Port 8080 already in use": มีโปรแกรมอื่นใช้ port 8080 อยู่ — ปิดโปรแกรมนั้นก่อน หรือแก้
application.propertiesเพิ่มบรรทัดserver.port=8090แล้ว restart
ผลลัพธ์ — ไม่ต้องตกใจ แปลว่า server ทำงานแล้ว เพียงแต่ยังไม่มี endpoint ที่ /:
text
Whitelabel Error Page
This application has no explicit mapping for /, so you are seeing this as a fallback.หน้านี้เรียกว่า Whitelabel Error Page (หน้า error สำเร็จรูปของ Spring ที่แสดงเป็นค่า default เมื่อไม่มีหน้าจัดการ error ของเราเอง)
ข้อความแปลว่า: app ของคุณยังไม่ได้ "จับคู่" path / กับ method ใด ๆ เลย Spring เลยแสดงหน้านี้แทน — server ทำงานแล้ว แค่ยังไม่มี endpoint (ปลายทาง URL) ใด ๆ เดี๋ยวเราจะเพิ่ม
3. เพิ่ม Controller แรก
สร้างไฟล์ใหม่ src/main/java/com/example/firstapp/HelloController.java:
java
package com.example.firstapp;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello, Spring Boot!";
}
}อธิบายทีละ annotation
@RestController
- รวม
@Controller+@ResponseBody @Controller— บอก Spring ว่า class นี้รับ HTTP request@ResponseBody— return ค่าของ method = response body (ไม่ใช่ template name)
ผลคือ: ทุก method return → Spring convert เป็น JSON (หรือ string ตรง ๆ) ส่งกลับ
@GetMapping("/hello")
- map HTTP GET
/hello→ method นี้ - annotation อื่น:
@PostMapping,@PutMapping,@DeleteMapping,@PatchMapping - ก่อนหน้านี้:
@RequestMapping(value="/hello", method=GET)— verbose มาก
⚠️ กับดักที่พลาดกันบ่อย: ถ้าเขียน
@RequestMapping("/hello")เฉย ๆ โดยไม่ระบุmethod=จะรับได้ ทุก HTTP verb (GET/POST/PUT/DELETE ฯลฯ) พร้อมกัน — ไม่ได้จำกัดแค่ GET แบบที่คนมักเข้าใจผิด เพราะแบบนี้ endpoint ที่ควรรับแค่ GET อาจดันไปรับ POST/DELETE โดยไม่ตั้งใจ ให้ใช้@GetMapping/@PostMappingที่เจาะจง verb เสมอ จะได้ชัดเจนและปลอดภัยกว่า
ลอง
restart app (หรือถ้ามี DevTools — แค่ save ไฟล์แล้ว IntelliJ จะ rebuild + restart context (= สร้าง Spring container ใหม่ทั้งหมด) ให้อัตโนมัติ ใช้เวลา 1-2 วินาที)
💡 DevTools ทำอะไรบ้าง — แยก classloader (กลไกที่ JVM ใช้โหลดไฟล์ .class เพื่อเอาไปรัน — เหมือนตัวเปิดกล่องโค้ดก่อนใช้งาน) ออกเป็น 2 ชั้น:
- ชั้น base = library ใน
~/.m2ไม่ reload ซ้ำ- ชั้น restart = โค้ดเราใน
target/classesreload ใหม่ทุกครั้งที่ไฟล์เปลี่ยน→ restart เร็วกว่าหยุด-เริ่ม JVM ทั้งหมด 5-10 เท่า
ใน IntelliJ ต้องเปิด: Settings → Build → Compiler → "Build project automatically" + Advanced Settings → "Allow auto-make to start..." = ON
เปิด: http://localhost:8080/hello
ผลลัพธ์:
text
Hello, Spring Boot!🎉 endpoint แรกใช้งานได้!
4. Return JSON
เพิ่ม method นี้เข้าไปใน HelloController เดิม และวาง record User ไว้ในไฟล์เดียวกัน (นอก class HelloController ก็ได้):
💡 วาง
record Userไว้ที่ไหนในไฟล์? Java อนุญาตให้ไฟล์มีหลาย type ได้ถ้าไม่ใช่public— วางrecord User(...)ต่อจาก}ปิด classHelloControllerตัวอย่างโครงสร้างไฟล์:java@RestController public class HelloController { // ... methods ... } // วางนอก class HelloController แต่ยังอยู่ในไฟล์เดียวกัน record User(String name, int age, String email) {}
java
@GetMapping("/user")
public User getUser() {
return new User("Anna", 25, "anna@example.com");
}
record User(String name, int age, String email) {}🔤 record (Java 14+) = วิธีประกาศ class แบบ immutable data สั้น ๆ — ได้ field + constructor + getter +
equals/hashCode/toStringฟรี ไม่ต้องเขียนเอง ในบทนี้เราจะใช้recordเป็นหลักเพราะสั้นและเหมาะกับ DTO/JSON
💡 หมายเหตุ:
Userrecord ตอนนี้มี 3 field (name,age,idเป็น 4 field เพื่อเก็บใน repository ให้ลบ/แทนที่ record นี้ตอนนั้นได้เลย
GET http://localhost:8080/user
json
{
"name": "Anna",
"age": 25,
"email": "anna@example.com"
}Spring (ผ่าน Jackson) convert object → JSON อัตโนมัติ — ไม่ต้องเขียนเอง
5. รับ parameter
endpoint ส่วนใหญ่ต้องรับ input จาก client — Spring มี annotation ให้ดึงค่าจากหลายที่: @PathVariable (ค่าใน path เช่น /users/{id}), @RequestParam (query string ?key=value), และ @RequestBody (JSON body) เลือกใช้ให้ตรงกับว่า client ส่งค่ามาทางไหน:
5.1 Path variable
java
@GetMapping("/users/{id}")
public String getUserById(@PathVariable String id) {
return "Getting user " + id;
}URL: /users/123 → method ได้ id = "123"
5.2 Query parameter
java
@GetMapping("/search")
public String search(@RequestParam String q,
@RequestParam(defaultValue = "10") int limit) {
return "Search: " + q + ", limit: " + limit;
}URL: /search?q=spring&limit=20 → q="spring", limit=20URL: /search?q=spring → q="spring", limit=10 (default)
5.3 Request body (POST)
java
@PostMapping("/users")
public User createUser(@RequestBody User user) {
System.out.println("Creating: " + user);
return user; // ส่งค่ากลับไปเหมือนเดิม (echo back)
}curl:
bash
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"Anna","age":25,"email":"anna@example.com"}'💡 Windows PowerShell: ตัว
\ท้ายบรรทัด (line continuation — การต่อบรรทัด เป็น syntax ของ bash) ใช้ไม่ได้ใน PowerShell — ใช้ backtick`แทน หรือเขียนเป็นบรรทัดเดียว:powershell# Windows PowerShell — ใช้ backtick ` แทน backslash \ curl.exe -X POST http://localhost:8080/users ` -H "Content-Type: application/json" ` -d '{"name":"Anna","age":25,"email":"anna@example.com"}'ทางเลือกที่สะดวกกว่า: Postman หรือ Insomnia (GUI tool ทดสอบ API ไม่ต้องสนใจ syntax)
Spring convert JSON → User object อัตโนมัติ
6. Status code + ResponseEntity
⚠️ ก่อนพิมพ์โค้ดใน §6: endpoint
GET /users/{id}ใน section นี้ใช้ path เดียวกัน กับที่เขียนใน §5.1 — ถ้าเก็บทั้งสองไว้พร้อมกัน Spring จะ start ไม่ขึ้น (IllegalStateException: Ambiguous mapping) ให้ลบหรือ commentgetUserByIdใน §5.1 ออกก่อน แล้วค่อยเพิ่มตัวอย่างใน §6 แทน
🔤 HTTP response มี 3 ส่วน: status code (เลข 200/404/500 ที่บอก "สำเร็จ/ไม่เจอ/พัง") + header (ข้อมูลเสริม เช่น
Content-Type: application/json) + body (เนื้อหา เช่น JSON ของ user) —ResponseEntityให้เราคุมทั้ง 3 ส่วน
java
@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable String id) {
if (id.equals("404")) {
return ResponseEntity.notFound().build();
}
User user = new User("Anna", 25, "anna@example.com");
return ResponseEntity.ok(user);
}ResponseEntity ให้ control เต็มที่ — status, header, body
shortcut
java
return ResponseEntity.ok(user); // 200 สำเร็จ
return ResponseEntity.status(HttpStatus.CREATED).body(user); // กำหนด status เอง (ใช้ enum ไม่ใช่ magic number 201)
return ResponseEntity.notFound().build(); // 404 ไม่เจอ
return ResponseEntity.badRequest().build(); // 400 request ผิด
return ResponseEntity.created(uri).body(user); // 201 + ใส่ Location header (ที่อยู่ของ resource ที่เพิ่งสร้าง)HTTP Status code semantics — ใช้ตัวไหนเมื่อไหร่?
มือใหม่ส่ง 200 ทุกอย่าง — กลายเป็น "200 OK ที่ body บอกว่า error" → client ต้อง parse body ทุก response
กฎตามมาตรฐาน HTTP (RFC 9110):
| Status | ความหมาย | ใช้ใน REST |
|---|---|---|
| 200 OK | สำเร็จ มี body | GET ที่เจอ, PUT/POST ที่ return resource |
| 201 Created | สร้าง resource ใหม่สำเร็จ | POST /users → 201 + Location: /users/123 |
| 202 Accepted | รับเข้า queue แล้ว ยังไม่เสร็จ | งานที่ทำเบื้องหลัง (async job) — POST /reports |
| 204 No Content | สำเร็จ ไม่มี body | DELETE, PUT ที่ไม่ต้อง return |
| 400 Bad Request | request format ผิด / validation fail | missing field, JSON malformed |
| 401 Unauthorized | ไม่มี/ผิด credentials (ชื่อชวนงง) | token หาย, token หมดอายุ (expired) |
| 403 Forbidden | มี credential แต่ไม่มีสิทธิ์ | user พยายามลบ resource ของคนอื่น |
| 404 Not Found | ไม่เจอ resource | GET /users/999 |
| 409 Conflict | state ขัด — เช่น duplicate, optimistic lock fail | POST email ซ้ำ, @Version mismatch |
| 410 Gone | เคยมี ตอนนี้ลบถาวรแล้ว | resource เคยอยู่ |
| 422 Unprocessable Entity | request format ถูก แต่ semantic ผิด | "transfer -100" — JSON valid แต่ business invalid |
| 429 Too Many Requests | rate limit | ใส่ Retry-After header |
| 500 Internal Server Error | server bug | exception ที่ไม่คาด |
| 502/503/504 | upstream / overloaded / timeout | proxy/gateway/circuit-break |
Location header — สำคัญสำหรับ 201 Created
📖 ตัวอย่างด้านล่างเป็นภาพรวมให้ดูเฉย ๆ ยังไม่ต้องพิมพ์ตาม — โค้ดนี้ต้องการ
userServiceและUserrecord 4-field ที่จะสร้างใน §7 ถ้าพิมพ์ตามตอนนี้จะ compile ไม่ผ่าน ดูตัวอย่างใช้งานจริงได้ที่ §7.4
java
// ⚠️ ตัวอย่างนี้ compile ไม่ผ่านจนกว่าจะสร้าง UserService ใน §7 — ดูเป็นภาพรวมก่อน
@PostMapping("/users")
public ResponseEntity<User> create(@RequestBody User user) { // @Valid ละไว้ก่อน — ดูตัวอย่าง validation ที่ครบถ้วนใน §7.4
User saved = userService.save(user);
// สร้าง URI ที่ชี้ไปยัง resource ที่เพิ่งสร้าง
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(saved.id()) // record ใช้ saved.id() ไม่ใช่ saved.getId()
.toUri();
// → http://host/users/123
return ResponseEntity.created(location).body(saved); // 201 + ใส่ Location header
}ทำไม Location สำคัญ:
- Client follow link ต่อได้ทันที — ไม่ต้องประกอบ URL เอง
- HTTP spec แนะนำว่า
201 CreatedควรมีLocationheader - บาง framework ขั้นสูงบังคับใช้ (HATEOAS/Hypermedia/OData — แบบแผน API ขั้นสูงที่ฝัง link ไว้ใน response — มือใหม่ข้ามได้ กางศัพท์ด้านล่าง)
🔤 กางศัพท์ขั้นสูง (อ่านเพิ่ม — ข้ามได้)
- HATEOAS (hate-ee-os — ย่อจาก Hypermedia As The Engine Of Application State) = แนวคิดให้ API ส่ง "ลิงก์ไปทำต่อ" มากับ response เพื่อให้ client เดินตามลิงก์ได้เลย
- Hypermedia (ไฮเปอร์-มี-เดีย) = การฝังลิงก์/action ลงไปในข้อมูล (แบบเดียวกับลิงก์ในหน้าเว็บ)
- OData (โอ-ดาต้า) = มาตรฐาน query API ของ Microsoft ที่ใช้ลิงก์แบบนี้
มือใหม่ยังไม่ต้องสนใจ 3 ตัวนี้ตอนนี้ — แค่รู้ว่า Location header เป็นมารยาทมาตรฐานของ 201 Created ก็พอ
204 No Content — DELETE / PUT (idempotent)
🔤 idempotent (ไอ-เดม-โพ-เทนต์) = "เรียกซ้ำกี่ครั้งผลก็เหมือนเดิม"
- DELETE (idempotent): ลบ user id 5 ซ้ำ 3 ครั้ง ผลสุดท้ายก็คือ user 5 หายไปเหมือนเดิม (ไม่พังเพิ่ม)
- POST (ไม่ idempotent): เรียก
POST /usersซ้ำ 10 ครั้ง ได้ user ใหม่ 10 คน — ผลลัพธ์เปลี่ยนไปทุกครั้งที่เรียก- PUT (idempotent): เรียก
PUT /users/5ด้วยข้อมูลเดิมซ้ำ 10 ครั้ง ผลก็ยังเป็น user 5 ที่มีข้อมูลเดิม ไม่ต่างจากเรียกครั้งเดียว
📖 ตัวอย่างด้านล่างเป็นภาพรวมให้ดูเฉย ๆ — ต้องมี
userServiceจาก §7 ก่อนจึงจะใช้ได้จริง
java
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) { // ใช้ String ให้สอดคล้องกับ User record
userService.delete(id);
return ResponseEntity.noContent().build(); // 204
}ไม่ส่ง body กลับ → client ไม่ต้อง parse
7. ระดับ Service + Repository — เริ่มเห็น DI
ตอนนี้ controller ทำทุกอย่างเอง — ไม่ดี ในจริง separate:
7.1 Repository — เก็บข้อมูล
ใน repository/UserRepository.java:
java
package com.example.firstapp.repository;
import com.example.firstapp.model.User;
import org.springframework.stereotype.Repository;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@Repository
public class UserRepository {
// ConcurrentHashMap — thread-safe เพราะ bean เป็น singleton ต้องรองรับ request พร้อมกันหลายตัว
private final Map<String, User> store = new ConcurrentHashMap<>();
public User save(User user) {
store.put(user.id(), user);
return user;
}
public Optional<User> findById(String id) {
return Optional.ofNullable(store.get(id));
}
public List<User> findAll() {
return new ArrayList<>(store.values());
}
public void deleteById(String id) {
store.remove(id);
}
}@Repository = @Component ที่บอกว่า "นี่เก็บข้อมูล"
7.2 Service — business logic
ใน service/UserService.java:
java
package com.example.firstapp.service;
import com.example.firstapp.model.User;
import com.example.firstapp.repository.UserRepository;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
public class UserService {
private final UserRepository repo;
// constructor injection — Spring ส่ง UserRepository ให้
public UserService(UserRepository repo) {
this.repo = repo;
}
public User createUser(String name, int age, String email) {
String id = UUID.randomUUID().toString();
User user = new User(id, name, age, email);
return repo.save(user);
}
public User getUser(String id) {
return repo.findById(id)
.orElseThrow(() -> new RuntimeException("User not found: " + id));
}
public List<User> listAll() {
return repo.findAll();
}
public void deleteUser(String id) {
repo.deleteById(id);
}
public User replaceUser(String id, String name, int age, String email) {
User existing = getUser(id); // throw ถ้าไม่เจอ
User replaced = new User(existing.id(), name, age, email);
repo.save(replaced);
return replaced;
}
// ⚠️ pattern นี้ใช้ Map<String, Object> + cast ตรงๆ เพื่อความสั้น — ในงานจริงควรใช้ typed DTO ที่มี Optional/nullable field แทน
// ⚠️ ระวัง ClassCastException: ถ้า caller ส่ง value ผิดชนิด (เช่น Integer แทน String ใน "name") จะ throw ClassCastException ตอน runtime — pattern นี้เป็นเพียงตัวอย่าง ไม่ใช้ใน production
// หมายเหตุ: intValue() truncate ทศนิยม เช่น age=25.5 จะกลายเป็น 25 โดยไม่มี error
public User patchUser(String id, Map<String, Object> partial) {
User existing = getUser(id);
String name = (String) partial.getOrDefault("name", existing.name());
int age = ((Number) partial.getOrDefault("age", existing.age())).intValue();
String email = (String) partial.getOrDefault("email", existing.email());
return replaceUser(id, name, age, email);
}
}@Service = @Component ที่บอกว่า "นี่ทำ business logic"
หมายเหตุ:
replaceUserกับpatchUserเพิ่มไว้รอ Controller ใน §7.4 ใช้ —Userเป็นrecord→ field access ใช้existing.id()ไม่ใช่existing.getId()
💡 ใน in-memory HashMap บทนี้ยังไม่เห็นปัญหา แต่เมื่อใช้กับ database จริงในบทที่ 2 operation ที่ read-then-write (เช่น
replaceUser,patchUser) ต้องใส่@Transactionalเพื่อป้องกัน race condition
⚠️ anti-pattern (แอนตี้-แพตเทิร์น — วิธีเขียนโค้ดที่ดูเหมือนใช้ได้ แต่จริง ๆ ก่อปัญหาในระยะยาว):
getUserตอนนี้throw new RuntimeException(...)แบบดิบ ๆ — ทำให้ controller คืน HTTP 500 แทน 404 อย่าใช้แบบนี้ในงานจริง §9 จะแทนด้วยUserNotFoundException(custom exception) + global handler ที่คืน 404 ถูกต้อง
7.3 Model
model/User.java:
java
package com.example.firstapp.model;
public record User(String id, String name, int age, String email) {}💡 เพิ่ม
idแล้ว —Userใน §4 มี 3 field (name,age,idเพื่อ lookup ตั้งแต่ §7 เป็นต้นไปใช้ version 4-field นี้ ลบ/แทนที่ record เดิมใน §4 ได้เลย
7.4 Controller ใช้ Service
✅ Checklist ก่อน copy โค้ด UserController นี้:
- ลบโค้ด §5–§6 ออกก่อน —
UserControllerมี@RequestMapping("/users")ซึ่งชนกับ endpoint ใน §5 (/users,/users/{id}) และ §6 — ถ้าไม่ลบออก app จะ start ไม่ขึ้น (IllegalStateException: Ambiguous mapping)- เพิ่ม
spring-boot-starter-validationในpom.xml— ถ้าไม่มี@Validจะกลายเป็น no-op เงียบ ๆ (ไม่ throw, ไม่ validate):xml(ถ้าเพิ่ม dependency นี้ไว้ก่อนหน้านี้แล้ว ให้เช็คใน<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>pom.xmlก่อน — ถ้ามีอยู่แล้วข้ามขั้นตอนนี้ได้)- Reload Maven — กดปุ่ม 🔄 Load Maven Changes ที่เด้งขึ้นมาใน IntelliJ
- Restart app
รายละเอียดเต็มของ
@Valid/@NotBlank/
java
package com.example.firstapp;
import com.example.firstapp.model.User;
import com.example.firstapp.service.UserService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService service;
public UserController(UserService service) {
this.service = service;
}
@GetMapping
public List<User> listAll() {
return service.listAll();
}
@GetMapping("/{id}")
public User getOne(@PathVariable String id) {
return service.getUser(id);
}
@PostMapping
public ResponseEntity<User> create(@Valid @RequestBody CreateUserRequest req) {
User created = service.createUser(req.name(), req.age(), req.email());
// ใส่ Location header ตาม HTTP spec — ชี้ไปยัง resource ที่เพิ่งสร้าง (เหมือนตัวอย่างใน §6)
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created); // 201 + Location header
}
@PutMapping("/{id}")
public ResponseEntity<User> replace(@PathVariable String id,
@Valid @RequestBody UpdateUserRequest req) {
// PUT = idempotent — replace ทั้ง resource ผู้เรียกส่ง state เต็มมา
User updated = service.replaceUser(id, req.name(), req.age(), req.email());
return ResponseEntity.ok(updated);
}
@PatchMapping("/{id}")
public ResponseEntity<User> patch(@PathVariable String id,
@RequestBody Map<String, Object> partial) {
// PATCH = partial update — ผู้เรียกส่งเฉพาะ field ที่จะแก้
User updated = service.patchUser(id, partial);
return ResponseEntity.ok(updated);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.deleteUser(id);
return ResponseEntity.noContent().build();
}
// DTO (Data Transfer Object) — ในงานจริงควรย้าย record เหล่านี้ออกเป็นไฟล์แยกใน dto/ หรือ model/
// เพื่อ reusability (ใช้ซ้ำจาก class อื่น) และ testability — วางไว้ใน controller แบบนี้เพื่อความสั้นในตัวอย่าง
record CreateUserRequest(@NotBlank String name, @Min(0) @Max(150) int age, @Email String email) {}
record UpdateUserRequest(@NotBlank String name, @Min(0) @Max(150) int age, @Email String email) {}
}✅ Controller ข้างบนเรียก
replaceUserกับpatchUserของUserService— เราเพิ่ม 2 method นี้ไว้ใน §7.2 แล้ว ไม่ต้องไปเพิ่มซ้ำ
7.5 ภาพรวม flow
text
HTTP Request
↓
UserController ← @RestController
↓ uses
UserService ← @Service
↓ uses
UserRepository ← @Repository
↓
HashMap (in-memory storage)Spring เชื่อมให้อัตโนมัติ (auto wire — "ต่อสายให้") — เห็น UserController(UserService) → หา bean ของ UserService → ส่งให้
8. ทดสอบ
หลังเขียน endpoint เสร็จ ลองยิงทดสอบด้วย curl เพื่อเห็นว่าทำงานจริง — ครบทุก operation ของ CRUD (create/read/update/delete) วิธีนี้เร็วกว่าเปิด browser และเห็น request/response ดิบ ๆ ชัดเจน:
restart app
Create
bash
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"Anna","age":25,"email":"anna@example.com"}'Response:
json
{"id":"abc-123-...","name":"Anna","age":25,"email":"anna@example.com"}List
bash
curl http://localhost:8080/usersGet one
bash
curl http://localhost:8080/users/abc-123-...Delete
bash
curl -X DELETE http://localhost:8080/users/abc-123-...9. Exception handling
ตอนนี้ถ้าหา user ไม่เจอ — return 500 error message ที่เปิดเผยข้อมูลภายใน ไม่ดี
แก้: เพิ่ม global exception handler
Custom exception
exception/UserNotFoundException.java:
java
package com.example.firstapp.exception;
public class UserNotFoundException extends RuntimeException {
private final String userId;
public UserNotFoundException(String id) {
super("User not found: " + id);
this.userId = id;
}
public String getUserId() {
return userId;
}
}แก้ service:
java
public User getUser(String id) {
return repo.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}Exception handler
exception/GlobalExceptionHandler.java:
ในบทนี้จะใช้
@RestControllerAdvice(=@ControllerAdvice+@ResponseBodyรวมในตัวเดียว) แทน@ControllerAdviceปกติ เพราะ REST API ของเราต้องคืน JSON เสมอ — ถ้าใช้@ControllerAdviceเปล่า ๆ ต้องประกาศ@ResponseBodyทุก method เอง
java
package com.example.firstapp.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Map;
@RestControllerAdvice // = @ControllerAdvice + @ResponseBody (เหมาะกับ REST API ที่คืน JSON)
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<Map<String, String>> handleNotFound(UserNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND) // ใช้ enum ไม่ใช่ magic number
.body(Map.of("error", e.getMessage()));
}
}@RestControllerAdvice— เหมือน@ControllerAdviceแต่รวม@ResponseBodyอัตโนมัติ → ทุก response คืน JSON โดยไม่ต้องประกาศซ้ำ เป็น default สำหรับ REST API ปี 2026@ExceptionHandler— บอกว่าจัดการ exception ชนิดไหน- ใช้
HttpStatus.NOT_FOUND(enum) แทน404(magic number) เพื่อให้อ่านง่ายและ refactor-safe
ทดสอบ:
bash
curl http://localhost:8080/users/nonexistent
# {"error":"User not found: nonexistent"}
# HTTP 4049.1 อัพเกรด — ใช้ Problem Details (RFC 9457) แทน Map
Map<String, String> แบบข้างบนง่ายดี แต่ ไม่เป็นมาตรฐาน — frontend (เว็บ/แอปฝั่งหน้าจอที่เรียก API ของเรา) หรือ partner (ทีม/บริษัทอื่นที่ใช้ API ของเรา) ต้องเดาเองว่า field ชื่ออะไร
RFC 9457 (เดิมคือ RFC 7807 — ชื่อเก่ายังเจอบ่อยใน blog และเอกสาร) = มาตรฐาน HTTP API error format ที่ Spring 6 / Spring Boot 3+ รองรับ built-in ผ่าน class ProblemDetail
โครงสร้างมาตรฐาน:
json
{
"type": "https://example.com/errors/user-not-found",
"title": "User Not Found",
"status": 404,
"detail": "User not found: abc-123",
"instance": "/users/abc-123"
}เขียนใหม่:
java
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ProblemDetail handleNotFound(UserNotFoundException e) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, "User with the given ID was not found"); // ข้อความที่ client อ่านได้ — ไม่ leak internal ID ลง detail
pd.setType(URI.create("https://example.com/errors/user-not-found"));
pd.setTitle("User Not Found");
pd.setProperty("userId", e.getUserId()); // ใส่ id ในคอลัมน์แยกแทน — พิจารณาตาม security requirement ของระบบ
return pd;
}
}Spring จะ set Content-Type: application/problem+json ให้อัตโนมัติ — frontend จัดการ error เป็นมาตรฐานทั่วโลก
📚 อ้างอิง: RFC 9457 — Problem Details for HTTP APIs (ชื่อเก่า RFC 7807) · Spring 6 ProblemDetail JavaDoc
จุดผิดพลาดที่พบบ่อย (common pitfall — แปลตรงตัวว่า "หลุมพราง"): ระวังอย่าใส่ stack trace หรือ SQL error ลง detail — leak ข้อมูล internal เป็นช่องโหว่ security ใช้ logger เก็บ stack trace ฝั่ง server เท่านั้น
10. Configuration ผ่าน application.properties
ใน src/main/resources/application.properties:
properties
# Server port
server.port=8081
# Application name
spring.application.name=firstapp
# Log level
logging.level.com.example.firstapp=DEBUGrestart → server run ที่ port 8081 แล้ว
⚠️ ทดลองเสร็จแล้ว ให้เปลี่ยน
server.portกลับเป็น 8080 หรือลบบรรทัดนั้นออก — ตัวอย่าง curl ที่เหลือในบทนี้ (§11, Checkpoint) ใช้ port 8080 เป็น default ถ้าไม่เปลี่ยนกลับจะ connection refused
อ่าน config ใน code
java
@Service
public class UserService {
// @Value field injection เป็นที่ยอมรับสำหรับ config value (ต่างจาก @Autowired ที่ควรหลีกเลี่ยง)
// ถ้าต้องการ immutability/testability สูงสุด ให้ inject ผ่าน constructor แทน หรือใช้ @ConfigurationProperties (ด้านล่าง)
@Value("${app.max-users:100}") // :100 = ค่า default ถ้าไม่ได้ตั้ง app.max-users ไว้
private int maxUsers;
// ...
}ใน properties:
properties
app.max-users=500หรือใช้ @ConfigurationProperties แบบ type-safe (ปลอดภัยจากชนิดข้อมูลผิด — เช่น compile error ทันทีถ้าใส่ String ในช่องที่ควรเป็น int) ดีกว่า:
java
import org.springframework.boot.context.properties.ConfigurationProperties;
// ต้องเพิ่ม @EnableConfigurationProperties(AppConfig.class) บน main class ด้วย (ดูตัวอย่างถัดไป)
@ConfigurationProperties(prefix = "app")
public record AppConfig(int maxUsers, String defaultRole) {}💡
@ConfigurationPropertiesสร้าง bean ของAppConfigจาก properties (app.max-users,app.default-role) ให้อัตโนมัติ — ต้องบอก Spring ผ่าน@EnableConfigurationProperties(AppConfig.class)บน main class (ดูข้างล่าง) เวอร์ชัน record ใช้ canonical constructor binding ของ Spring Boot 3 ได้เลย ไม่ต้องใส่@ConstructorBinding(ถ้าเป็น class ปกติที่มีหลาย constructor ค่อยใส่)
java
@SpringBootApplication
@EnableConfigurationProperties(AppConfig.class)
public class FirstappApplication { ... }java
// ⚠️ snippet นี้เป็นตัวอย่างวิธีใช้ AppConfig เท่านั้น
// UserService จริงต้องมีทั้ง UserRepository และ AppConfig ใน constructor:
// public UserService(UserRepository repo, AppConfig config) { ... }
@Service
public class UserService {
private final AppConfig config;
public UserService(AppConfig config) {
this.config = config;
}
}💡 AppConfig ใน application.properties — ต้องตั้งค่าครบทั้ง 2 ค่า (ค่าที่ไม่ได้ตั้งจะกลายเป็น null หรือ 0):
propertiesapp.max-users=500 app.default-role=USER
11. Validation
เพิ่ม dependency (ถ้าเพิ่มไว้ใน §7.4 แล้ว ข้ามขั้นตอนนี้ได้ — เช็คใน pom.xml ว่ามี spring-boot-starter-validation อยู่แล้วหรือเปล่า):
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>ใน request:
java
import jakarta.validation.constraints.*;
record CreateUserRequest(
@NotBlank String name,
@Min(0) @Max(150) int age,
@Email String email
) {}ใน controller:
java
@PostMapping
public ResponseEntity<User> create(@Valid @RequestBody CreateUserRequest req) { ... }@Valid → Spring เช็ค constraint (ข้อจำกัด/เงื่อนไข เช่น @NotBlank, @Email) อัตโนมัติ — ถ้าผิด Spring throw MethodArgumentNotValidException ออกมา เราจับด้วย @ExceptionHandler ใน global handler (เหมือน §9)
Handle ใน global handler — MethodArgumentNotValidException (= exception ที่ Spring โยนออกมาเมื่อ argument ที่ method รับมาไม่ผ่าน validation) ใช้ ProblemDetail เหมือน §9.1 เพื่อให้สอดคล้องกัน:
java
import java.util.HashMap;
import java.util.Map;
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException e) {
Map<String, String> errors = new HashMap<>();
e.getBindingResult().getFieldErrors().forEach(err ->
errors.put(err.getField(), err.getDefaultMessage()));
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Validation Failed");
pd.setProperty("errors", errors); // ใส่ field errors ใน property แทนการคืน Map ตรงๆ
return pd;
}ทดสอบ:
bash
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"","age":-1,"email":"notanemail"}'Response (RFC 9457 ProblemDetail envelope — Spring ตั้งค่า Content-Type เป็น application/problem+json ให้อัตโนมัติ):
json
{
"type": "about:blank",
"title": "Validation Failed",
"status": 400,
"errors": {
"name": "must not be blank",
"age": "must be greater than or equal to 0",
"email": "must be a well-formed email address"
}
}Bean Validation — annotation ที่ใช้บ่อย
| Annotation | ใช้กับ | ทำอะไร |
|---|---|---|
@NotNull | any | ห้าม null |
@NotBlank | String | ห้าม null/ว่าง/มีแต่ whitespace |
@NotEmpty | String/Collection/Map | ห้ามว่าง (length=0) |
@Size(min, max) | String/Collection | ขนาดในช่วง |
@Min(n) / @Max(n) | Number | ในช่วง |
@Positive / @Negative | Number | > 0 / < 0 |
@DecimalMin("0.0") | BigDecimal | สำหรับ decimal |
@Email | String | format email |
@Pattern(regexp="...") | String | match regex |
@Past / @Future | Date/time | วันที่ในอดีต/อนาคต |
@URL | String | (hibernate-validator — มาพร้อม spring-boot-starter-validation อยู่แล้ว) validate format URL เท่านั้น ไม่ได้เช็คว่า URL เข้าถึงได้จริง |
@Valid | nested object | cascade validate ลึก |
Custom Validator — เมื่อ built-in ไม่พอ (ขั้นสูง — ข้ามได้)
ตัวอย่าง: validate ว่า username ไม่ใช่คำที่ห้าม
💡 ส่วน
public @interfaceคือ วิธีสร้าง annotation ใหม่ของ Java —@interfaceในที่นี้ไม่ใช่interfaceธรรมดา แต่เป็น syntax พิเศษที่ Java ใช้เฉพาะสำหรับสร้าง annotation เท่านั้น ไม่ต้องเข้าใจลึกตอนนี้ก็ได้ — ถ้าเพิ่งเริ่มเรียน Java annotations ข้าม section นี้ได้ก่อน
java
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.Set;
// 1. annotation
@Target({ FIELD, PARAMETER }) // ใช้วาง annotation นี้บน field และ parameter ได้
@Retention(RUNTIME) // annotation ยังอยู่ตอน runtime (จำเป็นสำหรับ validation)
@Constraint(validatedBy = NotBannedUsernameValidator.class) // ชี้ไปยัง class ที่ทำ validation จริง
public @interface NotBannedUsername {
String message() default "username นี้ใช้ไม่ได้";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// 2. validator
public class NotBannedUsernameValidator
implements ConstraintValidator<NotBannedUsername, String> {
private static final Set<String> BANNED =
Set.of("admin", "root", "test", "system");
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
if (value == null) return true; // null → ใช้ @NotNull แยก
return !BANNED.contains(value.toLowerCase());
}
}
// 3. ใช้
record CreateUserRequest(
@NotBlank @NotBannedUsername String username,
@Email String email
) {}Cross-field validation (ตรวจหลาย field พร้อมกัน) — เช่น password ต้องตรงกับ confirm
java
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.Objects;
@Target(TYPE)
@Retention(RUNTIME)
@Constraint(validatedBy = PasswordsMatchValidator.class)
public @interface PasswordsMatch {
String message() default "password กับ confirm ไม่ตรงกัน";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// หมายเหตุ: validator นี้ผูกกับ RegisterRequest โดยตรง — ถ้าต้องการ reuse กับ request class อื่น ควรสร้าง interface กลาง เช่น PasswordConfirmable
public class PasswordsMatchValidator
implements ConstraintValidator<PasswordsMatch, RegisterRequest> {
@Override
public boolean isValid(RegisterRequest r, ConstraintValidatorContext ctx) {
if (!Objects.equals(r.password(), r.confirmPassword())) {
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(
"password กับ confirm ไม่ตรงกัน")
.addPropertyNode("confirmPassword")
.addConstraintViolation();
return false;
}
return true;
}
}
@PasswordsMatch
record RegisterRequest(@NotBlank String username,
@Size(min=8) String password,
String confirmPassword) {}Validation groups — validate ต่าง context (ขั้นสูง — ข้ามได้)
java
interface OnCreate {}
interface OnUpdate {}
record User(
@Null(groups = OnCreate.class)
@NotNull(groups = OnUpdate.class) Long id, // create=ห้ามมี, update=ต้องมี
@NotBlank String name
) {}
// controller (X = return type ใดก็ได้ เช่น ResponseEntity<User> — ตัวอย่างย่อ)
@PostMapping public ResponseEntity<User> create(@Validated(OnCreate.class) @RequestBody User u) { ... }
@PutMapping public ResponseEntity<User> update(@Validated(OnUpdate.class) @RequestBody User u) { ... }💡 ใช้
@Validated(Spring) แทน@Valid(Jakarta) เพื่อรองรับ group
12. สรุปภาพรวม annotation ที่ได้เรียน
Spring ทำงานด้วย annotation เป็นหลัก บทนี้ผ่านมาหลายตัวจนอาจสับสน — ตารางนี้รวมไว้ที่เดียวว่าแต่ละตัวใช้ทำอะไร ใช้เป็น cheat sheet ทบทวนก่อนไปบทถัดไป:
| Annotation | สำหรับ |
|---|---|
@SpringBootApplication | main class |
@Component / @Service / @Repository | bean type |
@RestController | REST API class |
@RequestMapping("/path") | base path |
@GetMapping / @PostMapping / ฯลฯ | HTTP method + path |
@PathVariable | URL parameter |
@RequestParam | query string |
@RequestBody | body → object |
@Valid | trigger validation |
@RestControllerAdvice / @ControllerAdvice | global exception handler (REST ใช้ @RestControllerAdvice) |
@ExceptionHandler(X.class) | handler ของ exception X |
@Value("${prop}") | inject property |
@ConfigurationProperties | type-safe config |
(Validation) @NotBlank, @Email, @Min, @Max | constraint |
13. Checkpoint ใหญ่
🛠️ Final Checkpoint — Todo API
สร้าง REST API:
POST /todos— สร้าง todoGET /todos— list ทั้งหมดGET /todos/{id}— get เดียวPUT /todos/{id}— update (title, done)DELETE /todos/{id}— ลบ- เก็บใน
Map<String, Todo>(in-memory) - validate input
- 404 ถ้าหาไม่เจอ
- 400 ถ้า validation ไม่ผ่าน
ใช้ structure: Controller → Service → Repository
📖 เฉลย (โครงสร้าง)
java
// model/Todo.java
public record Todo(String id, String title, boolean done) {}
// model/CreateTodoRequest.java
import jakarta.validation.constraints.NotBlank;
public record CreateTodoRequest(@NotBlank String title) {}
// model/UpdateTodoRequest.java
import jakarta.validation.constraints.*; // ครอบ NotBlank และ NotNull ทั้งคู่
public record UpdateTodoRequest(@NotBlank String title, @NotNull Boolean done) {}
// repository/TodoRepository.java
@Repository
public class TodoRepository {
private final Map<String, Todo> store = new ConcurrentHashMap<>(); // thread-safe singleton
public Todo save(Todo t) { store.put(t.id(), t); return t; }
public Optional<Todo> findById(String id) { return Optional.ofNullable(store.get(id)); }
public List<Todo> findAll() { return new ArrayList<>(store.values()); }
public void deleteById(String id) { store.remove(id); }
}
// service/TodoService.java
@Service
public class TodoService {
private final TodoRepository repo;
public TodoService(TodoRepository repo) { this.repo = repo; }
public Todo create(String title) {
Todo t = new Todo(UUID.randomUUID().toString(), title, false);
return repo.save(t);
}
public Todo get(String id) {
return repo.findById(id).orElseThrow(() -> new TodoNotFoundException(id));
}
public List<Todo> listAll() { return repo.findAll(); }
public Todo update(String id, String title, boolean done) {
get(id); // เช็คก่อนว่ามีอยู่จริง (ไม่เจอจะ throw)
return repo.save(new Todo(id, title, done));
}
public void delete(String id) { repo.deleteById(id); }
}
// controller/TodoController.java
// import org.springframework.http.HttpStatus; (จำเป็นสำหรับ HttpStatus.CREATED)
// import org.springframework.http.ResponseEntity;
// import org.springframework.web.bind.annotation.*;
// import jakarta.validation.Valid;
// import java.util.List;
@RestController
@RequestMapping("/todos")
public class TodoController {
private final TodoService service;
public TodoController(TodoService service) { this.service = service; }
@GetMapping
public List<Todo> listAll() { return service.listAll(); }
@GetMapping("/{id}")
public Todo getOne(@PathVariable String id) { return service.get(id); }
@PostMapping
public ResponseEntity<Todo> create(@Valid @RequestBody CreateTodoRequest req) {
// หมายเหตุ: ตัวอย่างนี้ละ Location header ไว้เพื่อความสั้น — ในงานจริงควรใส่ด้วยเหมือน §6 (ใช้ ServletUriComponentsBuilder)
return ResponseEntity.status(HttpStatus.CREATED).body(service.create(req.title()));
}
@PutMapping("/{id}")
public Todo update(@PathVariable String id, @Valid @RequestBody UpdateTodoRequest req) {
return service.update(id, req.title(), req.done());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
}
// exception/TodoNotFoundException.java — เหมือน UserNotFoundException แต่เปลี่ยน User → Todo:
// public class TodoNotFoundException extends RuntimeException {
// private final String todoId;
// public TodoNotFoundException(String id) { super("Todo not found: " + id); this.todoId = id; }
// public String getTodoId() { return todoId; }
// }
// GlobalExceptionHandler — เพิ่ม @ExceptionHandler(TodoNotFoundException.class) เหมือน UserNotFoundExceptionทดสอบ:
bash
# create
curl -X POST http://localhost:8080/todos \
-H "Content-Type: application/json" \
-d '{"title":"Learn Spring Boot"}'
# list
curl http://localhost:8080/todos
# update
curl -X PUT http://localhost:8080/todos/<id> \
-H "Content-Type: application/json" \
-d '{"title":"Learn Spring Boot","done":true}'14. ต่อจากนี้ไป
หลังจบบทนี้คุณมี skill base ของ Spring Boot แล้ว — REST endpoint + DI + exception + validation ครบ พอจะเริ่มเขียนแอปจริงได้
บทถัดไป — บทที่ 2: Database + JPA จะแทน HashMap ในบทนี้ด้วย PostgreSQL จริง ผ่าน Spring Data JPA — entity, transaction, relation, N+1 problem
แผนที่บทรวม (ดู README สำหรับลำดับเต็ม):
| บท | สอนอะไร |
|---|---|
| 02 — Database + JPA | PostgreSQL, Entity, Repository, query methods, transaction |
| 03 — Security + JWT | login, role, JWT token, CORS |
| 04 — Testing | JUnit + Mockito + MockMvc + Testcontainers |
| 05 — Production Readiness | Actuator, logging, Docker, observability |
| 06+ — Advanced | AOP, OAuth2, Microservices, Reactive, Caching, Messaging, etc. |
ฝึกให้คล่องบทนี้ก่อนค่อยขึ้นบทถัดไป — โดยเฉพาะ Checkpoint Todo API ข้างบน ถ้าทำได้คล่อง = พื้นฐาน Spring Boot แน่นพอจะรับ database ได้
15. สรุปบท
✅ สร้าง Spring Boot project จาก Spring Initializr ✅ @SpringBootApplication = @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan ✅ @RestController + @GetMapping/@PostMapping ฯลฯ — REST endpoint ✅ @PathVariable, @RequestParam, @RequestBody — รับ input ✅ Spring auto convert object ↔ JSON (Jackson) ✅ @Service, @Repository — แยก layer ✅ Constructor injection — Spring auto wire ✅ @RestControllerAdvice + @ExceptionHandler — global exception handling ✅ application.properties + @Value / @ConfigurationProperties — config ✅ @Valid + Jakarta Validation — validate input
← บทที่ 0: Spring คืออะไร | กลับสารบัญ | → บทที่ 2: Database + JPA