Đảm bảo Tương thích khi Thêm Field Mới với Jackson ObjectMapper

29 tháng 10, 2020
11 phút đọc
Đảm bảo Tương thích khi Thêm Field Mới với Jackson ObjectMapper

Trong các hệ thống server-side, việc nâng cấp code thường gây ra downtime nếu triển khai trực tiếp. Giải pháp phổ biến là sử dụng rolling upgrade: triển khai từ từ trên các replica (thường ≥ 3), kiểm tra từng cái trước khi tiếp tục.

Cách tiếp cận này dẫn đến một thực tế: phiên bản code cũ và mới cùng tồn tại song song trong hệ thống. Để đảm bảo dịch vụ hoạt động trơn tru, chúng ta cần đảm bảo tính tương thích trên cả hai chiều (Fully compatibility):

  • Tương thích ngược (Backward compatibility): Code mới có thể đọc dữ liệu được tạo bởi code cũ.

  • Tương thích tương lai (Forward compatibility): Code cũ có thể đọc dữ liệu được tạo bởi code mới.

Tương thích ngược thường dễ xử lý hơn: ta biết định dạng cũ và có thể giữ lại code xử lý nó. Tương thích tương lai phức tạp hơn, vì đòi hỏi phải "phòng ngừa" những thay đổi từ phiên bản tương lai mà code hiện tại chưa biết đến.


Vấn đề Thực Tế#

Trong quá trình phát triển phần mềm, việc thêm field mới vào data model là nhu cầu thường xuyên. Tuy nhiên, trong hệ thống phân tán với rolling deployment, điều này tạo ra thách thức:

Kịch bản vấn đề:

  1. Service mới (V2) thêm field photoURL vào model User

  2. V2 ghi dữ liệuphotoURL vào database

  3. Service cũ (V1) đọc bản ghi này, cập nhật các field khác

  4. V1 ghi lại bản ghi vào database

  5. Kết quả: Field photoURL bị mất!

Bài viết này trình bày các giải pháp với Jackson ObjectMapper để giải quyết vấn đề này, đảm bảo tính tương thích cả hai chiều.


Model Cơ Bản#

Trước khi đi vào các giải pháp, hãy xác định model cơ bản:

// Model phiên bản cũ (V1)
public class UserV1 {
    private String userName;
    private Long favoriteNumber;
    private List<String> interests;

    // Constructors, getters, setters
    // ...
}

// Model phiên bản mới (V2) - mở rộng từ V1
public class UserV2 extends UserV1 {
    private String photoURL;
    private String email;

    // Constructors, getters, setters
    // ...
}

Giải Pháp 1: Cấu Hình ObjectMapper Toàn Cục#

Nguyên lý#

Cấu hình ObjectMapper bỏ qua các field không xác định thay vì ném exception.

Demo#

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature;
import java.util.Arrays;

public class Solution1 {
    public static void main(String[] args) throws Exception {
        System.out.println("=== Giải pháp 1: Cấu hình FAIL_ON_UNKNOWN_PROPERTIES = false ===");

        // 1. Cấu hình ObjectMapper
        ObjectMapper mapper = new ObjectMapper()
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

        // 2. Service mới (V2) tạo và serialize user
        UserV2 newUser = new UserV2();
        newUser.setUserName("Martin");
        newUser.setFavoriteNumber(1337L);
        newUser.setInterests(Arrays.asList("hacking", "reading"));
        newUser.setPhotoURL("http://example.com/photo.jpg");
        newUser.setEmail("martin@example.com");

        String jsonFromV2 = mapper.writeValueAsString(newUser);
        System.out.println("JSON từ V2: " + jsonFromV2);

        // 3. Service cũ (V1) đọc JSON có field mới
        UserV1 oldUser = mapper.readValue(jsonFromV2, UserV1.class);
        System.out.println("V1 đọc được: " + oldUser.getUserName() + 
                          ", số: " + oldUser.getFavoriteNumber());

        // 4. V1 cập nhật và ghi lại
        oldUser.setFavoriteNumber(42L);
        String jsonFromV1 = mapper.writeValueAsString(oldUser);
        System.out.println("JSON từ V1 sau cập nhật: " + jsonFromV1);

        // 5. Kiểm tra: field mới đã bị mất
        System.out.println("Kết quả: Field photoURL và email đã bị mất khi V1 ghi lại");
    }
}

Kết quả:

=== Giải pháp 1: Cấu hình FAIL_ON_UNKNOWN_PROPERTIES = false ===
JSON từ V2: {"userName":"Martin","favoriteNumber":1337,"interests":["hacking","reading"],"photoURL":"http://example.com/photo.jpg","email":"martin@example.com"}
V1 đọc được: Martin, số: 1337
JSON từ V1 sau cập nhật: {"userName":"Martin","favoriteNumber":42,"interests":["hacking","reading"]}
Kết quả: Field photoURL và email đã bị mất khi V1 ghi lại

Ưu điểm:

  • Đơn giản, chỉ cần một dòng cấu hình

  • Service cũ không crash khi gặp field mới

Nhược điểm:

  • Field mới bị mất khi service cũ ghi lại dữ liệu

  • Không phát hiện được lỗi chính tả trong tên field


Giải Pháp 2: Annotation @JsonIgnoreProperties#

Nguyên lý#

Sử dụng annotation trên class để chỉ định bỏ qua các field không xác định.

Demo#

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Arrays;

// Model V1 với annotation
@JsonIgnoreProperties(ignoreUnknown = true)
class UserV1WithIgnore {
    private String userName;
    private Long favoriteNumber;
    private List<String> interests;

    // getters, setters

    public void printStatus() {
        System.out.println("UserV1WithIgnore: " + userName + 
                          ", số: " + favoriteNumber + 
                          ", sở thích: " + interests);
    }
}

public class Solution2 {
    public static void main(String[] args) throws Exception {
        System.out.println("=== Giải pháp 2: Sử dụng @JsonIgnoreProperties ===");

        ObjectMapper mapper = new ObjectMapper();

        // 1. Tạo JSON từ V2 (có field mới)
        UserV2 newUser = new UserV2();
        newUser.setUserName("Anna");
        newUser.setFavoriteNumber(999L);
        newUser.setInterests(Arrays.asList("coding", "music"));
        newUser.setPhotoURL("http://example.com/anna.jpg");
        newUser.setEmail("anna@example.com");

        String jsonFromV2 = mapper.writeValueAsString(newUser);
        System.out.println("JSON từ V2: " + jsonFromV2);

        // 2. V1 đọc JSON có field mới
        UserV1WithIgnore oldUser = mapper.readValue(jsonFromV2, UserV1WithIgnore.class);
        oldUser.printStatus();

        // 3. V1 cập nhật
        oldUser.setFavoriteNumber(100L);
        String jsonFromV1 = mapper.writeValueAsString(oldUser);
        System.out.println("JSON từ V1 sau cập nhật: " + jsonFromV1);

        // 4. Nhận xét
        System.out.println("Nhận xét: Field mới vẫn bị mất, nhưng code rõ ràng hơn");
    }
}

Kết quả:

=== Giải pháp 2: Sử dụng @JsonIgnoreProperties ===
JSON từ V2: {"userName":"Anna","favoriteNumber":999,"interests":["coding","music"],"photoURL":"http://example.com/anna.jpg","email":"anna@example.com"}
UserV1WithIgnore: Anna, số: 999, sở thích: [coding, music]
JSON từ V1 sau cập nhật: {"userName":"Anna","favoriteNumber":100,"interests":["coding","music"]}
Nhận xét: Field mới vẫn bị mất, nhưng code rõ ràng hơn

Ưu điểm:

  • Khai báo rõ ràng ngay trên class

  • Có thể áp dụng cho từng field cụ thể

Nhược điểm:

  • Vẫn mất field mới khi serialize lại

  • Không lưu được giá trị field không xác định


Giải Pháp 3: @JsonAnyGetter và @JsonAnySetter#

Nguyên lý#

Lưu trữ các field không xác định vào một Map, giữ nguyên chúng khi serialize.

Demo#

import com.fasterxml.jackson.annotation.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.*;

@JsonIgnoreProperties(ignoreUnknown = true)
class UserV1WithAny {
    private String userName;
    private Long favoriteNumber;
    private List<String> interests;

    // Map để lưu các field không xác định
    @JsonIgnore
    private Map<String, Object> unknownFields = new HashMap<>();

    // getters, setters cho field đã biết

    // Jackson annotations để xử lý field không xác định
    @JsonAnyGetter
    public Map<String, Object> getUnknownFields() {
        return unknownFields;
    }

    @JsonAnySetter
    public void setUnknownField(String key, Object value) {
        unknownFields.put(key, value);
    }

    public void printInfo() {
        System.out.println("User: " + userName + 
                          ", số: " + favoriteNumber + 
                          ", field lạ: " + unknownFields.size() + " field");
    }
}

public class Solution3 {
    public static void main(String[] args) throws Exception {
        System.out.println("=== Giải pháp 3: @JsonAnyGetter và @JsonAnySetter ===");

        ObjectMapper mapper = new ObjectMapper();

        // 1. V2 tạo user với field mới
        UserV2 newUser = new UserV2();
        newUser.setUserName("David");
        newUser.setFavoriteNumber(777L);
        newUser.setInterests(Arrays.asList("sports", "travel"));
        newUser.setPhotoURL("http://example.com/david.jpg");
        newUser.setEmail("david@example.com");

        String jsonFromV2 = mapper.writeValueAsString(newUser);
        System.out.println("JSON từ V2: " + jsonFromV2);

        // 2. V1 đọc và lưu field không xác định
        UserV1WithAny oldUser = mapper.readValue(jsonFromV2, UserV1WithAny.class);
        oldUser.printInfo();

        // 3. Hiển thị field không xác định
        System.out.println("Field photoURL từ unknownFields: " + 
                          oldUser.getUnknownFields().get("photoURL"));
        System.out.println("Field email từ unknownFields: " + 
                          oldUser.getUnknownFields().get("email"));

        // 4. V1 cập nhật và ghi lại
        oldUser.setFavoriteNumber(888L);
        String jsonFromV1 = mapper.writeValueAsString(oldUser);
        System.out.println("JSON từ V1 sau cập nhật: " + jsonFromV1);

        // 5. V2 đọc lại để xác nhận
        UserV2 readBack = mapper.readValue(jsonFromV1, UserV2.class);
        System.out.println("V2 đọc lại thấy photoURL: " + readBack.getPhotoURL());
        System.out.println("V2 đọc lại thấy email: " + readBack.getEmail());
    }
}

Kết quả:

=== Giải pháp 3: @JsonAnyGetter và @JsonAnySetter ===
JSON từ V2: {"userName":"David","favoriteNumber":777,"interests":["sports","travel"],"photoURL":"http://example.com/david.jpg","email":"david@example.com"}
User: David, số: 777, field lạ: 2 field
Field photoURL từ unknownFields: http://example.com/david.jpg
Field email từ unknownFields: david@example.com
JSON từ V1 sau cập nhật: {"userName":"David","favoriteNumber":888,"interests":["sports","travel"],"photoURL":"http://example.com/david.jpg","email":"david@example.com"}
V2 đọc lại thấy photoURL: http://example.com/david.jpg
V2 đọc lại thấy email: david@example.com

Ưu điểm:

  • Giữ nguyên được field mới khi service cũ ghi lại

  • Có thể truy cập field không xác định nếu cần

  • Bảo toàn dữ liệu hoàn toàn

Nhược điểm:

  • Code phức tạp hơn

  • Cần quản lý Map của field không xác định


Giải Pháp 4: Sử Dụng JsonNode (Linh Hoạt)#

Nguyên lý#

Làm việc trực tiếp với cấu trúc JSON thay vì POJO, giữ nguyên mọi field.

Demo#

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Arrays;

public class Solution4 {
    public static void main(String[] args) throws Exception {
        System.out.println("=== Giải pháp 4: Sử dụng JsonNode ===");

        ObjectMapper mapper = new ObjectMapper();

        // 1. V2 tạo JSON với field mới
        UserV2 newUser = new UserV2();
        newUser.setUserName("Emma");
        newUser.setFavoriteNumber(555L);
        newUser.setInterests(Arrays.asList("art", "photography"));
        newUser.setPhotoURL("http://example.com/emma.jpg");
        newUser.setEmail("emma@example.com");

        String jsonFromV2 = mapper.writeValueAsString(newUser);
        System.out.println("JSON từ V2: " + jsonFromV2);

        // 2. V1 đọc dưới dạng JsonNode
        JsonNode rootNode = mapper.readTree(jsonFromV2);
        System.out.println("V1 đọc dưới dạng JsonNode:");
        System.out.println("  userName: " + rootNode.get("userName"));
        System.out.println("  photoURL: " + rootNode.get("photoURL"));
        System.out.println("  email: " + rootNode.get("email"));

        // 3. V1 cập nhật
        ObjectNode objectNode = (ObjectNode) rootNode;

        // Cập nhật field cũ
        objectNode.put("favoriteNumber", 666L);

        // Thêm field mới từ V1
        objectNode.put("updatedBy", "V1-service");
        objectNode.put("updateCount", 1);

        // 4. V1 ghi lại
        String jsonFromV1 = mapper.writeValueAsString(objectNode);
        System.out.println("JSON từ V1 sau cập nhật: " + jsonFromV1);

        // 5. V2 đọc lại để kiểm tra
        UserV2 readBack = mapper.readValue(jsonFromV1, UserV2.class);
        System.out.println("V2 đọc lại:");
        System.out.println("  userName: " + readBack.getUserName());
        System.out.println("  favoriteNumber: " + readBack.getFavoriteNumber());
        System.out.println("  photoURL: " + readBack.getPhotoURL());
        System.out.println("  email: " + readBack.getEmail());

        // 6. Đọc toàn bộ JSON để xem field mới từ V1
        JsonNode finalNode = mapper.readTree(jsonFromV1);
        System.out.println("Field mới từ V1: updatedBy = " + finalNode.get("updatedBy"));
        System.out.println("Field mới từ V1: updateCount = " + finalNode.get("updateCount"));

        System.out.println("Kết quả: Tất cả field được bảo toàn, cả từ V1 và V2");
    }
}

Kết quả:

=== Giải pháp 4: Sử dụng JsonNode ===
JSON từ V2: {"userName":"Emma","favoriteNumber":555,"interests":["art","photography"],"photoURL":"http://example.com/emma.jpg","email":"emma@example.com"}
V1 đọc dưới dạng JsonNode:
  userName: "Emma"
  photoURL: "http://example.com/emma.jpg"
  email: "emma@example.com"
JSON từ V1 sau cập nhật: {"userName":"Emma","favoriteNumber":666,"interests":["art","photography"],"photoURL":"http://example.com/emma.jpg","email":"emma@example.com","updatedBy":"V1-service","updateCount":1}
V2 đọc lại:
  userName: Emma
  favoriteNumber: 666
  photoURL: http://example.com/emma.jpg
  email: emma@example.com
Field mới từ V1: updatedBy = "V1-service"
Field mới từ V1: updateCount = 1
Kết quả: Tất cả field được bảo toàn, cả từ V1 và V2

Ưu điểm:

  • Linh hoạt tuyệt đối, không bị ràng buộc bởi model class

  • Không bao giờ mất field không xác định

  • Dễ dàng thao tác với cấu trúc JSON phức tạp

Nhược điểm:

  • Không có type safety

  • Code khó đọc và bảo trì hơn

  • Không tận dụng được lợi ích của POJO


Giải Pháp 5: Kết Hợp Nhiều Phương Pháp (Enterprise)#

Nguyên lý#

Kết hợp các kỹ thuật trên với logging, monitoring cho hệ thống production.

Demo#

import com.fasterxml.jackson.annotation.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature;
import java.util.*;

abstract class ForwardCompatibleEntity {
    // Map để lưu field không xác định
    @JsonIgnore
    protected Map<String, Object> unknownFields = new HashMap<>();

    // Flag để bật/tắt logging
    @JsonIgnore
    private boolean logUnknownFields = true;

    @JsonAnyGetter
    public Map<String, Object> getUnknownFields() {
        return unknownFields;
    }

    @JsonAnySetter
    public void setUnknownField(String key, Object value) {
        unknownFields.put(key, value);

        // Log cảnh báo trong môi trường development
        if (logUnknownFields) {
            System.out.println("[WARN] Unknown field detected: " + key + 
                             " = " + value + " in " + getClass().getSimpleName());
        }
    }

    public void setLogUnknownFields(boolean log) {
        this.logUnknownFields = log;
    }
}

// Model V1 kế thừa từ base class
@JsonIgnoreProperties(ignoreUnknown = true)
class UserV1Enterprise extends ForwardCompatibleEntity {
    private String userName;
    private Long favoriteNumber;
    private List<String> interests;

    // getters, setters

    public void displayInfo() {
        System.out.println("UserV1Enterprise: " + userName + 
                          ", số: " + favoriteNumber + 
                          ", field lạ: " + unknownFields.keySet());
    }
}

public class Solution5 {
    public static void main(String[] args) throws Exception {
        System.out.println("=== Giải pháp 5: Kết hợp Enterprise ===");

        // Cấu hình ObjectMapper với nhiều tính năng
        ObjectMapper mapper = new ObjectMapper()
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
            .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true);

        // 1. Tạo JSON từ V2
        UserV2 newUser = new UserV2();
        newUser.setUserName("Sophia");
        newUser.setFavoriteNumber(321L);
        newUser.setInterests(Arrays.asList("science", "math"));
        newUser.setPhotoURL("http://example.com/sophia.jpg");
        newUser.setEmail("sophia@example.com");

        String jsonFromV2 = mapper.writeValueAsString(newUser);
        System.out.println("JSON từ V2: " + jsonFromV2);

        // 2. V1 đọc và log field không xác định
        UserV1Enterprise oldUser = mapper.readValue(jsonFromV2, UserV1Enterprise.class);
        oldUser.displayInfo();

        // 3. V1 cập nhật
        oldUser.setFavoriteNumber(123L);

        // 4. Tắt logging cho lần serialize cuối
        oldUser.setLogUnknownFields(false);

        // 5. V1 ghi lại
        String jsonFromV1 = mapper.writeValueAsString(oldUser);
        System.out.println("JSON từ V1: " + jsonFromV1);

        // 6. Phân tích kết quả
        System.out.println("\nPhân tích giải pháp kết hợp:");
        System.out.println("- Có logging cho field không xác định");
        System.out.println("- Field được bảo toàn hoàn toàn");
        System.out.println("- Có thể mở rộng với auditing, metrics...");
    }
}

Kết quả:

=== Giải pháp 5: Kết hợp Enterprise ===
JSON từ V2: {"userName":"Sophia","favoriteNumber":321,"interests":["science","math"],"photoURL":"http://example.com/sophia.jpg","email":"sophia@example.com"}
[WARN] Unknown field detected: photoURL = http://example.com/sophia.jpg in UserV1Enterprise
[WARN] Unknown field detected: email = sophia@example.com in UserV1Enterprise
UserV1Enterprise: Sophia, số: 321, field lạ: [photoURL, email]
JSON từ V1: {"userName":"Sophia","favoriteNumber":123,"interests":["science","math"],"photoURL":"http://example.com/sophia.jpg","email":"sophia@example.com"}

Phân tích giải pháp kết hợp:
- Có logging cho field không xác định
- Field được bảo toàn hoàn toàn
- Có thể mở rộng với auditing, metrics...

Ưu điểm:

  • Có logging và monitoring

  • Type-safe access cho field không xác định

  • Dễ dàng mở rộng với enterprise features

  • Cân bằng giữa flexibility và type safety

Nhược điểm:

  • Code phức tạp nhất

  • Cần thiết kế cẩn thận


So Sánh và Khuyến Nghị#

Giải phápGiữ field mớiĐộ phức tạpType SafetyPhù hợp
1. Cấu hình ObjectMapperKhôngThấpCaoPrototype, internal API
2. @JsonIgnorePropertiesKhôngThấpCaoPublic API, DTO
3. @JsonAnyGetter/SetterTrung bìnhTrung bìnhHệ thống phân tán
4. JsonNodeCaoThấpJSON processing, ETL
5. Kết hợp EnterpriseCaoCaoProduction critical

Khuyến nghị chọn giải pháp:#

  1. Cho public API: Dùng @JsonIgnoreProperties(ignoreUnknown = true) trên DTO

  2. Cho hệ thống phân tán với rolling deployment: Dùng @JsonAnyGetter@JsonAnySetter

  3. Cho xử lý JSON linh hoạt: Dùng JsonNode

  4. Cho hệ thống enterprise: Dùng giải pháp kết hợp với logging và monitoring

Cấu hình ObjectMapper mẫu cho production:#

ObjectMapper createProductionMapper() {
    return new ObjectMapper()
        // Quan trọng: không fail trên field không xác định
        .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
        // Chấp nhận single value như array
        .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true)
        // Không serialize null
        .setSerializationInclusion(JsonInclude.Include.NON_NULL);
}

Kết Luận#

Việc đảm bảo tương thích khi thêm field mới là yếu tố quan trọng cho hệ thống có thể nâng cấp liên tục mà không gây downtime. Jackson cung cấp nhiều công cụ linh hoạt để xử lý vấn đề này, từ cấu hình đơn giản đến các giải pháp phức tạp.

Nguyên tắc cốt lõi: Service cũ cần "khiêm tốn" trong việc đọc dữ liệu - đọc những gì mình hiểu, giữ nguyên những gì mình không hiểu, và không làm mất thông tin khi ghi lại.

Tùy theo nhu cầu cụ thể của hệ thống, chọn giải pháp phù hợp nhất và áp dụng nhất quán trong toàn bộ codebase để đảm bảo tính đồng nhất và dễ bảo trì.

Khám phá thêm