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

Parse, Don't Validate

แปลง​ที่​ขอบเขต​ให้​เป็น type ที่​การันตีความ​ถูกต้อง

Parse, Don’t Validate เป็น​หลักการ​ที่​ย้าย​ความ​รับผิดชอบ​เรื่อง​ความ​ถูกต้อง​จาก​การ​ตรวจสอบ (validation) ที่​กระจัดกระจาย ไป​สู่​การ​สร้าง type แทนที่​จะ​รับ input ดิบ​แล้ว​คอย​เช็ก​ทุก​ครั้ง​ที่​ใช้ คุณ parse input ดิบ​เพียง​ครั้ง​เดียว​ที่​ขอบเขต (boundary) แล้ว​ผลิต​ค่าที่​มี type ซึ่ง​การันตีความ​ถูกต้อง​ด้วย​ตัวการ​สร้าง​เอง หลัง parse สำเร็จ ก็​ไม่​ต้อง validate อีก​เพราะ type เอง​แสดง​สถานะ​ที่​ไม่​ถูกต้อง​ไม่​ได้

หลักการ​นี้​ถูก​อธิบาย​ไว้​โดย Alexis King ใน​บทความ Parse, Don’t Validate (2019):

Parsing is just validation with a return value — and that return value proves you did the work.

— Alexis King

ประเด็น​สำคัญ​คือ validation นั้น ทิ้ง หลักฐาน​ของ​งาน​ทั้งหมด: มัน​คืน​ค่า boolean หรือ​โยน exception แต่​ปล่อย​ข้อมูล​ไว้​ใน​รูป​ไร้ type เหมือน​เดิม ส่วน parsing นั้น เก็บ หลักฐาน​ไว้​ด้วย​การ​คืน type ใหม่ type นั้น​พก guarantee ว่า​ข้อมูล​ถูกต้อง ผู้​เรียก​จึง​ไม่​ต้อง​เช็ก​ซ้ำ King เรียก​แนวทาง​นี้​ว่าการ​ออกแบบ​ตาม “the shape of the data you wish you had” — เขียน function ให้​ทำงาน​กับ representation ที่​ต้องการ​จะ​มี ไม่ใช่ representation ดิบ​ที่​รับ​เข้า​มา

หลักการ​นี้​ยัง​ผูก​กับ​แนวคิด​จาก language-theoretic security ว่าด้วย shotgun parsing: ถ้า​โปรแกรม​ตรวจ input ที​ละ​ส่วน​กระจาย​ไป​ทั่ว​ทั้ง​ระบบ ข้อ​ผิดพลาด​ของ input อาจ​ถูก​พบ​ช้า หลัง​จาก​ส่วน​หนึ่ง​ของ input ที่​ยัง​ไม่​ผ่าน​การ​ยืนยัน​ถูก​ประมวล​ผล​ไป​แล้ว การ parse ที่​ขอบเขต​ครั้ง​เดียว​ช่วย​แบ่ง​โปรแกรม​เป็น​สอง​เฟส​อย่าง​ชัดเจน — เฟส parse ที่​ความ​ล้มเหลว​จาก input ผิด​รูปแบบ​เกิด​ขึ้น​ได้​ทั้งหมด กับ​เฟส execution ที่​เหลือ​ช่อง​ทาง​ความ​ล้มเหลว​น้อย​มาก เพราะ​ข้อมูล​ผ่าน​การ​รับประกัน​จาก type แล้ว

flowchart LR
    Raw[ข้อมูลดิบจาก HTTP request หรือ config]
    Parse{Parse ที่ boundary}
    Reject[คืน error ทันที]
    Typed[Typed value เช่น EmailAddress]
    Trust[code ชั้นในไม่ต้องตรวจซ้ำ]

    Raw --> Parse
    Parse -->|ไม่ผ่าน| Reject
    Parse -->|ผ่าน| Typed
    Typed --> Trust

หลักการ​นี้​ได้​ผล​ดี​ที่สุด​เมื่อ​ใช้​ที่ ขอบเขต​ของ​ระบบ — จุด​ที่​ข้อมูล​ไร้​โครงสร้าง​เข้า​มา ได้แก่ การ deserialize HTTP request, การ parse command-line หรือ config, การ​อ่าน​จาก​ฐาน​ข้อมูล และ​การ​รับ event เมื่อ​ผ่าน​ขอบเขต​มา​แล้ว code ปลายทาง​เชื่อถือ type ที่​ได้​รับ​ได้​โดย​ไม่​ต้อง​เช็ก​ซ้ำ​เชิง​ป้องกัน

เมื่อ​ข้อมูล​ดิบ​ถูก validate ที่​จุด​ใช้งาน​แทนที่​จะ​เป็น​ที่​ขอบเขต ปัญหา​หลาย​อย่าง​จะ​ตาม​มา:

  • ตรรกะ validation ซ้ำซ้อน ทั่ว codebase แต่ละ​จุด​เป็น​แหล่ง bug ที่​อาจ​คลาดเคลื่อน​ไม่​ตรง​กัน — แก้​กฎ​ที่​หนึ่ง​จุด​แต่​ลืม​อีก​สิบ​จุด
  • validate ซ้ำ​เชิง​ป้องกัน (defensive re-validation) method ที่​รับ type ดิบ​ต้อง​เช็ก​ซ้ำ​เสมอ ไม่​ว่า​ผู้​เรียก​จะ​ทำ​มา​แล้ว​หรือ​ไม่ เพราะ​ไม่มี​ทาง​รู้
  • type system ไม่​ช่วย​อะไร​เลย เพราะ string ที่​ผ่าน​การ​ตรวจ​ว่า​เป็น​อีเมล​แล้ว​แยก​ไม่​ออก​จาก string ที่​ยัง​ไม่​ตรวจ compiler มอง​ทั้ง​คู่​เป็น string เหมือน​กัน
  • validation ถูก​ลืม​ได้ เพราะ​ไม่มี​อะไร​ห้าม code ข้ามการเช็ก​ไป โดย​เฉพาะ​เมื่อ​ทีม​โต​ขึ้น​และ​ไม่มี​ใคร​จำ​ได้​ว่า​ต้อง​เช็ก​ตรง​ไหน​บ้าง

การ parse ที่​ขอบเขต​แก้​ปัญหา​เหล่า​นี้​พร้อม​กัน: กฎ​อยู่​ที่​เดียว compiler บังคับ​ให้​ผ่าน parse ก่อน​ถึง​จะ​ได้​ค่าที่ type นั้น และ​การ​ลืม​เช็ก​เป็น​ไป​ไม่​ได้​เพราะ​ไม่มี​ทาง​สร้าง​ค่า​ประเภท​นั้น​ด้วย​วิธี​อื่น หลักการ​นี้​จึง​ทำงาน​ร่วม​กับ Fail Fast โดยตรง — ความ​ล้มเหลว​จาก input เสีย ถูก​ดัก​ไว้​ที่​จุด​เดียว​ใกล้​แหล่ง​กำเนิด​ที่สุด แทนที่​จะ​ปล่อย​ให้​ข้อมูล​เสีย​ไหล​ลึก​เข้าไป​ใน​ระบบ​ก่อน​ถูก​จับ​ได้

parser แปลง input ที่​ยัง​ไม่​ผ่าน​การ​ยืนยัน​ให้​เป็น​ค่าที่​มี type ซึ่ง​จะ​มี​อยู่​ได้​ก็​ต่อ​เมื่อ​มัน​ถูกต้อง type ผลลัพธ์​คือ​หลักฐาน​ของ​การ validate — คุณ​ถือ​ค่า​ของ type นั้น​ไม่​ได้​เลย​หาก​ไม่​ได้ parse มา​ก่อน:

EmailAddress สร้าง​ได้​เฉพาะ​เมื่อ parse ผ่าน​เท่านั้น

public class EmailAddress
{
public string Value { get; }
private EmailAddress(string value)
{
Value = value;
}
public static EmailAddress Parse(string raw)
{
if (string.IsNullOrWhiteSpace(raw) || !raw.Contains('@'))
throw new ArgumentException($"'{raw}' is not a valid email address.", nameof(raw));
return new EmailAddress(raw.Trim().ToLowerInvariant());
}
public static bool TryParse(string raw, out EmailAddress? result)
{
if (!string.IsNullOrWhiteSpace(raw) && raw.Contains('@'))
{
result = new EmailAddress(raw.Trim().ToLowerInvariant());
return true;
}
result = null;
return false;
}
public override string ToString() => Value;
}

method ใด​ที่​รับ EmailAddress ย่อม​ได้​รับ​หลักฐาน​ว่า​ค่า​นั้น​ถูก validate แล้ว การ​ตรวจสอบ​เกิด​ขึ้น​ครั้ง​เดียว — ที่​การ​เรียก Parse — และ​ไม่​ถูก​ทำซ้ำ:

เทียบ​ก่อน/หลัง: type พก proof มา​ให้ ไม่​ต้อง guard ซ้ำ

// ก่อน: string ดิบ validate (หรือไม่) ที่จุดใช้งาน
public void SendWelcomeEmail(string email)
{
if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
throw new ArgumentException("Invalid email.");
_mailer.Send(email, "Welcome!");
}
// หลัง: EmailAddress พกหลักฐานความถูกต้องมาแล้ว ไม่ต้อง guard
public void SendWelcomeEmail(EmailAddress email)
{
_mailer.Send(email.Value, "Welcome!");
}

เป้าหมาย​ที่​กว้าง​กว่า​ของ Parse, Don’t Validate คือ​การ​ออกแบบ type ให้​ค่าที่​ไม่​ควร​มี​อยู่ มี​อยู่​ไม่​ได้ ซึ่ง​มัก​เรียก​ว่าการ​ทำให้​สถานะ​ผิด​กฎ​แสดง​แทน​ไม่​ได้ (Make Illegal States Unrepresentable) ลอง​พิจารณา​ออเดอร์​ที่​จัด​ส่ง​ได้​ก็​ต่อ​เมื่อ​มี​อย่าง​น้อยหนึ่ง​รายการ​และ​ได้​รับ​การ​ยืนยัน​การ​ชำระ​เงิน:

ออเดอร์​ใด ๆ ก็ “จัด​ส่ง​ได้” ใน​ทาง​ไวยากรณ์ ไม่มี​อะไร​กัน​สถานะ​ผิด

public class Order
{
public List<OrderLine> Lines { get; set; } = new();
public bool IsPaid { get; set; }
public string Status { get; set; } = "Pending";
}
public void Ship(Order order)
{
if (!order.IsPaid || !order.Lines.Any())
throw new InvalidOperationException("Order cannot be shipped.");
order.Status = "Shipped";
}

หลัง​จาก parse ณ จุด​ที่​ออเดอร์​กลาย​เป็น “จัด​ส่ง​ได้” method ปลายทาง​ก็​ไม่​ต้อง validate อีก เพราะ​ไม่มี​ทาง​สร้าง ShippableOrder จากออเดอร์​ที่​ยัง​ไม่​พร้อม​ได้​เลย:

ShippableOrder มี​อยู่​ใน​สถานะ​ที่​ไม่​ถูกต้อง​ไม่​ได้

public class ShippableOrder
{
public IReadOnlyList<OrderLine> Lines { get; }
public PaymentConfirmation Payment { get; }
private ShippableOrder(IReadOnlyList<OrderLine> lines, PaymentConfirmation payment)
{
Lines = lines;
Payment = payment;
}
public static ShippableOrder Parse(Order order)
{
if (!order.Lines.Any())
throw new InvalidOperationException("Cannot ship an order with no items.");
if (order.Payment is null)
throw new InvalidOperationException("Cannot ship an unpaid order.");
return new ShippableOrder(order.Lines, order.Payment);
}
}
public void Ship(ShippableOrder order)
{
// ไม่ต้อง guard — ShippableOrder อยู่ในสถานะที่ไม่ถูกต้องไม่ได้
_shipper.Dispatch(order);
}

สังเกต​ว่า Ship(ShippableOrder order) ไม่มี if แม้แต่​บรรทัด​เดียว — การ​รับประกัน​ทั้งหมด​ถูก​โอน​ย้าย​ไป​อยู่​ที่ ShippableOrder.Parse เพียง​จุด​เดียว นี่​คือ​หัวใจ​ของ​หลักการ: ยิ่ง​เลื่อน parse ให้​เร็ว​ที่สุด (เข้า​ใกล้ boundary มาก​ที่สุด) เท่าไร code ชั้น​ใน​ก็​ยิ่ง “โง่” และ​ปลอดภัย​มาก​ขึ้น​เท่านั้น

  • code ตรวจ format เดียวกัน (อีเมล, เลข​บัตร, ช่วง​วัน​ที่) ปรากฏ​ซ้ำ​ใน​หลาย method/หลาย layer แทนที่​จะ​อยู่​ที่​จุด​เดียว
  • domain concept ถูก​แทน​ด้วย primitive ตรง ๆ (string, int, List<T>) โดย​ไม่มี constructor หรือ factory ที่​บังคับ invariant — อาการ​ของ Primitive Obsession
  • object มี property แบบ IsValid หรือ IsPaid ที่​ผู้​เรียก​ต้อง​เช็กก่อน​ใช้งาน​ทุก​ครั้ง แทนที่ type เอง​จะ​รับประกัน​ความ​ถูกต้อง​อยู่​แล้ว
  • setter สาธารณะ ({ get; set; }) ที่​ปล่อย​ให้ object เปลี่ยน​กลับ​ไป​อยู่​ใน​สถานะ​ไม่​ถูกต้อง​ได้​หลัง​สร้าง​เสร็จ​แล้ว
  • คอมเมนต์​ประเภท // สมมติว่าถูก validate มาแล้ว หรือ guard clause ที่ก็อป​วาง​ซ้ำ ๆ ทุก​จุด​ที่​รับ input เดียวกัน
  • method Validate() หรือ TryParse() ถูก​เรียก​ลึก​อยู่​กลาง business logic แทนที่​จะ​เรียก​ครั้ง​เดียว​ใกล้​จุด​ที่ input เข้า​สู่​ระบบ