1. Hợp đồng: JSON như một cây giá trị
Khi bạn lần đầu thấy #include <nlohmann/json.hpp>, có thể bạn sẽ cảm giác như đó là "một phép màu". Thực ra đó chỉ là một thư viện biến văn bản JSON thành một đối tượng json tiện dụng và ngược lại. Trong khuôn khổ khóa học chúng ta giả định thư viện đã có sẵn trong dự án, nên không bàn tới cài đặt.
Mục tiêu của chúng ta là học cách sử dụng các thao tác cơ bản parse() và dump(), và phân biệt "JSON hỏng" với "JSON hợp lệ nhưng không đúng dạng".
Bắt đầu với "khung" tối thiểu để thống nhất phong cách:
#include <nlohmann/json.hpp>
#include <iostream>
int main() {
using nlohmann::json;
json j = json::object();
std::cout << j.dump() << '\n'; // {}
}
Chú ý hai điều.
Thứ nhất, json là kiểu "nút JSON bất kỳ": nó có thể là object, array, string, number, true/false hoặc null.
Thứ hai, dump() là cách để lấy chuỗi có thể in hoặc ghi vào tệp.
2. json::parse(): biến chuỗi thành cây JSON
Thông thường JSON đến với ta dưới dạng văn bản: từ tệp, từ mạng, từ cấu hình, từ test, từ người dùng (và điều này luôn hơi đáng ngờ). Vì vậy bước đầu tiên hầu như luôn giống nhau: lấy std::string, gọi json::parse(), nhận được cây giá trị.
Cần hiểu rằng: parse() không "đoán nghĩa", nó chỉ đọc cú pháp một cách nghiêm ngặt. Dấu phẩy thừa hoặc dấu ngoặc kép sai — sẽ gây lỗi parse.
Cách thuận tiện nhất để giữ văn bản JSON trong mã là raw-string R"( ... )", để không phải escape dấu ngoặc kép.
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
int main() {
using nlohmann::json;
const std::string text = R"({"id": 7, "name": "Alice", "active": true})";
json j = json::parse(text);
std::cout << j.dump() << '\n'; // {"active":true,"id":7,"name":"Alice"}
}
Đúng, định dạng xuất "nén" là bình thường: dump() mặc định tạo chuỗi gọn. Chúng ta sẽ xem cách in đẹp hơn ở phần sau của bài giảng.
Một điểm quan trọng nữa: số trong JSON chỉ là "number". Trong C++ chúng ta có int, long long, double… Vì vậy 42 và "42" là dữ liệu khác nhau về bản chất.
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
int main() {
using nlohmann::json;
json a = json::parse(R"({"age": 42})");
json b = json::parse(R"({"age": "42"})");
std::cout << a.at("age").dump() << '\n'; // 42
std::cout << b.at("age").dump() << '\n'; // "42"
}
Ở đây chúng ta tạm dùng at("age") chỉ để hiển thị sự khác nhau. Về "cách đọc trường" sẽ bàn thêm, nhưng không quá phức tạp: hôm nay tập trung vào parse() và dump().
3. dump(): biến JSON thành chuỗi và in đẹp
Sau khi phân tích JSON vào json j, thường ta cần thao tác ngược lại: lưu, in, log, gửi đi nơi khác. Dùng dump() để làm điều đó.
Nó có một tính năng hữu dụng tiết kiệm công sức: dump(2) (hoặc dump(4)) in JSON với thụt lề. Điều này không thay đổi ý nghĩa, chỉ làm cho con người dễ đọc hơn.
So sánh xuất gọn và xuất "đẹp":
#include <nlohmann/json.hpp>
#include <iostream>
int main() {
using nlohmann::json;
json j = {
{"id", 7},
{"name", "Alice"},
{"active", true}
};
std::cout << j.dump() << '\n'; // {"active":true,"id":7,"name":"Alice"}
std::cout << j.dump(2) << '\n'; // JSON đẹp với thụt lề
}
Nếu bạn muốn thật sự thấy dump(2) trông thế nào, nó sẽ xấp xỉ như sau:
// {
// "active": true,
// "id": 7,
// "name": "Alice"
// }
Và đây là một ý nghĩ thực tiễn: khi bạn lưu dữ liệu vào tệp "dành cho con người" (config, cài đặt, dữ liệu đào tạo), dump(2) gần như luôn tốt hơn xuất gọn. Cho lưu trữ "máy" thì xuất gọn có thể đủ, nhưng khả năng đọc thường bù lại được chi phí.
4. Thực hành nhỏ: tạo object và array thủ công
Trước khi đi vào tệp, hữu ích khi "sờ" vào JSON như cấu trúc dữ liệu trong bộ nhớ. Khi bạn tạo json bằng mã, bạn thực tế xây dựng một cây: object với các khóa, trong đó có mảng và object con.
Nó giống với std::map<std::string, ...> và std::vector<...>, chỉ có một kiểu "giá trị" duy nhất — json.
Tạo một tài liệu dạng "danh sách công việc", chưa tuần tự hóa struct (phần đó ở bài tiếp theo). Đây sẽ là JSON mà ta có thể in.
#include <nlohmann/json.hpp>
#include <iostream>
int main() {
using nlohmann::json;
json doc;
doc["version"] = 1;
doc["tasks"] = json::array();
doc["tasks"].push_back({{"id", 1}, {"title", "Buy milk"}, {"done", false}});
std::cout << doc.dump(2) << '\n';
}
Ở đây ta lần đầu dùng json::array() và push_back. Nó trông gần giống thao tác với std::vector, và đó là mục tiêu: thư viện cố gắng thân thiện.
Chú ý chỗ "tiện nhưng nguy hiểm": doc["version"] và doc["tasks"]. Toán tử operator[] khi ghi rất tiện. Nhưng khi đọc nó có thể gây rắc rối (đôi khi nó tạo trường nếu chưa tồn tại).
Vì vậy trong bài này chúng ta giữ quy tắc đơn giản: ghi — dùng [], đọc — tốt hơn dùng contains() + at().
5. Lỗi: parse và cấu trúc không đúng
Khi mọi thứ hỏng khi thao tác với JSON, rất quan trọng để biết đó là giai đoạn nào.
Lỗi parse nghĩa là "văn bản không phải tài liệu JSON". Lỗi truy cập/kiểu nghĩa là "JSON đã parse được nhưng cấu trúc không đúng mong đợi của chương trình". Hai trường hợp này xử lý khác nhau: trường hợp đầu sửa văn bản đầu vào, trường hợp sau sửa hợp đồng định dạng hoặc thêm kiểm tra hợp lệ.
Bắt lỗi parse: json::parse_error
Phá JSON cố ý: thêm dấu phẩy thừa. Đây là "kịch bản kinh điển".
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
int main() {
using nlohmann::json;
const std::string bad = R"({"x": 1, })"; // dấu phẩy thừa
try {
json j = json::parse(bad);
std::cout << j.dump() << '\n';
} catch (const json::parse_error& e) {
std::cerr << "Parse error: " << e.what() << '\n';
}
}
Ở đây quan trọng là ta bắt đúng json::parse_error. Điều này giúp trong chương trình lớn phân tách "vấn đề định dạng văn bản" và "vấn đề cấu trúc dữ liệu".
Nếu bạn bắt chung std::exception, cũng hoạt động, nhưng chẩn đoán sẽ kém cụ thể hơn.
Lỗi cấu trúc: khóa không tồn tại hoặc kiểu không đúng
Bây giờ giả sử JSON đã parse được nhưng không có trường mong muốn, hoặc trường có kiểu khác. Ví dụ, mong một số nhưng nhận chuỗi. Đây không phải parse_error.
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
int main() {
using nlohmann::json;
json j = json::parse(R"({"age": "42"})"); // age là chuỗi
try {
int age = j.at("age").get<int>(); // mong int
std::cout << age << '\n';
} catch (const json::type_error& e) {
std::cerr << "Type error: " << e.what() << '\n';
}
}
Nếu khóa không tồn tại thì at("...") sẽ ném ngoại lệ về thiếu khóa (thường là out_of_range bên trong nlohmann::json). Đây là chẩn đoán quan trọng: "không có trường" khác với "trường có kiểu sai".
6. Đọc JSON từ tệp: ifstream + in >> j
Cho đến giờ chúng ta đọc JSON từ chuỗi vì dễ minh họa. Nhưng thực tế thường lưu dữ liệu trong tệp: config.json, tasks.json, save.json — bạn đặt tên tùy ý.
Xuất hiện hai loại vấn đề: tệp có thể không mở được, hoặc JSON bên trong có thể bị hỏng. Cần phân biệt những trường hợp này, nếu không bạn sẽ sửa "parser" trong khi lỗi ở đường dẫn tệp.
Mẫu tối thiểu để đọc JSON từ tệp như sau:
#include <nlohmann/json.hpp>
#include <fstream>
#include <iostream>
int main() {
std::ifstream in("tasks.json");
if (!in) {
std::cerr << "Cannot open tasks.json\n";
return 1;
}
nlohmann::json j;
in >> j; // parse trực tiếp từ stream
std::cout << j.dump(2) << '\n';
}
Đây là dạng rất tiện: operator>> của nlohmann::json làm parse "từ stream". Nhưng nhớ: nếu bên trong tệp JSON không hợp lệ thì có thể ném ngoại lệ parse. Vì vậy phiên bản cẩn thận hơn thường thêm try/catch.
Phiên bản "chín chắn" hơn nhưng vẫn ngắn gọn:
#include <nlohmann/json.hpp>
#include <fstream>
#include <iostream>
int main() {
using nlohmann::json;
std::ifstream in("tasks.json");
if (!in) return 1;
try {
json j;
in >> j;
std::cout << "Loaded OK\n"; // Loaded OK
} catch (const json::parse_error& e) {
std::cerr << "Bad JSON: " << e.what() << '\n';
return 2;
}
}
Và bây giờ bạn biết chắc: return 1 — không mở được tệp, return 2 — mở được nhưng nội dung rác.
7. Ghi JSON vào tệp: ofstream + dump(2)
Ghi là nửa còn lại của chu kỳ "dữ liệu ↔ tệp". Ta đã có std::ofstream, và biết in JSON như chuỗi bằng dump().
Vì vậy lưu trữ trông rất đơn giản: mở tệp, kiểm tra, ghi chuỗi.
Lưu tài liệu vào tasks.json với thụt lề:
#include <nlohmann/json.hpp>
#include <fstream>
int main() {
using nlohmann::json;
json doc = {{"version", 1}, {"app", "TaskTracker"}};
std::ofstream out("tasks.json");
if (!out) return 1;
out << doc.dump(2) << '\n';
}
Về mặt kỹ thuật có thể không ghi '\n', nhưng thường thêm dòng mới ở cuối tệp làm việc với editor và diff trên git dễ chịu hơn.
8. Mảnh nhỏ ứng dụng: tải và lưu tài liệu
Bây giờ làm một bước quan trọng mặc dù nhỏ: tách đọc/ghi JSON vào hàm riêng. Điều này giúp giữ main() "gọn" và không rải try/catch khắp nơi.
Chúng ta chưa làm tuần tự hóa đầy đủ cho struct Task (phần sau), nhưng đã có thể lưu toàn bộ tài liệu như json.
Hàm tải tài liệu
Tạo hàm load_json_document trả về tài liệu hoặc — khi gặp sự cố — object rỗng (đơn giản cho phiên bản học tập). Về nguyên tắc tốt hơn trả optional hoặc expected, nhưng chúng ta sẽ dùng thiết kế tối giản ở đây.
#include <nlohmann/json.hpp>
#include <fstream>
#include <iostream>
#include <string>
nlohmann::json load_json_document(const std::string& path) {
using nlohmann::json;
std::ifstream in(path);
if (!in) return json::object();
try {
json doc;
in >> doc;
return doc;
} catch (const json::parse_error&) {
return json::object();
}
}
Ở đây không có "chẩn đoán thông minh" — ta đang học cơ chế parse()/dump(). Nhưng đã thấy lợi ích: trong main() có thể gọi load_json_document("tasks.json") rồi tiếp tục xử lý.
Hàm lưu tài liệu
Giờ làm phần đối xứng: lưu có định dạng đẹp để mở bằng mắt không thấy khó chịu.
#include <nlohmann/json.hpp>
#include <fstream>
#include <string>
bool save_json_document(const std::string& path, const nlohmann::json& doc) {
std::ofstream out(path);
if (!out) return false;
out << doc.dump(2) << '\n';
return true;
}
Và giờ ta có cảm giác "giao diện" dễ chịu: tải và lưu tài liệu là hai thao tác rõ ràng, không làm rối phần còn lại của mã.
Sử dụng nhỏ trong main()
Tạo kịch bản nhỏ: tải tài liệu, nếu rỗng thì tạo mặc định, rồi lưu lại. Không phải logic "hoàn hảo" nhưng minh họa tốt chu kỳ file → json → file.
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
nlohmann::json load_json_document(const std::string& path);
bool save_json_document(const std::string& path, const nlohmann::json& doc);
int main() {
nlohmann::json doc = load_json_document("tasks.json");
if (doc.empty()) {
doc["version"] = 1;
doc["tasks"] = nlohmann::json::array();
}
std::cout << doc.dump(2) << '\n';
save_json_document("tasks.json", doc);
}
Chú ý: chúng ta không bàn về "tính đúng đắn" của trường version/tasks. Hôm nay ta chỉ học phần kỹ thuật đọc và ghi JSON.
9. Các lỗi thường gặp khi làm việc với parse() và dump()
Lỗi #1: cố gắng "đọc JSON" mà không xử lý lỗi parse.
Người mới thường viết json j = json::parse(text); và tin rằng luôn ổn. Nhưng JSON có thể bị hỏng: dấu phẩy thừa, dấu ngoặc kép sai, tệp bị cắt. Nếu không bắt json::parse_error, chương trình sẽ trở nên "thỉnh thoảng chạy". Phòng ngừa tối thiểu là bọc parse bằng try/catch.
Lỗi #2: không phân biệt "tệp không mở được" và "JSON trong tệp bị hỏng".
Nếu bạn làm std::ifstream in(path); in >> j; mà không kiểm tra if (!in), khi gặp vấn đề tệp bạn sẽ nghi ngờ mọi thứ: quyền truy cập, parser, tia vũ trụ. Thứ tự đúng là kiểm tra stream trước, rồi parse, và bắt riêng parse_error.
Lỗi #3: dùng operator[] để đọc và vô tình "tạo" trường mới.
j["x"] trông tiện nhưng trong vài kịch bản có thể thay đổi object và thêm khóa không tồn tại. Điều này khó chịu khi bạn có luồng "đọc → ghi", vì có thể ghi ra các trường ma. Đọc an toàn hơn bằng contains() + at().
Lỗi #4: nghĩ rằng dump() là kiểm tra hợp lệ.
Parse xong rồi gọi dump() không có nghĩa dữ liệu "hợp nghĩa". Nó chỉ có nghĩa trong bộ nhớ có một cây JSON. Trường có thể là chuỗi thay vì số, khóa có thể thiếu, mảng có thể rỗng. Tính đúng nghĩa (semantic) là vấn đề riêng, sẽ bàn sau.
Lỗi #5: lưu JSON "tùy tiện" rồi đau đầu khi đọc bằng mắt.
Về mặt kỹ thuật dump() không thụt lề là hợp lệ, nhưng với con người thì đau lòng. Khi mở tasks.json mà thấy một dòng dài 5000 ký tự, muốn đóng file và đi tu. dump(2) gần như không tốn công nhưng cải thiện đáng kể khả năng đọc và bảo trì.
GO TO FULL VERSION