
Java Normalizer: ค้นหาชื่อไทยให้ตรงกัน เข้าใจ NFC กับ NFKC ก่อนใช้
ลูกค้าพิมพ์ชื่อเหมือนที่พนักงานเห็นในระบบ แต่ค้นหาไม่เจอ หรือระบบแจ้งว่ามีชื่อบัญชีซ้ำหลังคัดลอกจากอีกแอป ปัญหาอาจอยู่ที่ลำดับรหัส Unicode ซึ่งเป็นรหัสแทนตัวอักษร ไม่ใช่หน้าตาที่แสดงบนจอครับ บทความนี้จะสร้างคีย์สำหรับเทียบข้อความด้วย Java พร้อมเทสต์ให้เห็นว่าเทคนิคนี้ช่วยตรงไหน และกรณีชื่อไทยแบบไหนยังต้องมีนโยบายเพิ่ม
WHY: ข้อความที่ดูเหมือนกันอาจเทียบแล้วไม่เท่ากัน
การใช้ String.equals() กับข้อความดิบอาจไม่ตรงกับสิ่งที่ผู้ใช้มองว่าเป็นคำเดียวกัน เอกสาร Normalizer ยกตัวอย่าง Á ซึ่งเก็บได้ทั้งรหัสตัวเดียวและ A ตามด้วยเครื่องหมายเน้นเสียง การทำ normalization คือการแปลงข้อความให้อยู่ในรูปมาตรฐานที่เลือก เพื่อช่วยงานเปรียบเทียบและค้นหา
- งานบริการลูกค้าควรค้นเจอข้อมูล แม้ชื่อที่รับเข้ามาจะมีรูปแทนตัวอักษรต่างกันในกรณีที่เรารองรับ
- ข้อมูลคัดลอกอาจมี
123แทน123การกำหนดกติกาเดียวกันทั้งตอนบันทึกและค้นหาช่วยลดความต่างนี้ - งานตรวจรายการซ้ำต้องมีคีย์ที่อธิบายได้ พร้อมเก็บข้อความต้นฉบับไว้ตรวจสอบย้อนหลัง
อย่าเพิ่งสรุปว่าชื่อที่ได้คีย์เดียวกันคือคนเดียวกัน ชื่อเป็นเพียงตัวช่วยค้นหา การรวมประวัติลูกค้ายังต้องอาศัยข้อมูลยืนยันตามกติกาของงาน
ทำงานยังไง
นึกถึงการรับใบสั่งของจากหลายสาขา บางใบเขียนรหัสสินค้าด้วยเลขเต็มความกว้าง บางใบเว้นช่องไฟเกิน เราทำสำเนาที่จัดรูปแบบเหมือนกันไว้จับคู่ แต่เก็บใบจริงให้พนักงานอ่าน การ normalize ก็คล้ายการจัดสำเนานี้ครับ ไม่ใช่การเดาว่าคนเขียนตั้งใจหมายถึงอะไร
Normalizer.Form มีสี่รูปแบบ คำว่า decomposition คือแยกองค์ประกอบ ส่วน composition คือประกอบกลับตามกฎ:
NFD: แยกแบบ canonical หรือความเทียบเท่าตามรูปมาตรฐานNFC: แยกแบบ canonical แล้วประกอบกลับแบบ canonicalNFKD: แยกแบบ compatibility ซึ่งครอบคลุมรูปที่มีไว้เพื่อความเข้ากันได้ด้วยNFKC: แยกแบบ compatibility แล้วประกอบกลับแบบ canonical
จุดสำคัญของไทยคือ ำ หรือ U+0E33 มีการแยกแบบ compatibility ในกรณีที่ทดสอบ NFC และ NFD ยังเก็บ U+0E33 ไว้ ขณะที่ NFKC และ NFKD ให้ U+0E4D (นิคหิต) ตามด้วย U+0E32 (สระอา) จึงใช้ NFC อย่างเดียวเพื่อรวมสองรูปนี้ไม่ได้
แต่คำว่า “น้ำ” มีเรื่องลำดับวรรณยุกต์เพิ่มเข้ามา เทสต์ด้านล่างระบุรหัสอย่างชัดเจน: น + ้ + ำ ผ่าน NFKC แล้วได้ น + ้ + ํ + า ส่วน น + ํ + ้ + า ยังอยู่ในลำดับเดิม สองผลลัพธ์นี้ไม่เท่ากัน เอกสารอธิบายการจัดลำดับเครื่องหมายตามกฎ canonical แต่ไม่ได้หมายความว่าสลับเครื่องหมายไทยทุกแบบแล้วจะรวมกันได้
เราจะเลือก NFKC ตามด้วยตัดช่องว่างหัวท้ายและยุบช่องว่างสำหรับงานค้นหาตัวอย่างนี้ กติกาช่องว่างเป็นสิ่งที่เราเพิ่มเอง ไม่ใช่งานของ Normalizer และยังไม่รวมการแปลงตัวพิมพ์ใหญ่เล็กหรือแก้ลำดับวรรณยุกต์ไทยเอง
โค้ดตัวอย่างพร้อมเทสต์
บันทึกเป็น Main.java แล้วรัน javac -encoding UTF-8 Main.java ตามด้วย java -ea Main ต้องมี -ea เพื่อเปิดการตรวจ assert ตัวอย่างจำลองรายชื่อลูกค้าและรหัสเอกสาร เก็บชื่อเดิมแยกจากคีย์ค้นหา ใช้ escape ระบุรหัสในคู่ทดสอบที่ดูด้วยตาแล้วแยกยาก
import java.text.Normalizer;
import java.text.Normalizer.Form;
import java.util.List;
import java.util.Objects;
public class Main {
static String normalizeForMatching(String text) {
Objects.requireNonNull(text, "text");
return Normalizer.normalize(text, Form.NFKC)
.trim().replaceAll("\\s+", " ");
}
record Customer(String originalName, String searchKey) {
static Customer from(String name) {
return new Customer(name, normalizeForMatching(name));
}
}
public static void main(String[] args) {
boolean assertionsEnabled = false;
assert assertionsEnabled = true;
if (!assertionsEnabled) {
throw new IllegalStateException("Run with java -ea Main");
}
String saraAm = "\u0E33";
String splitAm = "\u0E4D\u0E32";
for (Form form : List.of(Form.NFC, Form.NFD)) {
assert Normalizer.normalize(saraAm, form).equals(saraAm);
assert !Normalizer.normalize(saraAm, form)
.equals(Normalizer.normalize(splitAm, form));
}
for (Form form : List.of(Form.NFKC, Form.NFKD)) {
assert Normalizer.normalize(saraAm, form).equals(splitAm);
assert Normalizer.normalize(splitAm, form).equals(splitAm);
}
assert Normalizer.isNormalized(saraAm, Form.NFC);
assert !Normalizer.isNormalized(saraAm, Form.NFKC);
assert Normalizer.isNormalized(splitAm, Form.NFKC);
String nam = "\u0E19\u0E49\u0E33"; // น + ้ + ำ
String sameOrder = "\u0E19\u0E49\u0E4D\u0E32";
String otherOrder = "\u0E19\u0E4D\u0E49\u0E32";
assert Normalizer.normalize(nam, Form.NFKC).equals(sameOrder);
assert Normalizer.normalize(otherOrder, Form.NFKC).equals(otherOrder);
assert normalizeForMatching(nam).equals(normalizeForMatching(sameOrder));
assert !normalizeForMatching(nam).equals(normalizeForMatching(otherOrder));
assert Normalizer.normalize("\u00C1", Form.NFD).equals("A\u0301");
assert Normalizer.normalize("A\u0301", Form.NFC).equals("\u00C1");
assert normalizeForMatching("123ABC").equals("123ABC");
assert normalizeForMatching("①").equals("1");
assert !normalizeForMatching("ABC").equals(normalizeForMatching("abc"));
assert normalizeForMatching(" \tสมชาย ใจดี\n").equals("สมชาย ใจดี");
assert normalizeForMatching(" ").isEmpty();
String original = " " + nam + " ใจดี ";
List<Customer> customers = List.of(
Customer.from(original), Customer.from("สมชาย ใจดี"));
String query = normalizeForMatching(sameOrder + " ใจดี");
List<Customer> found = customers.stream()
.filter(c -> c.searchKey().equals(query)).toList();
assert found.size() == 1;
assert found.get(0).originalName().equals(original);
assert normalizeForMatching(found.get(0).searchKey())
.equals(found.get(0).searchKey());
assert normalizeForMatching(" ใบสั่งซื้อ 123 ")
.equals("ใบสั่งซื้อ 123");
System.out.println("OK: all assertions passed");
}
}
ผมรันด้วย OpenJDK Temurin 21.0.12.1+1 ทั้งคอมไพล์และ java -ea Main แล้ว เทสต์ผ่านและพิมพ์ OK: all assertions passed ครับ
Normalizer.isNormalized(text, form) ใช้ตรวจว่าข้อความอยู่ในรูปที่เลือกแล้วหรือยัง ตาม เอกสารเมธอด ส่วน normalize() คืนข้อความที่แปลงแล้ว ตัวตรวจไม่ได้บอกว่าข้อความสะอาดตามกติกาธุรกิจ เช่น ช่องว่างที่เรายังต้องจัดเอง และทั้งสองเมธอดรับ null ไม่ได้
helper นี้ยอมให้ข้อความว่างได้ตามเทสต์ หากใช้สร้างบัญชีต้องตรวจความว่างหลังแปลงอีกขั้น ส่วน trim() กับ \s+ ในโค้ดตั้งใจรองรับช่องว่างทั่วไปตามตัวอย่าง เช่น space, tab และขึ้นบรรทัดใหม่ อย่าใช้เทสต์นี้เป็นหลักฐานว่าจัดการช่องว่าง Unicode ทุกชนิดแล้ว
จุดที่คนพลาดบ่อย
- คิดว่า NFC รวมสระอำสองรูปเสมอ — ใช้เทสต์รหัส U+0E33 เทียบกับ U+0E4D U+0E32 ก่อนเลือกฟอร์ม ตัวอย่างนี้ต้องใช้ compatibility normalization จึงรวมคู่นี้ได้
- คิดว่า NFKC แก้ลำดับวรรณยุกต์ไทยทั้งหมด — เก็บกรณีที่ยังไม่เท่ากันเป็นเทสต์ ถ้าจะเพิ่มกฎเฉพาะภาษา ต้องกำหนดคู่ที่ยอมให้เท่ากันและทดสอบผลข้างเคียงก่อน
- เก็บเฉพาะข้อความหลัง normalize — แยกชื่อเดิมกับคีย์สำหรับค้นหา เพราะ NFKC เปลี่ยน
①เป็น1ตามเทสต์ รายละเอียดรูปสัญลักษณ์จึงอาจหายไปจากข้อมูลที่แปลงแล้ว - เอา helper ไปครอบรหัสผ่านทันที — อย่าเปลี่ยนความลับที่ผู้ใช้ตั้งไว้หรือกติกาการตรวจรหัสผ่านอย่างเงียบ ๆ นี่เป็นการตัดสินใจออกแบบระบบยืนยันตัวตน ต้องมีนโยบายและแผนรองรับข้อมูลเดิม
- แปลงเฉพาะคำค้น — ใช้กติกาเดียวกันกับข้อมูลที่ทำดัชนีด้วย ถ้ามีฐานข้อมูลเดิม ให้เตรียมคีย์ย้อนหลังและตรวจรายการที่ชนกันก่อนบังคับเงื่อนไขห้ามซ้ำ
ใช้ตรงไหนในงานจริง
- ทำช่องค้นหาลูกค้าสำหรับเจ้าหน้าที่ โดยแสดงชื่อจาก
originalNameและเทียบด้วยsearchKey - ตรวจรายการนำเข้าจากไฟล์ว่าอาจซ้ำกัน แล้วให้พนักงานตรวจยืนยันก่อนรวมข้อมูล
- จัดรูปแบบรหัสเอกสารที่คัดลอกมามีเลขเต็มความกว้าง เมื่อธุรกิจยอมรับให้เป็นรหัสเดียวกับเลขปกติ
- เตรียมนโยบายชื่อบัญชีตอนสมัครและเข้าสู่ระบบให้สอดคล้องกัน พร้อมตรวจคีย์ชนกันก่อนเปิดใช้กับบัญชีเดิม
ตัวอย่างใช้การเท่ากันทั้งคีย์ ถ้าต้องการค้นหาบางส่วนหรือเรียงตามภาษาไทย ให้กำหนดวิธีค้นหาและเรียงเพิ่มต่างหาก การ normalize ไม่ใช่เครื่องมือค้นหาครบชุด และไม่ได้พิสูจน์ตัวตนของเจ้าของชื่อครับ
เทียบกับทางเลือกอื่น
| วิธี | ดีตรงไหน | ระวังอะไร |
|---|---|---|
| เทียบข้อความดิบ | รักษาความต่างทุกตัว เหมาะกับงานที่ต้องตรงเป๊ะ | รูปแทนตัวอักษรต่างกันอาจค้นไม่พบ |
| NFC | รวมรูป canonical เช่น Á ในเทสต์ | สระอำสองรูปในตัวอย่างยังต่างกัน |
| NFKC + กฎช่องว่าง | รวมคู่สระอำและเลขเต็มความกว้างที่ทดสอบ | สัญลักษณ์บางรูปถูกรวม และลำดับไทยบางคู่ยังต่าง |
| กฎแปลงเฉพาะงาน | ควบคุมคู่ข้อความที่ยอมให้ตรงกันได้ | ต้องดูแลกฎและเทสต์ ไม่ควรเดาสุ่มแล้วลบเครื่องหมาย |
Recap
- เลือกฟอร์มจากคู่ข้อความจริงที่งานต้องการให้เท่ากัน
- จำว่า NFC ไม่แยก U+0E33 ในเทสต์นี้ แต่ NFKC แยก
- มีเทสต์ลำดับวรรณยุกต์ที่ยังไม่ตรงกัน เพื่อเห็นขอบเขตชัดเจน
- แยกข้อความต้นฉบับสำหรับแสดงผลออกจากคีย์ค้นหา
- ใช้กฎเดียวกันทั้งฝั่งข้อมูลและคำค้น แล้วตรวจคีย์ที่ชนกัน
- ใช้ isNormalized ตรวจรูป Unicode และตรวจความว่างตามกติกางานเพิ่ม
- รันด้วย -ea และทบทวนนโยบายก่อนใช้กับชื่อบัญชีหรือความลับ