ข้าม​ไป​ยัง​เนื้อหา

body ที่​ตอบ​กลับ​ไป ไม่มี​ใคร​เอา​ไป​ทำ​อะไร​ต่อ​ได้

คอร์ส​ก่อน​หน้า​จบ​ด้วย​ระบบ​ที่​ถูก​ต้อง​ข้าง​ใน · โดเมน​ตรวจ​ถูก ขอบ transaction อยู่​ถูก​ที่ ฐาน​ข้อมูล​กัน​จอง​ซ้อน​ได้​จริง

แล้ว​คำถาม​ที่​ยัง​ไม่มี​ใคร​ถาม​คือ — ผู้​เรียก​ที่​อยู่​ปลาย​สาย HTTP ได้​อะไร​กลับ​ไป​บ้าง

🚪 คอร์ส​นี้​ต่อ​จาก​ของ​เดิม ไม่​ได้​เริ่ม​ใหม่

โปร​เจ​กต์​เดียว​กับ คอร์ส Kotlin + Spring Boot + Clean Architecture · โดเมน​กับ repository อยู่​ครบ · สิ่ง​ที่​คอร์ส​นี้​เพิ่ม​คือ​ชั้น​ที่​คุย​กับ​โลก​ข้าง​นอก

ทั้ง​คอร์ส​ไม่มี​หน่วย​เวลา​สัก​ตัว หน่วย​ที่​ใช้​คือ​รหัส HTTP · content-type · ชื่อ​และ​จำนวน​คีย์​ใน body · ชนิด​ของ exception · SQLSTATE · จำนวน​แถว

ฐาน​ข้อมูล​ของ​คอร์ส​นี้​เป็น​คนละ container กับ​คอร์ส​ก่อน สั่ง​ด้วย scripts/clinic-http-boundary/up.sh · เหตุผล​อยู่​ใน​บท​ที่ 4 ตอน​ที่ constraint เข้า​มา

ทุก​อย่าง​ที่​ข้าม​ออก​ไป เหลือ​แค่​รหัส หัวข้อ​ความ และ body เท่านั้น

boundaryboundaryเส้น​ที่​ระบบ​ของ​เรา​จบ และ​ผู้​เรียก​ที่​เรา​ควบคุม​ไม่​ได้​เริ่ม · ทุก​อย่าง​ที่​ข้าม​เส้น​นี้​ออก​ไป มี​แค่​รหัส หัวข้อ​ความ และ body เท่านั้น · สิ่ง​ที่​ถูก​ต้อง​อยู่​ข้าง​ใน​แต่​ไม่​ได้​ข้าม​ออก​มา ผู้​เรียก​ไม่มี​ทาง​รู้ ไม่ใช่​ชั้น​ใน​สถาปัตยกรรม · มัน​คือ​เส้น​ที่​ระบบ​ของ​เรา​จบ และ​คน​ที่​เรา​ควบคุม​ไม่​ได้​เริ่ม

ข้าง​ใน​เส้น​นี้​เรา​มี​ทุก​อย่าง · ชนิด​ของ exception ข้อความ​ของ​มัน ค่าที่​ทำให้​มัน​เกิด · ข้าง​นอก​เส้น​นี้​ผู้​เรียก​มี​สาม​อย่าง คือ​ตัวเลข​หนึ่ง​ตัว หัวข้อ​ความ​ไม่​กี่​บรรทัด และ body ก้อน​หนึ่ง

ของ​ที่​ถูก​ต้อง​อยู่​ข้าง​ใน​แต่​ไม่​ได้​ข้าม​ออก​มา ผู้​เรียก​ไม่มี​ทาง​รู้​ว่า​มัน​มี​อยู่

สอง​ปลาย​ทาง · ตัว​หนึ่ง​รับคำ​ขอ​จริง อีก​ตัว​มี​ไว้​พัง​อย่าง​เดียว

app/src/main/kotlin/clinic/http/BookingRequest.kt
package clinic.http
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
data class BookingRequest(
@field:NotBlank val doctorId: String,
@field:Min(0) val startMinute: Int,
@field:Min(1) val endMinute: Int,
)

สาม​ช่อง สาม​กติกา · @Valid@Validการ​สั่ง​ให้​ตรวจ body ก่อน​เข้า​สู่​ตัว​จัดการ · ตรวจ​แล้ว​ไม่​ผ่าน​จะ​โยน `MethodArgumentNotValidException` ซึ่ง​รู้​ครบ​ว่า​ช่อง​ไหน​ผิด​เพราะ​อะไร แต่​ความ​รู้​นั้น​ไม่​ได้​ออก​ไป​ถึง​ผู้​เรียก​เอง จะ​ตรวจ​ให้​ก่อน​ที่​ตัว​จัดการ​จะ​ได้​เห็น​ค่า

app/src/main/kotlin/clinic/http/BookingController.kt
package clinic.http
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
@RestController
class BookingController(private val rows: AppointmentRows) {
@PostMapping("/bookings")
fun book(@Valid @RequestBody req: BookingRequest): Map<String, Any> {
val saved = rows.saveAndFlush(
AppointmentRow(req.doctorId, req.startMinute, req.endMinute),
)
return mapOf("id" to saved.id, "saved" to true)
}
@GetMapping("/boom")
fun boom(): Nothing = throw IllegalStateException("อะไรบางอย่างพังข้างใน")
}

/boom ไม่ใช่​ของ​จริง​ใน​ระบบ · มัน​มี​ไว้​ให้​เรา​วัด​สิ่ง​ที่​ระบบ​ตอบ​เมื่อ​มี​อะไร​พัง​จริง โดย​ไม่​ต้อง​รอ​ให้​มัน​พัง​เอง

🧱 AppointmentRow กับ AppointmentRows มา​จาก​ไหน

ทั้ง​คู่​คือ​คู่ entity กับ repository ที่​คอร์ส​ก่อน​สร้าง​ไว้ ชี้​มา​ที่​ตาราง​ของ​คอร์ส​นี้ · บท​นี้​ยัง​ไม่​แตะ​มัน เพราะ​เรื่อง​ของ​บท​นี้​จบ​ที่​ปาก​ทางออก ยัง​ไม่​ถึง​ฐาน​ข้อมูล

ตาราง​กับ constraint ของ​มัน​จะ​เป็น​เรื่อง​หลัก​ใน​บท​ที่ 4 · ตอน​นั้น​จะ​เห็น​ทั้ง​สอง source เต็มๆ

ถ้า​คำขอ​ที่​ถูก​ต้อง​ยัง​ไม่​ผ่าน ทุก​อย่าง​ที่​วัด​ต่อ​จาก​นี้​ไม่มี​ความ​หมาย

คำขอ​ที่​ถูก​ต้อง
{"doctorId":"d-http-1","startMinute":600,"endMinute":630}

ได้​กลับ​มา 200 พร้อม body สอง​คีย์​คือ id กับ saved

ข้อ​นี้​ไม่​น่า​ตื่น​เต้น แต่​มัน​เป็น​ข้อ​ที่​ต้อง​วัด​ก่อน​เสมอ · เพราะ​ถ้า​เรา​วัด​ระบบ​ที่​พัง​อยู่ ทุก​ตัวเลข​ที่​ได้​มา​หลัง​จาก​นั้น​อธิบาย​อะไร​ผิด​ไป​หมด

สิ่ง​ที่​ระบบ​รู้ กับ​สิ่ง​ที่​ระบบ​บอก ไม่ใช่​ของ​อัน​เดียวกัน

คำขอ​ที่​ผิด​ทั้ง​สาม​ช่อง
{"doctorId":"","startMinute":-5,"endMinute":0}

doctorId ว่าง · startMinute ติดลบ · endMinute เป็น​ศูนย์​ทั้ง​ที่​ต้อง​มากกว่า​นั้น

ระบบ​รู้​ครบ​ทั้ง​สาม​ข้อ เพราะ @Valid ตรวจ​เจอ​ทั้ง​สาม​ข้อ​จริง แล้ว​รวบ​ไว้​ใน​ที่​เดียวกัน · สิ่ง​ที่​ออก​ไป​คือ

ตอบ​กลับ · 400 · application/json
{"error":"Bad Request","path":"/bookings","status":400,"timestamp":"..."}

สี่​คีย์ · ไม่มี​คีย์​ไหน​พูด​ถึง doctorId เลย ไม่มี​คีย์​ไหน​พูด​ถึง startMinute · ความ​รู้​ทั้ง​สาม​ข้อ​หยุด​อยู่​ที่​ขอบ ไม่​ได้​ข้าม​ออก​มา

ผู้​เรียก​ที่​ได้​ของ​แบบ​นี้​ทำ​อะไร​ต่อ​ได้​บ้าง · ลอง​แก้​ที​ละ​ช่อง​แล้ว​ยิง​ใหม่​ไป​เรื่อย​จนกว่า​จะ​ผ่าน · นั่น​คือ​การ​หา​คำ​ตอบ​ด้วย​การ​เดา ทั้ง​ที่​คำ​ตอบ​มี​อยู่​แล้ว​ใน​เครื่อง​เรา

นี่​คือ​ข้อ​ที่​ทำให้​บท​นี้​มี​อยู่

ยิง GET /boom ซึ่ง​โยน IllegalStateException ออก​มา​แบบ​ไม่มี​ใคร​ดัก

ตอบ​กลับ · 500 · application/json
{"error":"Internal Server Error","path":"/boom","status":500,"timestamp":"..."}

เอา​สอง body มา​วาง​ข้าง​กัน แล้ว​เทียบ​ชื่อ​คีย์​ที​ละ​ตัว

คำขอ​ที่​ผิดระบบ​พัง​จริง
รหัส400500
content-typeapplication/jsonapplication/json
ชื่อ​คีย์error path status timestamperror path status timestamp
จำนวน​คีย์44
บอก​ไหม​ว่า​ช่อง​ไหน​ผิดไม่ไม่

รูป​เดีย​วกันเป๊ะ ต่าง​กัน​แค่​ตัวเลข

ตัวเลข​นั้น​สำคัญ​มาก · 400 Bad Request400 Bad Requestรหัส​ที่​แปล​ว่า​คำขอ​ของ​คุณ​ผิด · ผู้​เรียก​แก้​เอง​ได้​ถ้า​รู้​ว่า​ต้อง​แก้​อะไร · ค่า​ตั้ง​ต้น​ให้​แค่​ตัวเลข​นี้ ไม่​ได้​ให้​ข้อมูล​ที่​จะ​แก้ แปล​ว่า​คุณ​ส่ง​มา​ผิด · 500 Internal Server Error500 Internal Server Errorรหัส​ที่​แปล​ว่า​เรา​พัง ไม่ใช่​คุณ​ผิด · เป็น​คำ​เชิญ​ให้​ลอง​ใหม่​โดย​ปริยาย · body ที่มา​กับ​มัน​มี​รูป​เดียว​กับ​ตอน 400 ทุก​คีย์ ต่าง​กัน​แค่​ตัวเลข แปล​ว่า​เรา​พัง ไม่ใช่​คุณ​ผิด

แต่ body ที่มา​กับ​ทั้ง​สอง​อย่าง​ไม่​ได้​ช่วย​แยก​อะไร​เลย · ผู้​เรียก​ที่​เขียน​ตัว​จัดการ​ฝั่ง​ตัว​เอง จึง​เหลือ​ทาง​เดียว​คือ​อ่าน​ตัวเลข แล้ว​เดา​เอา​ว่า​เกิด​อะไร​ขึ้น

⚠️ 500 คือ​คำ​เชิญ​ให้​ลอง​ใหม่ โดยที่​เรา​ไม่​ได้​ตั้งใจ​เชิญ

ตัว​เรียก API ส่วน​ใหญ่​ถูก​เขียน​ให้​ลอง​ใหม่​เมื่อ​เจอ 5xx เพราะ​รหัส​กลุ่ม​นั้น​แปล​ว่า ปัญหา​อยู่​ฝั่ง​เรา และ​มัก​จะ​หาย​ไป​เอง

บท​ที่ 4 จะ​แสดง​ให้​เห็น​ว่า​ข้อ​นี้​ทำให้​เกิด​อะไร เมื่อ​สิ่ง​ที่​ตอบ 500 ออก​ไป เป็น​คำขอ​ที่​ลอง​อีก​กี่​ครั้ง​ก็​ชน​เหมือน​เดิม

ข้อ​นี้​ทำให้​การ​ทดสอบ​ด้วย​มือ​หลอก​เรา​ได้

ยิง /boom อีก​ครั้ง คราว​นี้​ใส่​หัวข้อ​ความ Accept: text/html เข้าไป

ผล​ที่​ได้​ไม่ใช่ JSON อีก​ต่อ​ไป · มัน​คือ​หน้า HTML ที่ Spring Boot เตรียม​ไว้ ซึ่ง​รู้จัก​กัน​ใน​ชื่อ Whitelabel Error PageWhitelabel Error Pageหน้า​เปล่า​ที่ Spring Boot ตอบ​ให้​ผู้​เรียก​ที่​ขอ​ด้วย `Accept: text/html` · ปลาย​ทาง​เดียวกัน​จึง​ให้​ของ​คนละ​อย่าง​กับ browser และ​กับ​ตัว​เรียก API และ content-type เป็น text/html

หัวข้อ​ความ Acceptcontent-type ที่​ได้รูป​ของ body
application/jsonapplication/jsonสี่​คีย์
text/htmltext/htmlหน้า HTML

ปลาย​ทาง​เดียว รหัส​เดียวกัน แต่​สิ่ง​ที่​ออก​ไป​เป็น​คนละ​อย่าง · กลไก​นี้​เรียก​ว่า content negotiationcontent negotiationการ​ที่​ปลาย​ทาง​เดียวกัน​ตอบ​คนละ​รูป​ตาม​หัวข้อ​ความ `Accept` ที่​ผู้​เรียก​ส่ง​มา · เป็น​เหตุผล​ที่​การ​ทดสอบ​ด้วย browser กับ​ด้วย `curl` ให้​คำ​ตอบ​ไม่​เหมือน​กัน และ​มัน​เป็น​เหตุผล​ที่​การ​เปิด​ดู​ด้วย browser กับ​การ​ยิง​ด้วย curl ให้​ภาพ​ไม่​ตรง​กัน

เวลา​วัด​ขอบ ต้อง​ระบุ​หัวข้อ​ความ Accept เสมอ ไม่​งั้น​เรา​กำลัง​วัด​ของ​ที่​ไม่รู้​ว่า​ตัว​ไหน

สี่​ข้อ · ทุก​ข้อ​มา​จาก​การ​ยิง​คำขอ​จริง​เข้าไป​ที่ server ที่​ส​ตาร์ต​ขึ้น​มา​จริง

  • คำขอ​ที่​ถูก​ต้อง​ได้ 200 — ช่อง​ควบคุม​ผ่าน สิ่ง​ที่​วัด​ต่อ​จาก​นั้น​จึง​เชื่อ​ได้
  • คำขอ​ที่​ผิด​สาม​ช่อง​ได้ 400 พร้อม​สี่​คีย์​ที่​ไม่​บอก​ว่า​ช่อง​ไหน​ผิด
  • exception ที่​หลุด​ออก​มา​ได้ 500 พร้อม ชื่อ​คีย์​ชุด​เดีย​วกันเป๊ะ
  • ขอ​ด้วย text/html แล้ว​ได้ text/html จาก​ปลาย​ทาง​เดิม

error bodyerror bodyเนื้อ​ความ​ที่​ตอบ​กลับ​เมื่อ​คำขอ​ไม่​สำเร็จ · ค่า​ตั้ง​ต้น​ของ Spring Boot ให้​สี่​คีย์​คือ `error` `path` `status` `timestamp` ซึ่ง​ไม่มี​คีย์​ไหน​บอก​ว่า​ช่อง​ไหน​ผิด ที่​เรา​ส่ง​ออก​ไป​ตอน​นี้ ทำให้​ผู้​เรียก​ทำ​อะไร​ต่อ​ไม่​ได้​เลย​นอกจาก​เดา

บท​ที่ 2 จะ​เปิด​ของ​ที่​หลาย​คน​เชื่อ​ว่า​แก้​ปัญหา​นี้ แล้ว​วัด​ว่า​มัน​แก้​อะไร​จริง และ​ไม่​แก้​อะไร​บ้าง

🔬 ตัวเลข​ทุก​ตัว​ใน​บท​นี้​มา​จาก​ไหน
  • ยิง​จาก​ใน​เทสต์​ที่​ส​ตาร์ต context ขึ้น​เอง​ด้วย RANDOM_PORT · ไม่​ใช้ port คงที่ เพราะ server ที่​ตอบ ไม่​จำเป็น​ต้อง​เป็น server ที่​เพิ่ง​สร้าง
  • Spring Boot 4.1.1 · Kotlin 2.4.10 · JDK 25 · PostgreSQL 18 · pg driver 42.7.13
  • รัน​ซ้ำ​เอง​ได้​ด้วย npm run verify:clinic-http ซึ่ง​สร้าง​โปร​เจ​กต์​ทั้ง​ใบ​ขึ้น​ใหม่ แล้ว​เทียบ​ผล​ที​ละ​ช่อง · มัน​อยู่​นอก CI เพราะ​ต้อง​ใช้ Docker
  • Spring Boot — Error Handling (เข้า​ถึง 2026-09-02) — ตัว​จัดการ​ค่า​ตั้ง​ต้น​และ​หน้า Whitelabel
  • RFC 9110 §15 (เข้า​ถึง 2026-09-02) — ความ​หมาย​ของ​กลุ่ม 4xx กับ 5xx

เช็กความเข้าใจ — บทที่ 1

ข้อ 1 / 3

ทำไมการวัดของบทนี้ต้องเริ่มด้วยคำขอที่ถูกต้องเสมอ