Magic Strings
ค่า string ดิบใน code ที่มีผลต่อพฤติกรรม — บ่อเกิดของ bug
ปัญหาคืออะไร
หัวข้อที่มีชื่อว่า “ปัญหาคืออะไร”Magic string คือค่า string ที่พิมพ์ตรง ๆ ลงใน code (string literal) และ มีผลต่อพฤติกรรมของ app ไม่ใช่แค่ข้อความที่โชว์ให้ผู้ใช้อ่าน แต่เป็นค่าที่โปรแกรมใช้ตัดสินใจ เช่น เทียบเงื่อนไข, ใช้เป็น key ค้นหา, ใช้ตั้งชื่อ route หรือ config — ถ้าพิมพ์ผิดแม้แต่ตัวเดียว โปรแกรมจะทำงานผิดโดยไม่มี compiler เตือนสักคำ
ตัวอย่างที่พบบ่อย:
- ชื่อ role หรือ permission ที่เทียบด้วย
if (user.Role == "Admin") - key ของ configuration, connection string name, หรือ setting name
- ชื่อ event, ชื่อ header, หรือ error code ที่ส่งผ่านระบบเป็น string
- path ของ file หรือ URL segment ที่ผูกกับ routing
จุดร่วมของทั้งหมดคือ: มันมักถูก ทำซ้ำหลายที่ ในระบบ และเพราะเป็นแค่ string ธรรมดา เครื่องมือ refactor อัตโนมัติของ IDE (rename symbol) จะมองไม่เห็นมันเป็นสิ่งเดียวกัน — นี่คือรากของปัญหาทั้งหมด
ทำไมถึงดูน่าใช้
หัวข้อที่มีชื่อว่า “ทำไมถึงดูน่าใช้”- เขียนเร็ว ไม่ต้องคิดเยอะ — พิมพ์
"Admin"ตรง ๆ เร็วกว่าไปประกาศ constant หรือ enum ก่อน โดยเฉพาะตอน prototype หรือรีบส่งงาน - ดูเหมือนไม่มี overhead — ไม่ต้องสร้าง file ใหม่ ไม่ต้องออกแบบ type เพิ่ม รู้สึกเหมือนประหยัดเวลาในระยะสั้น
- ผูกกับ infrastructure ภายนอกอยู่แล้ว — ชื่อ file config, ชื่อ environment variable, หรือ URL ภายนอก เป็น string โดยธรรมชาติ จึงรู้สึกเป็นเรื่องปกติที่จะพิมพ์มันตรง ๆ ทุกจุดที่ใช้
- ทำงานได้จริงในตอนนั้น — code compile ผ่าน รันได้ ผ่าน test เคสที่เขียนไว้ ทำให้มองไม่เห็นปัญหาจนกว่าจะต้องแก้ค่านั้นทีหลัง
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”- แก้ไม่ครบ — เมื่อค่าต้องเปลี่ยน (เช่น เปลี่ยนชื่อ role จาก
"Admin"เป็น"Administrator") นักพัฒนาต้องไล่หาทุกจุดที่พิมพ์ string นี้ด้วยมือ หรือใช้ find-and-replace แบบข้อความซึ่งเสี่ยงไปโดน string อื่นที่บังเอิญเหมือนกันแต่ความหมายต่างกัน - ไม่มี compile-time safety — พิมพ์
"Admni"ผิดตัว โปรแกรมก็ยัง compile ผ่าน แต่เงื่อนไขจะไม่ทำงานตามที่ตั้งใจ กว่าจะรู้ตัวคือตอน production แล้ว - rename ไม่ปลอดภัย — feature rename symbol ของ IDE ใช้ไม่ได้กับ string เพราะ string สองก้อนที่ text เหมือนกันอาจไม่ได้สื่อความหมายเดียวกันเสมอไป
- ขาดจุดเดียวของความจริง (single source of truth) — ค่าเดียวกันกระจายอยู่หลาย file ขัดกับหลัก DRY โดยตรง
- อ่านยาก สื่อความหมายไม่ชัด —
"1"หรือ"P"ใน code ไม่บอกอะไรกับคนอ่าน ต่างจากชื่อ constant ที่บอกเจตนา ทำให้เข้าข่าย Poor Names ด้วย
ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”ตัวอย่างต่อไปนี้ใช้ string "Admin" เป็นทั้งเงื่อนไขสิทธิ์และ cache key — ถูกพิมพ์ซ้ำในหลายที่ของ class เดียวกัน:
public class OrderController{ public IActionResult Approve(int orderId, string role) { // magic string ตัวที่ 1: เทียบสิทธิ์ตรง ๆ if (role != "Admin") { return Forbid(); }
var cacheKey = "Admin_Orders_Cache"; // magic string ตัวที่ 2: ผูกกับคำเดียวกันโดยบังเอิญ _cache.Remove(cacheKey);
_orderService.Approve(orderId);
// magic string ตัวที่ 3: log event name พิมพ์มือทุกครั้งที่ log _logger.LogInformation("OrderApproved", orderId);
return Ok(); }
public bool IsAdmin(string role) => role == "Admin"; // ซ้ำอีกรอบ ในอีกจุดหนึ่ง}ถ้าวันหนึ่งต้องเปลี่ยนชื่อ role จาก "Admin" เป็น "Administrator" ผู้เขียน code ต้องไล่หาทุกจุดที่พิมพ์ "Admin" เอง — และเสี่ยงพลาด cacheKey ที่บังเอิญมีคำว่า "Admin" ปนอยู่แต่ไม่ได้เกี่ยวกับ role เลย
ทางแก้คือดึงค่าคงที่ออกมาไว้ที่เดียว แล้วให้ทุกจุดอ้างอิงชื่อเดียวกันผ่าน compiler:
public static class Roles{ public const string Admin = "Admin";}
public static class CacheKeys{ public const string AdminOrders = "Admin_Orders_Cache";}
public static class LogEvents{ public const string OrderApproved = "OrderApproved";}
public class OrderController{ public IActionResult Approve(int orderId, string role) { if (role != Roles.Admin) { return Forbid(); }
_cache.Remove(CacheKeys.AdminOrders); _orderService.Approve(orderId); _logger.LogInformation(LogEvents.OrderApproved, orderId);
return Ok(); }
public bool IsAdmin(string role) => role == Roles.Admin;}ตอนนี้ถ้าต้องเปลี่ยนชื่อ role แก้ที่ Roles.Admin จุดเดียว compiler จะพาไปทุกที่ที่ใช้ผ่านการ rename symbol ได้ตามปกติ และ cacheKey กับ role ไม่ปนกันอีกต่อไปเพราะเป็นคนละ constant กันชัดเจน
ถ้าค่าที่เป็นไปได้มีจำกัดและรู้ตายตัว (เช่น role มีแค่ Admin/Editor/Viewer) การใช้ enum แทน string จะปลอดภัยกว่าอีกขั้น เพราะ compiler จะบังคับให้ใส่ค่าที่มีอยู่จริงเท่านั้น:
public enum Role{ Admin, Editor, Viewer}
public IActionResult Approve(int orderId, Role role){ if (role != Role.Admin) { return Forbid(); } // ...}แผนภาพต่อไปนี้สรุปเส้นทางการตัดสินใจว่าจะเลือกทางแก้แบบไหน:
flowchart TD
A[พบ string literal ที่มีผลต่อพฤติกรรม] --> B{ค่าที่เป็นไปได้ตายตัวและรู้ครบไหม}
B -->|ใช่| C[ใช้ enum]
B -->|ไม่ใช่ ขึ้นกับ config ภายนอก| D{ค่ามาจาก file config หรือ environment}
D -->|ใช่| E[ดึงเป็น named constant หรือ configuration class]
D -->|ไม่ใช่ ต้องสร้างค่าใหม่ตาม parameter| F[ใช้ factory method สร้าง string แบบมีสูตรเดียว]
C --> G[compiler ช่วยตรวจสอบและ rename ได้]
E --> G
F --> G
ทางแก้และการ refactor
หัวข้อที่มีชื่อว่า “ทางแก้และการ refactor”- ดึงเป็น constant — ย้าย string ไปไว้ใน
const stringหรือstatic readonlyที่มีชื่อสื่อความหมาย รวมไว้ใน class เดียว เช่นRoles,CacheKeys,ConfigKeys - ใช้ enum เมื่อค่าจำกัดตายตัว — ถ้าไม่ต้องรองรับค่าที่มาจากภายนอกแบบไดนามิก enum ให้ความปลอดภัยระดับ compile-time ที่ string ให้ไม่ได้
- ใช้ factory method เมื่อ string ต้องประกอบจาก parameter — เช่น สร้างชื่อ cache key ที่มีตัวแปรผสมอยู่ ให้รวม logic การประกอบ string ไว้ที่ method เดียว แทนที่จะ interpolate string ซ้ำ ๆ ทุกจุดที่ใช้
- ห่อด้วย wrapper type — ถ้า string นั้นสื่อความหมายเฉพาะทาง (เช่น email, SKU, currency code) พิจารณาทำเป็น value object แทน string ดิบ เพื่อผูก validation และพฤติกรรมไว้ในที่เดียว ตามแนวทาง Primitive Obsession
- รวม0 config — สำหรับ string ที่ผูกกับ infrastructure ภายนอก (connection string, URL, ชื่อ file) ให้รวมไว้ใน configuration class เดียวที่โหลดค่าจากแหล่งเดียว แทนที่จะกระจายการอ้างอิงตรง ๆ ทั่ว code
- ตรวจสอบด้วย static analysis — เปิด analyzer หรือ linter ที่เตือนเมื่อพบ string literal ซ้ำกันหลายจุด เพื่อจับ magic string ตั้งแต่ช่วง code review
หลักทั้งหมดนี้คือการเปลี่ยนจาก “string ที่ compiler มองไม่เห็นความหมาย” ไปเป็น “สัญลักษณ์ที่ compiler ช่วยตรวจสอบและ refactor ให้ได้” ซึ่งตรงกับหลัก DRY ที่ว่าความรู้แต่ละชิ้นควรมีตัวแทนเดียวที่ชัดเจนในระบบ