【Spring Boot】第12課-使用 Jackson 控制回傳的 JSON 欄位

後端將資料回應給前端時,會將資料轉換成 JSON 。然而我們可能會想要自訂 JSON 格式,例如改變欄位名稱,或忽略某些欄位不回傳等。

本文將使用隨同 Spring Boot 一併帶入的第三方函式庫「Jackson」。它提供一些實用的標記,來幫助調整 JSON 的欄位。最後將回應主體與資料庫文件的 Java 類別區隔開來。

本文的練習用專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch11

一、認識 ObjectMapper

首先我們先來認識「Jackson」函式庫提供的 ObjectMapper 類別。它能將 Java 物件「序列化」(serialize)成 JSON 字串,或將 JSON 字串「反序列化」(deserialize)成 Java 物件。讀者可利用它來進行後面的練習。

筆者藉由寫測試的方式來示範這個類別物件的用法。我們建立一個測試類別,它內含 ObjectMapper 物件和 BookPublisher 兩個自訂類別,分別代表書和出版商。本文會使用這兩種物件確認 JSON 轉換的結果。

public class ObjectMapperTest {
private ObjectMapper mapper = new ObjectMapper();

private static class Book {
private String id;
private String name;
private int price;
private String isbn;
private Date createdTime;
private Publisher publisher;

// 略過 get 與 set 方法
}

private static class Publisher {
private String companyName;
private String address;
private String tel;

// 略過 get 與 set 方法
}
}

下面的範例程式是將 Java 物件序列化成 JSON 字串,接著再與原物件的資料進行比對。這邊使用 writeValueAsString 方法,傳入要轉換成字串的物件。

@Test
public void testSerializeBookToJSON() throws Exception {
Book book = new Book();
book.setId("B0001");
book.setName("Computer Science");
book.setPrice(350);
book.setIsbn("978-986-123-456-7");
book.setCreatedTime(new Date());

String bookJSONStr = mapper.writeValueAsString(book);
JSONObject bookJSON = new JSONObject(bookJSONStr);

Assert.assertEquals(book.getId(), bookJSON.getString("id"));
Assert.assertEquals(book.getName(), bookJSON.getString("name"));
Assert.assertEquals(book.getPrice(), bookJSON.getInt("price"));
Assert.assertEquals(book.getIsbn(), bookJSON.getString("isbn"));
Assert.assertEquals(book.getCreatedTime().getTime(), bookJSON.getLong("createdTime"));
}

下面的範例程式是將 JSON 字串反序列化成 Java 物件,接著再與原 JSON 資料進行比對。這邊使用 readValue 方法,傳入字串與目標類別,來轉換成物件。

@Test
public void testDeserializeJSONToPublisher() throws Exception {
JSONObject publisherJSON = new JSONObject()
.put("companyName", "Taipei Company")
.put("address", "Taipei")
.put("tel", "02-1234-5678");

String publisherJSONStr = publisherJSON.toString();
Publisher publisher = mapper.readValue(publisherJSONStr, Publisher.class);

Assert.assertEquals(publisherJSON.getString("companyName"), publisher.getCompanyName());
Assert.assertEquals(publisherJSON.getString("address"), publisher.getAddress());
Assert.assertEquals(publisherJSON.getString("tel"), publisher.getTel());
}

請注意,上述的 BookPublisher 類別屬於 ObjectMapperTest 的內部類別(inner class),需要宣告為靜態(static),否則 Jackson 無法進行反序列化。如果類別是分開的不同檔案,則不必為靜態。


二、序列化的原理

Spring Boot 在 Controller 接收請求或回傳 JSON 資料時,底層也是透過 Jackson 來處理資料。Java 物件序列化時,會根據其類別具有的 get 方法來產生 JSON 欄位。

舉例來說,若類別中有 getNamegetPriceisActive 方法,JSON 資料就會有 name、price 與 active 的欄位,而值則是 get 方法執行過後的回傳值。反序列化則相反,Jackson 會根據 set 方法來給 Java 物件設定欄位值。

Jackson 函式庫的標記,可使用在 get 與 set 方法、欄位或類別上,我們能藉此控制序列化或反序列化的結果。本文的範例將著重於 Java 物件序列化為 JSON 資料。


三、調整欄位名稱

在 Java 物件的欄位加上 @JsonProperty 標記,可以調整序列化為 JSON 的欄位名稱。

private static class Publisher {
private String companyName;
private String address;

@JsonProperty("telephone")
private String tel;

// 略過 get 與 set 方法
}

此處將 tel 欄位的名稱改為 telephone,則序列化後的結果為如下。

{
"companyName": "Taipei Company"
"address": "Taipei",
"telephone": "02-1234-5678"
}

四、忽略欄位

在 Java 物件的欄位加上 @JsonIgnore 標記,可以在序列化為 JSON 時忽略該欄位,連 null 值都不會出現。

private static class Book {
private String id;
private String name;
private int price;

@JsonIgnore
private String isbn;

private Date createdTime;
private Publisher publisher;

// 略過 get 與 set 方法
}

此處將 isbn 欄位在序列化為 JSON 時忽略掉,則序列化後的結果如下。

{
"id": "B0001",
"name": "Computer Science",
"price": 350,
"publisher": null
}


五、展開物件欄位

在 Java 物件的物件欄位加上 @JsonUnwrapped 標記,可以在序列化為 JSON 時,將該欄位展開。也就是將該欄位的內部欄位搬移到這一層。

private static class Book {
private String id;
private String name;
private int price;
private String isbn;
private Date createdTime;

@JsonUnwrapped
private Publisher publisher;

// 略過 get 與 set 方法
}

Book 類別中有個 Publisher 的物件欄位。假設在沒有加上該標記時,序列化後的結果如下。

{
"id": "B0001",
"name": "Computer Science",
"price": 350,
"isbn": "978-986-123-456-7",
"createdTime": 1585493050168,
"publisher": {
   "companyName": "Taipei company",
  "address": "Taipei",
  "tel": "02-1234-5678"
}
}

若加上該標記,則序列化後的欄位會被展開成如下。

{
"id": "B0001",
"name": "Computer Science",
"price": 350,
"isbn": "978-986-123-456-7",
"createdTime": 1585493050168,
"companyName": "Taipei company",
"address": "Taipei",
"tel": "02-1234-5678"
}

在使用這個標記時,要小心將物件展開後,發生欄位名稱相同的情況。比方說將 Publisher 類別的 companyName 欄位改名成 name,就會發生衝突。可搭配 @JsonProperty 標記來調整 JSON 的欄位。


六、忽略空欄位

在 Java 物件的類別加上 @JsonInclude 標記,並傳入特定參數,可在序列化時納入符合條件的欄位。

@JsonInclude(JsonInclude.Include.NON_NULL)
private static class Book {
// 略過欄位、get 與 set 方法
}

其中 NOT_NULLNOT_EMPTY 參數,分別針對「非 null」和「非空」的欄位序列化。後者的「空」,意思是指空的集合(如 List)與空字串。

此時若將 Book 物件的 isbnpublisher 欄位設為 null,則序列化後不會存在這些欄位。

{
"id": "B0001",
"name": "Computer Science",
"price": 350,
"createdTime": 1585493050168
}

會想避免將 null 值傳回給前端的原因,可能是開發人員認為 null 值的欄位是無意義的。與其取值後卻得到 null,不如後端一開始就不傳。另一方面也可減少資料的傳輸量。


七、調整日期格式

若將 Java 物件的 Date 欄位轉換為 JSON,則欄位值預設會是毫秒數。但只要加上 @JsonFormat 標記,並傳入格式參數,便可在序列化時轉換成指定的字串格式。

private static class Book {
private String id;
private String name;
private int price;
private String isbn;

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createdTime;

private Publisher publisher;

// 略過 get 與 set 方法
}

此處可以像使用 Java 的 SimpleDateFormat 那樣,傳入日期與時間的輸出格式。序列化後的結果為如下。

{
"id": "B0001",
"name": "Computer Science",
"price": 350,
"isbn": "978-986-123-456-7",
"createdTime": "2020-03-29 21:06:38",
"publisher": null
}


八、區隔回應主體與資料庫文件

筆者在第11課有建議過,將請求資料與資料庫文件的 Java 類別區隔開來,而本文著重的回應資料也是如此。這樣與前端開發人員合作時,在共同訂定回應資料的欄位之餘,後端仍可保有資料庫文件的欄位設計。

讓我們建立一個產品回應類別(ProductResponse),欄位內容與資料庫文件相同。

public class ProductResponse {
private String id;
private String name;
private int price;

// 略過 get 與 set 方法
}

接著 ProductConverter 撰寫轉換程式。

public class ProductConverter {
// 其餘略過

public static ProductResponse toProductResponse(Product product) {
ProductResponse response = new ProductResponse();
response.setId(product.getId());
response.setName(product.getName());
response.setPrice(product.getPrice());

return response;
}
}

完成後,再用這個 ProductResponse 物件,修改 Controller 方法的回傳值型態。

@RestController
@RequestMapping(value = "/products", produces = MediaType.APPLICATION_JSON_VALUE)
public class ProductController {

@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable("id") String id) {
ProductResponse product = productService.getProductResponse(id);
return ResponseEntity.ok(product);
}
// 略過其餘 Controller 方法,請參考本文完成的範例專案
}

而 Service 方法的回傳值型態與程式碼也要調整。

@Service
public class ProductService {

public ProductResponse getProductResponse(String id) {
Product product = repository.findById(id)
.orElseThrow(() -> new NotFoundException("Can't find product."));
return ProductConverter.toProductResponse(product);
}

public ProductResponse createProduct(ProductRequest request) {
Product product = new Product();
product.setName(request.getName());
product.setPrice(request.getPrice());
product = repository.insert(product);

return ProductConverter.toProductResponse(product);
}
// 略過其餘 Service 方法,請參考本文完成的範例專案
}

本文的完成範例:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch12

上一篇:【Spring Boot】第11課-驗證 request body 的請求資料

下一篇:【Spring Boot】第13課-在 application.properties 配置檔提供參數

留言

  1. ProductConverter 那裡原意是不是 顯示 toProductResponse() 的寫法而不是 toProduct() ? 因為前些課好像已經寫 toProduct() 了

    回覆刪除
    回覆
    1. 是的,那邊想表達的是將 Product 物件轉換成 ProductResponse 沒錯
      我當初寫錯了,已經修改,謝謝~

      刪除
  2. 自己看得霧傻傻哈哈 幫看不懂幹嘛做Converter的說明下~希望沒誤人子弟:
    @PostMapping("/example")
    public ResponseEntity example(@Valid @RequestBody Position p) {
    不管input是"抽象"或"父類" @Valid 只會 驗證 Position 這個位置的類所定義的 @NotEmpty
    它不會考慮父類的其他子類多型之驗證內容的事情。 驗證以父類寫的為主!
    因為Json傳入並不知道怎麼對應類型 ,故無法自適應各種子類多態驗證。
    回傳也一樣,回傳子類,取得也會只拿到以父類建構的JSON而已,但我們需要子類的格式。
    另外順便感謝這系列文章,很讚。

    回覆刪除
    回覆
    1. 嗨嗨,第 11、12 篇有建立 ProductRequest 與 ProductResponse 這兩個類別。它們與 DB 的 model 類別(Product)不存在父子類別的關係喔!好處是當我調整其中一個類別,並不會影響到其他類別。這也是為何會寫 ProductConverter 這個 util method 來轉換 🙂

      刪除
    2. 對誒,用字不當 ,不好意思>< ,沒錯,就只是透過介質就可以達成驗證,不用動刀原始類,避免造成其它地方產生奇怪問題。

      刪除

張貼留言