Đọc ranh giới ngữ pháp JSON từ RFC 8259
Kết luận: Chuyển các luật sinh ngữ pháp của RFC 8259 sang ngôn ngữ thường ngày: ký tự nào được xuất hiện trực tiếp, ký tự nào phải escape, và chỗ nào đặc tả cố ý để ngỏ.

Điểm mấu chốt: khi xuất hiện lỗi cú pháp JSON, hãy tìm ký tự bất hợp lệ đầu tiên và phân loại nó là dấu nháy đơn, dấu phẩy cuối, comment hay ký tự điều khiển, thay vì đọc lại toàn bộ tài liệu; về phía mã hóa, hãy xác nhận UTF-8 không có BOM và mang các số nguyên vượt 2^53 - 1 dưới dạng chuỗi.
Phần nội dung của đặc tả chỉ khoảng chục trang, nhưng sau mỗi "MUST", "SHOULD" và "MAY" đều ẩn giấu một sự cố từng khiến cả một nhóm thức trắng đêm.
1. Ba văn bản liên quan thế nào
Học JSON thường bị rối vì ba con số: RFC 4627, RFC 7159, RFC 8259, cộng thêm ECMA-404. Quan hệ giữa chúng thực ra khá rõ ràng:
| Văn bản | Ngày | Trạng thái | Khác biệt chính |
|---|---|---|---|
| RFC 4627 | 2006 | Đã bị thay thế | Cấp cao nhất phải là object hoặc array |
| RFC 7159 | 2014 | Đã bị thay thế | Nới lỏng cấp cao nhất thành bất kỳ giá trị JSON nào |
| RFC 8259 | 2017.12 | Chuẩn hiện hành | UTF-8 tường minh, bổ sung hướng dẫn tương thích |
| ECMA-404 | 2013, ấn bản thứ hai 2017.12 | Đặc tả ngữ pháp | Chỉ định nghĩa ngữ pháp, không đề cập mã hóa hay truyền tải |
Nên đọc cái nào? Chỉ RFC 8259 là đủ. Phần ngữ pháp của ECMA-404 tương đương; khác biệt là RFC 8259 còn quy định "truyền tải dưới dạng UTF-8" và "những vấn đề tương thích mà người triển khai cần chú ý".
2. Sáu kiểu giá trị
Các luật sinh ngữ pháp của đặc tả có thể cô đọng thành một sơ đồ duy nhất:
JSON-text = ws value ws
value = object / array / number / string / "true" / "false" / "null"
object = "{" [ member *( "," member ) ] "}"
member = string ws ":" ws value
array = "[" [ value *( "," value ) ] "]"
Chỉ có sáu điểm chính:
- Object dùng ngoặc nhọn, các member ngăn cách bằng dấu phẩy, và dấu phẩy sau member cuối cùng không được phép;
- Array dùng ngoặc vuông, các phần tử ngăn cách bằng dấu phẩy, và dấu phẩy cuối cũng không được phép;
- Số không cho phép
+đứng đầu, không có số 0 đứng đầu (01không hợp lệ), không có dấu chấm thập phân trần (1.không hợp lệ), và không cóNaNhayInfinity; - Chuỗi phải dùng dấu ngoặc kép; dấu nháy đơn không hợp lệ;
- Boolean và null là các literal viết thường;
TruevàNULLđều không hợp lệ; - Chỉ các ký tự khoảng trắng
0x20(dấu cách),0x09(tab),0x0A(xuống dòng), và0x0D(về đầu dòng) mới được xuất hiện giữa các phần tử cấu trúc.
Đoạn dưới đây là JSON hợp lệ, dù nhiều người lần đầu thấy sẽ tưởng là sai — vì nó là một chuỗi trần ở cấp cao nhất:
"just a string"
Từ RFC 7159, cấp cao nhất quả thực có thể là bất kỳ giá trị nào. Tuy vậy, trong API vẫn nên chỉ dùng object hoặc array; lý do nằm ở mục 6.
3. Thực ra những gì có thể xuất hiện trực tiếp trong một chuỗi
Đây là phần dễ bị "chạy trên máy tôi vẫn ổn" đánh lừa nhất. Quy tắc của đặc tả về chuỗi là:
- Các ký tự được phép xuất hiện trực tiếp:
%x20-21,%x23-5B,%x5D-10FFFF; - Nói cách khác, dấu ngoặc kép
"(0x22) và dấu gạch chéo ngược\(0x5C) phải được escape; - Mọi ký tự điều khiển dưới 0x20 đều phải được escape, bao gồm xuống dòng, tab và về đầu dòng;
- Các chuỗi escape duy nhất được dùng là
\",\\,\/,\b,\f,\n,\r,\t, và\uXXXX.
{
"ok": "Chinese text, emoji 🎉 and full-width symbols can be written directly",
"escaped": "A line break goes here\nand a tab\tfinishes it",
"control": "\u0007 is the bell character and must be escaped",
"quote": "He said: \"don't use single quotes\""
}
\/ là một escape kiểu cũ (nó ngăn </script> đóng sớm). Các tình huống hiện đại hiếm khi cần đến, nhưng trình phân tích phải hỗ trợ nó.
4. Mã hóa: UTF-8, không BOM
Mục 8.1 của RFC 8259 nói ba điều:
- Văn bản JSON dùng để trao đổi phải được mã hóa UTF-8;
- Các bản triển khai không được thêm BOM (
EF BB BF) vào đầu văn bản; - Trình phân tích có thể bỏ qua BOM để vững chắc hơn.
Hai lỗi điển hình trong thực tế:
- Notepad trên Windows lưu dưới dạng "UTF-8 with BOM", tệp bị
JSON.parsetừ chối vớiUnexpected token; - Một tệp JSON tiếng Trung lưu dưới dạng GBK sẽ thành văn bản lỗi font trên đường ống chỉ giải mã UTF-8, vậy mà cú pháp lại tình cờ qua được, khiến lỗi bị đẩy xuống tầng nghiệp vụ.
Cách xử lý rất đơn giản: gỡ BOM khi đọc tệp, rồi giải mã tường minh theo UTF-8; khi ghi, bảo đảm không thêm BOM.
/**
* Removes the UTF-8 BOM at the start of a string.
* @param text The original text
* @returns The text without a BOM
*/
function stripBom(text: string): string {
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}
Nhân đây nói luôn về media type: application/json không còn chấp nhận tham số charset; ghi vào cũng không báo lỗi, nhưng đó là thói quen đã lỗi thời.
5. Ba chỗ đặc tả cố ý để ngỏ
Những chỗ đặc tả không chịu ghim chặt lại chính là nơi sinh ra khác biệt giữa các bản triển khai.
1. Phạm vi và độ chính xác của số
RFC 8259 chỉ nói "đặc tả này không giới hạn phạm vi hay độ chính xác của số", đồng thời lưu ý rằng hầu hết bản triển khai dùng độ chính xác kép IEEE 754.
Điều này nghĩa là cùng một 9223372036854775807 sẽ thành 9223372036854776000 trong JavaScript, nhưng vẫn chính xác trong long của Java.
Kết luận: các số nguyên vượt 2^53 - 1, và các số tiền cần nhiều chữ số thập phân chính xác, nên luôn được mang dưới dạng chuỗi, với pattern được viết tường minh trong schema.
2. Khóa trùng
Cách diễn đạt của đặc tả là "các tên trong một object nên (SHOULD) là duy nhất"; nó không nói phải làm gì khi chúng không duy nhất.
JSON.parsegiữ khóa cuối cùng;- Một số trình phân tích giữ khóa đầu tiên;
- Một số thư viện đơn giản là báo lỗi.
Kết luận: đừng viết khóa trùng, và đừng dựa vào bất kỳ hành vi cụ thể nào trong bộ xác thực của bạn. Thêm một bước "phát hiện khóa trùng" ở thời điểm build rẻ hơn nhiều so với việc điều tra về sau.
3. Giới hạn về độ sâu và độ dài
Đặc tả không đặt giới hạn nào cho độ sâu lồng nhau, nhưng mỗi bản triển khai đều có. V8 ném RangeError: Maximum call stack size exceeded ở hàng chục nghìn cấp lồng nhau.
Máy chủ phải tự áp giới hạn; đây là yêu cầu bảo mật, không phải yêu cầu của đặc tả.
6. Kiểm tra hiểu biết bằng phản ví dụ
Mỗi dòng dưới đây đều là JSON không hợp lệ. Hãy đưa chúng cho trình phân tích và xem thông báo lỗi có hữu ích không:
{ 'name': 'json' } // single quotes
{ name: "json" } // key name not quoted
{"a": 1,} // trailing comma
[1, 2, 3,] // trailing comma in an array
{"a": 01} // leading zero
{"a": +1} // leading plus sign
{"a": .5} // missing integer part
{"a": NaN} // non-standard literal
{"a": undefined} // not a JSON value
{"a": 1} // comment // comment
Một trường hợp biên thú vị: {"a": 1} theo sau bởi một comment là hợp lệ trong JSON5, hợp lệ trong JSONC, và không hợp lệ trong JSON chuẩn.
Vì vậy "tệp cấu hình chạy ổn nhưng API lại từ chối" gần như luôn là vấn đề phương ngữ.
7. Danh sách kiểm tra cho môi trường production
- [ ] Mọi JSON truyền ra bên ngoài đều dùng
application/jsonkhông kèmcharset; - [ ] Đường ống đọc/ghi tệp gỡ BOM và dùng UTF-8 tường minh;
- [ ] Số nguyên vượt
2^53được mang dưới dạng chuỗi và ghi chú trong schema; - [ ] Khóa trùng được phát hiện ở thời điểm build;
- [ ] Máy chủ giới hạn kích thước body request và độ sâu lồng nhau;
- [ ] Giá trị cấp cao nhất của API công khai là object hoặc array, không bao giờ là scalar trần.
Câu hỏi thường gặp
Làm sao nhanh chóng biết một đoạn văn bản có phải JSON hợp lệ?
Đưa đoạn văn bản cho một trình phân tích hoặc một công cụ phân tích trực tuyến, đọc vị trí ký tự bất hợp lệ đầu tiên, rồi đối chiếu với sáu kiểu giá trị và quy tắc khoảng trắng của RFC 8259. Cấp cao nhất có thể là bất kỳ giá trị JSON nào, nhưng giữa các phần tử cấu trúc chỉ được phép có dấu cách, tab, xuống dòng và về đầu dòng. Một dấu nháy đơn, một khóa không đặt ngoặc kép, một dấu phẩy cuối hoặc một comment đều có nghĩa là nó không phải JSON chuẩn.
Vì sao JSON phải dùng UTF-8 và không mang BOM?
Mục 8.1 của RFC 8259 yêu cầu văn bản JSON dùng để trao đổi phải được mã hóa UTF-8, nói rằng các bản triển khai không được thêm BOM vào đầu, và cho phép trình phân tích bỏ qua BOM để vững chắc hơn. Một tệp có BOM bị JSON.parse từ chối với Unexpected token, dù mắt nhìn không thấy gì sai. Vì vậy hãy gỡ BOM khi đọc và giải mã tường minh theo UTF-8.
Tôi có nên dùng JSON5 hoặc JSONC để lách các giới hạn ngữ pháp này không?
Có, nhưng chỉ cho việc đọc cục bộ như tệp cấu hình; dữ liệu trao đổi giữa các hệ thống vẫn nên là JSON chuẩn. Các phản ví dụ cho thấy {"a": 1} theo sau bởi một comment là hợp lệ trong JSON5 và JSONC nhưng không hợp lệ trong JSON chuẩn, đó là lý do "tệp cấu hình chạy ổn nhưng API lại từ chối" gần như luôn là vấn đề phương ngữ. Khi chuyển đổi, hãy gỡ comment, dấu nháy đơn và dấu phẩy cuối trước rồi đưa kết quả cho một trình phân tích chuẩn.