annotation ครบทุกตัว แต่แอปไม่สตาร์ต
บทที่ 1 ติด @Cacheable ลงบน
method เดิมแล้ววัดผล บทนี้ถอยกลับไปหนึ่งก้าว ไปที่คำถามที่มาก่อนการวัด
ติด annotation ครบทุกตัวแล้ว แอปสตาร์ตหรือยัง
annotation ครบ แต่ยังไม่ได้แตะ build file
หัวข้อที่มีชื่อว่า “annotation ครบ แต่ยังไม่ได้แตะ build file”ชั้นที่จำคำตอบต้องมีสวิตช์เปิดหนึ่งตัว และบทนี้ใส่สวิตช์นั้นครบก่อนจะแตะอย่างอื่น
package clinic.cache
import org.springframework.cache.annotation.EnableCachingimport org.springframework.context.annotation.Configuration
/** หนึ่งบรรทัดที่เปิดชั้น cache ทั้งชั้น */@Configuration@EnableCachingclass CacheConfig@EnableCaching@EnableCachingannotation ที่เปิดกลไก cache ให้ทั้งแอป · ติดครบแล้วแต่ไม่มี CacheManager สักตัว แอปจะไม่สตาร์ต ไม่ใช่ทำงานแบบไม่เก็บอะไร คือบรรทัดที่บอก Spring ว่าให้อ่าน annotation ของ cache ที่ติดอยู่บน
method ทั้งแอป ถ้าไม่มีบรรทัดนี้ @Cacheable ที่ติดไว้ก็เป็นแค่ตัวอักษร
@Serviceclass VisitService(private val repo: VisitRepo, private val tenant: CurrentTenant) { @Cacheable("visits") fun visits(): List<Visit> = repo.findByTenantId(tenant.id)}ครบสองที่แล้ว และ build.gradle.kts ยังเป็นตัวเดิมของคอร์สก่อนหน้าทุกบรรทัด
ทรงนี้ ไม่สตาร์ต
ข้อความที่ได้ เอ่ยชื่อ bean ที่หายไปตรงๆ
หัวข้อที่มีชื่อว่า “ข้อความที่ได้ เอ่ยชื่อ bean ที่หายไปตรงๆ”ข้อความไม่ได้บอกแค่ว่าอะไรพัง มันบอกทางออกไว้สองทางในประโยคเดียวกัน
NoSuchBeanDefinitionException: No qualifying bean of type'org.springframework.cache.CacheManager' available: no CacheResolver specified -register a CacheManager bean or remove the @EnableCaching annotation from your configuration.CacheManagerCacheManagerbean ที่ Spring ตามหาเมื่อกลไก cache ถูกเปิด · ข้อความตอนไม่มีมันคือ NoSuchBeanDefinitionException ซึ่งเอ่ยทางแก้ไว้ในตัวเอง คือลงทะเบียน bean หรือถอด annotation ออก คือ bean ที่ชั้นนี้ต้องมีเพื่อไปหยิบช่องเก็บของออกมาตามชื่อ cache
เมื่อไม่มีใครสร้างมันขึ้นมา @EnableCaching ก็ไม่มีอะไรให้ทำงานด้วย
ทางออกที่ข้อความเสนอไว้มีสองทาง
- register a
CacheManagerbean เข้าไปเอง - ถอด
@EnableCachingออกจาก configuration
ทางที่สามซึ่งข้อความไม่ได้เอ่ย คือทางที่บทนี้เดิน ให้ Boot สร้าง bean ตัวนั้นให้เอง
เอกสารของ Boot เรียก starter ว่าทางลัด
หัวข้อที่มีชื่อว่า “เอกสารของ Boot เรียก starter ว่าทางลัด”เอกสารพูดถึงความสะดวก และประโยคที่มันเขียนไว้ก็เป็นความจริงตามที่มันเขียน
เอกสารเรื่อง caching ของ Spring Boot เขียนสองประโยคที่เกี่ยวกับทรงนี้ตรงๆ
- “Spring Boot auto-configures the cache infrastructure as long as caching support is
enabled by using the
@EnableCachingannotation.” - “Use the
spring-boot-starter-cachestarter to quickly add basic caching dependencies.”
ประโยคแรกวางเงื่อนไขไว้ที่ annotation ส่วนประโยคที่สองพูดถึง starter ในฐานะความสะดวก คำว่า quickly ชวนให้อ่านว่า starter เป็นตัวช่วย ไม่ใช่เงื่อนไข
สองทรงต่างกันที่ build file บรรทัดเดียว
หัวข้อที่มีชื่อว่า “สองทรงต่างกันที่ build file บรรทัดเดียว”probe เขียน code Kotlin เท่ากันทุก byte ทั้งสองทรง แล้วเทียบ runtimeClasspath ที่ได้
implementation("org.springframework.boot:spring-boot-starter-cache")| ช่องที่วัด | มี starter | ไม่มี starter |
|---|---|---|
module ของ Boot ที่ชื่อมีคำว่า cache บน runtimeClasspath | spring-boot-cache 4.1.1 อยู่ในรายการ | ไม่มีสักตัว |
| แอปสตาร์ต | สตาร์ต | ไม่สตาร์ต |
bean ของ CacheManager | มี | ไม่มี |
auto-configuration ของ cache อยู่ใน module ที่หายไปตัวนั้น ไม่มี module ก็ไม่มีของที่จะ สร้าง bean ให้ และไม่มี bean แอปก็ไม่ผ่านขั้นตอนสตาร์ต
สองข้อนี้จริงพร้อมกัน เอกสารพูดถึงความสะดวก และสิ่งที่วัดได้คือใน Boot 4 ความสะดวกนั้นเป็นเงื่อนไขจำเป็น ไม่ใช่ทางลัดที่ข้ามได้
เส้นทางสองสายจากบรรทัดเดียวกัน
หัวข้อที่มีชื่อว่า “เส้นทางสองสายจากบรรทัดเดียวกัน”ตั้งต้นที่ annotation ชุดเดียวกัน แล้วแยกทางที่คำถามว่า build file มี starter หรือไม่
flowchart TB
S["@EnableCaching และ @Cacheable ครบทุกตัว"] --> Q{"build file มี starter ของ cache ไหม"}
Q -->|"ไม่มี"| N1["ไม่มี module ของ Boot ที่ชื่อมีคำว่า cache บน runtimeClasspath"]
N1 --> N2["ไม่มี auto-configuration ของ cache"]
N2 --> N3["ไม่มี bean ของ CacheManager"]
N3 --> N4["แอปไม่สตาร์ต · NoSuchBeanDefinitionException"]
Q -->|"มี"| Y1["spring-boot-cache 4.1.1 อยู่บน runtimeClasspath"]
Y1 --> Y2["auto-configuration สร้าง ConcurrentMapCacheManager ให้"]
Y2 --> Y3["แอปสตาร์ต · cache ชื่อ visits พร้อมใช้"]
คำบรรยายภาพ: annotation ชุดเดียวกันพาไปได้สองปลายทาง สิ่งที่ตัดสินคือบรรทัดใน build file ไม่ใช่บรรทัดใน code
bean ที่ได้มา คือตัวที่ง่ายที่สุดที่มี
หัวข้อที่มีชื่อว่า “bean ที่ได้มา คือตัวที่ง่ายที่สุดที่มี”ใส่ starter แล้วสิ่งที่ฉีดเข้ามาได้คือตัวที่เก็บของไว้ใน map ตัวหนึ่ง
ในทรงที่มี starter bean ที่ฉีดได้จริงคือ ConcurrentMapCacheManagerConcurrentMapCacheManagerCacheManager ที่ starter เลือกให้เมื่อไม่มีใครกำหนดเอง · เก็บค่าไว้ในหน่วยความจำของ process เดียว ไม่มีที่เก็บภายนอกและไม่มีใครแชร์ด้วย และชื่อ cache
ที่มันถืออยู่คือ [visits] ตรงกับชื่อที่เขียนไว้ใน @Cacheable("visits")
เอกสารบอกที่มาของตัวนี้ไว้ตรงๆ — “If none of the other providers can be found, a simple
implementation using a ConcurrentHashMap as the cache store is configured.”
ไม่ได้ประกาศ provider ไหนไว้เลย จึงได้ตัวที่ใช้ ConcurrentHashMap เป็นที่เก็บ
นี่คือค่าเริ่มต้น ไม่ใช่ตัวเลือกที่ใครในทีมเลือก
ราคาของค่าเริ่มต้นนี้จ่ายทีหลัง ของที่เก็บอยู่คืออ็อบเจ็กต์ใน process เดียวกับแอป และบทที่ 5 วัดว่ามันยื่น อ็อบเจ็กต์ตัวเดิมออกไปให้ผู้เรียก ไม่ใช่สำเนา
บทนี้ปิดอะไร และเปิดอะไรทิ้งไว้
หัวข้อที่มีชื่อว่า “บทนี้ปิดอะไร และเปิดอะไรทิ้งไว้”รู้แล้วว่าอะไรทำให้สตาร์ต ยังไม่รู้ว่าใครเป็นคนตัดสินว่าคำขอสองใบถามเรื่องเดียวกัน
สิ่งที่บทนี้ปิดคือคำถามเชิงกลไกของการติดตั้ง ทรง cached ของบทที่ 1 ต้องมีของครบสี่อย่าง คือ
@EnableCaching บน CacheConfig · @Cacheable บน visits() · module ของ cache หนึ่งตัว
และ bean ของ CacheManager หนึ่งตัว ขาดอย่างใดอย่างหนึ่งก็ไม่ได้ทรงนั้น
สิ่งที่ยังเปิดอยู่คือรูที่บทที่ 1 วัดไว้ ทรงที่สตาร์ตได้ในบทนี้ คือทรงเดียวกับที่
clinic-b ได้แถวของ clinic-a กลับไป การใส่ starter ไม่ได้แก้ข้อนั้นสักช่อง
ConcurrentMapCacheManager ที่เพิ่งได้มา ตัดสินใจแทนเราไปแล้วหนึ่งเรื่อง คือกุญแจ
ของแต่ละคำขอหน้าตาเป็นอย่างไร บทที่ 3
เปิดกล่องนั้นดู
-
Spring Boot — Caching (เข้าถึง 2026-09-04) — ที่มาของทั้งสามประโยคที่ยกมาในบทนี้ ทั้งเงื่อนไขของ
@EnableCachingคำอธิบายของ starter และตัวที่ใช้ConcurrentHashMapเป็นที่เก็บ -
ตัวเลขและข้อความ error ในบทนี้มาจาก
scripts/clinic-cache/probe.pyซึ่งสร้างสองทรงที่ code Kotlin เท่ากันทุก byte แล้วเทียบruntimeClasspathทีละตัว · ต้องสั่งscripts/clinic-cache/up.shก่อน ซึ่งยก container ของคอร์สนี้เองขึ้นที่ port 55436
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3ติด `@EnableCaching` กับ `@Cacheable` ครบแล้ว แต่ไม่แตะ build file ผลที่วัดได้คืออะไร