Mục lục
Khi 2 Java class có mối quan hệ 2 chiều (trong A chứa B, trong B chứa A) sẽ dẫn đến các vấn đề khi làm việc với Jackson.
Trong bài viết này, chúng ta sẽ tập trung vào vấn đề đệ quy vô tận trong mối quan hệ quan hệ 2 chiều và tìm ra giải pháp để giải quyết vấn đề này.
Đệ quy vô tận
Trước tiên, chúng ta cần tạo ra mối quan hệ 2 chiều giữa 2 class User và Item.
public class User { public int id; public String name; public List<Item> userItems; public User(int id, String name) { this.id = id; this.name = name; userItems = new ArrayList<>(); } public void addItem(Item item) { this.userItems.add(item); } }
public class Item { public int id; public String itemName; public User owner; public Item(int id, String itemName, User owner) { this.id = id; this.itemName = itemName; this.owner = owner; } }
Khi chúng ta serialize một Item object, Jackson sẽ ném ra JsonMappingException exception.
public class Main { public static void main(String[] agrs) throws JsonProcessingException { User user = new User(1, "John"); Item item = new Item(2, "book", user); user.addItem(item); new ObjectMapper().writeValueAsString(item); } }
Output
Exception in thread "main" com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError) (through reference chain: User["userItems"]->java.util.ArrayList[0]->Item["owner"]->..
Ngoại lệ trên đã cho chúng ta thấy rõ vấn đề, Khi Jackson serialize Item object chứa thuộc tính owner là 1 User instance chứa một danh sách các Item trong đó có cả Item instance hiện hành mà chúng ta đang serializer. Kế đến khi Jackson serialize owner chứa Item instance nó sẽ quay lại serialize Item instance, cứ như vậy chúng ta sẽ có đệ quy vô tận,
Ở các phần sau, chúng ta sẽ đi tìm ra giải pháp để giải quyết vấn đề này.
Sử dụng @JsonManagedReference, @JsonBackReference
Cách giải quyết đầu tiên, chúng ta sẽ sử dụng @JsonManagerReference và @JsonBackReference để chú thích cho User và Item class.
Sử dụng @JsonManagerReference cho User class
public class User { public int id; public String name; @JsonBackReference public List<Item> userItems; public User(int id, String name) { this.id = id; this.name = name; userItems = new ArrayList<>(); } public void addItem(Item item) { this.userItems.add(item); } }
Và @JsonBackReference cho Item class
public class Item { public int id; public String itemName; @JsonManagedReference public User owner; public Item(int id, String itemName, User owner) { this.id = id; this.itemName = itemName; this.owner = owner; } }
Chạy lại đoạn code serializer trên
public class Main { public static void main(String[] agrs) throws JsonProcessingException { User user = new User(1, "John"); Item item = new Item(2, "book", user); user.addItem(item); String result = new ObjectMapper().writeValueAsString(item); System.out.println(result); } }
Output:
{"id":2,"itemName":"book","owner":{"id":1,"name":"John"}}
Note:
- @JsonManagedReference được xem như là thành phần chính trong mối quan hệ trong serialization, vì vậy chúng sẽ được serialized bình thường.
- @JsonBackReference được xem như phần phụ trợ, và nó sẽ bị lược bỏ trong serialization để tránh tình trạng lặp vô tận.
Nếu muốn serializer User thay vì Item, chúng ta có thể hoán đổi vị trí @JsonManagedReference cho User class và @JsonBackReference cho Item.
public class User { // ... @JsonManagedReference public List<Item> userItems; // ... } // -------------------------------- public class Item { // ... @JsonBackReference public User owner; // .... }
Serialize User Object
public class Main { public static void main(String[] agrs) throws JsonProcessingException { User user = new User(1, "John"); Item item = new Item(2, "book", user); user.addItem(item); String result = new ObjectMapper().writeValueAsString(user); System.out.println(result); } }
Output:
{ "id":1, "name":"John", "userItems":[{"id":2,"itemName":"book"}] }
Sử dụng @JsonIndentityInfor
Với việc sử dụng @JsonManagedReference và @JsonBackReference có một nhược điểm cực kỳ lớn đó là chúng ta phải thay đổi vị trí đặt @JsonManagedReference để có thể serialize chứa đầy đủ thông tin.
@JsonIndentityInfor sinh ra nhầm giải quyết vấn đề này, nó cho phép serialize theo mã định danh khi chúng gặp lại nhau lần thứ 2 trong serialization từ đó giải pháp đệ quy vô tận.
Cho ví dụ khi User object đã được tuần tự hóa có ID là 1, nó sẽ kéo theo Item Object có ID là 2 được serialize. Ở lần quay lại sau, chúng sẽ kiếm tra nếu đã tồn tại User Object có ID là 1 thì, hoặc Item ID 2 thì quá trình serialization sẽ ngừng lại.
@JsonIdentityInfo( generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") public class User { public int id; public String name; public List<Item> userItems; public User(int id, String name) { this.id = id; this.name = name; userItems = new ArrayList<>(); } public void addItem(Item item) { this.userItems.add(item); } } // ---------------------------------------------------------- @JsonIdentityInfo( generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") public class Item { public int id; public String itemName; public User owner; public Item(int id, String itemName, User owner) { this.id = id; this.itemName = itemName; this.owner = owner; } }
Note:
- generator – được sử dụng để tạo ra Object indentifier cho các object (Item, User)
- property – chỉ ra thuộc tính dùng để xác định duy nhất một tham chiếu. Ví dụ User định danh với thuộc tính id, khi Item object tham chiếu đến User object này sẽ thông qua id thay vì một tham chiếu đến nó.
Cuối cùng, chúng ta sẽ tiến hành serialize cả User và Item object.
public class Main { public static void main(String[] agrs) throws JsonProcessingException { ObjectMapper mapper = new ObjectMapper(); User user = new User(1, "John"); Item item = new Item(2, "book", user); user.addItem(item); String jsonUser = mapper.writeValueAsString(user); System.out.println("User Json"); System.out.println(jsonUser); String jsonItem = mapper.writeValueAsString(item); System.out.println("Item Json"); System.out.println(jsonItem); } }
Output:
User Json { "id":1, "name":"John", "userItems":[{"id":2,"itemName":"book","owner":1}] } Item Json { "id":2, "itemName":"book", "owner":{"id":1,"name":"John","userItems":[2]} }
Sử dụng @JsonIgnore
Ngoài ra, chúng ta có thể sử dụng @JsonIgnore để loại bỏ thuộc tính trong mối quan hệ 2 chiều, từ đó sẽ ngắt liên kết giữa chúng. Như vậy đệ quy vô tận sẽ được giải quyết nhanh chóng. Tuy nhiên giải pháp này chỉ sử dụng được khi Json output không yêu cầu thông tin của object refenrence còn lại.
Ví dụ ngắt link giữa User với Item khi serialize User object
public class User { // ... @JsonIgnore public List<Item> userItems; // .... }
public class Main { public static void main(String[] agrs) throws JsonProcessingException { User user = new User(1, "John"); Item item = new Item(2, "book", user); user.addItem(item); String result = new ObjectMapper().writeValueAsString(user); System.out.println(result); } }
Output
{"id":1,"name":"John"}
@JsonView
Tương tự @JsonIgnore, @JsonView cho phép nhóm các thuộc tính trong serialization, từ đó chúng ta có thể loại bỏ thuộc tính gây nên đệ quy vô tận.
Ví dụ tạo 2 nhóm JSON VIEW – Public và Internal
public class Views { public static class Public {} public static class Internal extends Public {} }
Chúng ta sẽ họp tất cả các thuộc tính trong User và Item trong Public View ngoại trừ owner và userItems
public class User { @JsonView(Views.Public.class) public int id; @JsonView(Views.Public.class) public String name; @JsonView(Views.Internal.class) public List<Item> userItems; // ... }
public class Item { @JsonView(Views.Public.class) public int id; @JsonView(Views.Public.class) public String itemName; @JsonView(Views.Public.class) public User owner; // ..... }
Khi chúng ta serialize sử dụng Public View chúng sẽ loại trừ các thuộc tính 2 chiều về chúng nằm trong Internal View, từ đó tránh được đệ quy vô tận.
public class Main { public static void main(String[] agrs) throws JsonProcessingException { User user = new User(1, "John"); Item item = new Item(2, "book", user); user.addItem(item); String result = new ObjectMapper() .writerWithView(Views.Public.class) .writeValueAsString(user); System.out.println(result); } }
Output
{"id":1,"name":"John"}
Tuy nhiên, nếu serialize với Internal View, Java sẽ ném ra ngoại lệ JsonMappingException vì tất cả các field được serialized, bao gồm các field gây nên đệ quy vô tận.
public class Main { public static void main(String[] agrs) throws JsonProcessingException { User user = new User(1, "John"); Item item = new Item(2, "book", user); user.addItem(item); String result = new ObjectMapper() .writerWithView(Views.Internal.class) .writeValueAsString(user); System.out.println(result); } }
Output: Exception in thread “main” com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError)
Custom Serializer
Nếu không muốn sử dụng các annotation mà Jackson đã cung cấp sẵn, chúng ta có thể custom một serializer riêng cho mình để phù hợp với yêu cầu của dự án.
Trong ví dụ này, chúng ta sẽ custom một serializer cho userItem của User class với @JsonSerialize
public class User { @JsonSerialize(using = CustomListSerializer.class) public List<Item> userItems; // ... }
public class CustomListSerializer extends StdSerializer<List<Item>> { public CustomListSerializer() { this(null); } public CustomListSerializer(Class<List<Item>> t) { super(t); } @Override public void serialize( List<Item> items, JsonGenerator generator, SerializerProvider provider) throws IOException, JsonProcessingException { List<Integer> ids = new ArrayList<>(); for (Item item : items) { ids.add(item.id); } generator.writeObject(ids); } }
Serialize Item và User object
public class Main { public static void main(String[] agrs) throws JsonProcessingException { User user = new User(1, "John"); Item item = new Item(2, "book", user); user.addItem(item); String userJson = new ObjectMapper() .writeValueAsString(user); System.out.println(userJson); String itemJson = new ObjectMapper() .writeValueAsString(item); System.out.println(itemJson); } }
Output:
{ "id":1, "name":"John", "userItems":[{"id":2,"itemName":"book","owner":1}] } { "id":2, "itemName":"book", "owner":{"id":1,"name":"John","userItems":[2]} }
Deserialize với@JsonIdentityInfo
@JsonIdentityInfor là một trong cách chúng ta đã dùng ở trên để giải quyết đệ quy vô tận, trong phần này chúng ta sẽ kiểm tra nó sẽ hoạt động thế nào khi chúng deserialize Json sang Object.
Note: Chúng ta phải thêm default constructor cho User và Item class, mặc định Jackson sẽ sử dụng default constructor để khởi tạo Object.
@JsonIdentityInfo( generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") public class User { public int id; public String name; public List<Item> userItems; public User() { } public User(int id, String name) { this.id = id; this.name = name; userItems = new ArrayList<>(); } public void addItem(Item item) { this.userItems.add(item); } }
public class Item { public int id; public String itemName; public User owner; public Item() { } public Item(int id, String itemName, User owner) { this.id = id; this.itemName = itemName; this.owner = owner; } }
Kiểm thử
public class Main { public static void main(String[] agrs) throws IOException { String json = "{\"id\":2," + "\"itemName\":\"book\"," + "\"owner\":{\"id\":1,\"name\":\"John\",\"userItems\":[2]}}"; Item item = new ObjectMapper().readerFor(Item.class).readValue(json); User user = item.owner; System.out.println(item.id + " - " + item.itemName); System.out.println(user.id + " - " + user.name); } }
Output:
2 – book
1 – John
Custom Deserializer
Cuối cùng, chúng ta sẽ custom deserializer nếu không muốn sử dụng @JsonIndentityInfo mà Jackson cung cấp sẵn. Trong ví dụ này, chúng ta sẽ custom deserializer for userItems trong User class.
public class User { public int id; public String name; @JsonDeserialize(using = CustomListDeserializer.class) public List<Item> userItems; // ... }
public class CustomListDeserializer extends StdDeserializer<List<Item>> { public CustomListDeserializer() { this(null); } public CustomListDeserializer(Class<?> vc) { super(vc); } @Override public List<Item> deserialize( JsonParser jsonparser, DeserializationContext context) { return new ArrayList<>(); } }
Kiểm thử
public class Main { public static void main(String[] args) throws IOException { String json = "{\"id\":2,\"itemName\":\"book\",\"owner\":{\"id\":1,\"name\":\"John\",\"userItems\":[2]}}"; Item item = new ObjectMapper().readerFor(Item.class).readValue(json); System.out.println(item.id + " - " + item.itemName); User user = item.owner; System.out.println(user.id + " - " + user.name); } }
Output:
2 – book
1 – John
Tóm lược
Các mối quan hệ 2 chiều thường xuyên xảy ra với các Java class đại diện cho một table trong cơ sở dữ liệu, khi serialize chúng sang Json thì việc xảy ra đệ quy vô tận. Hy vọng qua bài viết này sẽ giúp các bạn tự tin hơn khi gặp các vấn đề tương tự, cũng như chọn ra giải pháp phù hợp nhất với từng yêu cầu cụ thể của dự án.
Nguồn tham khảo
https://www.logicbig.com/tutorials/misc/jackson/json-identity-info-annotation.html
https://www.baeldung.com/jackson-bidirectional-relationships-and-infinite-recursion