Tự viết định dạng nhị phân riêng: hành trình từ ý tưởng đến trình biên dịch hoàn chỉnh
Một lập trình viên quyết định chứng minh mình là \"Real Developer\" bằng cách tự tạo định dạng nhị phân riêng để thay thế JSON. Kết quả là cả một ngôn ngữ schema nhị phân với lexer, parser, AST, trình phân tích ngữ nghĩa và bộ sinh mã đa ngôn ngữ, giúp giảm tới 80% kích thước dữ liệu.

Tự viết định dạng nhị phân riêng: hành trình từ ý tưởng đến trình biên dịch hoàn chỉnh
Có một lập trình viên thấy người ta tranh cãi trên Twitter rằng lưu dữ liệu dạng JSON thì "không phải dân lập trình xịn". Thế là anh quyết định tự xây dựng một định dạng nhị phân riêng — và cuối cùng viết luôn cả một ngôn ngữ schema kèm trình biên dịch hoàn chỉnh.
Kết quả: một công cụ có tên jBin có thể nén dữ liệu JSON xuống chỉ còn khoảng 20% kích thước ban đầu.
Nhị phân hóa dữ liệu là gì?
Hãy tưởng tượng chuỗi "hello world". Khi chuyển sang ASCII, mỗi ký tự chiếm đúng 1 byte:
01101000 01100101 01101100 01101100 01101111 00100000 ...
Việc ghi thẳng ra file nhị phân rất đơn giản trong C:
FILE *f = fopen("file.bin", "wb");
unsigned char data[] = "hello world";
fwrite(data, 1, sizeof(data) - 1, f);
fclose(f);
Nhưng vấn đề nảy sinh khi cần lưu số 104. Nếu ghi dưới dạng ký tự ASCII thì tốn tới 3 byte ('1' '0' '4'), trong khi số 104 chỉ cần 1 byte. Vậy làm sao để bộ giải mã biết đâu là số, đâu là chuỗi?
Câu trả lời nằm ở một byte tiêu đề (header) đứng trước dữ liệu:
[TYPE][DATA]
Trong đó 0 là số nguyên, 1 là chuỗi. Với chuỗi, cần thêm trường độ dài:
[TYPE][LENGTH][DATA]
Kỹ thuật varint và bit tiếp diễn
Vấn đề lớn hơn: nếu độ dài chuỗi vượt quá 127, một byte không đủ biểu diễn. Giải pháp là dùng bit có trọng số cao nhất (MSB) làm bit tiếp diễn — nếu MSB bằng 1, byte tiếp theo vẫn thuộc header; nếu bằng 0, header kết thúc.
Cách mã hóa này gọi là varint (số nguyên độ dài biến đổi), cho phép biểu diễn số lớn bằng nhiều byte mà vẫn tiết kiệm với số nhỏ.
Bước đột phá: dùng schema thay vì lặp lại kiểu dữ liệu
Điểm mấu chốt là hầu hết dữ liệu thực tế đều có cấu trúc, chứ không phải các giá trị rời rạc. Giống như trong C, khi khai báo struct, ta biết rõ kiểu của từng trường mà không cần lưu kiểu kèm theo mỗi giá trị. Vậy tại sao không làm điều tương tự với dữ liệu nhị phân?
Đó chính là ý tưởng của schema — và cũng chính là cách Protocol Buffers (protobuf) hoạt động.
Một schema đơn giản có thể trông như sau:
package "com.game.core"
enum Activity:
1. active
2. inactive
end
message Player:
1. name: string
2. health: i32 = 100
3. weapons: list(string)
4. connections: list(Player)
5. activeStatus: Activity
6. inventory: map(string, i32)
7. balance: union(string="empty", i32)
end
Điểm tinh tế: chỉ có 4 loại mã hóa (varint, f32, f64, delimited), vừa đủ 2 bit. Số thứ tự trường được dịch trái 2 bit rồi OR với 2 bit kiểu — nhờ vậy chỉ một byte là đủ chứa cả số trường lẫn kiểu dữ liệu. Thậm chí có thể nhồi cả độ dài vào cùng một varint duy nhất.
Xây dựng trình biên dịch
Để biến schema thành dữ liệu nhị phân hoặc mã nguồn, cần một trình biên dịch thực thụ với các thành phần cổ điển:
- Lexer: đọc file schema và tách thành các token như
Keyword_Message,Identifier,Number,Colon,Dot... - Parser: nhận danh sách token, xây dựng AST (cây cú pháp trừu tượng)
- Semantic Analyzer: kiểm tra tính hợp lệ — ví dụ không được dùng trùng số trường, không được lấy list làm khóa map, và kiểm tra các kiểu tham chiếu có tồn tại
- Encoder/Decoder: sử dụng thuật toán LEB128 để đóng gói và giải mã varint
Trình phân tích ngữ nghĩa chạy hai lượt: lượt đầu xây bảng ký hiệu, lượt sau kiểm tra AST dựa trên bảng đó. Nhờ vậy thứ tự khai báo trong file schema không quan trọng, và có thể hỗ trợ cả tham chiếu vòng (ví dụ list(Player) nằm ngay trong message Player).
Bộ đóng gói động và tiết kiệm kích thước
Dynamic Packer nhận một file JSON cùng schema, rồi duyệt song song cả hai để sinh ra dữ liệu nhị phân — không cần sinh mã C++ trung gian.
Ví dụ đơn giản:
{
"name": "Pranav",
"health": 100
}
File JSON này tốn khoảng 35 byte, nhưng sau khi đóng gói nhị phân chỉ còn 9 byte — giảm 74,29%. Đáng chú ý là 6 trong 9 byte đó dành cho chữ "Pranav".
Với một đối tượng game phức tạp hơn gồm tọa độ, danh sách vật phẩm và bảng thuộc tính, file JSON 401 byte sau khi nén chỉ còn 80 byte, tức giảm hơn 80%.
Sinh mã với Visitor Pattern
Nếu không muốn tra cứu động lúc chạy, có thể dùng sinh mã AOT (ahead-of-time) để tạo struct gốc trong ngôn ngữ đích. Để tránh nhồi nhét tất cả bộ sinh mã vào một lớp, tác giả dùng Visitor Pattern — một mẫu thiết kế cho phép mỗi ngôn ngữ đích (C, Python, JavaScript...) chỉ cần override các phương thức visit() tương ứng.
Kết quả sinh ra mã C hoàn chỉnh với struct, enum, hàm pack/unpack, getter và setter — không phụ thuộc thư viện ngoài.
Góc nhìn cho lập trình viên Việt Nam
Với các dự án backend tại Việt Nam thường xuyên phải truyền dữ liệu qua API hoặc lưu trữ log, việc hiểu rõ cơ chế mã hóa nhị phân và varint là kiến thức nền tảng quan trọng. Các hệ thống lớn như gRPC, Apache Kafka hay Protocol Buffers đều dựa trên những nguyên lý tương tự.
Tuy nhiên, cần lưu ý rằng JSON vẫn chiếm ưu thế trong giai đoạn phát triển nhờ tính dễ đọc và dễ debug. Chỉ nên cân nhắc định dạng nhị phân khi thực sự cần tối ưu băng thông hoặc dung lượng lưu trữ — đúng như tác giả jBin đã chứng minh qua thực nghiệm.
"Tôi bắt đầu chỉ vì muốn bỏ JSON cho bõ tức, và rồi vô tình xây luôn cả lexer, parser, AST, trình phân tích ngữ nghĩa, bộ đóng gói nhị phân động và cả bộ sinh mã đa ngôn ngữ. Và thật lòng mà nói? Đóng gói nhị phân hóa ra lại vui."
Bài viết liên quan

Công nghệ
Nộp đơn xin việc lẽ ra nên khó hơn. Thật đấy
25 tháng 8, 2026

Công nghệ
Mô hình AI hàng đầu giỏi Vật lý đến đâu? Nghiên cứu mới chỉ ra các bài kiểm tra hiện hành đang đánh giá sai
16 tháng 9, 2026

Công nghệ
Honor Magic 9 Pro Max ra mắt: Camera khủng, pin 8.800mAh và dấu ấn từ Arri
28 tháng 9, 2026