Regex ในงานจริง: anchor, quantifier และ capture group

เผยแพร่ 2026-04-13 อ่าน 8 นาที

สรุป (TL;DR)

วันที่ 2 กรกฎาคม 2019 Cloudflare ลดลงเกือบ 50% ของ traffic ทั่วโลกเป็นเวลา 27 นาที เพราะ regex เดียว — pattern แบบ .*(?:.*=.*) ใน rule ใหม่ของ WAF ที่ เกือบ match แต่ fail backtracking ระเบิด single core ถูก lock ที่ 100% ทุก request หยุด บทเรียนตรงไปตรงมาที่สุดที่เหตุการณ์นั้นสอนเกี่ยวกับ regex คือ regex ซ่อนต้นทุนของตัวเองได้เก่ง ปกติเล็ก เร็ว น่าใช้ แต่ “input ผิดบรรทัดเดียว” หยุดทั้งระบบได้ บทความนี้จึงออกแนวสงสัยเล็กน้อย Regex ในงานจริงส่วนใหญ่สร้างบนชิ้นส่วนไม่กี่อย่าง anchor ที่กำหนดจุดเริ่ม-จุดสิ้น-ขอบคำ, character class ที่อธิบาย “อักขระใดอักขระหนึ่งในชุด”, quantifier ที่กำหนด “กี่ครั้ง” และ group ที่ capture, reference หรือจัดกลุ่มทางเลือก ใช้ดีๆ ปัญหาปกติเช่น match email คร่าวๆ, ดึง field จาก log, normalize เบอร์โทรก็แก้ด้วย pattern สั้นและอ่านได้ ใช้ผิด match มากหรือน้อยเกินไป หรือเครื่อง stall อย่างกรณีข้างต้น อย่า parse HTML·JSON·XML ด้วย regex regular language อธิบาย nested balanced ไม่ได้ ความต่างของ engine ก็สำคัญ — ECMAScript, PCRE (Perl/PHP), Python re, Go RE2 ต่างกันใน lookaround, backreference, Unicode handling เหตุการณ์ Cloudflare เกิดจาก backtracking model ของ PCRE (ถ้าเป็น Go RE2 คงไม่เกิด)

ภูมิหลังและแนวคิด

Regex เป็นไวยากรณ์เล็กๆ สำหรับ match string ชิ้นส่วนที่ใช้บ่อยแยกตามชื่อดังนี้

Anchor ไม่บริโภคอักขระ แต่ ยืนยันตำแหน่ง ^ ต้น input (ใน multiline คือต้นบรรทัด), $ ท้าย, \b ขอบคำ abc ที่ไม่มี anchor match ที่ไหนก็ได้ในสตริงยาว แต่ ^abc$ match เมื่อสตริงทั้งหมดเป็น abc และ \babc\b match เมื่อ abc เป็นคำเดี่ยว

Character class แสดงว่าอักขระหนึ่งอยู่ในชุดใด [abc] คือ a, b หรือ c, [a-z] คืออักษรตัวเล็ก, [^0-9] คือ “ทุกอย่างที่ไม่ใช่ตัวเลข” มี shortcut ที่พบบ่อย \d (digit), \w (word char — letter, digit, underscore), \s (whitespace) ตัวใหญ่ \D, \W, \S คือ complement . โดย default match ทุกอักขระ ยกเว้น newline flag s (dotall) เปลี่ยนพฤติกรรมนี้

Quantifier ติดกับ token หน้าและกำหนดจำนวนการทำซ้ำ * 0 ครั้งขึ้นไป, + 1 ครั้งขึ้นไป, ? 0 หรือ 1, {n,m} “n ถึง m ครั้ง” (ขอบเขตละได้) Default คือ greedy: match ให้มากที่สุดเท่าที่ได้ แล้วคืนทีละอักขระเมื่อหลังมัน fail เติม ? เปลี่ยนเป็น lazy*?, +?, ?? — match น้อยที่สุดและขยายเมื่อถูกบังคับ บาง engine รองรับ possessive quantifier (*+, ++) ที่ match แบบ greedy แต่ ไม่คืนเมื่อ fail ซึ่งกันการระเบิดของ backtracking ใน pathological input — เครื่องมือที่ขาดในกรณี Cloudflare ข้างต้น

Group ห่อ sub-pattern (pattern) คือ capture group นับตามลำดับวงเล็บเปิดเริ่มจาก 1 อ้างใน pattern ด้วย \1 และใน host language ด้วย index accessor (?<name>pattern) คือ named capture (?:pattern) คือ non-capturing group ใช้สำหรับ alternation ((?:cat|dog)) หรือจัดกลุ่ม quantifier ล้วน โดยไม่บันทึก match | ภายใน group คือ alternation

สุดท้าย flag ปรับพฤติกรรม engine i ignore case, m multiline (^·$ match ขอบบรรทัดด้วย), s dotall, u Unicode ใน JavaScript flag u เปิด Unicode property escape เช่น \p{L} (“ทุกตัวอักษร”)

เปรียบเทียบและข้อมูล

QuantifierGreedy (default)Lazy (? suffix)Possessive (เมื่อรองรับ)
* / *? / *+มากสุด คืนเมื่อ failน้อยสุด ขยายเมื่อถูกบังคับมากสุด ไม่คืน
+ / +? / ++1+, greedy1+, lazy1+, ไม่คืน
? / ?? / ?+0–1, เลือก 10–1, เลือก 00–1, ไม่คืน
{n,m} / {n,m}? / {n,m}+range, greedyrange, lazyrange, ไม่คืน

Greedy เป็น default เพราะส่วนใหญ่ถูกต้อง Lazy จำเป็นเมื่อ “pattern หลัง” ใจดีเกินไป ดึง <b>...</b> หนึ่งคู่จาก HTML ต้องใช้ <b>(.*?)</b> lazy เพื่อไม่กลืนหลาย tag (แต่ก็อย่าใช้ regex กับ HTML จริง) possessive quantifier และ atomic group (?>...) ใช้กันการ regression แบบ exponential ใน pattern ที่ เกือบ match แต่ fail PCRE/Java/Oniguruma ของ Ruby รองรับ ECMAScript และ Python re ไม่รองรับ (Python 3.11 เพิ่ม atomic group ใน re module)

สถานการณ์จริง

สถานการณ์ 1 — match email ในงานจริง ไวยากรณ์ email เต็มตาม RFC 5322 อนุญาต comment, quoted local part, IP literal nested ฯลฯ regex ที่ครอบคลุมทั้งหมดจึงใหญ่มากชื่อเสีย (ที่ดังที่สุดคือ 6,425 char) และแม้อย่างนั้นก็ไม่ใช่ parser จริง pattern ที่ผมใช้ใน production แทบเสมอคือ ^[^\s@]+@[^\s@]+\.[^\s@]+$ — “ไม่ว่างเปล่า, ไม่มี whitespace, @ ไม่อยู่ผิดที่, domain มีจุดอย่างน้อยหนึ่ง” — พอสำหรับกรองที่พิมพ์ผิดชัดเจน ส่วนว่ามีอยู่จริงหรือไม่ ต้องส่ง email ทดสอบ รูปแบบด้วย regex การมีอยู่ด้วย email

สถานการณ์ 2 — เบอร์โทรรวม international format +82 2-1234-5678, (02) 1234-5678, 82-2-1234-5678 คือเบอร์โซลเดียวกันในรูปต่าง pattern ^\+?\d{1,3}[-\s().]*\d{1,4}[-\s().]*\d{3,4}[-\s().]*\d{3,4}$ รองรับเครื่องหมายวรรคตอนทั่วไป แล้วขั้น normalization ตัด punctuation ให้เหลือแค่ตัวเลขเป็น canonical form สำหรับ routing·storage·dial ที่สำคัญจริง ใช้ libphonenumber ของ Google ในทีมหนึ่งบทสรุปประชุมที่เร็วที่สุดที่ผมเห็นคือ “ไม่ทำด้วย regex” และวันนั้นป้องกันหนี้เทคนิคหนึ่งสัปดาห์ regex มีบทบาทแค่ surface check ว่า “ดูเหมือนเบอร์โทรไหม”

สถานการณ์ 3 — ดึง field จาก log บรรทัดเดียว บรรทัดเช่น 2026-04-13T02:11:05Z 192.0.2.42 "GET /search?q=foo HTTP/1.1" 200 1534 แยกได้ด้วย pattern เดียว ^(?<ts>\S+)\s+(?<ip>\S+)\s+"(?<method>\w+)\s+(?<path>\S+)\s+\S+"\s+(?<status>\d+)\s+(?<bytes>\d+)$ ที่นี่คุณค่าของ named group ปรากฏ match object ที่ได้เข้าถึงแบบ dictionary และแต่ละ field ดึงออก ด้วยชื่อ ถ้า log format เปลี่ยน pattern เองเป็นเอกสารของ format

ความเข้าใจผิดที่พบบ่อย

“regex ตรวจ email ได้สมบูรณ์” แค่รูปร่าง RFC 5322 เกินกว่าจะ encode ลง regex อย่างสมเหตุสมผล และแม้ encode ได้ “รูปที่ถูก” ก็ไม่ได้แปลว่า “mailbox มีอยู่” มาตรฐานอุตสาหกรรมคือ regex ง่ายๆ + email ยืนยัน

“greedy ช้ากว่า lazy เสมอ” ไม่จริง ถ้า sub-pattern ของ quantifier จำกัดมาก greedy match ทีเดียวยาวแล้วจบจะเร็วกว่า lazy ชนะเมื่อ pattern หลัง anchor match ให้เช่น <b>(.*?)</b> อย่าเติม ? แบบ reflex ก่อน benchmark ด้วย input จริง

“engine regex เหมือนกันหมด” ไม่ ECMAScript ไม่มี possessive quantifier และ atomic group (v flag ปรับปรุงเล็กน้อย), Python re มีชุด Unicode property ของตัวเอง, PCRE รองรับ backreference และ recursive pattern Oniguruma ที่ Ruby และ onig crate ของ Rust ใช้ก็อีก variant Go RE2 ละ backreference และ lookaround เพื่อรับประกัน execution time ที่เป็นเชิงเส้นต่อ input length — engine ที่ Cloudflare พิจารณา migrate หลังเหตุการณ์ pattern ที่ copy จาก Perl tutorial ไม่ทำงานใน JavaScript เป็นเรื่องปกติ

“parse HTML (หรือ JSON, XML) ด้วย regex ได้” regular language อธิบาย nested balanced ไม่ได้ regex ดึง sub-pattern ที่ define ดี เช่น ค่าของ attribute ได้ แต่ parse ทั้ง tree อย่างถูกต้องไม่ได้ nested format ต้องใช้ parser เฉพาะ (DOMParser, JSON.parse, XML library, CSV reader) legend “regex vs HTML” ของ Stack Overflow (คำตอบปี 2009) ไม่ใช่การถกเถียง แต่เป็นคำเตือน

เช็กลิสต์

  1. input และ counter-example คืออะไร เขียนทั้งสองไว้ ก่อน เขียน pattern
  2. ข้อมูลมีโครงสร้าง nested·recursive ไหม ถ้าใช่ ใช้ parser regex เป็นเครื่องมือผิด
  3. target engine คืออะไร JS·Python·Go·PCRE ต่างกันใน lookaround·backreference·Unicode
  4. ต้องการ match เองหรือ yes/no พอ group ที่ใช้เฉพาะ quantifier·alternation ใช้ (?:...) non-capturing
  5. pattern ใช้กับ input จาก user หรือ input ไม่น่าเชื่อถือ ใช้ atomic group·timeout·engine เวลาเชิงเส้นอย่าง RE2 กัน backtracking ระเบิด จะกลายเป็น Cloudflare
  6. มีขั้น text normalization หลังหรือไม่ อย่ายัดทุกอย่างใน pattern เดียว แยก surface check ง่ายๆ + post-processing เบา
  7. regex มี documentation ไหม comment ใน x (extended) mode หรือบรรทัดอธิบายเหนือก็เป็นประกันใหญ่สำหรับคนถัดไป

เครื่องมือที่เกี่ยวข้อง

Regex Tester ของ Patrache Studio รัน pattern กับ sample input และแสดง capture แบบ inline เร็วกว่าสลับแท็บ หากเป้าหมาย match เป็น string มีโครงสร้างเช่น JSON ใน log ใช้ร่วมกับ ความต่างระหว่าง JSON Formatting·Validation·JSON Schema แล้ว validate ชิ้นที่ดึงด้วย parser จริง ไม่ใช่ regex ที่สอง เป้าหมาย regex ทั่วไปอันหนึ่งคือ UUID ที่ฝังใน URL ดู เปรียบเทียบ UUID v1·v4·v7 ว่า string 36 char เดียวกันมีความหมายต่างกันตาม version bit ได้อย่างไร

อ้างอิง