1680 words
8 minutes
【備忘録】ThymeleafでOffsetDateTimeをformatしようとして盛大にコケた話 (Spring Boot)

【備忘録】ThymeleafでOffsetDateTimeをformatしようとして盛大にコケた話 (Spring Boot)#

あの時、たった一行のThymeleafテンプレートが、数十時間のデバッグ地獄を生むとは思っていなかった――。

CAUTION

注意!!これはjava初心者によるSpringBootで起きた話です!
これと同じ解決策をとっても必ずしも治るとは限らないのであしからず。

こんにちは、YAMAです。
今日は Thymeleaf × Spring Boot の組み合わせで地味にハマった、エラーの話と解決策を備忘録として残していこうと思います。
こういう罠、ほんとにあるんですよね。

APIとの戦いの記録:気象庁編#

1. 「天気 API?楽勝っしょ」から始まった#

気象庁の天気APIからJSONを取得して、天気を表示するだけ。
そう思って始めたSpringBootでの製作

public Forecast getTokyoForecast() {
        String url = "https://www.jma.go.jp/bosai/forecast/data/forecast/130000.json";
        ResponseEntity<Forecast[]> response = restTemplate.getForEntity(url, Forecast[].class);
        Forecast tokyoForecast = response.getBody()[0];

        // TempAverageの設定(例として最初のTimeSeriesから取得)
        if (!tokyoForecast.timeSeries.isEmpty()) {
            TimeSeries timeSeries = tokyoForecast.timeSeries.get(0);
            TempAverage tempAverage = new TempAverage();
            List<String> temps = null;
            if (!timeSeries.areas.isEmpty()) {
                temps = timeSeries.areas.get(0).getTemps();
            }
            if (temps != null && temps.size() >= 2) {
                tempAverage.setMinTemp(temps.get(0));
                tempAverage.setMaxTemp(temps.get(1));
            } else {
                tempAverage.setMinTemp("―");
                tempAverage.setMaxTemp("―");
            }
            tokyoForecast.setTempAverage(tempAverage);
        }

        return tokyoForecast;
    }

2. 地獄の JSON 構造#

最初につまずいたのは、JSONの構造が深くて長いこと。

[
  {
    "publishingOffice": "気象庁",
    "reportDatetime": "2024-05-20T05:00:00+09:00",
    "timeSeries": [
      {
        "timeDefines": [
          "2024-05-20T06:00:00+09:00",
          "2024-05-20T12:00:00+09:00",
          "2024-05-20T18:00:00+09:00"
        ],
        "areas": [
          {
            "area": {
              "name": "東京地方",
              "code": "130010"
            },
            "weatherCodes": ["101", "101", "101"],
            "weathers": ["くもり", "くもり", "くもり"],
            "winds": ["北の風", "北の風", "北の風"],
            "waves": ["0.5メートル", "0.5メートル", "0.5メートル"]
          }
        ]
      },
      ...
    ]
  }
]

ここから天気を取得しようとしたんです…

原因究明をする…#

まず、やらかしたコードについて
Thymeleaf側でこう書きました:

<span th:text="${#dates.format(T(java.time.OffsetDateTime).parse(weather.timeSeries[1].timeDefines[i]), 'MM月dd日 HH時')}"></span>

正直「うん、動くでしょ」と思っていました。

しかし帰ってきたのは

HTTP Status 500 – Internal Server Error  

泣きました。

(-ω-;)ウーン…困った。

試行錯誤:原因を探してさまよった話#

その1 APIからの取得がおかしい?

合ってた。むしろデータはきれいだった。

その2 JSONのキーが違った?

publishingOfficeのスペルもあってる。特に違和感なし。

その3 リストの要素がnull?

ちゃんと配列も入ってた。ダメな要素は見つからず。

真の原因:Thymeleafでは OffsetDateTime を直接フォーマットできない#

Spring Boot 3.x + Thymeleaf 3.x の組み合わせでは、#dates.format() は java.util.Date や java.util.Calendar などしか扱えません。
つまり:

T(java.time.OffsetDateTime)

これを Thymeleaf で使って .format() するのは無理です。

解決策:Controller側でパース&整形しよう#

Controller であらかじめ整形しておけば、Thymeleaf 側は超シンプルに書けます。

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import windows.java.demo.model.Forecast;
import windows.java.demo.service.WeatherService;

/**
 * 天気情報ページのコントローラー。
 * /weather へのリクエストを処理し、天気データを画面に渡す。
 */
@Controller
@RequestMapping("/weather")
public class WeatherController {

    // 天気情報取得サービス
    private final WeatherService weatherService;

    /**
     * DIによるWeatherServiceの注入
     */
    public WeatherController(WeatherService weatherService) {
        this.weatherService = weatherService;
    }

    /**
     * 天気ページの表示処理。
     * @param model テンプレートに渡すモデル
     * @param locale ロケール情報
     * @return テンプレート名
     */
    @GetMapping
    public String showWeather(Model model, Locale locale) {
        LocalDateTime dateObject = LocalDateTime.now();
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm", locale);

        // 天気データを取得してモデルに追加
        Forecast weather = weatherService.getTokyoForecast();
        model.addAttribute("weather", weather);

        model.addAttribute("dateObject", dateObject);
        model.addAttribute("formatter", formatter);
        return "weather";
    }
}

最終的にこんな感じに変更。

テンプレート:

body class="bg-light">
<div class="container py-5">
    <div class="mb-3">
        <a href="/index" class="btn btn-secondary">トップに戻る</a>
    </div>
    <h1 class="mb-4">🌤 東京の天気予報</h1>

    <p class="text-muted">発表日時: <span th:text="${weather?.reportDatetime}"></span></p>

    <div class="row">
        <!-- 天気カード -->
        <div class="col-md-6 mb-4">
            <div class="card border-primary shadow-sm">
                <div class="card-header bg-primary text-white">天気</div>
                <div class="card-body">
                    <ul class="list-group list-group-flush">
                        <li class="list-group-item"
                            th:each="i : ${#numbers.sequence(0, weather.timeSeries[0].areas[0].weathers.size() - 1)}">
                            <span th:text="${#temporals.format(dateObject)}"></span>
                            <span th:text="${weather.timeSeries[0].areas[0].weathers[i]}"></span>
                        </li>
                    </ul>
                </div>
            </div>
        </div>

        <!-- 降水確率カード -->
        <div class="col-md-6 mb-4">
            <div class="card border-info shadow-sm">
                <div class="card-header bg-info text-white">降水確率</div>
                <div class="card-body">
                    <ul class="list-group list-group-flush">
                        <li class="list-group-item"
                            th:each="i : ${#numbers.sequence(0, weather.timeSeries[1].areas[0].pops.size() - 1)}">
                            <span th:text="${#temporals.format(T(java.time.OffsetDateTime).parse(weather.timeSeries[1].timeDefines[i]), 'MM月dd日 HH時')}"></span>
                            <span th:text="${weather.timeSeries[1].areas[0].pops[i]} + '%'" ></span>
                        </li>
                    </ul>
                </div>
            </div>
        </div>
    </div>
</div>
</body>
</html>

それでもTOMCATは500を返す。#

Controllerでフォーマットを済ませて、テンプレートもきれいに書き直して、よしこれで大丈夫だろう!と思ってブラウザをリロードしたんですよ。
そしたら――  

HTTP Status 500 – Internal Server Error

またお前か TOMCAT!!
今度は何が悪いんだとログを見に行くと、

java.lang.IndexOutOfBoundsException

…あぁ、リストの index 指定ミスってるやつです。
Thymeleafで weather.timeSeries[1] って書いたけど、JSONによっては timeSeries が 1 要素しかないことがあるわけですね。

対応策#

テンプレートで index を指定する場合、要素数のチェックは事前に必ずやるべし。

理想的には Controller でちゃんとデータの形を整えて、テンプレート側にはロジックを持たせないこと。

結論:テンプレートにロジックを持たせすぎると TOMCAT が怒る(そして500を返す)。

教訓:APIとJacksonと私は三つ巴#

| APIの構造は変わる。

| JSONは必ずしも自分の思った通りには来ない。

| Jacksonは正しくマッピングされなければ、無言でnullを返してくる。

| 不明フィールドに爆発されないための 保険険(@JsonIgnoreProperties) を忘れるな。

そしてその全てを受け止めるのが「私」――。
デバッグ中、「このプロパティ、何でnullなの?」とJacksonに詰め寄っては、「いや、知らんけど……」と返される日々。
最終的に分かったのは、正しくデータを渡して、テンプレートには必要最低限しかやらせないってこと。
Controllerで全てを受け止め、加工し、テンプレートに「表示するだけ」の美しい責務を与える。
それが一番平和で、バグも少ない。

終わりに#

たった一行のテンプレートに「何時間も」悩まされたお話でした…
これからは テンプレートでの複雑な処理は避けて、Controllerで整形してから渡すという教訓を胸に刻んで生きていきます。
同じようにハマった人が少しでも楽になりますように!
以上YAMAでした。

おまけ POSTでこけた話#

Spring Security を有効にしていると、CSRF対策がデフォルトで有効になっています。

テンプレートに CSRFトークンを含めていない状態で POST をすると…

403 Forbidden

が帰ってきます。

有効策として、テンプレートにトークンを含めましょう。

<form th:action="@{/submit}" method="post">
  <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
  <!-- 他のフォーム項目 -->
</form>

もしくは、開発中だけ一時的に CSRF を無効にする設定もありますが、本番環境では非推奨です。