Jackson JSON 序列化:時區與日期處理

商業價值:正確的時區處理讓「跨平台訂單時間一致」,避免 導讀篇提到的「超賣損失」——如果訂單時間錯誤,先後順序就會亂,庫存扣減就會出問題。

為什麼不用其他方案?

方案 優點 缺點 適用場景
統一 ObjectMapper(本文) 全系統一致、自動時區轉換 需要初期設定 多平台整合系統
各處自行處理 彈性高 格式不一致、時區 bug 頻發 單一平台小專案
字串直接儲存 簡單 無法比較、排序困難 純展示用途
Gson 輕量 時區支援較弱、擴展性差 簡單 JSON 處理

前言:跨時區系統的日期處理

多通路系統需要處理各種日期格式:

平台 日期格式 範例
蝦皮 Unix timestamp 1710748800
Yahoo ISO 8601 2024-03-18T15:30:00+08:00
Momo 台灣時間字串 2024/03/18 15:30:00
資料庫 UTC 2024-03-18T07:30:00Z
問題:如果沒有統一處理,時區 bug 會在各種轉換中出現

解決方案:統一 JSON 處理

ObjectMapper 設定

@Configuration
public class JsonConfig {

@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();

// 1. 允許序列化私有欄位
mapper.setVisibility(PropertyAccessor.FIELD, Visibility.ANY);

// 2. 日期格式設定
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.setDateFormat(new SimpleDateFormat(“yyyy-MM-dd’T’HH:mm:ss.SSS’Z’”));

// 3. 設定 UTC 時區
mapper.setTimeZone(TimeZone.getTimeZone(“UTC”));

// 4. 支援 Java 8 時間 API
mapper.registerModule(new JavaTimeModule());

// 5. 自定義序列化器
SimpleModule module = new SimpleModule();
module.addSerializer(ZonedDateTime.class, new ZonedDateTimeSerializer());
mapper.registerModule(module);

// 6. 忽略未知欄位(平台加新欄位不會報錯)
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

return mapper;
}
}

設定 效果
FIELD visibility 不需要 getter 也能序列化
UTC 時區 系統內部統一用 UTC
JavaTimeModule 支援 LocalDate、ZonedDateTime 等
忽略未知欄位 API 加新欄位不會報錯

自定義日期序列化器

public class ZonedDateTimeSerializer extends JsonSerializer<ZonedDateTime> {

private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern(“yyyy-MM-dd’T’HH:mm:ss.SSS’Z’”);

@Override
public void serialize(
ZonedDateTime value,
JsonGenerator gen,
SerializerProvider provider) throws IOException {

// 無論輸入什麼時區,都轉成 UTC 輸出
ZonedDateTime utc = value.withZoneSameInstant(ZoneId.of(“UTC”));
gen.writeString(FORMATTER.format(utc));
}
}

效果展示

// 輸入:台灣時間
ZonedDateTime taiwanTime = ZonedDateTime.now(ZoneId.of(“Asia/Taipei”));
// 2024-03-18T15:30:00+08:00[Asia/Taipei]

// 輸出:自動轉成 UTC
String json = objectMapper.writeValueAsString(Map.of(“time”, taiwanTime));
// {“time”:”2024-03-18T07:30:00.000Z”}


JSON 工具類

@Component
public class JsonUtil {

private static ObjectMapper mapper;

@Autowired
public void setMapper(ObjectMapper mapper) {
JsonUtil.mapper = mapper;
}

/**
* 物件轉 JSON
*/

public static String toJson(Object obj) {
try {
return mapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
throw new JsonException(“序列化失敗”, e);
}
}

/**
* JSON 轉物件
*/

public static <T> T fromJson(String json, Class<T> clazz) {
try {
return mapper.readValue(json, clazz);
} catch (JsonProcessingException e) {
throw new JsonException(“反序列化失敗”, e);
}
}

/**
* JSON 轉泛型物件
*/

public static <T> T fromJson(String json, TypeReference<T> type) {
try {
return mapper.readValue(json, type);
} catch (JsonProcessingException e) {
throw new JsonException(“反序列化失敗”, e);
}
}

/**
* JSON 轉 List
*/

public static <T> List<T> fromJsonList(String json, Class<T> clazz) {
try {
JavaType type = mapper.getTypeFactory()
.constructCollectionType(List.class, clazz);
return mapper.readValue(json, type);
} catch (JsonProcessingException e) {
throw new JsonException(“反序列化失敗”, e);
}
}

/**
* JSON 轉 Map
*/

public static Map<String, Object> toMap(String json) {
return fromJson(json, new TypeReference<>() {});
}
}


平台日期轉換

public class DateConverter {

/**
* Unix timestamp → ZonedDateTime(蝦皮)
*/

public static ZonedDateTime fromUnixTimestamp(long timestamp) {
return Instant.ofEpochSecond(timestamp)
.atZone(ZoneId.of(“UTC”));
}

/**
* ISO 8601 → ZonedDateTime(Yahoo)
*/

public static ZonedDateTime fromISO(String isoString) {
return ZonedDateTime.parse(isoString);
}

/**
* 台灣時間字串 → ZonedDateTime(Momo)
*/

public static ZonedDateTime fromTaiwanTime(String timeStr) {
DateTimeFormatter formatter =
DateTimeFormatter.ofPattern(“yyyy/MM/dd HH:mm:ss”);

LocalDateTime ldt = LocalDateTime.parse(timeStr, formatter);
return ldt.atZone(ZoneId.of(“Asia/Taipei”));
}

/**
* 轉成台灣時間顯示
*/

public static String toTaiwanDisplay(ZonedDateTime time) {
ZonedDateTime taiwanTime = time.withZoneSameInstant(
ZoneId.of(“Asia/Taipei”)
);
return taiwanTime.format(
DateTimeFormatter.ofPattern(“yyyy/MM/dd HH:mm:ss”)
);
}
}


使用範例

// 從蝦皮 API 取得資料
long shopeeTimestamp = 1710748800;
ZonedDateTime orderTime = DateConverter.fromUnixTimestamp(shopeeTimestamp);

// 存入資料庫(UTC)
order.setCreatedAt(orderTime);
orderRepository.save(order);

// 回傳 API(自動轉成 UTC JSON)
return JsonUtil.toJson(order);
// {“createdAt”:”2024-03-18T08:00:00.000Z”}

// 前端顯示(轉成台灣時間)
String display = DateConverter.toTaiwanDisplay(order.getCreatedAt());
// 2024/03/18 16:00:00


實戰踩坑

踩坑 1:時區雙重轉換
情境:前端顯示時間比實際晚 8 小時
原因:資料庫已經是 UTC,但讀取時又被當成本地時間再轉一次 UTC
解法:確保 JDBC 連線設定 serverTimezone=UTC,並且 Entity 用 ZonedDateTime 而非 Date
踩坑 2:蝦皮 timestamp 單位搞錯
情境:訂單時間顯示成 1970 年
原因:蝦皮回傳秒級 timestamp,但程式用 Instant.ofEpochMilli() 處理
解法:確認平台 API 文件的時間單位,秒用 ofEpochSecond,毫秒用 ofEpochMilli
踩坑 3:SimpleDateFormat 執行緒不安全
情境:高併發時偶發日期解析錯誤或 NumberFormatException
原因:把 SimpleDateFormat 設成 static 共用
解法:改用 DateTimeFormatter(執行緒安全),或每次 new 新的 SimpleDateFormat

總結

設計 效果
統一 ObjectMapper 全系統一致的 JSON 處理
UTC 儲存 避免時區混亂
自定義序列化 控制輸出格式
平台轉換器 各平台格式統一處理

上一篇 系列目錄 下一篇
PDF生成與Builder Pattern 系列導讀 OpenTracing分散式追蹤

這是「多通路電商 OMS 系統實戰」系列的第八篇。下一篇會介紹分散式追蹤。

留言

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *