1. Giới thiệu
Hãy tưởng tượng: bạn viết một lớp Person, serialize một đối tượng vào file, rồi vài tháng sau quyết định là cần thêm, ví dụ, địa chỉ cư trú mới hoặc thay đổi kiểu của một vài thuộc tính. Trông có vẻ chuyện bình thường — nhưng khi bạn cố load (deserialize) dữ liệu cũ đã lưu theo định dạng cũ, có thể sẽ có bất ngờ: một số thứ không load được, ném exception, một vài giá trị thành rỗng hoặc thậm chí sai.
Hành vi như vậy là trường hợp điển hình của việc phá vỡ tương thích ngược. Trong phát triển thực tế chuyện này xảy ra còn thường hơn là sinh viên quên dấu chấm phẩy (tức là rất thường xuyên).
Minh hoạ vấn đề bằng ví dụ
Xem project học nhỏ của chúng ta. Giả sử lúc đầu ta có lớp như sau:
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
}
Serialize một instance của lớp này sang JSON:
Person p = new Person { Name = "Alice", Age = 35 };
string json = JsonSerializer.Serialize(p);
File.WriteAllText("person.json", json);
Trong file nhận được:
{"Name":"Alice","Age":35}
Bây giờ, sau một tuần, chúng ta quyết định làm app "thời thượng" hơn, thêm trường địa chỉ:
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public string Address { get; set; } // trường mới
}
Sau đó — thử load file cũ:
string json = File.ReadAllText("person.json");
Person p = JsonSerializer.Deserialize<Person>(json);
Chuyện gì sẽ xảy ra? Đối tượng của chúng ta sẽ không có địa chỉ: thuộc tính Address sẽ bằng null. Không có lỗi nào bị ném ra. Tạm thời mọi thứ vẫn chạy... Nhưng nếu bạn bắt đầu thay đổi kiểu, xoá trường hoặc làm gì đó "thú vị" hơn, thì vấn đề có thể xuất hiện!
2. Các kiểu thay đổi — và ảnh hưởng của chúng
Thay đổi cấu trúc lớp ảnh hưởng tới serialization theo nhiều cách khác nhau. Cùng phân tích vài kịch bản điển hình.
Thêm thuộc tính mới
Đây là trường hợp ít rủi ro nhất. Dữ liệu cũ (nơi những thuộc tính này chưa tồn tại) sẽ được deserialize bình thường: thuộc tính mới sẽ có giá trị mặc định (null cho tham chiếu, 0 cho int v.v.).
Lưu ý: Nếu thuộc tính mới của bạn không nullable và không có giá trị mặc định "hợp lý", có thể gây vấn đề (đặc biệt với thuộc tính required trong C# 11+).
Xoá thuộc tính
Nếu bạn xoá một thuộc tính, mà dữ liệu đã serialize vẫn có field đó — serializer thường sẽ đơn giản bỏ qua "thừa thãi" và việc load vẫn diễn ra.
Nhưng điều này phụ thuộc vào serializer dùng. Ví dụ, JsonSerializer và Newtonsoft.Json khá khoan dung: chúng sẽ không ném exception, còn một vài serializer cũ hoặc tuỳ chỉnh có thể xử lý khác.
Đổi tên thuộc tính
Đây là lúc vui. Nếu bạn đơn giản đổi tên thuộc tính từ FirstName sang Name, thì serializer không thể map được giữa các field của dữ liệu cũ và object mới. Nói cách khác, field sẽ trống (null/0) và trường cũ trong file bị bỏ qua.
Thay đổi kiểu của thuộc tính
Ví dụ trước kia bạn có public int Age, sau đó quyết định đổi nó thành public string Age (lỡ ai đó nhập "bất tử" — chuyện gì cũng có thể). Cố deserialize dữ liệu cũ có thể dẫn đến lỗi ("Cannot convert number to string") hoặc thuộc tính chỉ nhận giá trị mặc định. Tùy thuộc vào serializer cụ thể và cài đặt strict typing của nó.
Thay đổi hệ thống phân cấp (kế thừa, lồng nhau)
Nếu bạn thay đổi base class, di chuyển các thuộc tính sang chỗ khác hoặc làm một lớp bọc cho lớp khác — dữ liệu đã serialize trước đó có thể hoàn toàn không tương thích. Điều này đặc biệt hay xảy ra với XML và các cấu trúc object phức tạp.
3. Vấn đề tương thích
Làm sao biết có vấn đề tương thích?
Thường lỗi tương thích không xuất hiện ngay lập tức và không rõ ràng: app có thể bắt đầu hành xử "lạ", một phần dữ liệu mất, hoặc trong log có exception không mấy dễ hiểu. Thông thường vấn đề lộ ra khi:
- Người dùng load file cũ vào phiên bản mới của chương trình.
- Server nhận JSON/XML từ client "phiên bản cũ".
- Bạn làm việc với API bên ngoài mà họ cập nhật giao diện đột ngột.
Triệu chứng rất đa dạng: từ lỗi khi deserialize đến các trường "bất ngờ" rỗng.
Ảnh hưởng của serializer tới tương thích
Không phải serializer nào cũng xử lý giống nhau. Các serializer JSON thường "dễ chịu" hơn khi thay đổi cấu trúc — cả System.Text.Json chuẩn lẫn Newtonsoft.Json đều chấp nhận bỏ qua các thuộc tính không nhận diện được từ file và không serialize các field không có trong object.
Với XML thì nghiêm ngặt hơn chút: nếu đổi element gốc hoặc thứ tự/hệ thống phân cấp thì có thể phát sinh lỗi.
Với các định dạng nhị phân còn có khả năng ném exception nếu thứ tự hoặc kiểu thay đổi!
4. Làm sao giảm thiểu rủi ro? Cách tiếp cận và thực hành
Đây là vài cách giúp bạn giảm thiểu (thậm chí tránh hoàn toàn) phiền toái.
Dùng version cho lớp và dữ liệu
Thêm một field Version trong object serialize hoặc trong file. Điều này cho biết file được tạo theo version cấu trúc nào, và khi load có thể quyết định xử lý (ví dụ chạy upgrade dữ liệu).
public class PersonV2
{
public int Version { get; set; } = 2;
public string Name { get; set; }
public int Age { get; set; }
public string Address { get; set; }
}
Sử dụng attributes để map tên (cho serialization)
Với JSON và XML bạn có thể chỉ rõ tên của thuộc tính trong dữ liệu serialize. Nếu đổi tên thuộc tính — giữ lại tên cũ:
public class Person
{
[JsonPropertyName("FirstName")] // cho System.Text.Json
[JsonProperty("FirstName")] // cho Newtonsoft.Json
public string Name { get; set; }
public int Age { get; set; }
}
Dùng nullable types và giá trị mặc định
Nếu thêm field mới mà có thể không tồn tại trong dữ liệu cũ — làm cho nó nullable hoặc gán giá trị mặc định, để việc deserialize không gặp vấn đề:
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public string? Address { get; set; } = "Unknown";
}
Xử lý sự kiện "trường không rõ"
Trong Newtonsoft.Json bạn có thể bắt sự kiện/tuỳ chọn xử lý các trường không nhận diện được để, ví dụ, log lại tình huống tiềm ẩn nguy hiểm.
var settings = new JsonSerializerSettings
{
MissingMemberHandling = MissingMemberHandling.Error
};
try
{
var person = JsonConvert.DeserializeObject<Person>(json, settings);
}
catch (JsonSerializationException ex)
{
Console.WriteLine("Không thể deserialize: " + ex.Message);
}
Migration dữ liệu
Nếu thay đổi lớn, nên có bước migration: ví dụ load dữ liệu vào "cấu trúc cũ", rồi chuyển sang cấu trúc mới:
// Giả sử lớp PersonV1 không có address
public class PersonV1 { public string Name; public int Age; }
// Lớp mới — có address
public class PersonV2 { public string Name; public int Age; public string Address; }
// Migration:
string oldJson = File.ReadAllText("person.json");
PersonV1 oldPerson = JsonSerializer.Deserialize<PersonV1>(oldJson);
PersonV2 migrated = new PersonV2
{
Name = oldPerson.Name,
Age = oldPerson.Age,
Address = "Unknown"
};
5. Trường hợp phức tạp và lỗi bất ngờ
Tính bất biến của field và thuộc tính required
Từ C# 11 có thêm thuộc tính required. Nếu một field được đánh dấu required, việc deserialize có thể ném lỗi nếu trường đó không có trong dữ liệu:
public class Person
{
public string Name { get; set; }
[JsonPropertyName("Age")]
public required int Age { get; set; }
public string Address { get; set; }
}
Nếu trong dữ liệu cũ không có trường Age — sẽ có exception báo không khớp cấu trúc.
Đổi kiểu: int → string
// Trước:
public class Record { public int Count; }
// Sau:
public class Record { public string Count; }
Nếu trong dữ liệu có "Count":42, thì deserialize về string có thể thành công (smart-conversion), nhưng chiều ngược lại — có thể ném exception.
Xoá base class
Nếu object đã được serialize khi có inheritance, rồi bạn thay đổi hệ kế thừa — deserialize file cũ có thể gây lỗi, có khi lỗi im lặng (dữ liệu mất) hoặc lỗi rõ ràng.
6. Lỗi thường gặp khi làm việc với tương thích
Lỗi №1: thay đổi thuộc tính hiện có một cách vô tư.
Đổi tên hoặc thay đổi kiểu của thuộc tính mà không xem xét dữ liệu đã serialize dẫn tới mất thông tin khi deserialize.
Lỗi №2: quên nullable cho các field mới.
Thuộc tính mới nên hoặc là nullable, hoặc có giá trị mặc định hợp lý.
Lỗi №3: không test tương thích ngược.
Đổi lớp — nhất định kiểm thử rằng file/dữ liệu cũ vẫn load đúng.
Lỗi №4: trộn attributes của các thư viện khác nhau.
Không nên dùng đồng thời JsonPropertyName và JsonProperty trên cùng một thuộc tính.
GO TO FULL VERSION