🔖

Payara移行で直面した互換性問題とJSON-B実装

に公開

Payara移行で直面した互換性問題とJSON-B実装

はじめに

GlassFish 4.1 から Payara 6.11 への移行で最も苦労したのが Jakarta EE への対応日付型のシリアライズ問題です。

Payara 移行で直面した互換性問題と、カスタムシリアライザ・デシリアライザでの解決方法を書いていきます。

発生した問題

問題1: Java EE から Jakarta EE への移行

Payara 6.11 では、Java EE から Jakarta EE への移行が必須です。GlassFish 4.1 + Java 8 の環境から Payara 6.11 + Java 21 への移行に伴い、パッケージ名が変更されました。

変更内容:

  • javax.servlet.*jakarta.servlet.*
  • javax.persistence.*jakarta.persistence.*
  • javax.ws.rs.*jakarta.ws.rs.*

問題2: ZonedDateTime の出力形式変更

Payara 移行後、ZonedDateTime の JSON 出力形式が変更されました。

変更前(GlassFish):

{
  "createdAt": "2024-01-15T10:30:00Z"
}

変更後(Payara):

{
  "createdAt": "2024-01-15T10:30:00Z[UTC]"
}

この [UTC] 部分が問題でした。既存の VB クライアントが日付型として認識できず、エラーが発生しました。

問題3: 空文字のデシリアライズエラー

GlassFish では許容されていた空文字が、Payara では厳格にエラーとなりました。

GlassFish:

{
  "statusChangedDate": ""
}

↓ 自動的に null として扱われる

Payara:

{
  "statusChangedDate": ""
}

↓ デシリアライズエラーが発生

問題3.1: 数値フィールドへの空文字送信で落ちる(shotCount / disposedShotCount)

日付だけでなく、数値フィールドに空文字を送った場合も JSON-B が厳密化されており、GlassFish では「たまたま通っていた」ケースが Payara では例外になった。

実際に発生した例外は以下の通り。

Caused by: jakarta.json.bind.JsonbException: Unable to deserialize property 'disposedShotCount' because of: Error deserialize JSON value into type: int.
Caused by: java.lang.NumberFormatException: For input string: ""

原因は、リクエスト JSON が次のようになっていたことだった。

"shotCount": "0",
"disposedShotCount": ""

サーバ側のエンティティは int disposedShotCount; のように プリミティブな数値型 で定義されているため、

  • "0"int にはパースできるので OK
  • ""int にパースできず NumberFormatException

となり、JSON-B(Yasson)がデシリアライズに失敗して 400/500 系のエラーになっていた。

対応方針: 「数値は最初から数値として送る」

今回は「クライアント側(VB)の型定義をサーバ側に合わせる」方針を取った。

修正前(VB側)

Property shotCount As String = "0"
Property disposedShotCount As String = "0"

修正後(VB側)

Property shotCount As Integer
Property disposedShotCount As Integer

VB.NET の Integer は未代入時の初期値が 0 になるため、何も代入しなくても JSON には

"shotCount": 0,
"disposedShotCount": 0

のように 数値リテラルとして シリアライズされるようになった。
これにより、""(空文字)が送られることがなくなり、int へのデシリアライズも正常に完了する。

代替案として考えたこと

サーバ側で対処する選択肢もある。

  • エンティティ側を intInteger にして null を許容し、カスタムデシリアライザで ""null に変換する
  • いったん String disposedShotCount として受け、アプリ側で Integer.parseInt する際に空文字を 0 or null にマッピングする

ただし、「DB カラムも Java のフィールドも数値型なのに、クライアントの都合で文字列を混ぜる」という構造自体が負債なので、今回のケースでは

数値はクライアント側からも素直に数値として送る

という形に寄せるのが最もシンプルで、かつ将来の保守性も高いと判断した。

この事例からの教訓(数値版)

  • 「日付だけでなく、数値フィールドへの空文字送信も Payara / JSON-B ではきっちり怒られる
  • "" を「実質 0」や「実質 null」として扱うような あいまいな仕様は、ミドルウェア更新のタイミングで必ず爆発する
  • DB / Java / クライアントの型はできるだけ揃え、
    「数値は最初から数値」「null は最初から null」 の世界に寄せていくべき

GlassFish 4.1 時代は「たまたま通っていた」だけであり、Payara 6 ではそのツケが一気に表面化した形になった。
今回の対応を通して、型と表現(JSON)の設計を最初からきちんと揃えておくことの重要性を改めて痛感した。

問題4: コレクション要素の型不一致によるシリアライズ例外

List<String> として定義されたフィールドに、実際には List<Integer> が格納されている場合、GlassFish ではシリアライズ時に暗黙的に文字列化されていました。
しかし、Payara 6 (Jakarta EE 10) では型チェックが厳密になり、ClassCastException が発生してレスポンスの生成に失敗するようになりました。

発生した例外:
JsonbException: Unable to serialize property 'departmentIds' ... Caused by: java.lang.ClassCastException

解決策の実装

ステップ1: Java EE から Jakarta EE への一括置換

まず、パッケージ名を一括置換しました。

# javax.* を jakarta.* に一括置換
find src -name "*.java" -type f -exec sed -i 's/import javax\./import jakarta./g' {} \;

対象となる主なパッケージ:

  • javax.servlet.*jakarta.servlet.*
  • javax.persistence.*jakarta.persistence.*
  • javax.ws.rs.*jakarta.ws.rs.*
  • javax.json.*jakarta.json.*
  • javax.validation.*jakarta.validation.*

注意点:

  • javax.xml.* などは Jakarta EE の対象外なので置換しない
  • ライブラリの依存関係も Jakarta EE 対応版に更新が必要

ステップ2: カスタムシリアライザの実装

日付型の出力形式を統一するため、3つのカスタムシリアライザを実装しました。

1. ZonedDateTime 用シリアライザ

// UtcZonedDateTimeSerializer.java
package com.kmcj.karte.config;

import java.time.ZonedDateTime;
import java.time.ZoneOffset;
import java.util.Date;
import jakarta.json.bind.serializer.JsonbSerializer;
import jakarta.json.bind.serializer.SerializationContext;
import jakarta.json.stream.JsonGenerator;

/**
 * ZonedDateTime を UTC に正規化し、ISO 8601 (末尾Z固定) で出力するシリアライザ。
 * Payaraアップグレード後の "Z[UTC]" 形式を排除する目的。
 */
public class UtcZonedDateTimeSerializer implements JsonbSerializer<ZonedDateTime> {
    @Override
    public void serialize(ZonedDateTime value, JsonGenerator generator, SerializationContext ctx) {
        if (value == null) {
            generator.writeNull();
            return;
        }
        // ZonedDateTime を UTC に変換してから Date に変換
        Date utcDate = Date.from(value.withZoneSameInstant(ZoneOffset.UTC).toInstant());
        // Date のシリアライザに委譲することで、フォーマットを統一
        ctx.serialize(utcDate, generator);
    }
}
  • withZoneSameInstant(ZoneOffset.UTC) で UTC に正規化
  • Date に変換することで、既存の日付フォーマット設定を活用
  • ctx.serialize() で委譲することで、フォーマット設定を一元管理

2. OffsetDateTime 用シリアライザ

// UtcOffsetDateTimeSerializer.java
package com.kmcj.karte.config;

import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Date;
import jakarta.json.bind.serializer.JsonbSerializer;
import jakarta.json.bind.serializer.SerializationContext;
import jakarta.json.stream.JsonGenerator;

/**
 * OffsetDateTime を UTC に正規化し、ISO 8601 (末尾Z固定) で出力するシリアライザ。
 */
public class UtcOffsetDateTimeSerializer implements JsonbSerializer<OffsetDateTime> {
    @Override
    public void serialize(OffsetDateTime value, JsonGenerator generator, SerializationContext ctx) {
        if (value == null) {
            generator.writeNull();
            return;
        }
        Date utcDate = Date.from(value.withOffsetSameInstant(ZoneOffset.UTC).toInstant());
        ctx.serialize(utcDate, generator);
    }
}
  • OffsetDateTime も同様に UTC に正規化
  • withOffsetSameInstant(ZoneOffset.UTC) でオフセットを UTC に変換

3. Date 用シリアライザ

// UtcDateSerializer.java
package com.kmcj.karte.config;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.TimeZone;
import jakarta.json.bind.serializer.JsonbSerializer;
import jakarta.json.bind.serializer.SerializationContext;
import jakarta.json.stream.JsonGenerator;

/**
 * java.util.Date を UTC の ISO 8601 (末尾Z固定) で出力するシリアライザ。
 */
public class UtcDateSerializer implements JsonbSerializer<Date> {
    private static final ThreadLocal<SimpleDateFormat> FORMATTER = ThreadLocal.withInitial(() -> {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
        sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
        return sdf;
    });

    @Override
    public void serialize(Date date, JsonGenerator generator, SerializationContext serializationContext) {
        if (date == null) {
            generator.writeNull();
            return;
        }
        generator.write(FORMATTER.get().format(date));
    }
}
  • ThreadLocal を使ってスレッドセーフに
  • SimpleDateFormat は再利用するが、スレッドセーフでないため ThreadLocal で管理
  • タイムゾーンを明示的に UTC に設定

ステップ3: シリアライザの登録

JSON-B の設定プロバイダーを作成し、カスタムシリアライザを登録します。

// JsonbConfigProvider.java
package com.kmcj.karte.config;

import java.util.Locale;
import jakarta.json.bind.Jsonb;
import jakarta.json.bind.JsonbBuilder;
import jakarta.json.bind.JsonbConfig;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.ext.ContextResolver;
import jakarta.ws.rs.ext.Provider;

/**
 * JSON-B全体の日時フォーマットをISO 8601(末尾Z、タイムゾーンIDなし)に固定する。
 * Payaraアップグレード後に ZonedDateTime が "Z[UTC]" で出力されるため、VB側での日時変換エラーを防止する。
 */
@Provider
@Produces(MediaType.APPLICATION_JSON)
public class JsonbConfigProvider implements ContextResolver<Jsonb> {
    private final Jsonb jsonb;

    public JsonbConfigProvider() {
        JsonbConfig config = new JsonbConfig()
                .withDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
                .withSerializers(
                        new UtcDateSerializer(),
                        new UtcOffsetDateTimeSerializer(),
                        new UtcZonedDateTimeSerializer()
                );
        this.jsonb = JsonbBuilder.create(config);
    }

    @Override
    public Jsonb getContext(Class<?> type) {
        return jsonb;
    }
}
  • @Provider アノテーションで JAX-RS に登録
  • @Produces(MediaType.APPLICATION_JSON) で JSON に適用
  • ContextResolver<Jsonb> を実装して JSON-B の設定を提供

ステップ3.5: JSON-B 日付フォーマットの落とし穴と最終的な設計

Payara への移行中に、JSON-B の日付フォーマット周りで 二度ハマり ました。

1. withDateFormat を狭くしすぎて既存データを壊した

最初は、JsonbConfigProvider で次のように設定していました。

.withDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)

これは

  • 2025-08-30T15:00:00Z

のような ミリ秒なし・末尾 Z 固定 の文字列だけを許容する指定です。

ところが、実際の JSON には

  • 2025-08-30T15:00:00.000Z(Web側・ミリ秒あり)
  • 2025-12-08T16:24:47(タブレット側・Zなし)

のような値も混在しており、java.util.Date にバインドしているフィールドで デシリアライズエラー が発生するようになりました。

このため、java.util.Date / Calendar 向けのフォーマットを、
ミリ秒あり/なし・タイムゾーンあり/なし を許容する形に広げました。

// JsonbConfigProvider.java(抜粋)
JsonbConfig config = new JsonbConfig()
        // java.util.Date / Calendar 用
        // 例:
        //   2025-08-30T15:00:00
        //   2025-08-30T15:00:00.000Z
        //   2025-08-30T15:00:00+09:00
        .withDateFormat("yyyy-MM-dd'T'HH:mm:ss[.SSS][X]", Locale.JAPAN)
        .withSerializers(
                new UtcDateSerializer(),
                new UtcOffsetDateTimeSerializer(),
                new UtcZonedDateTimeSerializer()
        )
        .withDeserializers(
                new UtcZonedDateTimeDeserializer() // ZonedDateTime の入力(Zなし対応)
        );

ここで使っている [.SSS][X]オプション要素 で、

  • .SSS … ミリ秒があってもなくても良い
  • X … タイムゾーン(Z, +09:00 等)があってもなくても良い

という意味になります。

なお、このパターンは数字と固定文字だけなので、Locale.USLocale.JAPAN のどちらを指定しても挙動は変わりません(今回は日本語環境であることが分かりやすいよう Locale.JAPAN にしていますが、実質どちらでも同じです)。

2. withDateFormat は java.time.* には効かない問題

もう一つハマったのが、withDateFormatjava.time.* には効かない点です。

  • withDateFormat(...) が効くのは主に
    java.util.Date / java.util.Calendar
  • ZonedDateTime / OffsetDateTime / LocalDateTime などの java.time.*
    デフォルトでは DateTimeFormatter の標準フォーマット でパースされる

そのため、エンティティに ZonedDateTime を持っている箇所では、

  • 2025-12-08T16:24:47Z+09:00 付き → 問題なし
  • 2025-12-08T16:24:47(タブレットからの Z なし) →
    ZonedDateTime.parse(..., ISO_ZONED_DATE_TIME) が失敗し、DateTimeParseException になる

という状況になりました。

これを吸収するために、ZonedDateTime 専用のデシリアライザを追加しています。

// UtcZonedDateTimeDeserializer.java
package com.kmcj.karte.config;

import jakarta.json.bind.serializer.DeserializationContext;
import jakarta.json.bind.serializer.JsonbDeserializer;
import jakarta.json.stream.JsonParser;
import java.lang.reflect.Type;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;

/**
 * ZonedDateTime 用のデシリアライザ。
 * 以下のフォーマットを許容する:
 * 1. ISO_ZONED_DATE_TIME (例: "2025-12-08T16:24:47Z", "2025-12-08T16:24:47+09:00")
 * 2. ISO_LOCAL_DATE_TIME (例: "2025-12-08T16:24:47") -> システムデフォルトのタイムゾーンを付与
 */
public class UtcZonedDateTimeDeserializer implements JsonbDeserializer<ZonedDateTime> {

    @Override
    public ZonedDateTime deserialize(JsonParser parser, DeserializationContext ctx, Type rtType) {
        String dateStr = parser.getString();

        // 1. タイムゾーン付き (Z or +09:00) の場合は標準の ISO_ZONED_DATE_TIME でパース
        try {
            return ZonedDateTime.parse(dateStr, DateTimeFormatter.ISO_ZONED_DATE_TIME);
        } catch (DateTimeParseException e) {
            // 失敗したら次へ
        }

        // 2. タイムゾーンなしの場合は ISO_LOCAL_DATE_TIME として読み、
        //    システムデフォルトゾーン(今回の環境では Asia/Tokyo)を付与する
        try {
            LocalDateTime ldt = LocalDateTime.parse(dateStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
            return ldt.atZone(ZoneId.systemDefault());
        } catch (DateTimeParseException e) {
            throw new IllegalArgumentException("Failed to parse ZonedDateTime: " + dateStr, e);
        }
    }
}

これにより、

  • Web クライアント: 2025-08-30T15:00:00.000Z
  • タブレット: 2025-12-08T16:24:47
  • 将来的なクライアント: 2025-12-08T16:24:47+09:00

といった フォーマットの揺れをサーバ側で吸収 できるようになりました。

3. 最終的な方針

今回の一連のトラブルから得た方針は次の通りです。

  • java.util.Date / Calendar は、過去との互換性のために 受け側を少し広めに許容 する
    yyyy-MM-dd'T'HH:mm:ss[.SSS][X] のようにオプションを使う)
  • java.time.*型ごとに Serializer / Deserializer をセットで用意 し、
    「どう解釈してどう出力するか」を明示する
  • クライアント(VB / Web / タブレット)が落ち着いてきたら、
    受け側フォーマットの許容範囲を徐々に絞っていく

「とりあえず JSON-B のグローバル設定で何とかする」だけではなく、
java.util.Datejava.time.* の扱いの差をちゃんと意識して設計することの重要さを痛感しました。

補足: 「Z付きISO 8601」が現代的な理由とレガシークライアントとのギャップ

今回の移行対応を通して、「Z付きの ISO 8601 を前提にする世界」と「ローカル日時前提のレガシーな世界」のギャップをかなり痛感した。

Z の意味と現代的な扱い

2025-08-30T15:00:00.000Z の末尾に付いている Z は、ISO 8601 で UTC(協定世界時, +00:00) を表す記号で、+00:00 と同じ意味を持つ。

2025-08-30T15:00:00.000Z  ≒  2025-08-30T15:00:00.000+00:00

現代の REST / JSON API では、

  • サーバ・クライアント間の時差トラブルを避けるために UTC基準
  • 表記は ISO 8601(yyyy-MM-dd'T'HH:mm:ss.SSSX
  • タイムゾーンは Z または +09:00 のようなオフセット付き

という前提で設計されていることが多く、「Z付きISO 8601」が実質的な標準になっている。

一方で、2025-12-08T16:24:47 のように タイムゾーン情報が一切ない文字列は、

  • それが「どこのタイムゾーンの時間なのか」が分からない
  • サーバ側がローカルなのか UTC なのかを勝手に解釈するしかない
  • 結果として 9時間ズレる・日付が前後する、といった事故につながりやすい

という問題を抱えている。

VS2010 時代のタブレットアプリとのギャップ

今回の環境では、

  • Web側(ブラウザ/JavaScript)は
    2025-08-30T15:00:00.000Z のような UTC + ISO 8601 形式
  • タブレット側(VB / .NET 4.0 / Visual Studio 2010)は
    2025-12-08T16:24:47 のような タイムゾーンなしのローカル日時文字列

をサーバに送ってきていた。

VS2010 時代の .NET / VB では、

  • DateTimeKindLocal, Utc, Unspecified)があまり意識されていないコードが多い
  • ToString("yyyy-MM-ddTHH:mm:ss") のように、ローカル時間をそのまま文字列化する実装がよくある
  • タイムゾーンや UTC を前提とした API 設計が一般的ではなかった

という背景があり、「Z付きのISO 8601」を前提とする現代のサーバ実装(JSON-B / java.time.*)とのギャップが一気に表面化した。

今回の方針

最終的に、次のような方針に落ち着いている。

  • API の理想形としては「Z付きISO 8601(UTC)」を標準とする

    • 例: yyyy-MM-dd'T'HH:mm:ss.SSSX2025-08-30T15:00:00.000Z
  • ただし、レガシーな VS2010 タブレットなどの都合もあり、

    • java.util.Date については yyyy-MM-dd'T'HH:mm:ss[.SSS][X] のように 受け側を広めに許容
    • ZonedDateTime 用デシリアライザで、Z 付き / オフセット付き / Zなしローカル日時を段階的に解釈して吸収
  • クライアント(VB / Web / タブレット)が順次修正できた段階で、

    • サーバ側の受けフォーマットの許容範囲を徐々に 「Z付きISO 8601」へ収束させる

ざっくり言うと、

「長期的には Z付きISO 8601 を前提にした世界に寄せていきつつ、
当面はレガシークライアントの“ローカル日時文化”をサーバ側で面倒を見る」

という折衷案であり、そのための仕組みとして JsonbConfig + カスタム Serializer/Deserializer を整備した、という位置付けになる。

ステップ4: ApplicationConfig への登録

JAX-RS の ApplicationConfig でプロバイダーを登録します。

// ApplicationConfig.java (一部抜粋)
@jakarta.ws.rs.ApplicationPath("api")
public class ApplicationConfig extends Application {
    @Override
    public Set<Class<?>> getClasses() {
        Set<Class<?>> resources = new java.util.HashSet<>();
        addRestResourceClasses(resources);
        resources.add(MultiPartFeature.class);
        // JSON-B のカスタム設定を登録
        resources.add(com.kmcj.karte.config.JsonbConfigProvider.class);
        return resources;
    }
}

ステップ5: カスタムデシリアライザの実装

空文字を安全に null として扱うため、カスタムデシリアライザを実装しました。

// EmptyToNullDateDeserializer.java
package com.kmcj.karte.util;

import jakarta.json.bind.serializer.JsonbDeserializer;
import jakarta.json.bind.serializer.DeserializationContext;
import jakarta.json.stream.JsonParser;
import java.lang.reflect.Type;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Date;

/**
 * JSON-B の Date デシリアライザで、空文字や null を安全に null へ変換しつつ、
 * 受け取りフォーマットのばらつきを許容するためのユーティリティ。
 */
public class EmptyToNullDateDeserializer implements JsonbDeserializer<Date> {
    @Override
    public Date deserialize(JsonParser parser, DeserializationContext ctx, Type rtType) {
        String text = parser.getString();
        
        // 空文字や null は null として扱う
        if (text == null || text.isBlank()) {
            return null;
        }

        // オフセット付き日時 → ISO日付 → スラッシュ日付 の順で解釈
        try {
            return Date.from(OffsetDateTime.parse(text).toInstant());
        } catch (DateTimeParseException ignored) {
            // fall through
        }

        for (DateTimeFormatter formatter : new DateTimeFormatter[]{
                DateTimeFormatter.ISO_LOCAL_DATE,
                DateTimeFormatter.ofPattern("yyyy/MM/dd")
        }) {
            try {
                LocalDate localDate = LocalDate.parse(text, formatter);
                return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
            } catch (DateTimeParseException ignored) {
                // try next
            }
        }

        throw new DateTimeParseException("Unsupported date format", text, 0);
    }
}
  • isBlank() で空文字を判定(isEmpty() より厳密)
  • 複数のフォーマットを試行して柔軟に対応
  • サポートしていないフォーマットの場合は明確にエラー

ステップ6: エンティティへの適用

カスタムデシリアライザをエンティティのフィールドに適用します。

// MstMold.java (一部抜粋)
@Entity
@Table(name = "mst_mold")
public class MstMold implements Serializable {
    
    @JsonbTypeDeserializer(EmptyToNullDateDeserializer.class)
    @Column(name = "STATUS_CHANGED_DATE")
    @Temporal(TemporalType.DATE)
    private Date statusChangedDate;
    
    // その他のフィールド...
}
  • @JsonbTypeDeserializer でフィールド単位に適用
  • すべての Date フィールドに適用する必要がある場合は、グローバル設定も可能

ステップ7: オブジェクトマッピングによる型変換の明示

JPQL で ID (Integer) のリストを直接取得して List<String> に無理やり代入していた箇所を修正しました。
一度関連エンティティ(オブジェクト)のリストとして取得し、そこから ID を取り出して明示的に String に変換することで、型安全性を確保しました。

修正前:

// 戻り値は List<Integer> だが、List<String> 変数で受けていた(警告あり)
List<String> ids = entityManager.createNamedQuery("RelatedEntity.findIdsByParentId")
        .setParameter("parentId", parentEntity.getId())
        .getResultList();
parentEntity.setChildIds(ids); // 実行時に Integer のリストがセットされる

修正後:

// 一度エンティティのリストとして取得
List<RelatedEntity> relatedEntities = entityManager
        .createNamedQuery("RelatedEntity.findByParentId", RelatedEntity.class)
        .setParameter("parentId", parentEntity.getId())
        .getResultList();

// Integer として取り出し、String に明示的に変換
List<String> childIds = relatedEntities.stream()
        .map(e -> e.getChildId()) // Integer取得
        .map(String::valueOf) // String変換
        .collect(Collectors.toList());

parentEntity.setChildIds(childIds);

動作確認

シリアライズ(レスポンス)の確認

変更前:

{
  "createdAt": "2024-01-15T10:30:00Z[UTC]"
}

変更後:

{
  "createdAt": "2024-01-15T10:30:00Z"
}

VB クライアントで正常に日付型として認識されるようになりました。

デシリアライズ(リクエスト)の確認

GlassFish:

{
  "statusChangedDate": ""
}

自動的に null として扱われる

Payara(対応前):

{
  "statusChangedDate": ""
}

デシリアライズエラーが発生

Payara(対応後):

{
  "statusChangedDate": ""
}

null として安全に処理される

実装時の注意点

1. ThreadLocal の使用

SimpleDateFormat はスレッドセーフではないため、ThreadLocal で管理します。

private static final ThreadLocal<SimpleDateFormat> FORMATTER = ThreadLocal.withInitial(() -> {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
    sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
    return sdf;
});

2. タイムゾーンの統一

すべての日付を UTC に正規化することで、タイムゾーンの問題を回避します。

Date utcDate = Date.from(value.withZoneSameInstant(ZoneOffset.UTC).toInstant());

3. null チェックの徹底

シリアライザ・デシリアライザでは必ず null チェックを行います。

if (value == null) {
    generator.writeNull();
    return;
}

パフォーマンスへの影響

ThreadLocal によるオーバーヘッド

ThreadLocal を使用しているため、スレッドごとに SimpleDateFormat のインスタンスが作成されます。

測定結果:

  • 通常のレスポンス: 平均 50ms
  • カスタムシリアライザ適用後: 平均 52ms(+2ms)

実用上は問題ないレベルです。

代替案の検討

java.time.format.DateTimeFormatter はスレッドセーフなので、ThreadLocal 不要です。

private static final DateTimeFormatter FORMATTER = 
    DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
                     .withZone(ZoneId.of("UTC"));

ただし、Date を直接フォーマットできないため、一度 Instant に変換する必要があります。

トラブルシューティング

問題: カスタムシリアライザが適用されない

原因:
ApplicationConfigJsonbConfigProvider を登録していない。

解決策:

resources.add(com.kmcj.karte.config.JsonbConfigProvider.class);

問題: 一部のフィールドだけ古い形式で出力される

原因:
シリアライザが登録されていない型がある。

解決策:
必要な型のシリアライザをすべて登録します。

.withSerializers(
    new UtcDateSerializer(),
    new UtcOffsetDateTimeSerializer(),
    new UtcZonedDateTimeSerializer()
)

問題: デシリアライズで例外が発生する

原因:
デシリアライザがエンティティに適用されていない。

解決策:

@JsonbTypeDeserializer(EmptyToNullDateDeserializer.class)
private Date statusChangedDate;

教訓:型定義の不一致を避ける

今回の ClassCastException の根本原因は、DBのカラム定義とエンティティ(特に @Transient フィールド)の型定義が一致していなかったことにあります。

DBのカラムは INT で定義されていますが、エンティティ側では JSON レスポンスの表示都合に合わせて @Transient を付けた List<String> フィールドを用意していました。
GlassFish 4.1 (Java EE 7) 時代は、DB から取得した List<Integer> をこの List<String> フィールドにセットしても、JSON-B (EclipseLink MOXy 等) が暗黙的に型変換を行ってくれていたため、表面上は動いていました。

しかし、Payara 6 (Jakarta EE 10) への移行で仕様が厳格化されたことにより、この「型が違うものを無理やり突っ込む」実装が許容されなくなり、ClassCastException として顕在化しました。

教訓:

  • 表示するときのデータ型にバックエンドは引っ張られないようにする: フロントエンドの表示都合(Stringで欲しいなど)を、バックエンドのデータ構造(DBはInteger)に持ち込まない。
  • DBとJavaの型は必ず合わせる: DBが数値ならJavaも数値として扱う。
  • 暗黙の型変換に頼らない: フレームワークがやってくれるだろうという期待は、将来の移行時に負債となる。

「動いているからヨシ」ではなく、**「正しい型で定義され、正しくマッピングされているか」**を常に意識することが、将来的なトラブルを防ぐ一番の近道だと痛感しました。

まとめ

Payara 移行で直面した互換性問題と、その解決策として実装したカスタムシリアライザ・デシリアライザについて解説しました。

重要なポイント:

  • Jakarta EE への移行はパッケージ名の一括置換で対応
  • 日付型の出力形式は UTC に統一してタイムゾーン問題を回避
  • 空文字のデシリアライズはカスタムデシリアライザで対応
  • ThreadLocal でスレッドセーフを確保

これにより、レガシーな VB クライアントとの互換性を維持しつつ、GlassFish 4.1 から Payara 6.11 への移行を実現できました。

Discussion