[JavaNet p2] Java IO
Bài viết được lấy từ https://truongphuoc.wordpress.com/2024/07/18/javanet-p2-java-io/
Bài viết này gồm 4000 chữ có hơn, bao gồm cả code, bạn sẽ không muốn đọc nó trên điện thoại đâu, tin mình đi. Nếu bạn đang đọc bằng điện thoại hãy ghim bài này lại và đọc trên laptop hay ipad sau nhé.
Giới thiệu Java IO
Java IO (Java Input and Output) là một gói trong Java SE, cung cấp các tính năng cần thiết cho để xử lý dữ liệu từ bên ngoài ứng dụng Java. Cũng như con người có các giác quan tai, mắt, mũi, miệng,... để cảm nhận và giao tiếp với thế giới bên ngoài, thì Java cũng cung cấp Java IO để các ứng dụng Java có thể nhận biết được với thế giới bên ngoài vậy.
Các nguồn dữ liệu bên ngoài ứng dụng Java
Chúng ta có thể liệt kê một vài nguồn dữ liệu bên ngoài ứng dụng Java như:
- File: Được lưu trữ trong ổ cứng của máy tính.
- Console: Nhập và hiển thị dữ liệu thông qua ứng dụng Console hay Terminal.
- Socket: Có thể đọc ghi dữ liệu thông qua kết nối TCP hay UDP.
IO Stream
Java IO sử dụng khái niệm Stream để thao tác với dữ liệu. Sao lại là Stream, Stream là gì? Bạn có thể liên tưởng thế này:
Ứng dụng của chúng ta (Java Application) được xây dựng bên cạnh một dòng suối, hay một dòng chảy liên tục (dòng suối là stream trong tiếng Anh nha) và ứng dụng của chúng ta là một nhà máy chế biến gỗ.
Gỗ sẽ được khai thác trên thượng nguồn con suối, để dễ dàng vận chuyển thì khi chặt cây xong, người ta sẽ cưa những cây gỗ to thành các đoạn 8 nhỏ hơn, sau đó sẽ thả từng đoạn của một cây theo đúng thứ tự từ gốc lên ngọn vào dòng suối. Khi những đoạn gỗ chảy về tới ứng dụng của ta, chúng ta sẽ lấy những đoạn gỗ đó và xử lý. Chúng ta biết rằng đoạn nào tới trước thì là gần gốc hơn, đoạn nào tới sau thì là gần ngọn hơn và đúng 8 đoạn thì là một cây. Sau khi xử lý xong gỗ thô, để gửi gỗ đã xử lý đi chúng ta cũng làm tương tự khi nhận, ta thả những đoạn gỗ đã được xử lý xuống suối theo đúng thứ tự từ gốc lên ngọn, để các nhà máy bên dưới có thể lấy gỗ và xử lý tiếp. Oài, dài quá phải không, cố lên xíu nữa là hiểu khái niệm Stream một cách sâu và sắc rồi 😙 (Mình thích đoạn "sâu" 😄). Nhưng trước hết mời các bạn thưởng thức bức tranh nghệ thuật về dòng "suối mơ" của mình nha 😂
Giờ chúng ta có thể nói đoạn suối lúc chúng ta lấy những khúc gỗ gỗ thô là đoạn suối đầu vào (InputStream) và đoạn suối chúng ta thả những khúc gỗ đã được xử lý là đoạn suối đầu ra (OutputStream). Mỗi cái cây là một byte và một khúc là một bit (1 byte có 8 bits ⇔ 1 cây được cắt thành 8 khúc). Các bit trong Stream chảy giống như các khúc gỗ trong dòng suối vậy. Thế là chúng ta đã có khái niệm về Stream trong Java IO rồi, người ta gọi là Stream tại vì dữ liệu được truyền đi giống như một Stream 😂 Các bạn đã thấy thỏa mãn chưa ạ 😇.
InputStream & OutputStream
InputStream và OutputStream là ông tổ của các class "suối" khác, chúng chứa các phương thức để chúng ta có thể thao tác với dữ liệu như:
/**
* @since JDK1.0
*/
public abstract class InputStream implements Closeable {
public abstract int read() throws IOException; // Đọc 1 byte dữ liệu
public int read(byte b[]) throws IOException; // Đọc nhiều byte dữ liệu rồi ghi vào b[], giá trị trả về là số lượng byte được đọc
public int read(byte b[], int off, int len) throws IOException; // Đọc nhiều byte dữ liệu rồi ghi vào byte b[] từ vị trí "off" của b[] và ghi "len" byte
...
}
/**
* @since JDK1.0
*/
public abstract class OutputStream implements Closeable, Flushable {
public abstract void write(int b) throws IOException; // Ghi 1 byte dữ liệu
public void write(byte b[]) throws IOException; // Ghi nhiều byte dữ liệu từ b[]
public void write(byte b[], int off, int len) throws IOException; // Ghi nhiều byte dữ liệu từ byte b[] từ vị trí "off" của b[] và ghi "len" byte
public void flush() throws IOException; // Đẩy/Ghi dữ liệu từ bộ nhớ đệm,... được giải thích trong phần Buffered Stream nha
...
}
Để mình giải thích một chút, method int read() và void write(int) mình có note là đọc và ghi 1 byte dữ liệu, tại sao lại nhận và trả về giá trị integer? Việc trả về giá trị integer chứ không phải byte bởi vì nếu trả về byte thì không biết khi nào sẽ hết các thứ để đọc (End of File - EoF), byte để biểu diễn giá trị hết rồi còn đâu (không hiểu thì dừng ở đây nghĩ một lúc là hiểu nha 😂). Vì thế hàm read() sẽ trả về -1 nếu hết các thứ để đọc và trả về giá trị từ 0-255 để biểu diễn giá trị nhận được.
Oke lý thuyết là thế, giờ đến với ví dụ về InputStream và OutputStream nào. Ta sẽ làm ví dụ đọc dữ liệu từ file input.txt và ghi dữ liệu vào file output.txt nhé.
import java.io.*;
public class CopyBytes {
public static void main(String[] args) throws IOException {
InputStream in = null;
OutputStream out = null;
try {
in = new FileInputStream("input.txt");
out = new FileOutputStream("output.txt");
int c;
while ((c = in.read()) != -1) { // c == -1 có nghĩa là hổng còn gì để đọc nha
out.write(c);
}
} finally {
if (in != null) {
in.close();
}
if (out != null) {
out.close();
}
}
}
}
InputStream & OutputStream còn nhiều thứ thú vị nữa, nhưng trong khuôn khổ hạn chế của bài viết thì mình chỉ đề cập tới đây thôi, chúng ta còn chuyển tiếp sang phần tiếp theo ạ 🙏🏿
Buffered Stream
Trong ví dụ trên ta có thế thấy, chúng ta đọc từng byte rồi ghi từng byte vào file, điều đó rất tốn thời gian và tài nguyên. Ví dụ máy bạn sử dụng một ổ cứng HDD, hai file input.txt và output.txt được lưu trên đó. Ổ HDD thực chất là một cái đĩa, giống như đĩa CD vậy, và khi chạy thì nó sẽ quay. Đầu tiên khi đọc dữ liệu, OS (Hệ điều hành) phải chờ cho HDD quay cho tới khi vị trí ô nhớ của file input.txt tới đầu đọc, rồi đọc 1 byte dữ liệu, sau đó lại phải chờ cho tới khi HDD quay tới địa chỉ ô nhớ của file output.txt thì ghi 1 byte dữ liệu, cứ như thế. Quả là tốn thời gian và công sức.
Để tăng tính hiệu quả trong quá trình đọc ghi, chúng ta sẽ không đọc ghi 1 byte dữ liệu một lần, mà chúng ta sẽ đọc ghi nhiều byte dữ liệu một lần. Chúng ta viết lại ví dụ trên như sau:
import java.io.*;
public class CopyBytes {
public static void main(String[] args) throws IOException {
InputStream in = null;
OutputStream out = null;
try {
in = new FileInputStream("input.txt");
out = new FileOutputStream("output.txt");
int capacity = 3;
byte[] buffer = new byte[capacity];
int byteRead;
while ((byteRead = in.read(buffer)) != -1) { // byteRead == -1 có nghĩa là hổng còn gì để đọc nha
out.write(buffer, 0, byteRead);
}
} finally {
if (in != null) {
in.close();
}
if (out != null) {
out.close();
}
}
}
}
Để cuộc sống của các lập trình viên dễ dàng hơn thì Java đã viết sẵn cho chúng ta các class để lưu giá trị đọc ghi vào bộ nhớ đệm (buffer) trước khi chúng ta thao tác, đó là hai class BufferedInputStream và BufferedOutputStream. Chúng ta cùng viết lại ví dụ copy từng byte với hai class mới này nha.
import java.io.*;
public class CopyBytes {
public static void main(String[] args) throws IOException {
InputStream in = null;
OutputStream out = null;
try {
int capacity = 8;
// wrap FileInputStream bằng Buffer
in = new BufferedInputStream(new FileInputStream("input.txt"), capacity);
// wrap FileOutputStream bằng Buffer
out = new BufferedOutputStream(new FileOutputStream("output.txt"), capacity);
int c;
while ((c = in.read()) != -1) { // c == -1 có nghĩa là hổng còn gì để đọc nha
out.write(c);
}
} finally {
if (in != null) {
in.close();
}
if (out != null) {
out.close();
}
}
}
}
Bạn thấy đó, với Buffered Stream dữ liệu sẽ được lưu trong bộ nhớ đệm. Đối với BufferedInputStream khi chúng ta gọi method read() thì Java sẽ fill dữ liệu vào input buffer trước, sau đó ta làm việc với input buffer thôi. Đối với BufferedOutputStream Java sẽ lưu dữ liệu vào output buffer trước, khi out buffer đầy hoặc khi chúng ta gọi method flush() thì dữ liệu mới thật sự được đẩy đi.
Bài tập về nhà: Các bạn hãy viết lại các ví dụ về Java IO. Việc tự viết khiến các bạn phải tập trung vào từng dòng, câu lệnh, lúc đó chúng ta sẽ hiểu rõ hơn về Stream.
Stream implementation
Phần cuối cùng của Stream là những triển khai của nó. Có nhiều lớp triển khai Stream trong Java để phù hợp với từng tài nguyên khác nhau. Dưới đây là các triển khai đánh chú ý của Stream. Mình tin là với những kiến thức tổng quát ở trên thì các bạn cũng sẽ dễ dàng hơn trong việc tìm hiểu các Stream cho từng loại IO cụ thể.
Reader & Writer
Trong thực tế, khi xử lý IO ta rất thường xuyên làm việc với văn bản, mà đối với mỗi loại văn bản có thể có cách mã hóa (charater encoding) khác nhau. Ví dụ có ông thì sử dụng UTF-8, có ông thì sử dụng UTF-16 hay ASCII, chả biết đâu mà lần. Còn một vấn đề khó chịu hơn là mỗi loại character encoding lại cần lượng dữ liệu khác nhau để biểu diễn một ký tự hoặc thậm chí trong cùng một character encoding lại cần số lượng byte khác nhau để biểu diễn các ký tự khác nhau, ví dụ UTF-8 cần sử dụng 1 byte để biểu diễn ký tự b nhưng cần tới 2 byte để biểu diễn ký tự ú. Nếu chúng ta xử lý đống này bằng InputStream và OutputStream thì là cực hình: lúc nào thì 1 byte sẽ đại diện cho 1 ký tự, lúc nào thì 2 byte mới đại diện cho 1 ký tự, rồi khi đã có dữ liệu byte rồi thì ta chuyển thành ký tự thế nào? Lúc này đây chúng ta cần đến một thứ gì đó mới, mạnh mẽ hơn, ngon nghẻ hơn để xử lý văn bản, và hai ông thần Reader và Writer ra mặt 😎.
Reader và Writer là hai vị sư tổ của hai môn phái Read và Write với văn bản. Họ đã sáng tạo ra hai môn võ rất thâm hậu là đọc và ghi. Cùng xem qua bí kíp võ công của họ nha
/**
* @since JDK1.1
*/
public abstract class Reader implements Readable, Closeable {
public int read() throws IOException;
public int read(char[] var1) throws IOException;
public abstract int read(char[] var1, int var2, int var3) throws IOException;
...
}
/**
* @since JDK1.1
*/
public abstract class Writer implements Appendable, Closeable, Flushable {
public void write(int var1) throws IOException;
public void write(char[] var1) throws IOException;
public abstract void write(char[] var1, int var2, int var3) throws IOException;
public void write(String var1) throws IOException;
public void write(String var1, int var2, int var3) throws IOException;
public Writer append(CharSequence var1) throws IOExceptionl;
public abstract void flush() throws IOException;
...
}
Nhìn sơ qua ta thấy Reader không khác nhiều so với InputStream, nhưng Writer thì có thêm nhiều phương thức so với OutputStream để hỗ trợ cho việc ghi văn bản.
Một câu hỏi đặt ra là đâu là sự khác biệt rõ ràng của Reader, Writer và InputStream, OutputStream, khi phương thức quan trọng nhất lại trông giống hệt nhau ***void read(int), int writer()***. Chúng ta hãy cùng so sánh qua ví dụ dưới đây:
import java.io.*;
public class PrintBytes {
public static void main(String[] args) throws IOException {
System.out.println("Default Charset: " + Charset.defaultCharset());
InputStream in = null;
try {
in = new FileInputStream("input.txt"); // input.txt có giá trị là: bú
int c;
while ((c = in.read()) != -1) { // c == -1 có nghĩa là hổng còn gì để đọc nha
String binaryString = String.format("%8s", Integer.toBinaryString(c & 0xFF)).replace(' ', '0');
System.out.println("Byte: " + binaryString + " - UInt8: " + c + " - Char: " + (char) c);
}
} finally {
if (in != null) {
in.close();
}
}
}
}
// kết quả:
// Default Charset: UTF-8
// Byte: 01100010 - UInt8: 98 - Char: b
// Byte: 11000011 - UInt8: 195 - Char: Ã
// Byte: 10111010 - UInt8: 186 - Char: º
import java.io.*;
public class PrintCharacters{
public static void main(String[] args) throws IOException {
System.out.println("Default Charset: " + Charset.defaultCharset());
Reader in = null;
try {
in = new FileReader("input.txt"); // input.txt có giá trị là: bú
int c;
while ((c = in.read()) != -1) { // c == -1 có nghĩa là hổng còn gì để đọc nha
System.out.println("Unicode-Decimal: " + c + " - Char: " + (char) c);
}
} finally {
if (in != null) {
in.close();
}
}
}
}
// kết quả:
// Default Charset: UTF-8
// Unicode-Decimal: 98 - Char: b
// Unicode-Decimal: 250 - Char: ú
Ta thấy rằng cùng một đầu vào input nhưng khi đọc lại ra giá trị khác nhau.
Để giải thích mình sẽ nói sơ qua về quá trình decode từ byte sang character nha, mình sẽ nói về Unicode và UTF-8.
Đầu tiên, đối với ký tự bú thì có giá trị byte như sau trong UTF-8:
11000011 10111010 01100010
Để chuyển từ đoạn byte trên và hiển thị lên màn hình, ta có thể làm như sau:
Ta có thể coi Unicode là một cái bảng, cái bảng này kiểu thế này:
Decimal | Glyph |
---|---|
... | ... |
98 | b |
186 | º |
195 | Ã |
250 | ú |
... | ... |
Còn UTF-8 là cách biểu diễn các byte, để có thể từ các byte này ta suy ra được Decimal. Lúc đó ta tra trong Unicode table là ra được ký tự cần biểu diễn rồi.
Với ký tự "ú" thì trong Unicode ứng với Decimal là 250. Để biểu diễn được như thế thì UTF-8 phải sử dụng 2 byte: 11000011 10111010, rồi từ 2 byte này "biến hóa" để ra được Decimal là 250, sau đó tra trong Unicode table để hiểu là ký tự "ú". Còn đối với ký tự "b" thì có Decimal là 98, UTF-8 chỉ cần sử dụng 1 byte: 01100010 để biểu diễn mà thôi. (Về phần biến hóa này có lẽ mình phải làm một bài khác, có lẽ sau này, các bạn đón chờ nhé 😊)
Thế nên khi ta sử dụng InputStream, vì đọc từng byte nên ta thấy kết quả trả về là 3 byte như mình đã in ra ở ví dụ trên, phần Char: Ã hay Char: º là vì Java cố convert dữ liệu từ byte này sang character bằng cách coi Unit8 là Decimal trong Unicode để hiển thị lên màn hình nên thành ra thế (rồi đến giờ mà vẫn chưa hiểu thì lướt lại ví dụ là hiểu nha).
Còn khi ta sử dụng Reader, chỉ có hai giá trị integer được trả về, hai giá trị này chính là Decimal trong Unicode. Reader đã thực hiện hết các công đoạn "biến hóa" để ra được Decimal là 250.
Ta biết có nhiều lại character encoding để biểu diễn như UTF8, UTF16, ASCII,... Ở ví dụ trên không ghi sử dụng thằng nào thì Reader "biến hóa" như thế nào, biết character encoding nào để "biến hóa"? Mình cũng đã in ra ở trên, nếu không được chỉ định Charset, Reader sử dụng Charset.defaultCharset() để "biến hóa", đối với máy mình là UTF-8. Chúng ta có thể chỉ định thủ công Charset cho Reader như sau:
// JDK <= 6 phải kết hợp với Stream
Reader in = new InputStreamReader(new FileInputStream("input.txt"), StandardCharsets.US_ASCII);
// JDK > 7
Reader in = Files.newBufferedReader("input.txt", StandardCharsets.US_ASCII);
// JDK >= 11
Reader in = new FileReader("input.txt", StandardCharsets.US_ASCII);
Đó là về Reader, còn về Writer cũng tương tự như thế nha.
Qua hai ví dụ và giải thích ở trên có thể kết luận được rằng: InputStream, OutputStream đọc ghi với đơn vị cơ bản là byte, nên ta nói nó Byte Based, còn Reader và Writer đọc ghi với đơn vị cơ bản là character, nên ta nói nó Character Based.
BufferedReader & BufferedWriter
Java cũng cung cấp các lớp đệm cho Reader và Writer, phần này giống với Buffred Stream nên mình sẽ không nói thêm nhiều, chỉ có một vài lưu ý là đối với BufferedReader thì có thêm phương thức readLine() rất thuận tiện cho việc đọc văn bản, còn trong BufferedWriter thì không có phương thức ***writeLine()***, thay vào đó chúng ta phải kết hợp phương thức write() và newLine() để tạo ra một dòng văn bản.
BufferedReader in;
BufferedWriter out;
String lineValue = in.readLine(); // đọc một dòng dữ liệu
out.write("abc"); // ghi string
out.newLine(); // xuống dòng
PrintWriter
Trong khi làm việc với văn bản, đôi lúc chúng ta muốn in ra những văn bản có định dạng phức tạp, Java cung cấp cho chúng ta một class rất tiện lợi để làm việc này: PrintWriter. PrintWriter cung cấp cho chúng ta một loạt các phương thức print để dễ dàng định dạng văn bản theo ý muốn, hãy xem xét ví dụ bên dưới
PrintWriter printWriter;
printWriter.print(1234); // in số
printWriter.println("abc"); // in một dòng
printWriter.printf("Number: %d", 42); // in theo định dạng
printWriter.format("Formatted number: %.2f", 3.14159); // in theo định dạng
printWriter.println(); // xuống dòng
Ngoài ra PrintWriter nhiều method print khác nữa, nhưng về cơ bản gồm các loại trên, rất hữu ích phải không nào.
Còn một điều nữa là nếu chúng ta kết hợp giữa PrintWriter và OuputStream (Sẽ đề cập ngay dưới đây nhé, không phải lo 😌) thì PrintWriter sẽ sự động thêm lớp BufferedOutputStream, nên các bạn không cần phải thêm thêm Buffer nữa, class PrintWriter được định nghĩa kiểu thế này
public class PrintWriter {
Writer out;
public PrintWriter(OutputStream var1, boolean autoFlush) {
this.out = (Writer)(new BufferedWriter(..var1..)); // bọc outputStream bằng buffer
}
}
À, còn thêm một điều nữa là PrintWriter hỗ trợ auto flush, bạn nhớ method flush() trên Buffered Stream chứ, không nhớ thì đọc lại nha. Nếu bạn bật auto flush thì mỗi khi xuống dòng thì PrintWriter sẽ tự động gọi method flush() cho ta.
printWriter.println("Hello, world!"); // xống dòng nè
printWriter.printf("Number: %d\\n", 42); // xuống dòng nữa vì có \\n nè
printWriter.format("Formatted number: %.2f\\n", 3.14159); // đây cũng xuống dòng nè
printWriter.println(); // xuống dòng tiếp
Reader & Writer Implementation
Java có nhiều lớp triển khai Reader và Writer. Phần này dài quá, viết cực lắm, các bạn ráng tự tìm hiểu nha (mà mình cũng chẳng biết hết mà viết, hì hì). Quan trọng là chúng ta đã nắm được phần lõi rồi thì mình tin là tìm hiểu mấy đống này cũng không có gì khó khăn cả.
Kết hợp Stream và Reader
Stream và Reader có thể kết hợp với nhau tạo thành một phiên bản Lưỡng Long Nhất Thể "Gotenk" 😂 với sức mạnh vượt trội.
Reader in = new InputStreamReader(new ...InputStream(...));
Writer out = new OutputStreamWriter(new ...OutputStream(...));
Trên đây là sự kết hợp tạo ra "Gotenk", còn trong thực tế chúng ta sử dụng BufferedReader và PrintWriter để tạo ra một phiên bản Lưỡng Long Nhất Thể mạnh hơn: "Gogeta", ta sẽ có các kỹ năng cần thiết để chiến đấu với quái vật mang tên văn bản rồi.
BufferedReader in = new BufferedReader(new InputStreamReader(new ...InputStream(...)));
PrintWriter out = new PrintWriter(new ...OutputStream(...));
Nhớ tại sao không cần BufferedWriter cho PrintWriter chứ, nhanh quên vậy sao, vậy đọc lại từ đầu để nhớ nha ☝🏿☝🏿 🤭
Bài tập về nhà: Các bạn hãy viết một chương trình sử dụng BufferedReader và PrintWriter để xử lý văn bản. Tại sao cần làm bài tập này? Nhiều khi đọc chúng ta ậm ừ là hiểu rồi nhưng có thể chúng ta chưa hiểu rõ lắm, việc viết từng dòng, từng câu lệnh khiến các bạn phải để ý từng chút một, lúc đó ta sẽ ngộ ra được nhiều điều hơn đó nha.
Kết luận
Bài này này chúng ta đã cùng tìm hiểu kiến thức chính về Java IO: Stream, Buffer, Reader và Writer. Stream là byte-based được sử dụng để thao tác với các byte, còn Reader và Writer là character-based được sử dụng để thao tác với character. Lý do nên sử dụng Reader và Writer để thao tác với character vì vấn đề character encoding. Cuối cùng là Buffer được dùng để cache dữ liệu giúp đọc và ghi nhanh hơn. Trong bài tiếp theo chúng ta sẽ cùng kết hợp Java IO và Java Networking để tạo thành một Server nhé.
Các bạn mới, có thể lần đầu đọc chưa hiểu lắm thì nên đọc lại bài này một lần nữa để thấm nhuần tư tưởng nha, bài này rất hữu ích để các bạn có được cái nhìn tổng quan trước khi tìm hiểu những cái chi tiết đó.
Link bài viết gốc: https://truongphuoc.wordpress.com/2024/07/18/javanet-p2-java-io/
All rights reserved