Đọc và Ghi Tệp Tin
(Reading And Writing Files)

Trang này thảo luận chi tiết về việc đọc, ghi và mở các file. Có rất nhiều phương thức I/O file để lựa chọn. Để giúp bạn hiểu rõ API, sơ đồ sau sắp xếp các phương thức I/O file theo độ phức tạp.

Các phương pháp I/O tệp từ ít phức tạp đến phức tạp hơn - minh họa
Ảnh mô tả các phương pháp I/O tệp được sắp xếp từ ít phức tạp đến phức tạp hơn.

Các phương thức I/O file theo độ phức tạp

(1) Ít phức tạp nhất (bên trái):

↳ Phương thức static readAllBytes(Path path): Đọc toàn bộ byte của file.

↳ Phương thức static readAllLines(Path path): Đọc tất cả các dòng của file thành một danh sách.

↳ Phương thức static write(Path path, byte[] bytes, OpenOption... options): Ghi dữ liệu vào file.

↳ Phương thức static write(Path path, Iterable<? extends CharSequence> lines, Charset cs, OpenOption... options): Ghi một danh sách các dòng văn bản vào file với mã hóa ký tự đã chỉ định.

↳ Phương thức static write(Path path, Iterable<? extends CharSequence> lines, OpenOption... options): Ghi một danh sách các dòng văn bản vào file.

(2) Phức tạp hơn một chút:

↳ Phương thức static newBufferedReader(Path path): Tạo một đối tượng để đọc văn bản từ file theo từng dòng một cách hiệu quả.

↳ Phương thức static newBufferedReader(Path path, Charset cs): Mở một file để đọc với mã hóa ký tự đã chỉ định, trả về BufferedReader.

↳ Phương thức static newBufferedWriter(Path path, Charset cs, OpenOption... options): Mở hoặc tạo một file để ghi với mã hóa ký tự đã chỉ định, trả về BufferedWriter.

↳ Phương thức static newBufferedWriter(Path path, OpenOption... options): Mở hoặc tạo một file để ghi, trả về BufferedWriter.

↳ Phương thức static newInputStream(Path path, OpenOption... options): Mở một luồng đầu vào để đọc byte từ file.

↳ Phương thức static newOutputStream(Path path, OpenOption... options): Mở hoặc tạo một luồng đầu ra để ghi byte vào file.

Các phương thức này tương thích với gói java.io.

(3) Phức tạp hơn nữa:

↳ Phương thức static newByteChannel(Path path, OpenOption... options): Mở hoặc tạo một file, trả về một kênh byte có thể tìm kiếm để truy cập file.

↳ Phương thức static newByteChannel(Path path, Set<? extends OpenOption> options, FileAttribute<?>... attrs): Mở hoặc tạo một file với tùy chọn và thuộc tính bổ sung, trả về một kênh byte.

↳ Phương thức SeekableByteChannels(): Kênh byte có thể tìm kiếm, cho phép di chuyển con trỏ đọc/ghi trong file.

↳ Phương thức ByteBuffers(): Bộ đệm byte, vùng nhớ tạm thời để lưu trữ dữ liệu.

(4) Phức tạp nhất (bên phải):

↳ Các phương thức sử dụng FileChannel(): Cho phép các ứng dụng nâng cao cần khóa file hoặc I/O ánh xạ bộ nhớ.

Lưu ý: Tham số OpenOptions

Một số phương thức trong phần này chấp nhận tham số tùy chọn OpenOptions. Tham số này không bắt buộc và API sẽ cho bạn biết hành vi mặc định của phương thức khi không có tham số này được chỉ định. Các Enum StandardOpenOptions được hỗ trợ. Dưới đây là các tùy chọn enum của StandardOpenOptions:

↳ WRITE: Mở tệp để ghi dữ liệu.

↳ APPEND: Ghi dữ liệu mới vào cuối tệp. Tùy chọn này được sử dụng cùng với các tùy chọn WRITE hoặc CREATE.

↳ TRUNCATE_EXISTING: Xóa toàn bộ nội dung của tệp, làm cho kích thước tệp trở về 0 byte. Tùy chọn này được sử dụng cùng với WRITE.

↳ CREATE_NEW: Tạo một tệp mới và ném ra ngoại lệ nếu tệp đã tồn tại.

↳ CREATE: Mở tệp nếu tệp đã tồn tại hoặc tạo một tệp mới nếu tệp chưa tồn tại.

↳ DELETE_ON_CLOSE: Xóa tệp khi dòng (stream) được đóng lại. Tùy chọn này hữu ích cho các tệp tạm thời.

↳ SPARSE: Gợi ý rằng tệp mới tạo sẽ là tệp thưa (sparse file). Đây là một tùy chọn nâng cao, được hỗ trợ trên một số hệ thống tệp như NTFS, nơi các tệp lớn với các khoảng trống dữ liệu có thể được lưu trữ một cách hiệu quả hơn mà không chiếm dụng dung lượng đĩa.

↳ SYNC: Giữ nội dung tệp và siêu dữ liệu được đồng bộ với thiết bị lưu trữ cơ sở.

↳ DSYNC: Giữ nội dung tệp được đồng bộ với thiết bị lưu trữ cơ sở, không nhất thiết phải đồng bộ cả siêu dữ liệu.

Các tùy chọn này giúp bạn kiểm soát cách thức tệp được mở, tạo, và quản lý trong các tác vụ đọc/ghi, đặc biệt khi làm việc với các tệp tạm thời hoặc các tệp lớn trên những hệ thống tệp phức tạp.

Ⅰ. Các phương thức thường được sử dụng cho tệp tin nhỏ

(1) Đọc toàn bộ Bytes hoặc dòng từ một tệp

Nếu bạn có một tệp nhỏ và muốn đọc toàn bộ nội dung của nó trong một lần truy cập, bạn có thể sử dụng phương thức readAllBytes(Path) hoặc readAllLines(Path, Charset). Các phương thức này sẽ xử lý hầu hết các công việc cho bạn, chẳng hạn như mở và đóng dòng (stream), nhưng không phù hợp để xử lý các tệp lớn.

Giả sử bạn có một tệp example.txt chứa nội dung như sau:

Tệp tin: example.txt

Hello, world!
This is a test file.

Dưới đây là cách sử dụng phương thức readAllBytes():

Ví dụ: Example.java

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class Example {
    public static void main(String[] args) {
        // Đường dẫn tới tệp cần đọc
        Path filePath = Paths.get("example.txt");

        try {
            // Đọc toàn bộ nội dung của tệp vào một mảng byte
            byte[] fileBytes = Files.readAllBytes(filePath);

            // Chuyển đổi mảng byte thành chuỗi và in ra màn hình
            String fileContent = new String(fileBytes);
            System.out.println("Nội dung tệp:");
            System.out.println(fileContent);
        } catch (IOException e) {
            // Xử lý ngoại lệ nếu có lỗi xảy ra trong quá trình đọc tệp
            System.err.format("Lỗi khi đọc tệp: %s%n", e.getMessage());
        }
    }
}

Khi chạy chương trình thì toàn bộ nội dung của tệp example.txt in ra như sau:

Nội dung tệp:
Hello, world!
This is a test file.

(2) Ghi toàn bộ Bytes hoặc dòng vào một tệp tin

Bạn có thể sử dụng một trong các phương thức write() để ghi bytes hoặc dòng vào một tệp:

↳ Phương thức static write(Path path, byte[] bytes, OpenOption... options): Ghi dữ liệu vào file.

↳ Phương thức static write(Path path, Iterable<? extends CharSequence> lines, Charset cs, OpenOption... options): Ghi một danh sách các dòng văn bản vào file với mã hóa ký tự đã chỉ định.

Dưới đây là cách sử dụng một phương thức write():

Ví dụ: Example.java

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;

public class Example {
    public static void main(String[] args) {
        // Đường dẫn tới tệp cần ghi
        Path filePath = Paths.get("example.txt");

        // Các dòng ký tự để ghi vào tệp
        List<String> lines = List.of(
           "Xin chào thế giới!",
           "Đây là tệp thử nghiệm.",
           "Java NIO rất mạnh mẽ."
        );

        try {
            // Ghi các dòng ký tự vào tệp
            Files.write(filePath, lines, StandardCharsets.UTF_8);
            System.out.println("Các dòng đã được ghi vào tệp.");
        } catch (IOException e) {
            // Xử lý ngoại lệ nếu có lỗi xảy ra trong quá trình ghi tệp
            System.err.format("Lỗi khi ghi tệp: %s%n", e.getMessage());
        }
    }
}

Khi chương trình chạy thành công, tệp example sẽ chứa nội dung như sau:

Xin chào thế giới!
Đây là tệp thử nghiệm.
Java NIO rất mạnh mẽ.

Ⅱ. Các phương thức Buffered I/O đối với các tệp văn bản

Gói java.nio.file hỗ trợ I/O kênh, giúp di chuyển dữ liệu qua các bộ đệm (buffers), qua đó bỏ qua một số lớp có thể làm giảm hiệu suất của I/O dòng (stream I/O).

(1) Đọc tệp sử dụng Buffered Stream I/O

Phương thức newBufferedReader(Path, Charset) mở một tệp để đọc và trả về một BufferedReader có thể được sử dụng để đọc văn bản từ tệp một cách hiệu quả.

Giả sử bạn có một tệp example.txt chứa nội dung như sau:

Tập tin: example.txt

Hello, world!
This is a test file.
It contains multiple lines of text.

Dưới đây là ví dụ về cách sử dụng phương thức newBufferedReader() để đọc từ một tệp. Tệp được mã hóa bằng "US-ASCII":

Ví dụ: Example.java

import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class Example {
    public static void main(String[] args) {
        // Đường dẫn tới tệp cần đọc
        Path filePath = Paths.get("example.txt");

        // Đọc tệp sử dụng mã hóa US-ASCII
        try (BufferedReader reader = Files.newBufferedReader(filePath, StandardCharsets.US_ASCII)) {
            String line;
            // Đọc từng dòng và in ra màn hình
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            // Xử lý ngoại lệ nếu có lỗi xảy ra trong quá trình đọc tệp
            System.err.format("Lỗi khi đọc tệp: %s%n", e.getMessage());
        }
    }
}

Khi bạn chạy ví dụ mã trên, nó sẽ đọc từng dòng từ example.txt và in chúng ra màn hình như sau:

Hello, world!
This is a test file.
It contains multiple lines of text.

Lưu ý mã hóa ký tự: Mã hóa "US-ASCII" chỉ bao gồm các ký tự ASCII cơ bản (0-127). Nếu tệp sử dụng các ký tự ngoài phạm vi này, bạn nên sử dụng một mã hóa khác như "UTF-8".

(2) Ghi tệp sử dụng Buffered Stream I/O

Bạn có thể sử dụng phương thức newBufferedWriter(Path, Charset, OpenOption...) để ghi vào một tệp bằng BufferedWriter.

Dưới đây là ví dụ về cách tạo một tệp mã hóa bằng "US-ASCII" bằng phương thức này:

Ví dụ: Example.java

import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

public class Example {
    public static void main(String[] args) {
        // Định nghĩa đường dẫn đến tệp
        Path path = Paths.get("example.txt");

        // Nội dung cần ghi vào tệp
        String content = "Hello, world!\nThis is a test file.\nIt contains multiple lines of text.";

        // Mã hóa ký tự
        Charset charset = Charset.forName("US-ASCII");

        try (BufferedWriter writer = Files.newBufferedWriter(path, charset, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
            // Ghi nội dung vào tệp
            writer.write(content);
        } catch (IOException e) {
            System.err.format("IOException: %s%n", e);
        }
    }
}

Khi chạy mã này, nó sẽ tạo (hoặc ghi đè) tệp example.txt với nội dung được chỉ định và mã hóa bằng “US-ASCII“:

Hello, world!
This is a test file.
It contains multiple lines of text.

Các phương thức này giúp bạn thực hiện các hoạt động I/O văn bản một cách hiệu quả, bằng cách giảm thiểu số lần truy cập hệ thống tệp và tăng cường hiệu suất thông qua việc sử dụng các bộ đệm.

Ⅲ. Các phương thức đối với Stream không đệm và tương thích với các API java.io

(1) Đọc tệp sử dụng Stream I/O

Để mở một tệp cho việc đọc, bạn có thể sử dụng phương thức newInputStream(Path, OpenOption...). Phương thức này trả về một dòng nhập không đệm (unbuffered input stream) để đọc byte từ tệp.

Giả sử bạn có một tệp example.txt chứa nội dung như sau:

Tệp tin: example.txt

Xin chào thế giới!
Chào mừng bạn đến với ví dụ đọc tập tin.
Đây là một tập tin văn bản mẫu.

Dưới đây là ví dụ về cách sử dụng phương thức newInputStream() để đọc từ một tệp và kết hợp với BufferedReader để đọc từng dòng văn bản:

Ví dụ: Example.java

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class Example {
    public static void main(String[] args) {
        // Định nghĩa đường dẫn đến tệp
        Path path = Paths.get("example.txt");

        try (BufferedReader reader = new BufferedReader(new InputStreamReader(Files.newInputStream(path)))) {
            String line;
            // Đọc từng dòng từ tệp
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            System.err.format("IOException: %s%n", e);
        }
    }
}

Khi bạn chạy đoạn mã này, nó sẽ đọc và in từng dòng của tệp example.txt ra màn hình.

Xin chào thế giới!
Chào mừng bạn đến với ví dụ đọc tập tin.
Đây là một tập tin văn bản mẫu.

(2) Tạo và ghi tệp sử dụng Stream I/O

Bạn có thể tạo một tệp, thêm vào tệp hoặc ghi vào một tệp bằng cách sử dụng phương thức newOutputStream(Path, OpenOption...). Phương thức này mở hoặc tạo một tệp để ghi byte và trả về một dòng xuất không đệm (unbuffered output stream).

Phương thức này nhận một tham số tùy chọn OpenOption. Nếu không có tùy chọn mở nào được chỉ định và tệp không tồn tại, một tệp mới sẽ được tạo. Nếu tệp đã tồn tại, nó sẽ bị cắt ngắn. Tùy chọn này tương đương với việc gọi phương thức với các tùy chọn CREATE và TRUNCATE_EXISTING.

Dưới đây là ví dụ về cách mở một tệp log. Nếu tệp không tồn tại, nó sẽ được tạo. Nếu tệp đã tồn tại, nó sẽ được mở để thêm dữ liệu:

Ví dụ: Example.java

import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

public class Example {
    public static void main(String[] args) {
        Path path = Paths.get("log.txt");

        // Dữ liệu sẽ được ghi vào tệp
        String data = "Đây là một mục nhật ký.\n";

        // Ghi dữ liệu vào tệp
        try (OutputStream out = Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
            // Chuyển đổi dữ liệu thành mảng byte và ghi vào tệp
            out.write(data.getBytes());
            System.out.println("Dữ liệu đã được ghi vào tệp.");
        } catch (IOException e) {
            System.err.format("IOException: %s%n", e);
        }
    }
}

Kết quả của chương trình là

Nếu log.txt chưa tồn tại, nó sẽ được tạo mới và dữ liệu “Đây là một mục nhật ký.\n“ sẽ được ghi vào tệp.
Nếu log.txt đã tồn tại, dữ liệu mới sẽ được thêm vào cuối tệp mà không làm mất dữ liệu hiện có.

Ⅳ. Các phương thức đối với Channels và ByteBuffers

Khi sử dụng I/O qua streams, bạn đọc hoặc ghi từng ký tự một. Ngược lại, khi sử dụng I/O qua channels, bạn đọc hoặc ghi từng buffer một. Đây là một cách tiếp cận hiệu quả hơn, đặc biệt khi làm việc với các tệp lớn hoặc cần hiệu suất cao hơn.

Các khái niệm chính:

↳ interface ByteChannel: Cung cấp các chức năng cơ bản để đọc và ghi byte từ/đến một tệp hoặc kênh.

↳ interface SeekableByteChannel: Là một dạng mở rộng của ByteChannel cho phép bạn duy trì và thay đổi vị trí trong kênh. SeekableByteChannel cũng hỗ trợ cắt tệp và truy vấn kích thước tệp.

↳ Khả năng di chuyển đến các điểm khác nhau trong tệp và đọc từ hoặc ghi vào vị trí đó giúp bạn thực hiện việc truy cập ngẫu nhiên vào tệp. Bạn có thể tham khảo thêm về Random Access Files để biết thêm thông tin.

Các phương thức đọc và ghi sử dụng Channel I/O

Có hai phương thức chính để đọc và ghi khi sử dụng channel I/O:

↳ newByteChannel(Path, OpenOption...): Mở một tệp để đọc và ghi sử dụng các tùy chọn OpenOption. Trả về một SeekableByteChannel.

↳ newByteChannel(Path, Set<? extends OpenOption>, FileAttribute<?>...): Tương tự như trên, nhưng cho phép chỉ định một tập hợp các tùy chọn mở (OpenOption) và thuộc tính tệp (FileAttribute).

Lưu ý:

Các phương thức newByteChannel() trả về một instance của SeekableByteChannel. Với hệ thống tệp mặc định, bạn có thể ép kiểu kênh byte có thể tìm kiếm này thành một FileChannel để có quyền truy cập vào các tính năng nâng cao hơn, chẳng hạn như:

↳ Mapping: Ánh xạ một vùng của tệp trực tiếp vào bộ nhớ để truy cập nhanh hơn.

↳ Locking: Khóa một vùng của tệp để các tiến trình khác không thể truy cập nó.

↳ Absolute Positioning: Đọc và ghi byte từ một vị trí tuyệt đối mà không ảnh hưởng đến vị trí hiện tại của kênh.

Cả hai phương thức newByteChannel() đều cho phép bạn chỉ định danh sách các tùy chọn OpenOption. Các tùy chọn mở này giống với các tùy chọn được sử dụng bởi các phương thức newOutputStream(), cùng với một tùy chọn khác: READ là bắt buộc vì SeekableByteChannel hỗ trợ cả đọc và ghi.

Các tùy chọn mở (OpenOption):

↳ READ: Mở kênh tệp cho việc đọc dữ liệu. Đây là tùy chọn mặc định nếu không chỉ định gì.

↳ WRITE: Mở kênh tệp để ghi dữ liệu.

↳ APPEND: Mở kênh tệp để ghi dữ liệu và thêm dữ liệu vào cuối tệp.

Khi không chỉ định bất kỳ tùy chọn nào, kênh tệp sẽ được mở cho việc đọc dữ liệu.

Đọc tệp sử dụng Channel I/O

Dưới đây là một ví dụ về cách sử dụng SeekableByteChannel để đọc nội dung của tệp và in ra màn hình. Đoạn mã này sử dụng ByteBuffer để đọc dữ liệu từ tệp, với kích thước buffer là 10 byte.

Ví dụ: FileReader.java

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.SeekableByteChannel;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;

public class FileReader {

    public static void readFile(Path path) throws IOException {
        // Mở kênh tệp, mặc định là READ
        try (SeekableByteChannel sbc = Files.newByteChannel(path)) {
            final int BUFFER_CAPACITY = 10;
            ByteBuffer buf = ByteBuffer.allocate(BUFFER_CAPACITY);

            // Đọc dữ liệu với mã hóa phù hợp cho nền tảng này
            String encoding = System.getProperty("file.encoding");
            while (sbc.read(buf) > 0) {
                buf.flip();
                // Chuyển đổi từ ByteBuffer sang String và in ra
                System.out.print(Charset.forName(encoding).decode(buf));
                buf.clear();
            }
        }
    }
}

Lưu ý:

↳ Lớp ByteBuffer được sử dụng để lưu trữ dữ liệu được đọc từ kênh.

↳ Vòng lặp đọc dữ liệu theo từng phần cho đến khi hết file.

↳ Phương thức read() của SeekableByteChannel trả về số byte được đọc.

↳ Phương thức flip() chuẩn bị ByteBuffer để đọc.

↳ Phương thức decode() của Charset giải mã byte thành ký tự dựa trên mã hóa được chỉ định.

↳ Phương thức clear() chuẩn bị ByteBuffer cho chu kỳ đọc tiếp theo.

Ghi tệp sử dụng Channel I/O

Dưới đây là ví dụ về việc tạo một tệp log với quyền truy cập cụ thể trên các hệ thống tệp POSIX như UNIX. Đoạn mã này tạo một tệp log hoặc thêm dữ liệu vào tệp log nếu tệp đã tồn tại. Tệp log được tạo với quyền đọc/ghi cho chủ sở hữu và quyền đọc cho nhóm.

Ví dụ: FileWrite.java

import static java.nio.file.StandardOpenOption.*;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.Files;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.FileAttribute;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.HashSet;
import java.util.Set;

public class FileWrite {

    public static void main(String[] args) {
  
        // Tạo tập hợp các tùy chọn để thêm dữ liệu vào tệp.
        Set<OpenOption> options = new HashSet<OpenOption>();
        options.add(APPEND);
        options.add(CREATE);

        // Tạo thuộc tính quyền truy cập tùy chỉnh.
        Set<PosixFilePermission> perms = PosixFilePermissions.fromString("rw-r-----");
        FileAttribute<Set<PosixFilePermission>> attr = PosixFilePermissions.asFileAttribute(perms);

        // Chuyển đổi chuỗi thành ByteBuffer.
        String s = "Hello World! ";
        byte[] data = s.getBytes();
        ByteBuffer bb = ByteBuffer.wrap(data);
        
        Path file = Paths.get("./permissions.log");

        try (SeekableByteChannel sbc = Files.newByteChannel(file, options, attr)) {
            sbc.write(bb);
        } catch (IOException x) {
            System.out.println("Exception thrown: " + x);
        }
    }
}

Lưu ý:

↳ Interface SeekableByteChannel cung cấp khả năng ghi dữ liệu vào tệp với quyền truy cập cụ thể.

↳ Phương thức PosixFilePermissions.fromString() cho phép bạn xác định quyền truy cập cho tệp.

↳ Lớp ByteBuffer được sử dụng để chuẩn bị dữ liệu trước khi ghi vào tệp.

Hy vọng rằng qua các ví dụ trên, bạn sẽ hiểu rõ hơn về cách đọc và ghi tệp tin trong Java.

Câu Nói Truyền Cảm Hứng

“Bắt đầu ở đâu không quan trọng, quan trọng là bạn sẵn sàng bắt đầu.” – W. Clement Stone

Không Gian Tích Cực

“Chúc bạn luôn giữ vững niềm tin và sức mạnh để vượt qua mọi thử thách trong cuộc sống.”