Learning Objectives
- 用 file_get_contents、file_put_contents(含 FILE_APPEND)與 file() 一次讀寫整個檔案
- 說明 fopen 的 r、w、a、x 模式差在哪裡,並用 fgets 逐行讀取大檔案
- 處理檔案不存在的情況:看懂 Warning、先用 file_exists 檢查
- 用 mkdir、scandir、glob、rename、copy、unlink 管理檔案,用 basename、dirname、pathinfo 拆解路徑
- 讀寫 CSV 與 JSON 檔,並說明為什麼不能直接用使用者輸入當檔名
讓資料留下來
變數只活在程式執行的那一段時間:網頁回應送出、程式結束,變數就消失了。要讓資料下次還在,就要寫進檔案或資料庫。 小型網站常用檔案記錄這些東西:
- 設定檔(網站名稱、每頁顯示幾筆),常用 JSON 格式
- 紀錄檔(log),每發生一件事就在檔案最後面加一行
- 匯入與匯出的資料,常用 CSV 格式,Excel 也能打開
- 使用者上傳的圖片與文件
PHP 的檔案函式分成兩類:一次讀寫整個檔案(簡單,適合小檔案),以及打開檔案、一行一行處理(適合大檔案)。
在 CodeLab 裡,程式在一個虛擬的網站根目錄
/var/www/html中執行,檔名寫相對路徑(todo.txt)就是這個目錄裡的檔案。 範例需要的檔案會先放好;程式寫出來的檔案,可以在播放器的「檔案」分頁看到執行結束時的內容。
一次讀寫整個檔案
todo.txt 已經放好三行待辦。注意第 10、12、14 行三次寫入 done.txt 的差別:有 FILE_APPEND 是加在最後面,沒有就是整個覆蓋。最後一段示範讀取不存在的檔案。
Warning 不會停止程式
讀取不存在的檔案時,PHP 印出 Warning、回傳 false,然後繼續執行下一行:
Warning: file_get_contents(memo.txt): Failed to open stream: No such file or directory in /var/www/html/index.php on line 17
如果沒有檢查回傳值,false 會被當成空字串一路往下用,最後在很遠的地方出錯,很難找到原因。兩個好習慣:
- 讀之前先用
file_exists($path)檢查(或用is_file,它還會確認不是目錄) - 讀完檢查回傳值:
if ($text === false) { … }
正式網站不會把 Warning 顯示給使用者看(display_errors 會關閉,只寫進紀錄檔),所以程式要自己處理失敗的情況。
把錯誤統一交給例外處理的作法,下一課(第 13 課)介紹。
| 函式 | 回傳 |
|---|---|
file_exists($p) |
檔案或目錄存在就是 true |
is_file($p) |
是一般檔案才是 true |
is_dir($p) |
是目錄才是 true |
filesize($p) |
檔案大小(位元組) |
fopen:打開檔案,一行一行處理
file_get_contents 和 file() 會把整個檔案讀進記憶體。紀錄檔可能有幾 GB,一次讀進來會用光記憶體(PHP 預設每個請求的記憶體上限是 128 MB)。
大檔案要用 fopen 打開,拿到一個檔案代碼(handle),再一次讀一行:
$fp = fopen('access.log', 'r'); // 打開,拿到檔案代碼
while (($line = fgets($fp)) !== false) { // 讀一行;讀到檔案結尾回傳 false
// 處理 $line(包含結尾的換行字元)
}
fclose($fp); // 用完關閉
同一時間記憶體裡只有一行,再大的檔案都能處理。fopen 的第二個參數是模式:
| 模式 | 用途 | 檔案不存在 | 檔案已存在 | 寫入位置 |
|---|---|---|---|---|
r |
只讀 | 失敗(Warning,回傳 false) | 從頭讀 | 不能寫 |
w |
只寫 | 建立 | 清空原本的內容 | 從頭寫 |
a |
附加(append) | 建立 | 保留內容 | 一律寫在最後面 |
x |
建立新檔 | 建立 | 失敗(Warning,回傳 false) | 從頭寫 |
在模式後面加 +(r+、w+、a+、x+)表示同時可以讀和寫。最常犯的錯誤是想「加一行」卻用了 w,把原本的資料整個清掉。
x 適合「只在檔案不存在時才建立」,例如避免蓋掉別人已經上傳的同名檔案。
| 函式 | 作用 |
|---|---|
fgets($fp) |
讀一行(含換行字元),沒有了回傳 false |
fwrite($fp, $s) |
寫入字串,回傳寫入的位元組數 |
feof($fp) |
是否已經讀到檔案結尾 |
fclose($fp) |
關閉檔案 |
逐行找出錯誤紀錄
access.log 有 5 行紀錄,程式只挑出含有 ERROR 的行。看變數區的 $line:fgets 讀到的字串結尾有換行,第 6 行用 rtrim 去掉。$fp 顯示成 resource:這是 PHP 表示「打開的檔案」的特殊型別。
想一想:讀寫模式
下面的程式會印出什麼?
<?php
file_put_contents('a.txt', "one\n");
file_put_contents('a.txt', "two\n");
file_put_contents('a.txt', "three\n", FILE_APPEND);
echo implode(',', file('a.txt', FILE_IGNORE_NEW_LINES));
notes.txt 的內容是兩行 ab、cd(每行結尾都有換行)。下面的程式會印出什麼?
<?php
$lines = file('notes.txt');
echo strlen($lines[0]), ' ', count($lines);
你想在 log.txt 的最後面加一行紀錄,而且不能清掉原本的內容。fopen('log.txt', ?) 該用哪個模式?
目錄與路徑
| 函式 | 作用 |
|---|---|
mkdir($dir) |
建立目錄;已經存在時出現 Warning、回傳 false |
mkdir($dir, 0777, true) |
第三個參數 true:一次建立多層目錄(backup/2026/09) |
scandir($dir) |
列出目錄裡的所有項目(含 . 與 ..),依名稱排序 |
glob('uploads/*.txt') |
用萬用字元找檔案,回傳路徑陣列 |
copy($from, $to) |
複製檔案 |
rename($from, $to) |
改名或搬移 |
unlink($file) |
刪除檔案(刪目錄用 rmdir,目錄要先清空) |
拆解路徑的函式只處理字串,不會檢查檔案是否存在:
| 函式 | '/var/www/html/uploads/report.final.pdf' |
|---|---|
basename($p) |
report.final.pdf |
dirname($p) |
/var/www/html/uploads |
pathinfo($p)['extension'] |
pdf(最後一個點之後) |
pathinfo($p)['filename'] |
report.final |
__DIR__ 是「目前這支程式所在的目錄」。程式被別的檔案 include(第 15 課)時,相對路徑是以執行的那支程式為準,
所以實務上常寫 __DIR__ . '/data/config.json',不論從哪裡被呼叫都指向同一個檔案。
管理上傳目錄
這個範例幾乎沒有變數,重點在輸出:第 8、9 行的 scandir、glob 列出目錄當下的內容,可以看出 copy、rename 的效果。程式結束時的檔案內容在播放器的「檔案」分頁(被刪除或改名的舊檔案顯示成空白)。
CSV 檔
CSV(comma-separated values)是最常見的表格交換格式:一行一筆資料,欄位用逗號分隔,第一行通常是欄位名稱。
name,dept,note
Amy,資工,"喜歡 PHP"
小明,企管,"早餐, 午餐"
麻煩在於欄位內容本身也可能有逗號或引號。不要自己用 explode(',', $line) 拆,交給 PHP 的兩個函式處理:
fputcsv($fp, $陣列):把一個陣列寫成一行。欄位含有逗號、空白、雙引號或換行時,會用雙引號包起來;內容裡的雙引號寫成兩個("")fgetcsv($fp):讀一行並拆成陣列,會正確處理引號;讀到結尾回傳false
讀進來的值一律是字串,要計算時再轉成數字。常見的寫法是先讀第一行當欄位名稱,再用 array_combine($header, $row) 把每一行變成關聯陣列。
用 Excel 打開 PHP 寫出的 UTF-8 CSV,中文可能變成亂碼(Excel 預設用系統的編碼讀檔)。這時可以在檔案開頭先寫入
"\xEF\xBB\xBF"(UTF-8 的 BOM),讓 Excel 認出編碼。
寫入與讀取 CSV
看第 14 行印出的 CSV:「喜歡 PHP」有空白、「早餐, 午餐」有逗號、他說 "hi" 有雙引號,三者都被加上雙引號。讀回來的時候,fgetcsv 把它們還原成原本的字串。
JSON 檔
JSON 可以存巢狀的資料(陣列裡有陣列),很適合當設定檔,也是網頁 API 最常用的格式。
| 寫法 | 作用 |
|---|---|
json_encode($a) |
陣列 → JSON 字串(中文預設寫成 \u4e2d 這種跳脫碼) |
json_encode($a, JSON_PRETTY_PRINT \| JSON_UNESCAPED_UNICODE) |
加上縮排與換行,中文直接寫出來,方便人閱讀 |
json_decode($s, true) |
JSON 字串 → 關聯陣列 |
json_decode($s) |
JSON 字串 → 物件,用 $obj->theme 讀取 |
json_last_error_msg() |
上一次 JSON 轉換的錯誤訊息 |
兩個常數用 | 連起來,表示同時開啟兩個選項。json_decode 遇到格式錯誤的 JSON 回傳 null,不會出現 Warning,要自己檢查。
(PHP 8.3 另外加入 json_validate($s),只檢查字串是不是合法的 JSON。)
存檔的固定模式:讀出 → 解碼成陣列 → 修改 → 編碼 → 寫回。
儲存與讀回設定
比較第 5 行和第 9 行印出的 JSON:前者中文變成跳脫碼、全部擠在一行;加上兩個選項之後有縮排、中文直接顯示。第 11 行的第二個參數 true 讓 json_decode 回傳關聯陣列。
不要直接用使用者輸入當檔名
假設網址 view.php?file=a.txt 會顯示 docs/a.txt 的內容:
// 危險:使用者可以讀到網站上的任何檔案
echo file_get_contents('docs/' . $_GET['file']);
如果有人送出 ?file=../config/db.php,路徑就變成 docs/../config/db.php,.. 代表上一層目錄,結果讀到了資料庫密碼。
這叫做路徑穿越(path traversal)。兩道防線:
$name = basename($_GET['file'] ?? ''); // 只留下最後一段,去掉所有目錄
$allowed = ['a.txt', 'b.txt', 'intro.txt']; // 白名單:只允許這幾個檔名
if (!in_array($name, $allowed, true)) {
exit('找不到這個檔案');
}
echo file_get_contents(__DIR__ . '/docs/' . $name);
上傳檔案時也一樣:不要沿用使用者給的檔名,由程式產生新的檔名,並限制副檔名。安全性的完整說明在第 18 課。
想一想:CSV、JSON 與路徑
下面的程式會印出什麼?
<?php
$fp = fopen('t.csv', 'w');
fputcsv($fp, ['Amy', 'a,b', 'c']);
fclose($fp);
echo file_get_contents('t.csv');
下面的程式執行的結果是什麼?
<?php
$data = json_decode('{"name":"Amy"}');
echo $data['name'];
下面的程式會印出什麼?
<?php
echo basename('../../config/db.php');
動手做:從 CSV 產生成績報表
scores.csv 已經放好,欄位是 name,mid,final。請讀取每一位學生,算出兩次分數的平均,寫成新的 CSV 檔 report.csv:第一行是 name,avg,之後每人一行。最後把 report.csv 的內容印出來。
重點回顧
- 小檔案:
file_get_contents讀成字串、file()讀成一行一個元素的陣列(FILE_IGNORE_NEW_LINES去掉換行)、file_put_contents寫入(預設覆蓋,FILE_APPEND附加) - 大檔案:
fopen→fgets逐行讀(結尾回傳 false)/fwrite寫 →fclose;同一時間記憶體裡只有一行 - 模式:
r只讀、w清空後寫、a附加在最後、x只建立新檔(已存在就失敗) - 檔案不存在時是 Warning + 回傳 false,程式不會停;用
file_exists/is_file先檢查,或用=== false檢查回傳值 - 目錄:
mkdir(第三個參數 true 建立多層)、scandir、glob、copy、rename、unlink;路徑:basename、dirname、pathinfo、__DIR__ - CSV 用
fputcsv/fgetcsv,不要自己用逗號拆;讀進來的值都是字串 - JSON:
json_encode($a, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)、json_decode($s, true)拿到陣列;格式錯誤回傳 null - 不要直接用使用者輸入當檔名:
basename+ 白名單(第 18 課)