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

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 เคส​ที่​เขียน​ไว้ ทำให้​มอง​ไม่​เห็น​ปัญหา​จนกว่า​จะ​ต้อง​แก้​ค่า​นั้น​ทีหลัง
  1. แก้​ไม่​ครบ — เมื่อ​ค่า​ต้อง​เปลี่ยน (เช่น เปลี่ยน​ชื่อ role จาก "Admin" เป็น "Administrator") นัก​พัฒนา​ต้อง​ไล่​หา​ทุก​จุด​ที่​พิมพ์ string นี้​ด้วย​มือ หรือ​ใช้ find-and-replace แบบ​ข้อความ​ซึ่ง​เสี่ยง​ไป​โดน string อื่น​ที่​บังเอิญ​เหมือน​กัน​แต่​ความหมาย​ต่าง​กัน
  2. ไม่มี compile-time safety — พิมพ์ "Admni" ผิด​ตัว โปรแกรม​ก็​ยัง compile ผ่าน แต่​เงื่อนไข​จะ​ไม่​ทำงาน​ตาม​ที่​ตั้งใจ กว่า​จะ​รู้ตัว​คือ​ตอน production แล้ว
  3. rename ไม่​ปลอดภัย — feature rename symbol ของ IDE ใช้​ไม่​ได้​กับ string เพราะ string สอง​ก้อน​ที่ text เหมือน​กัน​อาจ​ไม่​ได้​สื่อ​ความหมาย​เดียวกัน​เสมอ​ไป
  4. ขาด​จุด​เดียว​ของ​ความ​จริง (single source of truth) — ค่า​เดียวกัน​กระจาย​อยู่​หลาย file ขัด​กับ​หลัก DRY โดยตรง
  5. อ่าน​ยาก สื่อ​ความหมาย​ไม่​ชัด"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
  • ดึง​เป็น 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 ที่​ว่า​ความ​รู้​แต่ละ​ชิ้น​ควร​มี​ตัวแทน​เดียว​ที่​ชัดเจน​ใน​ระบบ