Bỏ qua để đến nội dung

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.

Trong thư mục dự án, chạy:

Terminal window
cargo add regex

Lệ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ả:

true
false
▶ Tự chạy thử

\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.

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 group
▶ Tự chạy thử

Thô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.

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
▶ Tự chạy thử

\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).

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-17
năm: 2024
tháng: 05
ngày: 17
17/05/2024
02/06/2024
▶ Tự chạy thử

Ba 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ột Option, 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.

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ắng
Từ 17/05/2024 đến 2024-06-02
Từ 17/05/2024 đến 02/06/2024
Từ 2024 đến 2024
Từ 05_2024 đến 06_2024
▶ Tự chạy thử

Hã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.

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"]
▶ Tự chạy thử

[,;] 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() {
for input in ["[email protected]", "[email protected]", "not-an-email", "x@y"] {
println!("{input:22} -> {}", is_email(input));
}
}

Kết quả:

not-an-email -> false
x@y -> false
▶ Tự chạy thử

Nhữ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ớp B và M.
  • ^ và $ nghĩa là “đầu văn bản” và “cuối văn bản”. Không có chúng, is_match sẽ 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ỉ đó.

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 in
2024-05-17 09:15:44 ERROR payment failed for [email protected]
dòng này là rác
2024-05-18 11:02:10 WARN disk 91% full
2024-05-18 11:05:00 ERROR could not email [email protected] or [email protected]";
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ỗi
▶ Tự chạy thử

Vài ghi chú:

  • "\ ở đầu log là 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à continue sang 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ì INFO và WARN được đệm thêm một dấu cách để thẳng hàng với ERROR.
  • 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.

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 supported

Thô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.

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: \., \(, \$.

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.

  • Thêm crate bằng cargo add regex, và viết mẫu dưới dạng chuỗi thô: r"\d+".
  • Regex::new trả về một Result, vì mẫu có thể không hợp lệ.
  • is_match trả lời có hay không; find/find_iter cho văn bản khớp; captures/captures_iter cho các nhóm theo số hoặc theo (?<name>...).
  • replace và replace_all có 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 static LazyLock.
  • 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

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