不學 JAVA 換學 C# 之覺得心累 - L1:ch12 檔案與資料流

AdSense

前言

在 C# 中,操作檔案與資料流是十分重要的,尤其當我們需要將資料儲存下來、讀取設定檔、紀錄使用日誌或處理大筆資料時。透過檔案與資料流的操作,我們可以有效地將程式與外部檔案進行互動。

暫存 vs 永久儲存

  • 暫存 (Temporary / Volatile storage):存在記憶體 (RAM) 中,程式結束或斷電後資料會遺失,例如變數。
  • 永久儲存 (Permanent / Nonvolatile storage):存在硬碟、USB 等儲存裝置中,不會因關機而消失,例如文字檔案。

檔案

檔案 (File) 是一組資訊的集合,儲存在永久儲存性裝置上。又分為:

  • 文字檔案 (Text File):可使用文字編輯器開啟,如 .txt, .csv
  • 二進位檔案 (Binary File):儲存非文字資料,以二進為格式儲存,如圖片、音訊等。

Write 寫入

將資料從「記憶體 (RAM)」中複製到「儲存裝置的檔案」中

Read 讀取

將資料從「儲存裝置的檔案」中複製到「記憶體 (RAM) 」中

File、Directory 與 Path 靜態類別

System.IO 命名空間中,C# 提供一些靜態類別,讓我們不用建立物件,就能方便快速地處理檔案、資料夾和路徑,畢竟簡單地處理檔案、資料夾和路徑是不需要依賴個別物件的。

File 類別

操作檔案。

  • File.Exists(path):檢查檔案是否存在。
  • File.GetCreationTime(path):取得檔案建立時間。
  • File.ReadAllText(path):一次性讀取整個檔案內容為一個字串,適合用在小檔案、組態設定或純文字格式 (如 .txt, .json, .csv)。
  • File.WriteAllText(path, content):一次性寫入整個字串到檔案中。如果檔案已經存在,會直接覆蓋原有內容。如果檔案不存在,會自動建立。
  • File.OpenRead(path, content):回傳 FileStream 檔案資料流,可以搭配 StreamReader 來逐步讀取,適合大檔案。記得使用 using 釋放資源。

Directory 類別

操作資料夾。

  • Directory.Exists(path):檢查資料夾是否存在。
  • Directory.GetFiles(path):取得資料夾中所有檔案名稱陣列。

Path 類別

處理檔案或資料夾的路徑字串。

  • Path.Combine(path1, path2, path3):拼出完整路徑。
  • Path.GetFileName(path):取得檔案名稱。

練習

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
using System;
using static System.Console;
using System.IO;

namespace FileDirectoryPath;

class MainClass
{
public static void Main()
{
// string fileName = "JS_Note.txt";
// File 靜態 class 傳入的參數,必須是完整路徑,不能只有 JS_Note.txt,會找不到
string fileName = "/Users/zhengzhurong/Desktop/JS_Note.txt";
// Directory 靜態 class 傳入的參數,必須是完整路徑
string dirName = "/Users/zhengzhurong/Desktop";

WriteLine("執行此程式的目錄:\n" + Directory.GetCurrentDirectory());

// File 是靜態類別 (static class),不能創造物件,直接使用
if (File.Exists(fileName))
{
WriteLine("\nFile exists!");

// Attributes:Compressed 是檔案已壓縮、Directory 是檔案是目錄、Hidden 是檔案被隱藏
// Normal 是檔案是沒有特殊屬性的標準檔案、ReadOnly 是唯獨檔案、System 是檔案是系統檔案
WriteLine("檔案屬性:" + File.GetAttributes(fileName));
WriteLine("建檔時間:" + File.GetCreationTime(fileName));
WriteLine("最後一次寫入檔案時間:" + File.GetLastWriteTime(fileName));
}
else { WriteLine("File doesn't exist!"); }

// Directory 是靜態類別 (static class),不能創造物件,直接使用
if (Directory.Exists(dirName))
{
WriteLine("\nDirectory exists!");
// 如果這邊傳入的是 fileName,會取得 fileName 的資訊,不會報錯
WriteLine("建立目錄時間:" + Directory.GetCreationTime(dirName));
WriteLine("最後一次寫入目錄時間:" + Directory.GetLastWriteTime(dirName));
WriteLine("根目錄:" + Directory.GetDirectoryRoot(dirName));
WriteLine("目錄的父層:" + Directory.GetParent(dirName));
}
else { WriteLine("Directory doesn't exist!"); }

// Path 是靜態類別 (static class),不能創造物件,直接使用
WriteLine("\n檔案名稱:" + Path.GetFileName(fileName));
WriteLine("檔案所在資料夾:" + Path.GetDirectoryName(fileName));
WriteLine("目錄名稱:" + Path.GetFileName(dirName));
WriteLine("目錄所在資料夾:" + Path.GetDirectoryName(dirName));

ReadLine();
}
}

搭配使用範例

1
2
3
4
5
6
7
8
9
10
string folder = "C:\\Data";
string fileName = "report.txt";
string fullPath = Path.Combine(folder, fileName);

if (!Directory.Exists(folder))
{
Directory.CreateDirectory(folder);
}

File.WriteAllText(fullPath, "報表內容...");

位元 (bit) 和 位元組 (byte)

bit 是電腦中資料的最小單位,全名為 binary digit (二進位數位),它的值只能是 01,分別代表關閉與開啟的狀態。在電腦系統中,所有資料最終都是由大量的 bit 所組成,例如文字、圖片、影片、聲音等資訊,都是以二進位的形式存在。

byte 是電腦儲存和處理資料時最基本的單位之一,由 8 個 bit (位元) 所組成。由於一個 bit 只能表示 0 或 1,資訊量非常有限,而一個 byte 則可以表示 256 種不同的數值 (從 0 到 255),足以儲存一個英文字元,例如 A。因此,我們必須熟悉以 byte 為單位的資料大小,如:1KB = 1024 bytes。

檔案的資料結構

  • 字元 (Character):資料最小單位,由 byte(s) 組成,而 1 byte = 8 bits。
  • 欄位 (Field):有意義的字元集合,如姓名、員工編號。
  • 紀錄 (Record):相關欄位集合,例如一位員工的完整資料。
  • 檔案 (File):多筆員工資料的集合。

有兩種存取方式:

  • Sequential Access (順序存取):資料依序讀寫,欄位也可以拿來當作每一筆紀錄的 id,適合日誌、報表。
  • Random Access (隨機存取):可直接跳到檔案任意位置,適合資料庫應用。

資料流 Stream

資料流 (stream)在應用程式與裝置 (如硬碟、鍵盤、螢幕) 之間傳遞位元組 (byte) 的通道。它像水管一樣,把資料從輸入端 (像是鍵盤、檔案) 傳遞到程式,或從程式傳送到輸出端 (像是螢幕、檔案)。

鍵盤 -> 程式、程式 -> 螢幕:使用 Console 類別。
檔案 -> 程式、程式 -> 檔案

  • FileStream:直接操作檔案的原始位元組 (bytes),可以讀取 .txt.jpg.mp3.exe 等任何檔案的內容 (不論格式),相比於讀取文字檔,更適合讀寫大型檔案或二進位資料 (例如圖片、音樂)
  • StreamReader:讀取文字檔。
  • StreamWriter:寫入文字檔。

使用完 stream 需用 Close() 關閉,以確保資料寫入與釋放資源。

或是

使用 using 自動釋放資源,using 區塊結束時會自動呼叫 Dispose()

使用 FileStream

使用 FileStream 操作字串需要自己手動將字串轉成 byte,或將 byte 轉成字串

寫入

建立一個新檔案,使用 UTF-8 編碼格式,把字串寫入檔案中。

  1. 建立一個檔案資料流物件 fs,用來操作一個名稱為 "example.txt" 的檔案,FileMode.Create 表示若檔案存在會覆蓋,不存在則建立新檔案,而 FileAccess.Write 會將資料流設定為寫入模式
  2. 將字串 "Hello World" 轉為 UTF-8 編碼的位元組陣列
  3. 將位元組資料寫入檔案中,0 是起始位置,data.Length 是要寫入的長度。
  4. 關閉檔案資料流,釋放資源並確保資料正確寫入。
1
2
3
4
5
6
using System.Text; // 引入 `Encoding.UTF8` 所需的命名空間。

FileStream fs = new FileStream("example.txt", FileMode.Create, FileAccess.Write);
byte[] data = Encoding.UTF8.GetBytes("Hello World");
fs.Write(data, 0, data.Length);
fs.Close();

讀取

讀取檔案內容,並將位元組轉為字串顯示在 Console。

  1. 建立檔案資料流物件 fs,開啟檔案 "image.jpg",將資料流設定為唯讀模式
  2. 檢查檔案大小是否超過陣列可用最大長度 int.MaxValue
  3. 檔案大小超過 int.MaxValue:分批讀取
    a. 建立固定大小的 buffer 位元組陣列 (緩衝區)。
    b. 使用迴圈搭配 fs.Read(...) 逐區塊讀取資料進入 buffer,並將結果轉成 UTF-8 字串,輸出到 Console
  4. 檔案大小未超過 int.MaxValue:一次性讀取
    a. 建立和檔案一樣大的 buffer 位元組陣列。
    b. 一次讀取整個檔案進入 buffer,將全部結果一次轉成 UTF-8 字串,並輸出到 Console
  5. 關閉檔案資料流,釋放資源並確保資料正確寫入。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
FileStream fs = new FileStream("image.jpg", FileMode.Open, FileAccess.Read);

// C# 中陣列長度必須是 int
// 如果「檔案的總位元組大小 (fs.Length) 」超過「陣列能設定的最大長度」
// 分批讀取
if (fs.Length > int.MaxValue)
{
byte[] buffer = new byte[1024]; // 建立固定大小的「buffer 位元組陣列」來接收檔案內容
UTF8Encoding temp = new UTF8Encoding(true);
int readLength;
// 讀取 fs 的資料到 buffer 中,一次只讀取 1024 的長度
// 如果寫入 buffer 的真正長度 readLength 有大於 0
while ((readLength = fs.Read(buffer, 0, buffer.Length)) > 0)
{
// 將 byte[] 轉成字串
string result = Encoding.UTF8.GetString(buffer, 0, readLength);
Console.WriteLine(result);
}
}
// 如果「檔案的總位元組大小 (fs.Length) 」沒有超過「陣列能設定的最大長度」
// fs.Length 可以當作陣列長度,將資料一次讀取完畢
else
{
byte[] buffer = new byte[fs.Length]; // 建立和檔案一樣大的「buffer 位元組陣列」來接收檔案內容
fs.Read(buffer, 0, buffer.Length); // 讀取 fs 的資料到 buffer 中

// 將 byte[] 轉成字串
string result = Encoding.UTF8.GetString(buffer);
Console.WriteLine(result);
}

fs.Close();

使用 StreamWriterStreamReader

如果確定處理的是文字資料 (例:文字檔、CSV、設定檔),直接使用 StreamWriterStreamReader,因為它們會自動處理文字與位元組的編碼解碼,不需要手動將字串轉成 byte

寫入

1
2
3
StreamWriter sw = new StreamWriter("data.txt");
sw.WriteLine("Hello, world!");
sw.Close();

改用 using的寫法 (自動關閉資源):

1
2
3
4
using (StreamWriter sw = new StreamWriter("data.txt"))
{
sw.WriteLine("Hello, world!");
}

讀取

1
2
3
4
StreamReader sr = new StreamReader("data.txt");
string line = sr.ReadLine();
Console.WriteLine(line);
sr.Close();

改用 using的寫法 (自動關閉資源):

1
2
3
4
5
using (StreamReader sr = new StreamReader("data.txt"))
{
string line = sr.ReadLine();
Console.WriteLine(line);
}

使用 FileStream 搭配 StreamWriterStreamReader

有點多此一舉,但是也可以這樣搭配使用。

寫入

1
2
3
4
5
6
7
using (FileStream fs = new FileStream("data.txt", FileMode.Create, FileAccess.Write))
{
using (StreamWriter sw= new StreamWriter(fs, Encoding.UTF8))
{
sw.WriteLine("Hello, world!");
}
}

讀取

1
2
3
4
5
// 條狀 using (C# 8.0+ 的寫法)
using FileStream fs = new FileStream("data.txt", FileMode.Open, FileAccess.Read);
using StreamReader sr = new StreamReader(fs, Encoding.UTF8);
string line = sr.ReadLine();
Console.WriteLine(line);

支援隨機存取

可用 Seek(offset, origin) 方法重設讀取指標位置,offset 是以位元組為單位的偏移量 ,而 origin 是參考起點

注意:Seek() 只適用於支援可定位的 stream,否則會拋出例外。

1
fs.Seek(0, SeekOrigin.Begin); // 從頭開始重讀

序列化和反序列化 (Serialization / Deserialization)

序列化

物件轉換為可以儲存的格式,例:JSON、XML 等。主要是為了方便將物件儲存在資料庫、記憶體或檔案中。

反序列化

儲存的資料還原回原本的物件

使用原因

  • 可將物件直接轉為儲存格式儲存起來,再從儲存格式還原為物件,序列化時會自動處理欄位順序、類型、巢狀結構,資料結構不會錯亂。
  • 比文字儲存更安全,因為可附加加密或簽章。

二進位格式 BinaryFormatter

BinaryFormatter 在 .NET 5.0 之後被標記為不安全並建議避免使用,適合用來學習什麼是序列化和反序列化,但實務上推薦用 System.Text.JsonXmlSerializer 等方式。

使用 BinaryFormatter 一定要在 class 上加 [Serializable] 屬性,否則會拋出例外。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;

[Serializable] // 標記類別可序列化
class Person
{
public string Name;
public int Age;
}

class Program
{
static void Main()
{
string path = "data.txt";

// 序列化
Person p = new Person { Name = "Alice", Age = 30 };
using (FileStream fs = new FileStream(path, FileMode.Create, FileAccess.Write))
{
BinaryFormatter formatter = new BinaryFormatter();
formatter.Serialize(fs, p); // 將物件寫入檔案
}
Console.WriteLine("序列化完成");

// 反序列化
using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read))
{
BinaryFormatter formatter = new BinaryFormatter();
Person person = (Person)formatter.Deserialize(fs); // 從檔案還原物件
Console.WriteLine($"反序列化結果:{person.Name}, {person.Age}");
}
}
}

JSON 格式 JsonSerializer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System;
using System.IO;
using System.Text;
using System.Text.Json;

class Person
{
public string Name { get; set; }
public int Age { get; set; }
}

class Program
{
static void Main()
{
string path = "data.txt";

// 序列化:物件轉 JSON 字串,並寫入檔案
Person p = new Person { Name = "Alice", Age = 30 };
using (StreamWriter sw = new StreamWriter(path))
{
string jsonString = JsonSerializer.Serialize(p);
sw.Write(jsonString); // 將 JSON 字串寫入檔案
}
Console.WriteLine("序列化完成:");

// 反序列化:讀取檔案,並將 JSON 字串轉回物件
using (StreamReader sr = new StreamReader(path))
{
string jsonString = sr.ReadToEnd(); // ReadToEnd() 可以讀完整個 JSON
Person person = JsonSerializer.Deserialize<Person>(jsonString); // 將 JSON 字串轉回物件
Console.WriteLine($"反序列化結果:{person.Name}, {person.Age}");
}
}
}