บันทึกการแก้ปัญหา ioctopus และการทำให้ Type-Safe ผ่าน TypeScript โดยใช้ AI
วันนี้จะมาคุยกันเรื่อง Dependency Injection (DI) ใน TypeScript โดยเฉพาะปัญหาที่เกิดขึ้นเมื่อใช้ DI บน Edge Runtime เช่น Vercel Edge ซึ่งเป็นข้อจำกัดของบาง Library ที่ใช้ Reflection API
Background: Dependency Injection ใน Framework ดังๆ
หลายๆ คนที่เขียน Backend กันอยู่แล้ว อาจจะเคยใช้ Framework พวก Nest.js หรือถ้า .NET หรือ Java Spring Boot สิ่งที่ Framework พวกนี้ทำคล้ายๆ กันก็คือ เรื่องของ Decorator สำหรับทำ Dependency Injection และใช่ครับ นั่นเป็นหนึ่งใน Use Case ที่พบเห็นได้ทั่วไป และเป็นส่วนประกอบสำคัญของระบบระบบถอดประกอบส่วนต่างๆ ได้ อย่างเช่น Clean Architecture ของ ลุง Bob...
ตัวอย่างใน Nest.js:
;
แต่จริงๆ แล้ว Dependency Injection ไม่จำเป็นต้องใช้ decorator ก็ได้นะ
การที่ library ใน Node.js จะใช้ decorator เพื่อทำ Dependency Injection มันต้องใช้ Reflection API (Reflect-metadata) เพื่อที่จะทำให้มันเกิดได้ แต่มันปัญหาก็คือมันผูกอยู่กับ Node.js Runtime กับพวกอื่นๆ ถ้าเราจะทำให้มันทำงานทุก Runtime ได้ ตรงนี้จึงไม่ตอบโจทย์
แรงจูงใจ: ทำไมถึงอยากใช้ ioctopus?
ช่วงที่ทำระบบ Dependency Injection (DI) ต้องการหา Runtime Agnostic Library ที่รองรับ Edge Runtime ของ Vercel หรือ Runtime อื่นๆ ได้ โดยที่ไม่ต้องใช้ Reflection API ซึ่งเป็นข้อจำกัดของ Inversify ทำให้ได้ลองใช้ @evyweb/ioctopus ซึ่งถูกออกแบบมาเพื่อลดความซับซ้อนและช่วยให้โค้ดสะอาดขึ้น
version ของ @evyweb/ioctopus ณ ตอนนี้เป็นเวอร์ชั่น 1.2.0
จริงๆ ไม่ใช่เฉพาะ Inversify นะ พวก DI ดังๆ อย่าง Tsyringe, Typedi เองก็มีปัญหาเดียวกัน ที่ไม่สามารถใช้งานได้บน Edge Runtime ได้ สามารถไปดูจากวิดิโอและ github ของทางผู้สร้าง from YouTube และ Next.js Clean Architecture PR
อ่านเพิ่มเติมเรื่อง Refection API ได้ที่นี่นะ TypeScript’s Reflect Metadata: What it is and How to Use it
โดยปกติแล้ว ioctopus มีการใช้งานดังนี้:
;
;
// Step 1: สร้าง DI เป็น Symbol
;
// Step 2: สร้าง Container
;
// Step 3: เริ่มผูกความสัมพันธ์ของ Dependencies ว่าแต่ละ Dependencies จะไปหาในไหน
DI.DEP1'dependency1';
DI.DEP242;
'CURRIED_FUNCTION_WITH_DEPENDENCIES'
curriedFunctionWithDependencies,;
// Step 4: ใช้งาน Dependencies ผ่าน Container
// ตรงนี้เราจะได้ Data จาก Dependencies ที่ผูกไว้ก่อนหน้า และเอาไปใช้งานได้จริง
;
แต่ปัญหาของ @evyweb/ioctopus พบว่าเราไม่สามารถทำให้เกิด Type Safety ได้เลย เพราะไม่ว่าเราจะ Bind หรือตอนที่เราใช้ toCurry
ใน dependencies เราใส่ Symbol แต่เราต้องใส่อะไรเข้าไปบ้าง ไม่มีการระบุ Type ของ Dependencies ที่เราใช้งาน ดังนั้นใน @thaitype/ioctopus
โดยที่มีเป้าหมายให้ Type-safety โดย จะเปลี่ยนเป็น
// Step 1: สร้าง serivceRegistry และกำหนด Type ของแต่ละ Dependencies โดยที่ไม่ต้องสร้าง Symbol เอง เพราะ serviceRegistry จะทำการ Map ให้เอง
'DEP1'
'DEP2'
'CURRIED_FUNCTION_WITH_DEPENDENCIES'
// Step 2: สร้าง Container โดยที่รับ serviceRegistry เข้าไป
;
'DEP1''dependency1';
'DEP2'42;
'CURRIED_FUNCTION_WITH_DEPENDENCIES'
curriedFunctionWithDependencies,;
// Step 3: ใช้งาน Dependencies ผ่าน Container
;
ถ้าสังเกตุจากโค๊ด เราจะเห็นว่าเราไม่ต้องมี DI
object แล้ว และเราสามารถกำหนด Type ของ Dependencies ได้ใน serviceRegistry
และเราสามารถใช้งาน Dependencies ได้ผ่าน container.get
โดยที่ไม่ต้องใช้ Symbol อีกต่อไป และเราจะรู้เลยว่าเราใส่ค่าถูกต้องหรือไม่ เพราะมันจะบอก Type ให้เราทันที
เช่น ตอนที่เราใช้แบบนี้ มันจะ Error เพราะว่า curriedFunctionWithDependencies
ต้องการ DEP1 เป็น string แต่เราใส่เป็น number
'CURRIED_FUNCTION_WITH_DEPENDENCIES'
curriedFunctionWithDependencies,;
หมายเหตุ: ณ เวลาที่เขียน function
toCurry
ใน @thaitype/ioctopus ยังบอก type ผิดอยู่ แต่ถ้าใช้toHigherOrderFunction
จะบอก Type ถูก เดี๋ยวจะแก้อีกทีนะ
ตัวอย่าง Type-safety
กลับมาที่โจทย์ของวันนี้กัน
เนื่องจากอาจจะตามกันไม่ทัน สามารถไปดูตัวอย่างโค๊ดเต็มๆ ได้ที่นี่ https://github.com/thaitype/ioctopus/pull/2/files
คือการเปลี่ยนโค้ดจาก:
;
มาเป็นรูปแบบที่อ่านง่ายขึ้นแบบนี้ และ Type-safe โดยรับแค่ key ของ serviceRegistry มาเท่านั้น:
;
เพราะ createContainer(serviceRegistry)
ได้รับ serviceRegistry อยู่แล้ว เลยไม่จำเป็นต้อง serviceRegistry.get(...)
ก่อนเรียก container.get(...)
และเพื่อให้เห็นภาพมากขึ้นเราลองมาดูตัวอย่างโค๊ดกัน
🔥 ปัญหาที่เจอ: Unit Test ไม่ผ่าน เพราะ serviceRegistry.get(...)
คืนค่า undefined
ป.ล. ใน Class ServiceRegistry จะมี field ที่ชื่อ keyMap ที่เก็บ key ของ service ทั้งหมดไว้ ดูที่โค๊ด https://github.com/thaitype/ioctopus/blob/master.type-safe.container-get-type-safe/src/service-registry.ts#L2
หลังจากเปลี่ยนโค้ดและรัน Unit Test บน Jest / Vitest กลับพบว่า:
serviceRegistry.keyMap
ตอนเริ่มต้น มีค่า (ตรง 📌 A)- แต่ตอน
get(...)
ค่าserviceRegistry.get(dependency)
กลับundefined
(ตรง 📌 B) - ทำให้เกิด Error:
Error: No key found for dependency: undefined
ตอนแรก คิดว่าเป็นปัญหาจาก Jest / Vitest Runtime เพราะใน AI ก็บอกมาแบบนั้น แต่เมื่อไปรันบน Node.js ตรงๆ กลับพบว่า ไม่เกี่ยวเลย! จากที่เคยเข้าใจว่า
แต่ตอน get(...) ค่า serviceRegistry.get(dependency) กลับ undefined (ตรง 📌 B)
เราดันไปเข้าใจว่า serviceRegistry.keyMap
มันเป็น object ว่าง เกิดจาที่ AI บอกว่า serviceRegistry มากกว่าหนึ่งตัว แต่จริงๆ แล้วมันมีแค่ตัวเดียว และเราก็ไม่ได้มีปัญหาเรื่อง Duplicate Imports หรือ Circular Dependency ด้วย
ปัญหาที่แท้จริงคือ container เรียก get(...)
หลายรอบ โดยที่ไม่ได้เข้าใจกลไกของโค๊ดทั้งหมด และในการเรียกซ้ำ มันใช้ Symbol ที่ resolve มาแล้วแทน String Key เดิม ซึ่งทำให้ serviceRegistry.get(...)
ไม่สามารถหา Symbol ได้ เพราะใน serviceRegistry มันเก็บเป็น String Key ไม่ใช่ Symbol ของ Key
จุดที่เข้าใจผิด: คิดว่าปัญหาคือ serviceRegistry ถูกสร้างซ้ำ
ช่วงแรกมั่นใจมากว่า ปัญหาคือมี serviceRegistry มากกว่าหนึ่งตัว เพราะ AI (GPT-4 o1) พยายามตอบไปในทางนั้นซ้ำๆ ว่า:
“Ensure every place references the same instance (imported from a single path, with no duplicates or circular imports).”
แต่จริงๆ แล้ว:
- serviceRegistry ถูกสร้าง แค่ครั้งเดียว
- ไม่มีปัญหาเรื่อง Duplicate Imports
- ไม่มีปัญหาเรื่อง Circular Dependency
ซึ่งทำให้เสียเวลามากกว่าที่ควรจะเป็น เพราะต้องคุยกับ AI หลาย Prompt จนกว่ามันจะเสนอคำตอบอื่นที่ใกล้เคียงกับปัญหาจริง
จุดที่เจอหลังรันบน Node.js
หลังจากลอง Debug บน Node.js โดยไม่ผ่าน Jest / Vitest ก็พบว่า:
ตอน
get(...)
ครั้งแรก:container.get('DEP1');
- ระบบเอา
"DEP1"
ไปหาSymbol(‘DEP1’)
จาก serviceRegistry.keyMap
- ระบบเอา
แต่พอ
get(...)
ครั้งต่อมา:container.get(Symbol('DEP1'));
- มันใช้
Symbol(‘DEP1’)
ที่ได้มาแล้ว ไปหาในserviceRegistry.keyMap
- แต่
keyMap
เก็บเป็นString Key (“DEP1”)
ไม่ใช่ Symbol Key - เลยหาไม่เจอ ทำให้
serviceRegistry.get(...)
คืนค่า undefined
- มันใช้
วิธีแก้: ใช้ฟังก์ชัน resolveDependencyKey(...)
เพื่อแก้ปัญหานี้ จึงเพิ่มฟังก์ชัน resolveDependencyKey(...) ขึ้นมา เพื่อให้แน่ใจว่า ทุกครั้งที่เรียก get(...) จะใช้ String Key ก่อนเสมอ:
ผลลัพธ์หลังแก้ไข:
- ไม่ว่า
container.get(...)
จะถูกเรียกด้วย String หรือ Symbol ก็ทำงานได้ถูกต้อง serviceRegistry.get(...)
ไม่คืนค่า undefined อีกแล้ว- Unit Test ผ่าน ✅
บทเรียนจากการใช้ AI Model GPT-4 o1
- AI บางครั้งมั่นใจเกินไป
- Model พยายามตอบว่า “มี serviceRegistry ซ้ำกัน” ถึง 10 ครั้ง ซึ่งเป็น สมมติฐานผิด
- ต้องกดดัน AI ด้วยหลาย Prompt จนกว่ามันจะให้คำตอบที่ต่างออกไป
- ต้อง Debug ข้อมูลเอง อย่าเชื่อ AI 100%
- AI วิเคราะห์ปัญหาได้ดีในหลายกรณี แต่ก็พลาดง่ายๆ ในจุดที่ มนุษย์ คิดออกทันที
- ใช้ AI เป็น เครื่องมือช่วยคิด ไม่ใช่ เครื่องมือคิดแทน
- อย่าหลงประเด็นของ AI นานเกินไป
- ถ้า AI ตอบแนวเดิมซ้ำๆ ลอง หยุดเชื่อ AI แล้วกลับมาไล่โค้ดเอง
- ในเคสนี้ AI ย้ำเรื่อง Multiple Instances ทั้งที่ปัญหาจริงคือ Key Type Mismatch
สรุป
การตั้งสมมติฐานควรตั้งอย่างใจเย็น ค่อยๆ ลดความเป็นไปได้ลง อย่าเชื่อ AI เยอะ แม้ว่าจะเป็น Model ที่ฉลาดๆ อย่าง o1 ก็ตาม เพราะบริบทที่เข้าใจของ AI อาจจะไม่ตรงกับบริบทที่เราเข้าใจ และอาจจะทำให้เสียเวลาไปมากกว่าที่ควรจะเป็น
ถ้าใครเคยเจอปัญหาแนวนี้ มาแชร์กันได้ในคอมเมนต์เลยครับ