Java - I/O cơ bản

I/O là viết tắt của Input / Output, và được hiểu là tất cả những thao tác liên quan đến dữ liệu vào ra của 1 chương trình.
I - I/O Stream
Trong Java, có 1 thuật ngữ cơ bản là I/O Stream. Được hiểu là dòng dữ liệu có nguồn, có đích, mà có thể xuất phát từ (hay đi tới) bất cứ 1 file trên đĩa nào, hay là thiết bị, chương trình bên ngoài, hay là mảng bộ nhớ.
Các loại dữ liệu có thể được chứa trong Stream cũng rất đa dạng: byte stream, character stream, primitive data stream hay object stream.
Hầu hết các lớp làm việc với I/O Stream nằm trong gói java.io

1. Byte stream:
Đây là loại stream được dùng để xử lý từng byte 8-bit một.
Các lớp liên quan thuộc lớp InputStream và OutputStream. Có rất nhiều lớp xử lý byte stream và chúng ta sẽ demo lớp FileInputStream và FileOutputStream (các lớp khác tương tự):

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

final static String INPUTFILE = "D:\\Programming\\Java\\Proj\\input.txt";
final static String OUTPUTFILE = "D:\\Programming\\Java\\Proj\\output.txt";

public static void myByteStream(String args[]) throws IOException
    {
        FileInputStream fis = null;
        FileOutputStream fos = null;
        try {
            fis = new FileInputStream(INPUTFILE);
            fos = new FileOutputStream(OUTPUTFILE);
            int c;
            while((c = fis.read()) != -1)
            {
                fos.write(c);
            }
        } finally {
            if (fis != null) {
                fis.close();
            }
            if (fos != null) {
                fos.close();
            }
        }
    }
Thông thường ít khi chúng ta dùng đến byte stream  trong các chương trình thông thường do chúng xử lý từng byte một, không cần thiết phải level thấp đến vậy :)

2. Character Stream:
Java sử dụng quy ước UNICODE cho việc lưu trữ ký tự. Character stream tự động chuyển đổi internal format sang bộ ký tự địa phương.
Các lớp xử lý Character stream trực thuộc lớp Reader và Writer. Tương tự phần trên sau đây chúng ta sẽ demo các lớp FileReader và FileWriter:

import java.io.IOException;
import java.io.FileReader;
import java.io.FileWriter;
public static void myCharacterStream(String args[]) throws IOException
    {
        FileReader fr = null;
        FileWriter fw = null;
        try {
            fr = new FileReader(INPUTFILE);
            fw = new FileWriter(OUTPUTFILE);
            int c;
            while((c = fr.read()) != -1)
            {
                fw.write(c);
            }
        } finally {
            if (fr != null) {
                fr.close();
            }
            if (fw != null) {
                fw.close();
            }
        }
    }

Character stream sử dụng byte stream để giao tiếp với phần cứng I/O, trong khi sử dụng các "cầu nối" để chuyển từ byte sang character (với charset được chỉ định). Ví dụ:
FileReader sử dụng FileInputStream và cầu nối InputStreamReader

Có 1 cách thức khác để đọc dữ liệu từ Character stream, đó là đọc theo dòng:

public static void myCharacterStream2(String args[]) throws IOException
    {
        BufferedReader br = null;
        PrintWriter pw = null;
        try {
            br = new BufferedReader(new FileReader(INPUTFILE));
            pw = new PrintWriter(new FileWriter(OUTPUTFILE));
            String l;
            while((l = br.readLine()) != null)
            {
                pw.println(l);
            }
        } finally {
            if (br != null) {
                br.close();
            }
            if (pw != null) {
                pw.close();
            }
        }
    }

3. Buffered stream
Buffered là 1 thuật ngữ chỉ việc lưu tạm dữ liệu đến 1 thời điểm nào đó thì mới thao tác, do đó nó giảm thiểu số thao tác phải dùng.
Buffered được sử dụng để wrap các unbuffered stream tương ứng. Ví dụ:
BufferedReader brr = new BufferedReader(new FileReader(input.txt));  // Character stream
BufferedInputStream bris = new BufferedInputStream(new FileInputStream(input.txt));  // Byte stream

4. Scanning
Đây là khái niệm chuyển dữ liệu từ input sang các token (những mảnh dữ liệu được phân tách từ 1 chuỗi dữ liệu ban đầu - kiểu như 1 string phân thành cách từ bởi đấu cách).
Chúng ta xem demo sau:

public static void myScanning(String args[]) throws IOException
    {
        Scanner sc = null;
        double sum = 0;
        try {
            sc = new Scanner(new BufferedReader(new FileReader(INPUTFILE)));
            while (sc.hasNext()) {
                if (sc.hasNextDouble()) {
                    sum += sc.nextDouble();
                } else {
                    sc.next();
                }
            }
        } finally {
            if (sc != null) {
                sc.close();
            }
        }
        log((Double.toString(sum)));
    }

Scanner dùng phương thức sau để định ký tự phân cách
s.useDelimiter()

5. I/O from Console
Standard stream gồm System.in, System.out, System.err tương ứng với 3 cổng I/O kinh điển trong bất cứ HĐH nào (các bạn tự hiểu)
Cả 3 đều là byte character nhưng System.out, System.err được định nghĩa bởi PrintStream, nên được hỗ trợ chuyển đổi thành character. Với System.in, để sử dụng, ta cần dùng cầu nối để chuyển thành character stream:
InputStreamReader cin = new InputStreamReader(System.in);

Console I/O: Hỗ trợ 1 số tính năng mà standard stream không có. Để sử dụng, bạn bất buộc phải gọi
System.console():

Ta xét ví dụ sau:

public static void myPassword(String args[])
    {
        Console c = System.console();
        if (c == null) {
            log("Console not permit !");
            System.exit(1);
        }
        String login = c.readLine("Enter Username: ");
        char[] pass = c.readPassword("Enter Password: ");
       
        c.format("You just entered: ");
        c.format("User: "+login+"%n");
        c.format("Pass: "+(new String(pass)));
    }

6. Data Stream
Java hỗ trợ thao tác I/O với binary data : bolean, byte, char, short, int, long, float, double (primitive data type).
Data stream được hỗ trợ bởi các class implement các giao diện DataInput và DataOutput. Sau đây chúng ta khảo sát DataInputStrean và DataOutputStream.

static final String dataFile = "D:\\Programing\\Java\\Proj\\invoicedata";
    static final double[] prices = { 19.99, 9.99, 15.99, 3.99, 4.99 };
    static final int[] units = { 12, 8, 13, 29, 50 };
    static final String[] descs = {
        "Java T-shirt",
        "Java Mug",
        "Duke Juggling Dolls",
        "Java Pin",
        "Java Key Chain"
    };
    public static void myDataStream(String args[]) throws IOException
    {
        DataOutputStream dos = null;
        try {
            dos = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(dataFile)));
            for (int i = 0; i < prices.length; i++) {
                dos.writeDouble(prices[i]);
                dos.writeInt(units[i]);
                dos.writeUTF(descs[i]);
            }
        } finally {
            if (dos != null)
                dos.close();
        }
       
        DataInputStream dis = null;
        try {
            dis = new DataInputStream(new BufferedInputStream(new FileInputStream(dataFile)));
            double price = 0;
            int unit = 0;
            String desc = null;
            try {
                while(true) {
                    price = dis.readDouble();
                    unit = dis.readInt();
                    desc = dis.readUTF();
                    System.out.format("Price: %.2f$ - Unit: %d - Desc: %s%n", price, unit, desc);
                }
            } catch (EOFException e) {
                log("End.");
            }
        } finally {
            if (dis != null)
                dis.close();
        }
    }

7. Object stream
Object stream hỗ trợ I/O của các reference object (đồng thời cả primitive data). Các lớp Object stream là ObjectInputStream và ObjectOutputStream. Các lớp này thực ra implement các interface ObjectInput và ObjectOutput (là subinterface của DataInput và DataOutput).
Một điều phải lưu ý là để writeObject 1 Object nào đó chứa reference tới các Object khác, rõ ràng nó cũng write luôn cả những Object thêm vào đó. Quá trình readObject cũng tương tự.
Chúng ta xem ví dụ sau đây:

public static void myObjectStream(String args[]) throws IOException
    {
        ObjectOutputStream oos = null;
        try {
            oos = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(dataFile)));
            for (int i = 0; i < prices2.length; i++) {
                oos.writeObject(prices[i]);
                oos.writeInt(units[i]);
                oos.writeUTF(descs[i]);
            }
        } finally {
            if (oos != null) {
                oos.close();
            }
        }
        ObjectInputStream ois = null;
        try {
            ois = new ObjectInputStream(new BufferedInputStream(new FileInputStream(dataFile)));
            BigDecimal price2;
            int unit = 0;
            String desc = null;
            try {
                while(true) {
                    price2 = new BigDecimal((Double)ois.readObject());
                    unit = ois.readInt();
                    desc = ois.readUTF();
                    System.out.format("Price: %.2f$ - Unit: %d - Desc: %s%n", price2, unit, desc);
                }
            } catch (EOFException e) {
                log("End.");
            } catch (ClassNotFoundException e) {
                log("Error: "+ e.toString());
            }
        } finally {
            if (ois != null) {
                ois.close();
            }
        }
    }

II - File I/O
Có thể coi file I/O là 1 trường hợp của I/O mà bởi tính quan trọng và phổ biến, nó được tách ra phần riêng và có những gói xử lý riêng. Tất cả các gói, class nằm dưới java.nio.file
Đây là những gói MỚI - NEW IO (nio) không phải để thay thế java.io mà đơn giản là mở rộng nó.
(Tất nhiên, như chúng đã xem phần đầu, các thao tác với file I/O cũng được xử lý trong java.io bởi nhiều lớp khác nhau, nhưng nói chung khi vào trận thì với file, bạn nên dùng mấy thứ được chuyên biệt hóa này :))
Chúng ta sẽ bắt đầu với Path và các thứ liên quan

1. Path
Đây là 1 khái niệm quen thuộc. Chúng ta có lớp Path để xử lý các thao tác với nó.

Tạo 1 path mới, ví dụ:
Path p1 = Paths.get("/home/go/to/file");
Path p2 = Paths.get("C:\\go\\to\\file");
Path p3 = Paths.get(URI.create("file:///home/go/to/file"));   // cần import java.net
Path p4 = Paths.get(System.getProperty("user.home"), "go", "to", "file");

Lấy thông tin về path:
public static void myPath(String args[]) throws IOException
    {
        Path p1 = Paths.get("C:\\go\\to\\file");
        log("toString(): " + p1.toString());
        log("getFileName(): " + p1.getFileName());
        log("getName(0): " + p1.getName(0));
        log("getNameCount(): " + p1.getNameCount());
        log("subpath(0,2): " + p1.subpath(0,2));
        log("getParent(): " + p1.getParent());
        log("getRoot(): " + p1.getRoot());
    }

Chuyển đổi path
Hàm toUri chuyển path thành String mà có thể dùng trên các trình duyệt
Hàm toAbsolutePath chuyển đường dẫn tương đối thành tuyệt đối và 1 số thao tác như:
Hàm toRealPath kiểm tra và trả về dường dẫn thực của file có tồn tại 
- Nếu true được gửi vào và file system cho phép symbolic links, nó sẽ trả về đường dẫn thật của file (resolves symbolic link)
- Nếu là path tương đối, chuyển thành tuyệt đối
- Nếu path có chứa thành phần dư thừa (".", "directory/.."), nó loại bỏ các thành phần này

Path p1 = Paths.get("C:\\go\\to\\..\\file");
Path p2 = p1.toRealPath();

Nối path
Rất đơn giản
Path p1 = Paths.get("C:\\go\\to");
Path p2 = p1.resolve("file");

Tạo path từ 2 path đã cho
Path p1 = Paths.get("C:\\go");
Path p1 = Paths.get("C:\\go\\to\\file");
Path p3 = p1.relativize(p2);  // p3 = "to\\file"
Path p4 = p2.relativize(p1);  // p4 = "..\\.."

So sánh 2 path
phương thức equals được dùng để kiểm tra 2 path có giống nhau không.

2. File
Chúng ta sẽ làm quen với một vài thuật ngữ:

Giải phóng tài nguyên hệ thống:
Nhiều tài nguyên được sử dụng bởi các hàm API như stream, chanel,... được kế thừa hay mở rộng từ interface java.io.Closable.  Việc giải phóng sau khi sử dụng rất quan trọng, thông thường chúng ta gọi phương thức close(). Tuy nhiên, chúng ta có thể cho chúng tự động close với cú pháp try-with-resources.
Bất kỳ lớp nào  implement java.lang.AutoCloseable đề có thể dùng được với try-with-resources:

try (BufferedReader br = BufferedReader(new FileReader(file))) {
     ...
} catch (IOException e) {
     ...
}

Atomic Operation
Những thao tác không thể ngắt quãng, chia nhỏ nếu không muốn bị fails. Ví dụ lệnh move file.

Method chaining
Nhiều trường hợp chúng ta gọi 1 method, method này trả về 1 đối tượng nào đó, và ta gọi tiếp method 2 sử dụng kết quả này, ... Ví dụ:
String value = Charset.defaultCharset().decode(buf).toString();

Glob
Glob pattern khá giống regular express, nó được xem như là String và được dùng để match một String khác
  • An asterisk, *, matches any number of characters (including none).
  • Two asterisks, **, works like * but crosses directory boundaries. This syntax is generally used for matching complete paths.
  • A question mark, ?, matches exactly one character.
  • Braces specify a collection of subpatterns. For example:
    • {sun,moon,stars} matches "sun", "moon", or "stars".
    • {temp*,tmp*} matches all strings beginning with "temp" or "tmp".
  • Square brackets convey a set of single characters or, when the hyphen character (-) is used, a range of characters. For example:
    • [aeiou] matches any lowercase vowel.
    • [0-9] matches any digit.
    • [A-Z] matches any uppercase letter.
    • [a-z,A-Z] matches any uppercase or lowercase letter.
    Within the square brackets, *, ?, and \ match themselves.
  • All other characters match themselves.
  • To match *, ?, or the other special characters, you can escape them by using the backslash character, \. For example: \\ matches a single backslash, and \? matches the question mark.
3. Kiểm tra file và thư mục
Kiểm tra sự tồn tại:
Files.exists(path) và Files.notExists(path). Chú ý Files.notExists(path) khác với !Files.exists(path)
Kiểm tra sự truy cập
Files.isReadable(path)
Files.isWritable(path)
Files.isExcutable(path)
Kiểm tra 2 path có dẫn tới cùng 1 file
Files.isSameFile(path, path)

4. Một số thao tác file / thư mục
Delete:
Files.delete(path);

Copy:
Path p1 = Paths.get("D:\\Programming\\Java\\Proj\\output.txt");
Path p2 = Paths.get("D:\\Programming\\Java\\Proj\\output1.txt");
Files.copy(p1, p2);   // nếu p2 chưa có
Files.copy(p1, p2, REPLACE_EXISTING);  // nếu p2 đã có sẵn + cần import static java.nio.file.StandardCopyOption.*;
Nếu p1 là symbolic link thì chỉ có target file được copy, nếu muốn chỉ copy symbolic link thì:
Files.copy(p1, p2, NOFOLLOW_LINKS);
Cũng có thể copy từ/đến 1 InputStream, ví dụ:
Files.copy(new FileInputStream(p1.toString()), p2);

Chú ý rằng khi copy thư mục, chỉ thư mục được copy, các file bên trong thì không được.

Move:
Một chú ý với lệnh move là nếu move 1 thư mục có chứa nội dung bên trong, lệnh move chỉ được phép nếu thư mục đó được phép move mà không cần move cả nội dung bên trong. Riêng với UNIX, trong cùng partition, thư mục được phép move ngay khi nó có chứa nội dung bên trong

import static java.nio.file.StandardCopyOption.*;
Path p1 = Paths.get("D:\\Programming\\Java\\Proj\\output.txt");
Path p2 = Paths.get("D:\\Programming\\Java\\Proj\\output1.txt");
Files.move(p1, p2);   // nếu p2 chưa có
Files.move(p1, p2, ATOMIC_MOVE);  // nếu hệ thống cho phép, lệnh move với ATOMIC_MOVE sẽ đảm bảo quá trình move được ưu tiên để hoàn thành mà không bị ngắt quãng, lỗi.

5. File attributes
Có nhiều phương thức và lớp liên quan đến việc lấy attributes và thiết lập attributes file.
Trong java.nio.file.Files cũng có 1 số phương thức lấy attributes như:
size
isDirectory
isRegularFile
...
Tuy nhiên, để lấy nhiều attributes cùng lúc mà dùng từng phương thức như trên thì không tốt cho hiệu suất, vì vậy, ta có phương thức: readAttributes (đọc thêm tài liệu về các phương thức này)

Đối với từng hệ thống khác nhau, chúng ta có những loại attributes khác nhau, vì vậy, java xếp theo các nhóm.
Các attributes cơ bản, ta có  java.nio.file.attribute.BasicFileAttributes
Với Windows file, ta có java.nio.file.attribute.DosFileAttributes
Với POSIX file, java.nio.files.attribute.PosixFileAttributes
...
Ta xét ví dụ sau:

public static void myFileAttributes(String args[]) throws IOException
    {
        Path p1 = Paths.get("D:\\Programming\\Java\\Proj\\outputSmall.txt");
        BasicFileAttributes bfa = Files.readAttributes(p1, BasicFileAttributes.class);
        DosFileAttributes dfa = Files.readAttributes(p1, DosFileAttributes.class);
       
        log("creationTime: " + bfa.creationTime());
        log("lastModifiedTime: " + bfa.lastModifiedTime());
        log("size: " + bfa.size());
        log("isHidden: " + dfa.isHidden());
       
        long curTime = System.currentTimeMillis();
        FileTime ft = FileTime.fromMillis(curTime);
        Files.setLastModifiedTime(p1, ft);
        log("after edit - lastModifiedTime: " + bfa.lastModifiedTime());
    }

Ở đây có 1 cái bẫy: log("after edit - lastModifiedTime: " + bfa.lastModifiedTime()); --> sẽ in ra thông số giống như lúc chưa setLastModifiedTime ! Vì ta sử dụng biến bfa - nó đã lấy thông tin tất cả các attributes từ trước, bởi vậy, muốn cập nhật thì không thể dùng lại bfa

File Store attributes
Chúng ta xét ví dụ sau để hiểu các lấy file store:

        import java.nio.file.FileStore;
        FileStore fs = Files.getFileStore(p1);
        log("getTotalSpace: " + fs.getTotalSpace()/1024);
        log("getUnallocatedSpace: " + fs.getUnallocatedSpace()/1024);
        log("get used: " + (fs.getTotalSpace() - fs.getUnallocatedSpace())/1024);
        log("getUsableSpace: " + fs.getUsableSpace()/1024);

6. File reading, writing, creating
Có nhiều phương thức, class để làm việc này tùy theo trường hợp, mức độ

Đối với Small file:
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
final static String INPUTFILE = "D:\\Programming\\Java\\Proj\\inputSmall.txt";
final static String OUTPUTFILE = "D:\\Programming\\Java\\Proj\\outputSmall.txt";
public static void myFileOperation_SmallFile(String args[]) throws IOException
    {
        Path p1 = Paths.get(INPUTFILE);
        Path p2 = Paths.get(OUTPUTFILE);
        byte[] fileArrBytes;
        fileArrBytes = Files.readAllBytes(p1);
        log(String.valueOf(fileArrBytes));
       
        Files.write(p2, fileArrBytes, StandardOpenOption.WRITE, StandardOpenOption.APPEND);
    }

Đối với file lớn - dùng buffer:
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.nio.file.Files;
import static java.nio.file.StandardOpenOption.*;
public static void myFileOperation_Buffered(String args[]) throws IOException
    {
        Path p1 = Paths.get(INPUTFILE);
        Path p2 = Paths.get(OUTPUTFILE);
        Charset charset = Charset.forName("UTF-8");
        // or Charset charset = StandardCharsets.UTF_8;
        String sLine = null;
        try (BufferedReader readerBuf = Files.newBufferedReader(p1, charset);
            BufferedWriter writerBuf = Files.newBufferedWriter(p2, charset, WRITE, APPEND)){
            while ((sLine = readerBuf.readLine()) != null) {
                log(sLine);
                writerBuf.write(sLine, 0, sLine.length());
                writerBuf.newLine();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

Dùng I/O Stream kết hợp với buffered

public static void myFileOperation_Stream(String args[]) throws IOException
    {
        Path p1 = Paths.get(INPUTFILE);
        Path p2 = Paths.get(OUTPUTFILE);
        String sLine;
        try (InputStream in = Files.newInputStream(p1);
            BufferedReader bin = new BufferedReader(new InputStreamReader(in));
            OutputStream ou = Files.newOutputStream(p2, WRITE, APPEND);
            BufferedWriter bou = new BufferedWriter(new OutputStreamWriter(ou));) {
            while ((sLine = bin.readLine()) != null) {
                log(sLine);
                bou.write(sLine, 0, sLine.length());
                bou.newLine();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

Liệt kê các thư mục gốc
import java.nio.file.FileSystems;
import java.nio.file.FileSystem;
        FileSystem dfFst = FileSystems.getDefault();    // get default File system
        Iterable<Path> dirs = dfFst.getRootDirectories();
        // or Iterable<Path> dirs = FileSystems.getDefault().getRootDirectories();
        for (Path d : dirs) {
            log(d.toString());
        }

Tạo thư mục:
        Path p1 = Paths.get("D:\\Programming\\Java\\Proj\\testDir\\subTestDir");  // có thể tạo nhiều dir nhiều level
        Files.createDirectories(p1);

Liệt kê nội dung thư mục
        try (DirectoryStream<Path> stream = Files.newDirectoryStream(p1, "*.txt")) {
            for (Path p : stream) {
                log(p.getFileName());
            }
        } catch (DirectoryIteratorException e) {
            e.printStackTrace();
        }
Chú ý rằng trong: Files.newDirectoryStream(path, filter) thì filter nếu không có nó sẽ liệt kê tất cả. Nếu cần điều kiện để liệt kê, filter là dạng Glob String như trên.

Walking the File Tree
Để duyệt nội dung xuyên qua các thư mục khác nhau, hay 1 hoạt động cụ thể với 1 nhóm file trong 1 cây thư mục... chúng ta sẽ dùng tới các lớp và phương thức là implement của interface: FileVisitor
FileVisitor là 1 interface quy định cụ thể các hành vi cần thiết tại các điểm quan trọng trong quá trình "walking file tree":  
- khi một tập tin được truy cập,  
- trước khi một thư mục được truy cập, 
- sau khi một thư mục được truy cập, 
- hoặc khi thất bại xảy ra. 
Giao diện có bốn phương pháp tương ứng với những tình huống.

Nếu chúng ta không cần triển khai 4 tình huống trên, thay vì implement FileVisitor, chúng ta sẽ extends SimpleFileVisitor:

import java.nio.file.FileVisitResult;
import java.nio.file.SimpleFileVisitor;


class PrintFiles extends SimpleFileVisitor<Path> {
    /**
    This is extended class for print all files in specified directory
    */
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attr) {
        if (attr.isSymbolicLink()) {
            Calc.log("Symbolic link: " + file.toString());
        } else if (attr.isRegularFile()) {
            Calc.log("Regular file: " + file.toString());
        } else {
            Calc.log("Other: " + file.toString());
        }
        Calc.log(" (" + attr.size() + "bytes)");
        return FileVisitResult.CONTINUE;
    }
    @Override
    public FileVisitResult postVisitDirectory(Path dir, IOException e) {
        Calc.log("Dir: " + dir.toString());
        return FileVisitResult.CONTINUE;
    }
    @Override
    public FileVisitResult visitFileFailed(Path file, IOException e) {
        System.err.println(e);
        return FileVisitResult.CONTINUE;
    }
}


class Calc
{

public static void myDirectory(String args[]) throws IOException
    {
        Path p2 = Paths.get("D:\\Programming\\Java\\Proj\\testDir");
        PrintFiles pf = new PrintFiles();
        Files.walkFileTree(p2, pf);
    }
    public static void main(String args[])
    {
        try {
            myDirectory(args);
        }
        catch (IOException e) {
            log("Error: " + e);
        }
    }
   
    public static void log(Object aMsg){
        System.out.println(String.valueOf(aMsg));
    }
}

Nhận xét

Đăng nhận xét