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

Descriptive Error Messages

ข้อความ error ที่​ช่วย​ทั้ง​ผู้​ใช้​และ​นัก​พัฒนา​ให้​เข้าใจ​ปัญหา

เมื่อ​มี​สิ่ง​ไม่​คาด​คิด​หรือ​ผิด​ปกติ​เกิด​ขึ้น​ใน application มัก​มี​ข้อความ error แสดง​ต่อ​ผู้​ใช้ และ/หรือ​ถูก log ไว้​ที่​ใด​ที่​หนึ่ง​ให้​นัก​พัฒนา​ไป​ตรวจสอบ ข้อความ​เหล่า​นี้​มี​ผู้​อ่าน​สอง​กลุ่ม​ที่​ต้องการ​สิ่ง​ต่าง​กัน:

  • ผู้​ใช้​ปลายทาง ต้องการ​รู้​ว่า​เกิด​อะไร​ขึ้น เกี่ยวข้อง​กับ​สิ่ง​ที่​เขา​ทำ​อยู่​หรือ​ไม่ และ (ถ้า​เป็น​ไป​ได้) ควร​ทำ​อย่างไร​ต่อ
  • นัก​พัฒนา/ทีม on-call ต้องการ​บริบท​และ​รายละเอียด​ทาง technical ให้​พอที่​จะ​ระบุ​ต้นตอ​ของ​ปัญหา​และ​แก้ไข​ได้​โดย​ไม่​ต้อง​เดา

หาก​เป็น​ข้อ​ผิดพลาด​ชั่วคราว เช่น​การ​ขาด​การ​เชื่อม​ต่อ​เครือข่าย หรือ​เป็น​สิ่ง​ที่​ผู้​ใช้​อาจ​แก้ไข​เอง​ได้​ด้วย​การ troubleshoot ข้อความ​ควร​ให้​รายละเอียด​พอที่​ผู้​ใช้​จะ​เข้าใจ​และ​แก้​ปัญหา​ได้ หาก​มุ่ง​ให้​ข้อมูล diagnostic แก่​นัก​พัฒนา ก็​ควร​ให้​บริบท​และ stack trace ที่​ครบถ้วน ข้อความ error ที่​ไม่​ช่วย​ทั้ง​ผู้​ใช้​และ​ไม่มี​ประโยชน์​ต่อ​นัก​พัฒนา​นั้น​แย่​ยิ่ง​กว่า​ไร้​ประโยชน์ เพราะ​มัน​สร้าง​แต่​ความ​หงุดหงิด​โดย​ไม่​ให้​ทางออก — ข้อความ​อย่าง “An error occurred” หรือ “Something went wrong” คือ​ตัวอย่าง​คลาสสิก​ของ​สิ่ง​ที่​ควร​หลีก​เลี่ยง

หลักการ​นี้​ผูก​กับ Fail Fast โดยตรง: การ fail เร็ว​มี​ประโยชน์​ก็​ต่อ​เมื่อ​ข้อความ​ที่​ตาม​มาบอก​ได้​ว่า ทำไม จึง fail ไม่​เช่น​นั้น​ก็​เป็น​เพียง​การ crash เร็ว​ขึ้น​โดย​ไม่​ช่วย​วินิจฉัย​อะไร​เลย Nielsen Norman Group จัด​ให้การ​รายงาน error ที่​ดี​เป็น​หนึ่ง​ใน usability heuristic หลัก (heuristic #9: “Help Users Recognize, Diagnose, and Recover from Errors”) และ​สรุป​ไว้​เป็น​แนวทาง 12 ข้อ​สำหรับ​ข้อความ error ที่ effective — เน้น​ความ​ชัดเจน (explicit) อ่าน​ง่าย (human-readable) สุภาพ (polite) แม่นยำ (precise) และ​ให้​คำ​แนะนำ​ที่​ทำ​ตาม​ได้ (constructive)

ข้อความ error ที่​ผู้​ใช้​เห็น​เป็น​ส่วน​หนึ่ง​ของ UI และ UX เช่น​เดียว​กับ​ปุ่ม​หรือ​ฟอร์ม — ถ้า​คุณ​ใส่ใจ​ออกแบบ UX มา​ทั้ง​ระบบ อย่า​ปล่อย​ให้​ทุก​อย่าง​พัง​ทลาย​ตอน​เกิด error ด้วย​การ​โชว์ stack trace หรือ​ข้อความ​ที่​เขียน​มา​เพื่อ debugger ไม่ใช่​มนุษย์ ตาม NNGroup ข้อความ error ที่​ดี​ควร​จะ:

  1. เจาะจง (specific) — บอกว่า​อะไร​ผิดพลาด​จริง ๆ แทน​คำ​กลาง ๆ เช่น “เกิด​ข้อ​ผิดพลาด”
  2. อ่าน​ง่าย (human-readable) — ใช้​ภาษา​ที่​คน​ทั่วไป​เข้าใจ ซ่อน​รหัส error ที่​ไม่มี​ความหมาย​ไว้​เบื้องหลัง (แต่​ยัง​เก็บ​ไว้​สำหรับ support)
  3. สุภาพ​และ​ไม่​ตำหนิ​ผู้​ใช้ (polite) — เลี่ยง​คำ​ว่า “invalid” หรือ “คุณ​ทำ​ผิด”; ระบบ​ควร​ปรับ​ตัว​เข้าหา​ผู้​ใช้ ไม่ใช่​โยน​ความ​ผิด​ให้​เขา
  4. ให้​คำ​แนะนำ​ที่​ทำ​ตาม​ได้ (constructive) — ไม่ใช่​แค่​บอก​ปัญหา แต่​บอก​วิธี​แก้​หรือ​ขั้นตอน​ถัด​ไป​ด้วย เช่น​แทน “สินค้า​หมด” ด้วย “สินค้า​หมด​ชั่วคราว จะ​กลับ​มา​วัน​ที่ … หรือ​ลง​ทะเบียน​รับ​แจ้ง​เตือน​ได้ที่​นี่”

แนว​ปฏิบัติ​เสริม: วางข้อความ​ไว้​ใกล้​จุด​ที่​เกิด​ปัญหา ใช้​สัญลักษณ์​ทาง​สายตา (ตัว​หนา สี​ตัด​กัน) ประกอบ​ข้อความ และ​รักษา​ข้อมูล​ที่​ผู้​ใช้​กรอก​ไว้​ไม่​ให้​หาย​เมื่อ​เกิด error (ให้​แก้ไข​ต่อ​ได้ ไม่​ต้อง​เริ่ม​ใหม่)

อย่า​ให้ stack trace, connection string หรือ query ดิบ​รั่วไหล​ไป​ถึง​ผู้​ใช้ — นอกจาก​จะ​น่า​กลัว​และ​อ่าน​ไม่รู้เรื่อง​แล้ว​ยัง​เป็น​ความ​เสี่ยง​ด้าน security ทาง​ที่​ดี​คือ log รายละเอียด​เต็ม​ไว้​ฝั่ง server พร้อม correlation id หรือ trace id แล้ว​แสดง​ข้อความ​สั้น ๆ ที่​เป็น​มิตร​พร้อม id นั้น​ให้​ผู้​ใช้ เพื่อ​ให้​ทีม support อ้างอิง​กลับ​ไป​หา log ได้ — แนวทาง​นี้​เชื่อม​กับ​การ​ทำ Observability ใน​ระบบ

flowchart TD
    A[Exception เกิดขึ้น] --> B{รู้จักชนิด error หรือไม่}
    B -->|รู้จัก domain exception| C[แปลงเป็นข้อความที่ผู้ใช้เข้าใจ]
    B -->|ไม่รู้จัก generic exception| D[Log เต็มพร้อม correlation id]
    D --> E[แสดงข้อความกลางพร้อม id อ้างอิง]
    C --> F[แสดงพร้อมคำแนะนำขั้นตอนถัดไป]
    D --> G[แจ้งทีม on-call ถ้าจำเป็น]
  • อย่า catch โดย​ไม่ throw ต่อ (เว้น​แต่​คุณ​แก้​ปัญหา​ได้​ทันที)
  • อย่า catch แล้ว throw Exception ตัว​ใหม่​โดย​ไม่​ห่อ exception เดิม​ไว้​เป็น inner exception — ถ้า​จะ log แล้ว re-throw เฉย ๆ ให้​ใช้ throw; ไม่ใช่ throw ex;
  • เลี่ยง​การ catch generic exception (เช่น System.Exception) ยกเว้น​ที่​ระดับ​สูงสุด​ของ app (global exception handler)
  • สร้าง domain-specific exception (เช่น InventoryUnavailableException) แทน​กรณี​ที่​รู้จัก เพื่อ​สื่อ​ความหมาย​ใน​ระดับ abstraction ที่​สูง​ขึ้น และ catch แยก​จาก exception ทั่วไป​ได้
  • ตาม Microsoft Learn: ตั้ง​ชื่อ custom exception ให้​ลงท้าย​ด้วย Exception, มี constructor อย่าง​น้อย​สาม​แบบ (ไม่มี parameter, รับ message, รับ message กับ inner exception), เขียน message เป็น​ประโยค​สมบูรณ์​ที่​มี​เครื่องหมาย​จบ​ประโยค
  • เลี่ยง​การ​ใช้ exception เป็น​ตรรกะ​การ​ไหล​ปกติ​ของ app (เช่น​เช็กว่า file มี​อยู่​ก่อน​เขียน แทนที่​จะ​เขียน​แล้ว​ค่อย​ดัก exception) — ใช้ method แบบ TryParse/TryGetValue เมื่อ error เป็น​กรณี​ปกติ​ที่​คาด​ว่า​จะ​เกิด
  • เวลา log ให้ log Exception.ToString() (หรือ​ทั้ง Exception หาก log framework รับ​ได้) ไม่ใช่​แค่ Exception.Message เพราะ ToString() มี stack trace และ inner exception ครบ

เมื่อ validate ข้อมูล​นำ​เข้า (เช่น​ฟอร์ม) การ throw exception ทันที​ที่​เจอ error แรก​จะ​บังคับ​ให้​ผู้​ใช้​แก้​ที​ละ​จุด​แบบ “ตี​ตัว​ตุ่น” Martin Fowler เสนอ Notification pattern: แทนที่​จะ throw ทันที ให้​สะสม error ทั้งหมด​ไว้​ใน object notification แล้ว​รายงาน​ให้​ผู้​ใช้​เห็น​ครบ​ใน​ครั้ง​เดียว ซึ่ง​เหมาะ​กับ error ที่​เป็น “พฤติกรรม​ที่​คาด​ไว้​แล้ว” (เช่น​ผู้​ใช้​กรอก​ฟอร์ม​ผิด) มากกว่า​การ​ใช้ exception ซึ่ง​ควร​สงวน​ไว้​กับ​สิ่ง​ที่​อยู่​นอกเหนือ​ความ​คาดหมาย​จริง ๆ

อย่า​ทำ — throw ex; รีเซ็ต stack trace ต้นตอ​หาย

catch (Exception ex)
{
_log.Error(ex);
throw ex; // stack trace เดิมหาย! debug ยากขึ้นมาก
}

ทำ​แบบ​นี้ — throw; คง stack trace เดิม​ไว้​ครบ

catch (Exception ex)
{
_log.Error(ex.ToString());
throw; // re-throw โดยรักษา stack trace ต้นตอไว้ครบ
}

Domain-specific exception ตาม​แนวทาง Microsoft Learn — 3 constructor มาตรฐาน

public class InventoryUnavailableException : Exception
{
public string Sku { get; }
public InventoryUnavailableException() { }
public InventoryUnavailableException(string message) : base(message) { }
public InventoryUnavailableException(string message, Exception innerException)
: base(message, innerException) { }
public InventoryUnavailableException(string sku, string message, Exception innerException)
: base(message, innerException)
{
Sku = sku;
}
}
// ใช้งาน: ข้อความเจาะจง อ่านง่าย และให้ context (sku) สำหรับ diagnostic
if (!inventory.HasStock(sku))
{
throw new InventoryUnavailableException(
sku,
$"สินค้ารหัส {sku} หมดสต็อกชั่วคราว ไม่สามารถจองออเดอร์ได้ในขณะนี้",
innerException: null);
}

แปลง exception ภายใน​ให้​เป็น​ข้อความ​ที่​ผู้​ใช้​เข้าใจ พร้อม correlation id สำหรับ support

try
{
orderService.PlaceOrder(order);
}
catch (InventoryUnavailableException ex)
{
// รู้จักชนิดปัญหา — แสดงข้อความที่นำไปทำอะไรต่อได้
return Results.Conflict(new { message = ex.Message, sku = ex.Sku });
}
catch (Exception ex)
{
// ไม่รู้จักชนิดปัญหา — log เต็มพร้อม correlation id แล้วส่งข้อความกลางกลับ
var correlationId = Guid.NewGuid();
_log.Error($"[{correlationId}] {ex}");
return Results.Problem(
title: "ไม่สามารถทำรายการได้ในขณะนี้",
detail: $"กรุณาลองใหม่อีกครั้ง หากยังพบปัญหาโปรดแจ้งรหัสอ้างอิง {correlationId}");
}

ประโยชน์

  • ผู้​ใช้​แก้​ปัญหา​เอง​ได้​บ่อย​ขึ้น​โดย​ไม่​ต้อง​ติดต่อ support ลด​ภาระ​ทีม
  • นัก​พัฒนา​วินิจฉัย​ต้นตอ​ปัญหา​ได้​เร็ว​ขึ้น​เมื่อ​เกิด incident เพราะ​มี context และ stack trace ครบ ช่วย​ลด MTTR (mean time to resolution)
  • ข้อความ​ที่​สุภาพ​และ​มี​คำ​แนะนำ​ช่วย​รักษา​ความ​ไว้ใจ​ของ​ผู้​ใช้​ต่อ​ผลิตภัณฑ์ แม้​ใน​สถานการณ์​ที่​แย่​ที่สุด
  • แยก​ข้อความ​ผู้​ใช้​ออก​จาก log diagnostic ทำให้​ปรับปรุง​แต่ละ​ฝั่ง​ได้​อิสระ (เช่น localize ข้อความ​ผู้​ใช้ โดย​ไม่​ต้อง​แตะ log format)

ข้อ​ควร​ระวัง

  • อย่า​ปล่อย​ให้​รายละเอียด​ภายใน (stack trace, connection string, query, path บน server) รั่วไหล​ไป​ถึง​ผู้​ใช้ ทั้ง​เรื่อง UX ที่​แย่​และ​ความ​เสี่ยง​ด้าน security
  • ข้อความ​ที่​ยาว​หรือ technical เกิน​ไป​สำหรับ​ผู้​ใช้​ทั่วไป​สร้าง​ความ​เหนื่อย​ล้า (alert fatigue) เช่น​เดียว​กับ​ข้อความ​ที่​สั้น​เกิน​จน​ไม่มี​ประโยชน์
  • การ catch generic exception มาก​เกิน​ไป​เพื่อ “ครอบคลุม​ทุก​กรณี” มัก​ซ่อน bug จริง​ไว้ และ​ทำให้ debug ยาก​ขึ้น​ใน​ระยะ​ยาว — ใช้ domain-specific exception แทน
  • ถ้า​ระบบ log ต้อง localize ข้อความ​ผู้​ใช้​หลาย​ภาษา ต้อง​แยก resource string ออก​จาก log message ตั้งแต่​ต้น ไม่​เช่น​นั้น​จะ​ปน​กัน​จน​ดูแล​ยาก
  • การ​ใช้ exception แทน control flow ปกติ (เช่น​แทน TryParse) มี​ต้นทุน​ด้าน performance ที่​ไม่​คุ้ม​เมื่อ error เป็น​กรณี​ที่​คาด​ว่า​จะ​เกิด​บ่อย