Regex กับ AI: เขียนให้ถูกและทดสอบให้ครบ

Regular expression เป็นเครื่องมือที่นักพัฒนาส่วนใหญ่ใช้บ่อยแต่ไม่ค่อยมีใครจำ syntax ได้หมด จึงไม่แปลกที่หลายคนให้ AI เขียน regex ให้แทบทุกครั้ง ปัญหาคือ regex ที่ AI เขียนมักดูถูกและผ่านตัวอย่างที่เรานึกออก แต่พลาดกับกรณีที่เราไม่ได้นึกถึง เช่น ข้อความภาษาไทย ช่องว่างแปลก ๆ หรือ input ที่ยาวผิดปกติจนเว็บค้าง บทความนี้จะพาดูวิธีใช้ AI กับ regex อย่างเป็นระบบ: ขอคำอธิบายทีละส่วน สร้างชุดทดสอบทั้งกรณีที่ต้องผ่านและไม่ผ่าน ป้องกัน ReDoS และใช้ preg_match ของ PHP กับภาษาไทยให้ถูกต้องด้วย flag u
อย่ารับ regex ที่อ่านไม่ออก: ขอคำอธิบายทีละส่วน
กฎข้อแรกคือ ถ้าเราอ่าน regex ไม่ออก เราก็ตรวจไม่ได้ว่ามันถูก และอีกหกเดือนข้างหน้าก็แก้ไม่ได้ ดังนั้นทุกครั้งที่ให้ AI เขียน regex ให้ขอคำอธิบายแยกทีละส่วนมาด้วยเสมอ
ไม่ดี:
ขอ regex ตรวจเบอร์มือถือไทย
ดี:
เขียน regex สำหรับ PHP preg_match ตรวจเบอร์มือถือไทย
- รับรูปแบบ 0812345678, 081-234-5678 และ +66812345678
- ไม่รับเบอร์บ้าน (02xxxxxxx) และไม่รับเบอร์ที่มีตัวอักษรปน
- ต้องตรงทั้งสตริง ไม่ใช่แค่บางส่วน
ให้ผลลัพธ์เป็น:
1. pattern
2. ตารางอธิบายทีละส่วนของ pattern
3. ตัวอย่างที่ต้องผ่าน 8 ข้อ และไม่ผ่าน 8 ข้อ รวมกรณีขอบ
4. ข้อจำกัดที่ pattern นี้ยังไม่ครอบคลุม
ตัวอย่างคำอธิบายที่ควรได้:
| ส่วน | ความหมาย |
|---|---|
^ | เริ่มต้นสตริง |
(?:\+66|0) | ขึ้นต้นด้วย +66 หรือ 0 (group แบบไม่จับค่า) |
[689] | ตัวเลขหลักแรกหลังรหัส คือ 6, 8 หรือ 9 |
\d-?\d{3}-?\d{4} | ตัวเลขที่เหลืออีก 8 หลัก คั่นด้วยขีดได้ในตำแหน่งที่กำหนด |
$ | จบสตริง |
เมื่ออ่านคำอธิบายแล้ว ให้ถามตัวเองว่าตรงกับกติกาของระบบจริงหรือไม่ เช่น ระบบเราต้องรับเบอร์ที่มีช่องว่างคั่นด้วยไหม ซึ่ง AI ไม่มีทางรู้ถ้าเราไม่บอก อีกทางเลือกที่มักดีกว่าคือ "ทำให้ข้อมูลเป็นรูปแบบเดียวก่อน" เช่น ตัดขีดและช่องว่างออก แล้วค่อยตรวจด้วย regex ที่ง่ายลง
ชุดทดสอบ: กรณีที่ต้องผ่านและต้องไม่ผ่าน
regex ที่ไม่มีเทสต์คือ regex ที่รอพัง ให้ AI สร้างชุดทดสอบทั้งสองฝั่ง โดยเฉพาะฝั่ง "ต้องไม่ผ่าน" ซึ่งคนมักลืม แล้วเก็บไว้เป็นไฟล์ที่รันซ้ำได้ทุกครั้งที่แก้ pattern
<?php
declare(strict_types=1);
$pattern = '/^(?:\+66|0)[689]\d-?\d{3}-?\d{4}$/D';
$mustMatch = [
'0812345678',
'081-234-5678',
'+66812345678',
'0612345678',
'0912345678',
];
$mustNotMatch = [
'',
'021234567', // เบอร์บ้าน
'081234567', // สั้นไป
'08123456789', // ยาวไป
'081234567a', // มีตัวอักษร
' 0812345678', // มีช่องว่างนำหน้า
"0812345678\n", // มีขึ้นบรรทัดใหม่ต่อท้าย
'๐๘๑๒๓๔๕๖๗๘', // เลขไทย
];
$failed = 0;
foreach ($mustMatch as $s) {
if (preg_match($pattern, $s) !== 1) {
echo "FAIL (ควรผ่าน): " . json_encode($s, JSON_UNESCAPED_UNICODE) . PHP_EOL;
$failed++;
}
}
foreach ($mustNotMatch as $s) {
if (preg_match($pattern, $s) !== 0) {
echo "FAIL (ไม่ควรผ่าน): " . json_encode($s, JSON_UNESCAPED_UNICODE) . PHP_EOL;
$failed++;
}
}
echo $failed === 0 ? "ผ่านทั้งหมด" . PHP_EOL : "ไม่ผ่าน {$failed} กรณี" . PHP_EOL;
exit($failed === 0 ? 0 : 1);
มีสองจุดที่น่าสนใจในชุดทดสอบนี้:
- ขึ้นบรรทัดใหม่ต่อท้าย ใน PCRE ค่าเริ่มต้นของ
$จะยอมให้มี\nหนึ่งตัวท้ายสตริง ถ้าไม่ใส่ modifierD(หรือใช้\zแทน$) สตริง"0812345678\n"จะผ่าน ซึ่งเป็นช่องโหว่ที่ AI มักมองข้าม - เลขไทย pattern นี้ไม่มี flag
uทำให้\dจับเฉพาะ 0-9 แต่ใน PHP ถ้าใส่ flaguเมื่อไร\dและ\wจะกลายเป็นแบบ Unicode และจับเลขไทย๐-๙ได้ด้วย ถ้าต้องการเฉพาะเลขอารบิกให้เขียน[0-9]ตรง ๆ เรื่องแบบนี้ AI ตอบผิดได้ง่าย การมีเทสต์ทำให้เรารู้พฤติกรรมจริงแทนที่จะเดา
ใน Yii2 เราสามารถย้ายชุดทดสอบนี้ไปเป็น unit test และใช้ pattern เดียวกันใน model rule ได้ เช่น ['phone', 'match', 'pattern' => self::PHONE_PATTERN] เพื่อให้มีแหล่งความจริงแหล่งเดียว
ภาษาไทยกับ flag u
ฟังก์ชัน preg_* ของ PHP ทำงานกับสตริงแบบ byte โดยค่าเริ่มต้น แต่ตัวอักษรไทยใน UTF-8 ใช้ 3 byte ต่อตัว ถ้าลืม flag u จะเจอปัญหาแปลก ๆ เช่น . จับแค่ 1 byte ทำให้ตัดตัวอักษรขาดครึ่ง หรือ {1,50} นับเป็น byte ไม่ใช่ตัวอักษร
<?php
$name = 'สมชาย ใจดี';
// ไม่มี u: นับ byte ข้อความไทย 10 ตัวอักษรจะยาวเกิน 20 byte
var_dump(preg_match('/^.{1,20}$/', $name)); // int(0)
// มี u: นับเป็นตัวอักษร
var_dump(preg_match('/^.{1,20}$/u', $name)); // int(1)
// ตรวจชื่อภาษาไทยด้วย Unicode script property
var_dump(preg_match('/^[\p{Thai}]+(?: [\p{Thai}]+)*$/uD', $name)); // int(1)
// ข้อความที่ไม่ใช่ UTF-8 ที่ถูกต้อง preg_match จะคืน false เมื่อใช้ u
$broken = "\xE0\xB8";
var_dump(preg_match('/^.+$/u', $broken)); // bool(false)
echo preg_last_error_msg(), PHP_EOL; // Malformed UTF-8 characters...
ข้อสังเกตสำคัญ:
\p{Thai}ครอบคลุมพยัญชนะ สระ วรรณยุกต์ และเลขไทยด้วย ถ้าไม่ต้องการให้ชื่อมีเลขไทย ต้องเพิ่มการตรวจแยก เช่น ปฏิเสธเมื่อpreg_match('/[๐-๙]/u', $name) === 1- เมื่อใส่ flag
uใน PHP\wจะจับตัวอักษรไทยได้ แต่\bยังใช้หาขอบเขตคำไทยไม่ได้ เพราะภาษาไทยไม่เว้นวรรคระหว่างคำ ถ้าต้องการตัดคำให้ใช้IntlBreakIteratorจาก extension intl แทน - เมื่อใช้ flag
uต้องตรวจค่าfalseจากpreg_matchเสมอ เพราะหมายถึงเกิด error ไม่ใช่ "ไม่ตรง" ใช้=== 1ในการตรวจ ไม่ใช่แค่ truthy/falsy - ข้อความไทยบางครั้งพิมพ์ด้วยลำดับต่างกันแต่หน้าตาเหมือนกัน เช่น สระอำ ถ้าระบบต้องเปรียบเทียบข้อความ ควรพิจารณา normalize ก่อนด้วย
Normalizerของ intl
ReDoS: regex ที่ทำให้เซิร์ฟเวอร์ค้าง
ReDoS (Regular expression Denial of Service) เกิดจาก pattern บางแบบที่เมื่อเจอ input ที่ "เกือบตรงแต่ไม่ตรง" เครื่องจะลองจับคู่ (backtracking) แบบทวีคูณ input ยาวแค่ไม่กี่สิบตัวอักษรก็ทำให้ CPU ทำงานหนักได้ ผู้โจมตีจึงใช้จุดนี้ส่ง input ที่ออกแบบมาให้ระบบช้า
รูปแบบอันตรายที่ควรจำ:
- quantifier ซ้อน quantifier เช่น
(a+)+,(\w*)*,(.*a){10} - ทางเลือกที่ทับซ้อนกันภายใต้ quantifier เช่น
(a|a)*หรือ(\w|\d)+ .*หลายตัวต่อกัน ใน pattern เดียวกับข้อความยาว
<?php
// อันตราย: quantifier ซ้อน เมื่อท้ายสตริงไม่ตรง จะ backtrack มหาศาล
$evil = '/^(\w+\s?)+$/u';
$input = str_repeat('a', 5000) . '!';
$result = preg_match($evil, $input);
if ($result === false) {
// PHP มีเพดาน pcre.backtrack_limit จึงหยุดและคืน false แทนการค้างตลอดไป
error_log('regex error: ' . preg_last_error_msg());
}
// ปลอดภัยกว่า: เขียนให้แต่ละตำแหน่งมีทางจับคู่ได้ทางเดียว
$safe = '/^\w+(?:\s\w+)*$/uD';
var_dump(preg_match($safe, $input)); // int(0) และจบเร็ว
PHP มีด่านป้องกันชั้นหนึ่งคือค่า pcre.backtrack_limit และ JIT ของ PCRE แต่เมื่อชนเพดาน preg_match จะคืน false ซึ่งถ้าโค้ดเช็กแค่ falsy ก็จะถือว่า "ไม่ตรง" แบบเงียบ ๆ และยังเสีย CPU ไปจนถึงเพดานแล้ว ดังนั้นแนวทางป้องกันที่ดีคือ:
- จำกัดความยาว input ก่อนส่งเข้า regex เช่น ตรวจ
mb_strlen()ใน validation rule - หลีกเลี่ยง quantifier ซ้อน และขอให้ AI ตรวจ pattern ด้วยคำถามตรง ๆ ว่า "pattern นี้เสี่ยง catastrophic backtracking หรือไม่ ยกตัวอย่าง input ที่ทำให้ช้า"
- ทดสอบด้วย input ยาว ๆ ที่เกือบตรง แล้ววัดเวลา
- ตรวจค่า
falseและ log ด้วยpreg_last_error_msg()
อ่านเพิ่มเติมเรื่อง ReDoS ได้จากเอกสารของ OWASP และพฤติกรรมของฟังก์ชัน PCRE ได้จากคู่มือที่ php.net
Workflow สรุปเมื่อใช้ AI กับ regex
- เขียนกติกาเป็นภาษาคนให้ชัด รวมตัวอย่างที่ต้องผ่านและไม่ผ่าน
- ขอ pattern พร้อมคำอธิบายทีละส่วน และอ่านจนเข้าใจ
- สร้างชุดทดสอบเป็นโค้ดที่รันซ้ำได้ เพิ่มกรณีขอบ: ค่าว่าง ช่องว่าง ขึ้นบรรทัดใหม่ ภาษาไทย เลขไทย อีโมจิ ข้อความยาวมาก
- ตรวจความเสี่ยง ReDoS และจำกัดความยาว input
- ถ้า pattern ซับซ้อนเกินไป ลองถามว่าควรใช้วิธีอื่นแทนหรือไม่ เช่น
filter_var($email, FILTER_VALIDATE_EMAIL)สำหรับอีเมล หรือ parser เฉพาะสำหรับ HTML และ JSON
สรุป
- อย่ารับ regex ที่อ่านไม่ออก ให้ AI อธิบายทีละส่วนและตรวจว่าตรงกับกติกาจริงของระบบ
- ชุดทดสอบต้องมีทั้งกรณีที่ต้องผ่านและต้องไม่ผ่าน และเก็บเป็นโค้ดที่รันซ้ำได้
- ข้อความภาษาไทยต้องใช้ flag
uเสมอ และตรวจผลpreg_matchด้วย=== 1เพราะfalseคือ error - ใช้
Dหรือ\zเมื่อต้องการให้ตรงถึงท้ายสตริงจริง ๆ - ระวัง quantifier ซ้อนที่ทำให้เกิด ReDoS และจำกัดความยาว input ก่อนตรวจ
ลองทำ
ให้ AI เขียน regex ตรวจรูปแบบเลขบัตรประชาชนไทยที่รับทั้ง 1234567890123 และ 1-2345-67890-12-3 โดยใช้ prompt แบบ "ดี" ข้างบน นำชุดทดสอบไปใส่ในสคริปต์แบบเดียวกับตัวอย่างเบอร์มือถือ เพิ่มกรณีที่คุณคิดเองอีกอย่างน้อย 5 กรณี (เช่น เลขไทย ขีดผิดตำแหน่ง ขึ้นบรรทัดใหม่ท้ายสตริง) แล้วลองถาม AI ต่อว่า regex ตรวจ checksum ของเลขบัตรได้หรือไม่ ซึ่งคำตอบที่ถูกคือควรตัดขีดออกแล้วคำนวณ checksum ด้วยโค้ด PHP แยกต่างหาก ลองเขียนฟังก์ชันนั้นพร้อมเทสต์ด้วย
ความคิดเห็น (0)
ยังไม่มีความคิดเห็น — เริ่มคุยเป็นคนแรก ถามสิ่งที่สงสัย หรือแชร์ประสบการณ์ของคุณ