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

Đọc ghi file trong Rust

Đọc và ghi file là một trong những chỗ đầu tiên mà mọi thứ bạn đã học về Result và toán tử ? thôi còn là lý thuyết — hầu như mọi thao tác với file đều có thể thất bại (file có thể không tồn tại, bạn có thể không có quyền), nên thư viện chuẩn buộc bạn phải xử lý chuyện đó một cách trực tiếp.

Cách đơn giản nhất để đọc nội dung một file vào một String:

use std::fs;
fn main() {
let contents = fs::read_to_string("hello.txt");
match contents {
Ok(text) => println!("{text}"),
Err(e) => println!("Couldn't read file: {e}"),
}
}

fs::read_to_string trả về một Result<String, std::io::Error> — Ok kèm văn bản nếu thành công, Err kèm lý do nếu thất bại (thiếu file, không có quyền, v.v.).

match trên mọi thao tác file sẽ nhanh chóng trở nên lặp đi lặp lại. Nếu bạn đang ở trong một hàm cũng trả về Result, toán tử ? (trong bài Result) giúp code gọn đi rất nhiều:

use std::fs;
use std::io;
fn read_file(path: &str) -> Result<String, io::Error> {
let contents = fs::read_to_string(path)?; // trả về sớm nếu thất bại
Ok(contents)
}
fn main() {
match read_file("hello.txt") {
Ok(text) => println!("{text}"),
Err(e) => println!("Error: {e}"),
}
}
use std::fs;
fn main() {
let result = fs::write("output.txt", "Hello, file!");
match result {
Ok(()) => println!("Wrote successfully"),
Err(e) => println!("Failed to write: {e}"),
}
}

fs::write tạo file nếu nó chưa tồn tại, và ghi đè hoàn toàn nếu nó đã có — hãy nhớ điều đó nếu bạn cần thêm vào file thay vì thay thế nó.

Để thêm vào một file đã có thay vì ghi đè nó, hãy dùng OpenOptions:

use std::fs::OpenOptions;
use std::io::Write;
fn main() -> std::io::Result<()> {
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open("log.txt")?;
writeln!(file, "A new log line")?;
Ok(())
}

Để ý rằng ở đây chính main trả về Result — đó là một mẫu đáng biết: khi main trả về Result, bạn có thể dùng ? ngay bên trong nó thay vì bọc mọi thứ trong match.

Với file lớn, đọc toàn bộ vào bộ nhớ một lần không phải lúc nào cũng lý tưởng. Thay vào đó, hãy đọc từng dòng:

use std::fs::File;
use std::io::{BufRead, BufReader};
fn main() -> std::io::Result<()> {
let file = File::open("hello.txt")?;
let reader = BufReader::new(file);
for line in reader.lines() {
println!("{}", line?);
}
Ok(())
}

Đến giờ, mọi lỗi đều được xử lý theo cùng một cách: in ra. Nhưng thường bạn muốn phản ứng khác nhau tùy theo điều gì đã sai. Thiếu file thường không sao (có thể đây là lần chạy đầu tiên, và bạn chỉ cần bắt đầu với dữ liệu rỗng). “Permission denied” (không có quyền) thì là vấn đề thật mà bạn nên báo lại.

Một io::Error cho bạn biết loại lỗi của nó qua .kind(), phương thức này trả về một enum ErrorKind (tiếng Anh) mà bạn có thể match:

use std::fs;
use std::io::ErrorKind;
fn main() {
// một file không tồn tại trong thư mục tạm của hệ thống
let path = std::env::temp_dir().join("rusttutorial-missing-notes.txt");
match fs::read_to_string(&path) {
Ok(text) => println!("Đã nạp {} dòng", text.lines().count()),
Err(e) => match e.kind() {
ErrorKind::NotFound => println!("Chưa có ghi chú nào, bắt đầu từ đầu"),
ErrorKind::PermissionDenied => println!("Không được phép đọc file ghi chú"),
_ => println!("Có lỗi khác xảy ra: {e}"),
},
}
}

Kết quả:

Chưa có ghi chú nào, bắt đầu từ đầu
▶ Tự chạy thử

std::env::temp_dir() cho bạn đường dẫn của thư mục tạm của hệ thống (như /tmp trên Linux), và .join(...) thêm một tên file vào đó. Các hàm làm việc với file nhận đường dẫn kiểu này cũng vui vẻ như nhận một &str.

Luôn giữ một nhánh _ ở cuối: ErrorKind có rất nhiều biến thể, và theo thời gian còn được thêm mới. Nhánh _ bắt mọi thứ bạn chưa liệt kê.

Cả hai bài tập đều tự ghi file của mình vào thư mục tạm trước, nên chúng chạy được ở bất cứ đâu, kể cả Playground.

Ghi ba việc cần làm vào một file bằng fs::write, mỗi việc một dòng. Sau đó đọc lại file bằng fs::read_to_string và in từng dòng kèm số thứ tự phía trước, tiếp theo là tổng số việc.

Gợi ý: đặt \n giữa các việc để tách thành từng dòng. text.lines() cho bạn lần lượt từng dòng. Cho main trả về std::io::Result<()> để bạn dùng được ?.

Xem lời giải
use std::fs;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-todo.txt");
fs::write(&path, "học ownership\nviết một module\nđọc một file\n")?;
let text = fs::read_to_string(&path)?;
let mut count = 0;
for line in text.lines() {
count += 1;
println!("{count}. {line}");
}
println!("{count} việc");
Ok(())
}

Kết quả:

1. học ownership
2. viết một module
3. đọc một file
3 việc
▶ Tự chạy thử

Vì fs::write thay thế cả file, bạn luôn nhận được đúng ba dòng đó dù chạy bao nhiêu lần. Mỗi ? trả lỗi về từ main nếu việc ghi hoặc đọc thất bại.

Bắt đầu một file nhật ký mới với một dòng tiêu đề, rồi ghi nối thêm hai dòng vào đó bằng OpenOptions và writeln!. Cuối cùng, đọc lại toàn bộ file và in ra.

Gợi ý: dùng fs::write cho dòng tiêu đề (để mỗi lần chạy đều bắt đầu sạch sẽ), rồi OpenOptions::new().append(true).open(&path)? cho các dòng thêm vào. print! (không phải println!) tránh một dòng trống thừa ở cuối, vì file đã kết thúc bằng một ký tự xuống dòng.

Xem lời giải
use std::fs::{self, OpenOptions};
use std::io::Write;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-log.txt");
fs::write(&path, "=== nhật ký ===\n")?; // bắt đầu mới
let mut file = OpenOptions::new().append(true).open(&path)?;
writeln!(file, "đã bắt đầu")?;
writeln!(file, "đã kết thúc")?;
print!("{}", fs::read_to_string(&path)?);
Ok(())
}

Kết quả:

=== nhật ký ===
đã bắt đầu
đã kết thúc
▶ Tự chạy thử

use std::fs::{self, OpenOptions}; đưa vào cả chính module fs lẫn OpenOptions. Nếu thiếu lệnh fs::write đầu tiên, mỗi lần chạy sẽ tiếp tục thêm vào file cũ, và nhật ký sẽ dài ra mãi. Đó đúng là điều bạn muốn với một nhật ký thật, nhưng không phải với một bài tập có kết quả cố định.

  • fs::read_to_string(path) đọc toàn bộ file vào một String, và trả về một Result.
  • fs::write(path, contents) ghi một file, ghi đè nếu file đã tồn tại.
  • OpenOptions::new().append(true) cho phép thêm vào file thay vì thay thế nó.
  • main() -> std::io::Result<()> cho phép dùng ? ngay trong main, thay vì match mọi lời gọi.
  • e.kind() cho bạn biết loại lỗi nào đã xảy ra, như ErrorKind::NotFound, để bạn xử lý từng loại khác nhau.
  • Các ví dụ dùng file trong thư mục dự án nên được thử trên máy; std::env::temp_dir() cho bạn một thư mục dùng được ở mọi nơi.

Kiểm tra nhanh

1. Điều gì xảy ra nếu bạn gọi fs::write với một đường dẫn đã tồn tại?

2. Làm sao để thêm vào file thay vì ghi đè nó?

3. Khi chính main trả về std::io::Result<()>, điều đó cho phép gì?

4. Toán tử ? làm gì khi fs::read_to_string(path)? thất bại?

5. Vì sao nên đọc file lớn từng dòng bằng BufReader thay vì fs::read_to_string?

Điểm: 0 / 5