Biểu thức chính quy trong Rust với regex
Một biểu thức chính quy (regular expression, gọi tắt là regex) là một ngôn ngữ mẫu nhỏ xíu dành cho văn bản. Thay vì nói “tìm chữ cat”, bạn có thể nói “tìm bốn chữ số, một gạch ngang, hai chữ số, một gạch ngang, hai chữ số”, và khớp được mọi ngày tháng như 2024-05-17 cùng lúc.
Thư viện chuẩn của Rust có những phương thức chuỗi tiện dụng như contains, split và find (xem Chuỗi). Khi một đoạn văn bản cố định là không đủ, và bạn cần một hình dạng văn bản, hãy dùng crate regex (tiếng Anh). Nó được dự án Rust duy trì và chạy rất nhanh.
Thêm regex và lần khớp đầu tiên
Phần tiêu đề “Thêm regex và lần khớp đầu tiên”Trong thư mục dự án, chạy:
cargo add regexLệnh đó thêm regex = "1" vào phần [dependencies] trong Cargo.toml của bạn. (Mới làm quen với crate? Xem Cargo và crate.) Các nút “Try it” mở Rust Playground, nơi đã có sẵn regex.
use regex::Regex;
fn main() { let re = Regex::new(r"\d{4}-\d{2}-\d{2}").unwrap();
println!("{}", re.is_match("Phát hành ngày 2024-05-17.")); println!("{}", re.is_match("Phát hành vào mùa xuân năm ngoái."));}Kết quả:
truefalse\d nghĩa là “một chữ số bất kỳ” và {4} nghĩa là “đúng bốn lần thứ đứng trước”. is_match trả lời một câu hỏi: có chỗ nào khớp ở bất kỳ đâu trong văn bản không?
Hãy để ý chữ r trước mẫu: r"\d{4}". Đó là một chuỗi thô (raw string). Trong một chuỗi Rust bình thường, \ mở đầu một chuỗi thoát (escape) như \n (xuống dòng). Mẫu regex thì đầy dấu gạch chéo ngược, nên chuỗi thô giúp bạn khỏi phải viết "\\d{4}". Hãy luôn dùng chuỗi thô cho mẫu regex.
Vì sao Regex::new trả về Result
Phần tiêu đề “Vì sao Regex::new trả về Result”Một mẫu là một chương trình nhỏ, và nó có thể có lỗi, chẳng hạn quên đóng ngoặc. Regex::new kiểm tra mẫu và trả về một Result: Ok(Regex) nếu mẫu hợp lệ, Err kèm một thông báo hữu ích nếu không:
use regex::Regex;
fn main() { match Regex::new(r"(\d+") { Ok(_) => println!("mẫu hợp lệ"), Err(e) => println!("mẫu không hợp lệ:\n{e}"), }}Kết quả:
mẫu không hợp lệ:regex parse error: (\d+ ^error: unclosed groupThông báo lỗi của crate (bằng tiếng Anh) nói rằng có một nhóm chưa được đóng ngoặc. Khi mẫu được viết sẵn trong code và bạn biết nó đúng, .unwrap() là ổn: nếu sai, bạn sẽ phát hiện ngay lần đầu chương trình chạy. Khi mẫu đến từ người dùng (ví dụ một ô tìm kiếm), hãy xử lý Err thay vì để chương trình crash.
Tìm chỗ khớp: find và find_iter
Phần tiêu đề “Tìm chỗ khớp: find và find_iter”find trả về chỗ khớp đầu tiên dưới dạng một Option. find_iter cho bạn một iterator đi qua mọi chỗ khớp:
use regex::Regex;
fn main() { let re = Regex::new(r"\d+").unwrap(); let text = "3 quả táo, 12 quả lê và 7 quả mận";
if let Some(m) = re.find(text) { println!("số đầu tiên: {} (ở byte {}..{})", m.as_str(), m.start(), m.end()); }
let all: Vec<&str> = re.find_iter(text).map(|m| m.as_str()).collect(); println!("mọi con số: {:?}", all);
let total: u32 = re.find_iter(text).map(|m| m.as_str().parse::<u32>().unwrap()).sum(); println!("tổng số quả: {total}");}Kết quả:
số đầu tiên: 3 (ở byte 0..1)mọi con số: ["3", "12", "7"]tổng số quả: 22\d+ nghĩa là “một hoặc nhiều chữ số”, nên 12 được tìm thấy như một chỗ khớp, không phải hai. Mỗi chỗ khớp biết văn bản của nó (as_str()) và vị trí của nó (start() và end(), tính bằng byte).
Nhóm bắt và nhóm có tên
Phần tiêu đề “Nhóm bắt và nhóm có tên”Thường thì bạn không chỉ muốn chỗ khớp, mà muốn từng phần của nó. Hãy bọc một phần của mẫu trong dấu ngoặc tròn để bắt (capture) nó. Đặt tên cho nhóm bằng (?<name>...) để code dễ đọc hơn:
use regex::Regex;
fn main() { let re = Regex::new(r"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})").unwrap(); let text = "Bản sao lưu chạy ngày 2024-05-17 và 2024-06-02.";
if let Some(caps) = re.captures(text) { println!("toàn bộ chỗ khớp: {}", &caps[0]); println!("năm: {}", &caps["year"]); println!("tháng: {}", &caps[2]); println!("ngày: {}", caps.name("day").unwrap().as_str()); }
for caps in re.captures_iter(text) { println!("{}/{}/{}", &caps["day"], &caps["month"], &caps["year"]); }}Kết quả:
toàn bộ chỗ khớp: 2024-05-17năm: 2024tháng: 05ngày: 1717/05/202402/06/2024Ba cách đọc một nhóm:
caps[0]luôn là toàn bộ chỗ khớp. Các nhóm được đánh số từ 1, từ trái sang phải:caps[2]là tháng.caps["year"]đọc một nhóm theo tên. Truy cập bằng[...]sẽ panic nếu nhóm đó không tham gia vào chỗ khớp.caps.name("day")trả về mộtOption, là lựa chọn an toàn khi một nhóm là tùy chọn.
captures tìm chỗ khớp đầu tiên. captures_iter đi qua mọi chỗ khớp, giống như find_iter.
Thay thế văn bản
Phần tiêu đề “Thay thế văn bản”replace thay chỗ khớp đầu tiên, còn replace_all thay mọi chỗ khớp. Trong văn bản thay thế, $name (hoặc $1) chèn vào những gì một nhóm đã bắt được:
use regex::Regex;
fn main() { let spaces = Regex::new(r"\s+").unwrap(); println!("{}", spaces.replace_all("quá nhiều khoảng trắng", " "));
let date = Regex::new(r"(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})").unwrap(); let text = "Từ 2024-05-17 đến 2024-06-02"; println!("{}", date.replace(text, "$d/$m/$y")); println!("{}", date.replace_all(text, "$d/$m/$y")); println!("{}", date.replace_all(text, "$m_$y")); println!("{}", date.replace_all(text, "${m}_${y}"));}Kết quả:
quá nhiều khoảng trắngTừ 17/05/2024 đến 2024-06-02Từ 17/05/2024 đến 02/06/2024Từ 2024 đến 2024Từ 05_2024 đến 06_2024Hãy nhìn dòng thứ tư. Nó in ra Từ 2024 đến 2024, chứ không phải 05_2024. Vì sao? Tên nhóm có thể chứa _, nên $m_ được hiểu là một nhóm tên m_. Không có nhóm nào như vậy, nên nó thành văn bản rỗng. Hãy đặt tên trong dấu ngoặc nhọn, ${m}_, để nói rõ tên kết thúc ở đâu. Khi phân vân, hãy dùng ngoặc nhọn.
\s+ nghĩa là “một hoặc nhiều ký tự khoảng trắng”, nên thay nó bằng một dấu cách là cách kinh điển để dọn dẹp dữ liệu nhập lộn xộn.
Tách văn bản
Phần tiêu đề “Tách văn bản”split cắt một chuỗi ở bất cứ chỗ nào mẫu khớp. Nó giống str::split, nhưng dấu phân cách có thể là một mẫu:
use regex::Regex;
fn main() { let sep = Regex::new(r"\s*[,;]\s*").unwrap(); let parts: Vec<&str> = sep.split("táo, chuối;anh đào , chà là").collect(); println!("{:?}", parts);
let two: Vec<&str> = sep.splitn("a, b, c, d", 2).collect(); println!("{:?}", two);}Kết quả:
["táo", "chuối", "anh đào", "chà là"]["a", "b, c, d"][,;] nghĩa là “một dấu phẩy hoặc một dấu chấm phẩy”, và \s* ở hai bên nuốt mọi dấu cách quanh nó. splitn(text, 2) dừng lại sau hai phần.
Biên dịch một lần, đừng biên dịch trong vòng lặp
Phần tiêu đề “Biên dịch một lần, đừng biên dịch trong vòng lặp”Tạo một Regex là phần chậm: crate biến mẫu của bạn thành một bộ khớp hiệu quả. Còn dùng nó thì nhanh. Vì vậy đừng bao giờ gọi Regex::new bên trong vòng lặp hay trong một hàm chạy thường xuyên. Hãy tạo nó một lần và dùng lại.
Với một mẫu được dùng khắp chương trình, hãy đặt nó vào một static với std::sync::LazyLock. Nó được tạo ở lần đầu bạn dùng, rồi được dùng chung:
use regex::Regex;use std::sync::LazyLock;
static EMAIL: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"(?i)^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$").unwrap()});
fn is_email(s: &str) -> bool { EMAIL.is_match(s)}
fn main() { println!("{input:22} -> {}", is_email(input)); }}Kết quả:
[email protected] -> true[email protected] -> truenot-an-email -> falsex@y -> falseNhững mảnh mới trong mẫu này:
(?i)bật chế độ khớp không phân biệt hoa thường, nên[a-z]cũng khớpBvàM.^và$nghĩa là “đầu văn bản” và “cuối văn bản”. Không có chúng,is_matchsẽ trả lời “có” với"hello [email protected]!!", vì địa chỉ email xuất hiện đâu đó trong đó.\.là một dấu chấm thật. Dấu.trơn nghĩa là “bất kỳ ký tự nào”.
Đây là phép kiểm tra “trông giống email”, đủ tốt để bắt lỗi gõ nhầm. Kiểm tra thật sự mọi địa chỉ email hợp lệ bằng regex nổi tiếng là khó, nên phép thử thật duy nhất là gửi một email tới địa chỉ đó.
Ví dụ: lấy dữ liệu từ các dòng log
Phần tiêu đề “Ví dụ: lấy dữ liệu từ các dòng log”Hãy kết hợp mọi thứ lại. Ta đọc một file log, bỏ qua những dòng không đúng định dạng, và với mỗi dòng ERROR thì in ra ngày, giờ và mọi địa chỉ email trong thông điệp:
use regex::Regex;use std::sync::LazyLock;
static LOG_LINE: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"^(?<date>\d{4}-\d{2}-\d{2}) (?<time>\d{2}:\d{2}:\d{2}) (?<level>[A-Z]+) +(?<msg>.*)$").unwrap()});
static EMAIL: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}").unwrap()});
fn main() { let log = "\2024-05-17 09:12:03 INFO user [email protected] logged in2024-05-17 09:15:44 ERROR payment failed for [email protected]dòng này là rác2024-05-18 11:02:10 WARN disk 91% full
let mut errors = 0; for line in log.lines() { let Some(caps) = LOG_LINE.captures(line) else { println!("bỏ qua: {line:?}"); continue; }; if &caps["level"] == "ERROR" { errors += 1; let emails: Vec<&str> = EMAIL.find_iter(&caps["msg"]).map(|m| m.as_str()).collect(); println!("{} {} -> {:?}", &caps["date"], &caps["time"], emails); } } println!("tìm thấy {errors} lỗi");}Kết quả:
2024-05-17 09:15:44 -> ["[email protected]"]bỏ qua: "dòng này là rác"2024-05-18 11:05:00 -> ["[email protected]", "[email protected]"]tìm thấy 2 lỗiVài ghi chú:
"\ở đầuloglà một mẹo của Rust: dấu gạch chéo ngược ở cuối dòng trong một chuỗi sẽ bỏ qua ký tự xuống dòng, nên dòng log đầu tiên bắt đầu ngay lập tức.let Some(caps) = ... else { ... }là let-else: nếu dòng không khớp, ta in nó ra vàcontinuesang dòng tiếp theo.(?<level>[A-Z]+) +khớp với mức log rồi một hoặc nhiều dấu cách, vìINFOvàWARNđược đệm thêm một dấu cách để thẳng hàng vớiERROR.- Trong một chương trình thật, bạn sẽ đọc log từ file, từng dòng một. Xem Đọc ghi file.
Những gì regex (cố ý) không làm
Phần tiêu đề “Những gì regex (cố ý) không làm”Nếu bạn đã dùng regex trong JavaScript, Python hay Perl, có thể bạn sẽ tìm tới look-around ((?=...), (?<=...)) hoặc backreference (\1, “cùng văn bản với nhóm 1”). Crate regex không hỗ trợ chúng, và nói rõ cho bạn biết:
regex parse error: foo(?=bar) ^^^error: look-around, including look-ahead and look-behind, is not supportedThông báo nói rằng look-around, gồm cả look-ahead và look-behind, không được hỗ trợ. Đây là một sự đánh đổi có chủ đích. Những tính năng đó có thể khiến một số mẫu tốn thời gian theo cấp số mũ với một số đầu vào nhất định, nên một mẫu ngắn và một chuỗi đủ dài có thể làm đơ cả chương trình (hiện tượng này gọi là catastrophic backtracking, và nó từng làm sập những trang web thật). Không có chúng, crate regex có thể đảm bảo thời gian khớp chỉ tăng tỷ lệ thuận với độ dài văn bản (nhân với kích thước của mẫu). Văn bản dài gấp đôi thì việc cũng gần gấp đôi, không bao giờ bùng nổ. Điều đó giúp nó an toàn ngay cả khi mẫu hoặc văn bản đến từ người dùng.
Thường thì bạn có thể đạt cùng kết quả theo cách khác: dùng một nhóm bắt và chỉ đọc phần bạn cần, hoặc kiểm tra thêm một bước đơn giản bằng code Rust thường. Nếu bạn thật sự cần các tính năng này, crate fancy-regex (tiếng Anh) bổ sung chúng, nhưng không có đảm bảo về thời gian.
Bảng tra nhanh
Phần tiêu đề “Bảng tra nhanh”| Mẫu | Khớp với |
|---|---|
. |
bất kỳ ký tự nào trừ ký tự xuống dòng |
\d \w \s |
một chữ số, một ký tự “từ” (chữ cái, chữ số, _), khoảng trắng |
\D \W \S |
ngược lại: không phải chữ số, không phải ký tự từ, không phải khoảng trắng |
[abc] [a-z] [^0-9] |
một trong a, b, c; bất kỳ chữ thường nào; bất cứ thứ gì trừ chữ số |
* + ? |
không hoặc nhiều, một hoặc nhiều, không hoặc một lần thứ đứng trước |
{3} {2,5} {2,} |
đúng 3, từ 2 đến 5, ít nhất 2 |
^ $ |
đầu và cuối văn bản |
\b |
ranh giới từ (giữa một ký tự từ và một ký tự không phải từ) |
(...) (?<name>...) |
một nhóm đánh số, một nhóm có tên |
(?i) |
không phân biệt hoa thường từ đây trở đi |
Thêm một cái nữa: dấu gạch đứng nghĩa là “hoặc”. Ví dụ, cat|dog khớp với một trong hai từ, và gr(a|e)y khớp với gray và grey. Để khớp đúng một ký tự đặc biệt, hãy đặt một dấu gạch chéo ngược phía trước: \., \(, \$.
Lỗi thường gặp
Phần tiêu đề “Lỗi thường gặp”Quên chuỗi thô. Trong một chuỗi bình thường, \d không phải chuỗi thoát hợp lệ, nên Rust dừng lại trước khi crate regex kịp nhìn thấy mẫu của bạn:
use regex::Regex;
fn main() { let re = Regex::new("\d+").unwrap(); println!("{}", re.is_match("id=22"));}error: unknown character escape: `d` --> src/main.rs:4:27 |4 | let re = Regex::new("\d+").unwrap(); | ^ unknown character escape |help: if you meant to write a literal backslash (perhaps escaping in a regular expression), consider a raw string literal |4 | let re = Regex::new(r"\d+").unwrap(); | +Trình biên dịch báo \d là chuỗi thoát không xác định, và còn gợi ý luôn cách sửa: thêm r phía trước.
Tạo regex bên trong vòng lặp. Code này biên dịch được và chạy được, nhưng chậm, vì mẫu được biên dịch lại cho mỗi dòng:
for line in lines { let re = Regex::new(r"\d+").unwrap(); // bị biên dịch đi biên dịch lại if re.is_match(line) { /* ... */ }}Hãy chuyển Regex::new lên trên vòng lặp, hoặc vào một static LazyLock như ở trên.
Quên ^ và $ khi kiểm tra hợp lệ. is_match tìm mẫu ở bất kỳ đâu. Để kiểm tra rằng toàn bộ đầu vào có một hình dạng nhất định, hãy neo mẫu ở cả hai đầu.
Tóm tắt
Phần tiêu đề “Tóm tắt”- Thêm crate bằng
cargo add regex, và viết mẫu dưới dạng chuỗi thô:r"\d+". Regex::newtrả về mộtResult, vì mẫu có thể không hợp lệ.is_matchtrả lời có hay không;find/find_itercho văn bản khớp;captures/captures_itercho các nhóm theo số hoặc theo(?<name>...).replacevàreplace_allcó thể chèn nhóm bằng$name; dùng${name}khi phía sau là chữ cái hoặc_.- Biên dịch mẫu một lần rồi dùng lại, ví dụ trong một
staticLazyLock. - Không có look-around hay backreference, theo thiết kế: đổi lại, việc khớp luôn có thời gian tuyến tính.
Kiểm tra nhanh
Phần tiêu đề “Kiểm tra nhanh”Kiểm tra nhanh
1. Vì sao mẫu regex trong Rust thường bắt đầu bằng r, như trong r"\d+"?
2. Với mẫu (?<y>\d{4})-(?<m>\d{2}), caps["m"] cho ra gì với văn bản 2024-05?
3. Vì sao crate regex không hỗ trợ look-around và backreference?
4. Đâu là chỗ tốt nhất để gọi Regex::new cho một mẫu bạn dùng trên mỗi dòng của một file lớn?
Điểm: 0 / 4
Đọc thêm
Phần tiêu đề “Đọc thêm”- Tài liệu crate
regex— docs.rs (tiếng Anh): hướng dẫn chính thức, với nhiều ví dụ và bảng tham khảo cú pháp đầy đủ. - API
Regex— docs.rs (tiếng Anh): mọi phương thức, kể cảcaptures_iter,replacenvàsplit. LazyLock— tài liệu thư viện chuẩn (tiếng Anh): cách các static khởi tạo lười (lazily-initialized) hoạt động.- Regular Expressions — Rust Cookbook (tiếng Anh): các công thức ngắn để trích xuất hashtag, số điện thoại, email và nhiều thứ khác.