99 Ngày Java — Ngày 28: Regex trong Java
Ngày 27 làm chủ con số — hôm nay ta học ngôn ngữ tìm kiếm theo mẫu mạnh nhất của lập trình: biểu thức chính quy (regular expression, gọi tắt regex). Một dòng như (?<nam>\d{4})-(?<thang>\d{2}) đọc như mật mã, nhưng thay thế được vài chục dòng indexOf/substring (Ngày 9) và xuất hiện ở khắp nơi: validate form, phân tích log, đổi định dạng dữ liệu, @Pattern trong Bean Validation. Bài hôm nay đi qua cặp Pattern/Matcher, bộ cú pháp dùng hằng ngày, group để trích xuất, replaceAll/split với những bẫy của chúng, và ranh giới nơi regex nên dừng lại.

Pattern & Matcher — hai nửa của một phép khớp
import java.util.regex.Matcher;
import java.util.regex.Pattern;
// 1. Biên dịch MỘT LẦN — Pattern là bất biến, thread-safe, nên đặt static final
private static final Pattern MA_DON = Pattern.compile("DH-\\d{6}");
// 2. Mỗi lần dùng tạo một Matcher — Matcher CÓ trạng thái, KHÔNG chia sẻ giữa thread
Matcher m = MA_DON.matcher("Đơn DH-240815 và DH-240816 đã giao");
// Ba cách khớp — khác nhau ở phạm vi:
m.matches(); // false — CẢ chuỗi phải khớp mẫu
m.lookingAt(); // false — khớp từ ĐẦU chuỗi, không cần hết
while (m.find()) { // tìm lần lượt từng vị trí khớp
System.out.println(m.group() + " tại " + m.start() + "-" + m.end());
}
// DH-240815 tại 4-13
// DH-240816 tại 17-26
// Đường tắt: String.matches / replaceAll / split — tiện, nhưng
// BIÊN DỊCH LẠI mẫu ở mỗi lần gọi → tránh trong vòng lặp nóng
"DH-240815".matches("DH-\\d{6}"); // true — tương đương Pattern.matches(...)Java tách quy trình thành hai bước có chủ đích:
Pattern.compilebiên dịch chuỗi mẫu thành máy trạng thái (đắt, làm một lần), cònmatcher()tạo đối tượng chạy máy đó trên một chuỗi cụ thể (rẻ, tạo mỗi lần).Patternbất biến và thread-safe nên xứng đáng làstatic final(Ngày 18);Matchergiữ con trỏ vị trí nên không bao giờ chia sẻ giữa các luồng.Ba động từ khớp dễ nhầm:
matches()đòi toàn bộ chuỗi khớp — dùng cho validate;find()tìm lần lượt từng vị trí khớp — dùng cho trích xuất;lookingAt()khớp từ đầu nhưng không cần đến cuối — ít gặp. Lỗi "regex đúng mà không khớp" đa phần là gọimatcheskhi ý làfind.Các đường tắt
String.matches/replaceAll/splitbiên dịch lại mẫu mỗi lần gọi — vô hại ở một chỗ, tốn kém trong vòng lặp xử lý triệu dòng log; quy tắc: viết một lần thì đường tắt, chạy lặp thìPatternbiên dịch sẵn.
Cú pháp dùng hằng ngày
// Lớp ký tự
\d chữ số [0-9] \w chữ/số/_ \s khoảng trắng
\D \W \S — phủ định [aeiou] [^0-9] [a-zA-Z]
// Lượng từ (quantifier)
a? 0 hoặc 1 a* 0 trở lên a+ 1 trở lên
a{3} đúng 3 a{2,5} từ 2–5 a{2,} từ 2 trở lên
// Neo (anchor) & ranh giới
^ đầu chuỗi $ cuối chuỗi \b ranh giới từ
// Tham lam vs lười — bẫy kinh điển khi cắt thẻ HTML:
"<b>Java</b> và <i>Regex</i>"
.replaceAll("<.+>", "") // "" — .+ THAM LAM nuốt hết tới > cuối cùng
.replaceAll("<.+?>", ""); // "Java và Regex" — .+? LƯỜI dừng ở > gần nhất
// Escape hai lớp: regex muốn \d, chuỗi Java phải viết "\\d"
Pattern.compile("\\d+"); // khớp chuỗi số
Pattern.compile("a.b"); // . = MỌI ký tự — "a.b" khớp cả "axb"
Pattern.compile("a\\.b"); // dấu chấm thật
Pattern.compile(Pattern.quote("1+1=2")); // khớp NGUYÊN VĂN — không escape tay
// Text block (Ngày 9) + cờ COMMENTS: regex dài mà vẫn đọc được
Pattern EMAIL = Pattern.compile("""
[\\w.+-]+ # phần tên
@
[\\w-]+ # tên miền
(\\.[\\w-]+)+ # ít nhất một đuôi .xx
""", Pattern.COMMENTS);Ba nhóm ký hiệu gánh 90% nhu cầu: lớp ký tự (
\d \w \s [a-z]) nói khớp ký tự gì, lượng từ (? * + {n,m}) nói bao nhiêu lần, neo (^ $ \b) nói ở đâu. Ghép ba nhóm này đọc được gần như mọi regex ngoài đời.Lượng từ mặc định tham lam — nuốt nhiều nhất có thể rồi mới nhả dần; thêm
?thành lười dừng sớm nhất. Ví dụ cắt thẻ HTML trong code là bài học kinh điển:<.+>xóa sạch cả câu,<.+?>mới đúng ý.Regex sống trong chuỗi Java nên escape hai lớp: regex muốn
\d, chuỗi phải viết"\\d". Hai cứu tinh:Pattern.quotekhi cần khớp nguyên văn một chuỗi đầy ký tự đặc biệt, và text block + cờCOMMENTSđể regex dài có chú thích, xuống dòng — đồng đội review được.
Group — trích xuất dữ liệu từ mẫu
// Group = ngoặc tròn — vừa gom cụm, vừa TRÍCH XUẤT dữ liệu
Pattern NGAY = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
Matcher m = NGAY.matcher("Giao ngày 2026-08-24");
if (m.find()) {
m.group(0); // "2026-08-24" — toàn bộ phần khớp (= group())
m.group(1); // "2026"
m.group(3); // "24"
}
// Group có tên — tự tài liệu hóa, không đếm ngoặc bằng tay
Pattern NGAY_TEN = Pattern.compile("(?<nam>\\d{4})-(?<thang>\\d{2})-(?<ngay>\\d{2})");
Matcher n = NGAY_TEN.matcher("2026-08-24");
if (n.matches()) {
int nam = Integer.parseInt(n.group("nam")); // 2026
n.namedGroups(); // {nam=1, thang=2, ngay=3} — Java 20+
}
// (?:...) — gom cụm nhưng KHÔNG đánh số: nhẹ hơn khi chỉ cần lượng từ
Pattern.compile("(?:ha)+"); // "hahaha" — không tạo group 1
// Backreference — "cùng một thứ lặp lại": \1 tham chiếu group 1
Pattern.compile("(\\w+) \\1").matcher("lỗi lỗi thường gặp").find(); // true — "lỗi lỗi"
// Stream mọi kết quả — MatchResult là "ảnh chụp" bất biến của mỗi lần khớp
List<String> maDon = Pattern.compile("DH-\\d{6}")
.matcher("DH-000001, DH-000002, DH-000003")
.results() // Stream<MatchResult> — Java 9+
.map(MatchResult::group)
.toList(); // [DH-000001, DH-000002, DH-000003]Ngoặc tròn làm hai việc: gom cụm để áp lượng từ cho cả cụm, và chụp (capture) phần khớp để lấy ra bằng
group(n)— đánh số theo thứ tự ngoặc mở từ trái sang,group(0)luôn là toàn bộ phần khớp.Group có tên
(?<nam>...)là phiên bản nên dùng: thêm một ngoặc ở giữa không làm lệch mọi chỉ số phía sau, vàgroup("nam")tự nói lên ý nghĩa — cùng triết lý với tên biến rõ ràng. Java 20 thêmnamedGroups()để liệt kê;(?:...)khi chỉ cần gom mà không cần chụp.results()trảStream<MatchResult>— cách hiện đại để duyệt mọi lần khớp thay cho vòngwhile (find());MatchResultlà ảnh chụp bất biến của một lần khớp nên an toàn để chuyển tiếp, khác vớiMatcherthay đổi liên tục.
replaceAll & split — hai đường tắt hay dùng, nhiều bẫy
// replaceAll với tham chiếu group: $1 hoặc ${tên}
"2026-08-24".replaceAll("(\\d{4})-(\\d{2})-(\\d{2})", "$3/$2/$1"); // 24/08/2026
"2026-08-24".replaceAll("(?<n>\\d{4})-(?<t>\\d{2})-(?<d>\\d{2})", "${d}/${t}/${n}");
// BẪY: chuỗi thay thế cũng có cú pháp riêng — $ và \ là ký tự đặc biệt
"gia".replaceAll("gia", "100$"); // IllegalArgumentException: Illegal group reference
"gia".replaceAll("gia", Matcher.quoteReplacement("100$")); // "100$" — an toàn
// Không cần regex? replace() (không có All) tìm NGUYÊN VĂN và thay TẤT CẢ:
"a.b.c".replace(".", "-"); // "a-b-c" — không cần escape gì cả
// Thay thế bằng LAMBDA — logic tùy ý trên mỗi lần khớp (Java 9+)
String ketQua = Pattern.compile("\\d+")
.matcher("Giá 100 và 250")
.replaceAll(mr -> String.valueOf(Integer.parseInt(mr.group()) * 2));
// "Giá 200 và 500"
// split — tham số là REGEX, không phải chuỗi thường
"a.b.c".split("."); // [] — . khớp mọi ký tự → không còn gì!
"a.b.c".split("\\."); // [a, b, c]
"a|b|c".split(Pattern.quote("|")); // [a, b, c] — | là toán tử "hoặc"
"a,b,,".split(","); // [a, b] — phần tử rỗng CUỐI bị bỏ
"a,b,,".split(",", -1); // [a, b, , ] — giữ lại tất cả
// Tách theo mẫu và giữ phần khớp? — dùng find() thay vì split khi cần cả hai phíaChuỗi thay thế có cú pháp riêng:
$1/${nam}tham chiếu group — nên$và\trong dữ liệu thật phải quaMatcher.quoteReplacement, nếu không sẽ nổIllegalArgumentExceptionngay khi giá tiền có dấu đô la. Không cần mẫu thì dùngreplace()— nguyên văn, và vẫn thay tất cả lần xuất hiện (tên gây hiểu lầm).replaceAll(Function<MatchResult, String>)(Java 9) là công cụ bị bỏ quên: thay thế bằng logic tùy ý — nhân đôi số, tra bảng, viết hoa — thay vì chỉ chuỗi cố định; loại bỏ hẳn kiểu vòng lặpfind+appendReplacementdài dòng.splitnhận regex:split(".")trả mảng rỗng vàsplit("|")tách từng ký tự — hai lỗi kinh điển của người mới; và mặc định nó bỏ phần tử rỗng cuối, nên đọc CSV có cột trống cuối phải truyềnlimit = -1.
Bẫy & ranh giới của regex
// 1. Catastrophic backtracking — regex "treo" CPU với input xấu
Pattern XAU = Pattern.compile("(a+)+b");
XAU.matcher("aaaaaaaaaaaaaaaaaaaaaaaaaaaa!").matches(); // 2^28 cách thử → hàng phút
// Lượng từ lồng lượng từ trên cùng lớp ký tự = ReDoS. Viết lại: "a+b"
// Với input từ người dùng: giới hạn độ dài TRƯỚC khi đưa vào regex.
// 2. Unicode — \w, \d, \b mặc định chỉ hiểu ASCII
Pattern.compile("\\w+").matcher("Việt").matches(); // false — ệ không phải \w!
Pattern.compile("\\w+", Pattern.UNICODE_CHARACTER_CLASS)
.matcher("Việt").matches(); // true
Pattern.compile("(?U)\\w+"); // cùng ý, cờ nội tuyến
// So khớp không phân biệt hoa/thường với tiếng Việt:
Pattern.compile("việt", Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE);
// 3. Nhiều dòng — ^ và $ mặc định là ĐẦU/CUỐI CẢ chuỗi
Pattern.compile("^ERROR", Pattern.MULTILINE); // khớp đầu MỖI dòng
Pattern.compile("a.b", Pattern.DOTALL); // . khớp cả xuống dòng
// 4. Regex không phải búa vạn năng
// - Email: [^@\s]+@[^@\s]+ là đủ — chuẩn RFC 5322 dài 6 nghìn ký tự, không ai cần
// - HTML/JSON/XML: cấu trúc lồng nhau → parser, không regex
// - Ngày tháng: khớp dạng bằng regex, KIỂM TRA giá trị bằng LocalDate.parse (Ngày 26)Catastrophic backtracking là lỗi bảo mật thật (ReDoS): lượng từ lồng lượng từ như
(a+)+tạo số cách thử tăng theo lũy thừa khi không khớp — một request chứa 30 ký tự có thể treo một luồng vài phút. Phòng thủ: viết lại mẫu không lồng, và giới hạn độ dài input trước khi khớp.Mặc định
\w/\d/\bchỉ hiểu ASCII —"Việt"không khớp\w+; bậtUNICODE_CHARACTER_CLASS(hoặc(?U)) khi xử lý tiếng Việt, vàUNICODE_CASEđi kèmCASE_INSENSITIVE.Biết khi nào không dùng regex: cấu trúc lồng nhau (HTML, JSON) cần parser thật; kiểm tra giá trị (ngày 31/02, email tồn tại) cần logic thật — regex chỉ kiểm tra hình dạng. Quy tắc thực dụng: regex đơn giản + validate bằng code, thay vì một regex "hoàn hảo" không ai bảo trì nổi.
Bài tập nhỏ
Viết
Patterncho số điện thoại Việt Nam dạng0xxxxxxxxxhoặc+84xxxxxxxxx— dùng group có tên tách đầu số và phần còn lại, rồi chuẩn hóa mọi số về dạng+84bằngreplaceAll.Từ một dòng log
2026-08-24 18:10:05 ERROR [order-service] Timeout after 3000mstrích thời gian, mức, tên service và thông điệp bằng một regex với text block +COMMENTS.Dùng
results()đếm số lần mỗi từ xuất hiện trong một đoạn tiếng Việt — cần cờ gì để\w+nhận "học" là một từ?Tái hiện catastrophic backtracking với
(a+)+bvà đo thời gian theo độ dài input 20, 25, 30 — rồi viết lại mẫu để chạy tức thì.Chạy
"a,b,,".split(",")và với-1, giải thích khác biệt; rồi tách chuỗi"1+1=2"theo dấu+mà không escape tay.
Kết luận
Bốn ý mang về: Pattern biên dịch một lần và thread-safe, Matcher có trạng thái và dùng riêng — phân biệt matches (toàn bộ) với find (tìm); cú pháp gói trong ba nhóm lớp ký tự – lượng từ – neo, nhớ lượng từ mặc định tham lam; group có tên để trích xuất và results() để duyệt; replaceAll/split nhận regex nên quote/quoteReplacement là bạn — và luôn đề phòng backtracking lũy thừa lẫn ASCII mặc định. Ngày 29 ta thiết kế lớp bất biến đúng chuẩn: immutable & defensive copy — vì sao final chưa đủ, List.copyOf và món quà thread-safe miễn phí. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


