Chuyển đến nội dung chính (Skip to content)
Mục lục bài viết
PHP

PHP EP 16: Đọc ghi tệp, khóa tệp và xử lý JSON trong PHP

PHP EP 16: Đọc ghi tệp, khóa tệp và xử lý JSON trong PHP

Thực hành thao tác Filesystem và luồng I/O trong PHP: tạo thư mục tạm an toàn, khóa tệp bằng flock, kiểm tra đường dẫn realpath và giải mã JSON bắt ngoại lệ.

Đọc ghi tệp và giải mã JSON cần kiểm tra lỗi

Khi xây dựng tính năng lưu tệp cấu hình hoặc trao đổi dữ liệu JSON, hai lỗi phổ biến thường xuất hiện trên môi trường đa tiến trình: thứ nhất là hai request cùng ghi vào một tệp tại cùng thời điểm mà không có cơ chế khóa (flock), khiến nội dung tệp bị cắt cụt hoặc trộn lẫn làm hỏng cấu trúc; thứ hai là gọi json_decode($raw, true) ở chế độ mặc định mà không biết rằng hàm này chỉ âm thầm trả về null khi gặp chuỗi JSON sai cú pháp, dẫn tới việc ứng dụng hiểu nhầm dữ liệu đang rỗng.

Bài học này hướng dẫn bạn thao tác với hệ thống tệp (Filesystem) và luồng vào/ra (Streams I/O) an toàn trong PHP 8.4: kiểm chứng đường dẫn bằng realpath() chống tấn công vượt thư mục (Path Traversal), sử dụng fopen('cb+') kết hợp khóa độc quyền flock(LOCK_EX), và bật cờ JSON_THROW_ON_ERROR khi xử lý JSON. Bài viết kế thừa kiến thức từ PHP EP 15: Xử lý ngoại lệ PHP với Throwable và try/catch và chuẩn bị cho PHP EP 17: Xử lý form HTTP và mã hóa đầu ra trong PHP. Tài liệu tham chiếu gồm PHP Manual: Filesystem Functions, Streams và JSON Functions.

Hồ sơ kiểm chứng & Môi trường Lab (Verification Profile):

  • Ngày kiểm chứng: 2026-10-26.
  • Môi trường thực thi: Container cô lập php-course-qa:8.4 (--network none, --read-only, --tmpfs /tmp:rw,exec,nosuid,size=64m, user phplab UID 1001).
  • Phiên bản runtime: PHP 8.4.27 (cli) trên Alpine Linux 3.21 (aarch64), Zend Engine v4.4.27.
  • Giới hạn kiểm chứng: Kiểm chứng thao tác tệp cục bộ, khóa flock và xử lý JSON trên hệ thống tệp POSIX (tmpfs); không tuyên bố kiểm chứng khóa tệp qua mạng NFS/SMB.

Hợp đồng của hàm tệp, flock và JSON

Bảng dưới đây tổng hợp hợp đồng kỹ thuật của các hàm thao tác I/O và JSON cốt lõi:

Nhóm thao tác Hàm / Cờ hiệu chuẩn Hành vi thực tế trong PHP 8.4 Điều kiện / Không nên giả định
Kiểm tra đường dẫn thực realpath($path) + str_starts_with() Phân giải toàn bộ ký hiệu ../, ./ và symlink thành đường dẫn tuyệt đối chính tắc; trả về false nếu đích chưa tồn tại. Phải kiểm tra đường dẫn trả về từ realpath() có bắt đầu bằng thư mục gốc cho phép hay không trước khi mở tệp.
Mở và khóa tệp an toàn fopen($p, 'cb+') + flock($fp, LOCK_EX) Chế độ 'c+' tạo tệp nếu chưa có mà không xóa trắng tệp ngay lúc mở, chờ lấy khóa LOCK_EX xong mới gọi ftruncate($fp, 0). Nếu mở bằng fopen($p, 'w'), tệp sẽ bị cắt về 0 byte ngay lập tức trước cả khi kịp gọi lệnh chờ khóa flock()!
Mã hóa JSON json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) Chuyển mảng/đối tượng sang chuỗi JSON, giữ nguyên chữ tiếng Việt UTF-8 và ném JsonException nếu dữ liệu lỗi. Thiếu JSON_THROW_ON_ERROR, hàm chỉ trả về false khi gặp lỗi truyền resource hoặc chuỗi UTF-8 hỏng.
Giải mã JSON json_decode($json, true, 512, JSON_THROW_ON_ERROR) Phân tích chuỗi JSON thành mảng kết hợp (associative = true) và ném JsonException nếu sai cú pháp. Ở chế độ mặc định không có cờ, json_decode('{bad') trả về null giống hệt chuỗi hợp lệ 'null'.

Khóa tệp và JSON_THROW_ON_ERROR hoạt động ra sao

Một bẫy kỹ thuật rất sâu trong lập trình hệ thống với PHP nằm ở chế độ mở tệp của fopen() (PHP Manual: fopen). Nếu mở tệp bằng fopen($file, 'w') rồi mới gọi flock($fp, LOCK_EX), chế độ 'w' sẽ ra lệnh cho hệ điều hành cắt độ dài tệp về 0 byte ngay tại thời điểm mở tệp, tức là trước khi lệnh flock() kịp chạy! Nếu tiến trình khác đang giữ khóa và ghi dở, lệnh fopen('w') sẽ xóa sạch dữ liệu ngay lập tức.

Quy trình chuẩn mực để cập nhật tệp có khóa độc quyền gồm 4 bước:

  1. Mở tệp bằng chế độ 'cb+' (tạo tệp nếu chưa có, mở đọc/ghi, không cắt tệp về 0 byte lúc mở).
  2. Yêu cầu khóa độc quyền flock($fp, LOCK_EX).
  3. Sau khi đã giữ khóa, gọi ftruncate($fp, 0) và rewind($fp) để xóa nội dung cũ, ghi nội dung mới bằng fwrite($fp, $data) và fflush($fp).
  4. Mở khóa flock($fp, LOCK_UN) và đóng fclose($fp).

Với JSON, cờ JSON_THROW_ON_ERROR biến mọi lỗi cú pháp hoặc chuỗi UTF-8 hỏng thành ngoại lệ JsonException, giúp bắt gọn trong khối try / catch.

Quy trình 4 bước kiểm tra đường dẫn, khóa tệp, ghi dữ liệu và đóng con trỏ I/O.
Hình 1. Quy trình 4 bước kiểm tra đường dẫn, khóa tệp, ghi dữ liệu và đóng con trỏ I/O. Sơ đồ minh họa khái niệm; không phải ảnh chụp hay bản ghi kiểm thử. Mở hình để xem chi tiết.

Đọc ghi cấu hình JSON với khóa độc quyền

Chúng ta sẽ viết kịch bản kiểm tra đường dẫn bằng realpath(), mở tệp với fopen('cb+') + flock(LOCK_EX), ghi dữ liệu tiếng Việt bằng json_encode() và đọc lại bằng json_decode() kèm JSON_THROW_ON_ERROR.

Bước 1: Tạo vùng làm việc tạm an toàn

Mục đích: Khởi tạo thư mục thực hành riêng trong /tmp.

WORKDIR="$(mktemp -d /tmp/php-ep16-XXXXXX)"
cd "$WORKDIR"
pwd

Bước 2: Tạo kịch bản storage_io.php ghi và đọc JSON với khóa flock

Mục đích: Thực hiện chu trình khóa tệp, cắt tệp, ghi chuỗi JSON tiếng Việt và giải mã lại an toàn.

cat <<'EOF' > "$WORKDIR/storage_io.php"
<?php
declare(strict_types=1);

$baseDir = realpath(__DIR__);
$file = $baseDir . '/config.json';

$fp = fopen($file, 'cb+');
if ($fp === false || !flock($fp, LOCK_EX)) {
    throw new RuntimeException('Cannot lock file');
}

$payload = ['app' => 'TicketHub', 'utf8' => 'Đạt chuẩn', 'active' => true];
$json = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);

ftruncate($fp, 0);
rewind($fp);
fwrite($fp, $json);
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);

$decoded = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
echo "App={$decoded['app']} | UTF8={$decoded['utf8']} | Active=" . ($decoded['active'] ? 'true' : 'false') . "\n";
EOF
php "$WORKDIR/storage_io.php"

Kết quả quan sát được:

  • Đầu ra hiển thị: App=TicketHub | UTF8=Đạt chuẩn | Active=true.
  • Nhờ cờ JSON_UNESCAPED_UNICODE, chuỗi 'Đạt chuẩn' được lưu nguyên bản UTF-8 trong tệp config.json.

Cảnh báo an toàn: Không truyền trực tiếp tên tệp do người dùng nhập vào fopen(). Hãy lọc qua basename($userInput) hoặc kiểm tra str_starts_with(realpath($target), $allowedDir . DIRECTORY_SEPARATOR) để chống Path Traversal.

Đọc từng dòng bằng SplFileObject

Khi phân tích tệp nhật ký (log) hàng trăm megabyte, gọi file_get_contents() sẽ nạp toàn bộ tệp vào RAM và gây lỗi Allowed memory size exhausted. Giải pháp chuẩn là đọc cuốn chiếu từng dòng bằng SplFileObject.

Bước 3: Tạo kịch bản stream_lines.php đọc tệp theo luồng từng dòng

Mục đích: Dùng SplFileObject kết hợp cờ DROP_NEW_LINE | SKIP_EMPTY | READ_AHEAD để duyệt từng dòng với mức tiêu thụ RAM cố định.

cat <<'EOF' > "$WORKDIR/stream_lines.php"
<?php
declare(strict_types=1);

$logPath = __DIR__ . '/access.log';
file_put_contents($logPath, "200 /health\n\n500 /api/orders\n200 /docs\n", LOCK_EX);

$fileObj = new SplFileObject($logPath, 'rb');
$fileObj->setFlags(SplFileObject::DROP_NEW_LINE | SplFileObject::SKIP_EMPTY | SplFileObject::READ_AHEAD);

$errors = 0;
foreach ($fileObj as $line) {
    if (is_string($line) && str_starts_with($line, '500 ')) {
        $errors++;
    }
}

echo "ErrorLines={$errors}\n";
EOF
php "$WORKDIR/stream_lines.php"

Kết quả quan sát được:

  • Màn hình in ra: ErrorLines=1. Tại mỗi bước lặp, PHP chỉ giữ đúng 1 dòng văn bản trong RAM và bỏ qua dòng trống.

Cảnh báo an toàn: Khi bật SplFileObject::SKIP_EMPTY, bắt buộc phải bật kèm SplFileObject::READ_AHEAD để vòng lặp foreach bỏ qua đúng các dòng trống.

Kiểm tra JsonException và dọn dẹp

Chúng ta sẽ kiểm chứng cách cờ JSON_THROW_ON_ERROR chặn đứng một chuỗi JSON bị cắt cụt cú pháp ngay tại biên giải mã.

Bước 4: Gọi json_decode với chuỗi hỏng kèm JSON_THROW_ON_ERROR và kiểm tra mã thoát

Mục đích: Xác nhận PHP 8.4 ném ngoại lệ JsonException tường minh thay vì trả về null.

set +e
php -r 'json_decode("{bad_json", true, 512, JSON_THROW_ON_ERROR);'
RC=$?
set -e
echo "RC=$RC"

Kết quả quan sát được:

  • Tiến trình kết thúc với RC=255 kèm thông báo: Fatal error: Uncaught JsonException: Syntax error in Command line code:1.
Đối chiếu giải mã JSON mặc định (trả về null) với chế độ ném lỗi JSON_THROW_ON_ERROR.
Hình 2. Đối chiếu giải mã JSON mặc định (trả về null) với chế độ ném lỗi JSON_THROW_ON_ERROR. Sơ đồ minh họa khái niệm; không phải ảnh chụp hay bản ghi kiểm thử. Mở hình để xem chi tiết.

Bước 5: Dọn dẹp vùng làm việc tạm

Mục đích: Xóa thư mục thực hành trong /tmp.

rm -rf "$WORKDIR"

Giới hạn của khóa tệp và ghi tệp nguyên tử

Trên môi trường Production tải cao, hãy áp dụng hai nguyên tắc:

  1. Ghi tệp nguyên tử (Atomic Write bằng tệp tạm + rename): Ghi toàn bộ nội dung mới ra tệp tạm cùng thư mục ($tmp = $file . '.tmp.' . getmypid()), đóng tệp rồi gọi rename($tmp, $file). Lệnh rename() trên cùng phân vùng POSIX là thao tác nguyên tử nên tiến trình đọc không bao giờ nhìn thấy tệp đang ghi dở.
  2. Chuyển sang cơ sở dữ liệu khi cần truy vấn phức tạp: Ngay khi cần lọc theo điều kiện hoặc đảm bảo ràng buộc toàn vẹn, hãy dùng cơ sở dữ liệu quan hệ với PDO (Chặng 5).

Tự kiểm tra và giới hạn

Hãy tự kiểm tra lại kiến thức qua 4 câu hỏi tình huống:

  • Câu hỏi 1: Vì sao mở tệp bằng fopen($path, 'w') rồi mới gọi flock($fp, LOCK_EX) lại làm mất dữ liệu khi chạy song song? -> Vì chế độ 'w' cắt tệp về 0 byte ngay lúc mở, trước khi lệnh flock() kịp chạy. Phải dùng 'cb+', lấy khóa xong mới gọi ftruncate($fp, 0).
  • Câu hỏi 2: Nếu không truyền cờ JSON_THROW_ON_ERROR, hàm json_decode('{invalid', true) sẽ trả về gì? -> Âm thầm trả về null, dễ bị nhầm với giá trị JSON hợp lệ "null".
  • Câu hỏi 3: Làm sao ngăn lỗ hổng Path Traversal (../../etc/passwd) khi mở tệp? -> Dùng basename() hoặc phân giải bằng realpath() rồi kiểm tra str_starts_with($realPath, $allowedDir . DIRECTORY_SEPARATOR).
  • Câu hỏi 4: Cờ JSON_UNESCAPED_UNICODE trong json_encode() có tác dụng gì? -> Giữ nguyên ký tự tiếng Việt UTF-8 trong chuỗi JSON đầu ra thay vì mã hóa thành \uXXXX.

Hoàn thành bài học này, bạn đã nắm vững kỹ thuật thao tác Filesystem và JSON an toàn. Hãy tiếp tục sang PHP EP 17: Xử lý form HTTP và mã hóa đầu ra trong PHP để bước vào lập trình ứng dụng Web HTTP.

Ôn bài trước: PHP EP 15: Xử lý ngoại lệ PHP với Throwable và try/catch. Học tiếp: PHP EP 17: Xử lý form HTTP và mã hóa đầu ra trong PHP.

Bài liên quan trong bộ PHP

Tìm hiểu thêm