はじめに
GMOコネクトの稲葉です。
社内のマルチモジュール構成のプロジェクトで、共通のThymeleafテンプレートを別モジュールにライブラリ化(jar化)したところ、既存の画面がまるごと表示できなくなるという事態に遭遇しました😇
原因自体はすぐに見当が付いたのですが、話はそこで終わりませんでした。同じコード・同じ手順で試したはずなのに、動く人と動かない人に綺麗に分かれてしまったのです。
この記事は、Claude Codeと一緒に調査した、たった1文字(スラッシュ)を巡るバグの正体と、「環境によって再現しない」という厄介な副産物にどう向き合ったかの記録です。
先にまとめ
原因は二重スラッシュ、そして「環境で再現しない」の正体はIDEの依存解決の副作用でした。
- Spring Bootの標準
SpringResourceTemplateResolverは、prefix + テンプレート名 + suffixを単純に文字列連結するだけで、連続スラッシュを畳み込まない - ローカルディレクトリ配下のリソースはOS/ファイルAPIが冗長なスラッシュを吸収してくれるので問題にならないが、jarの中のリソースはZipEntry名との厳密一致でしか探せないため、二重スラッシュの時点で「存在しない」扱いになる
- 対処は
SpringResourceTemplateResolverをラップして、算出済みのリソース名に対して連続スラッシュを1つに畳み込むだけ。既存テンプレート側の記法(先頭スラッシュ)は1文字も変更不要 - 加えて、IDEの実行方式によっては依存先ライブラリをjarではなく該当プロジェクトのビルド出力ディレクトリで解決してしまうことがあり、これが「環境によって再現しない」の正体だった
Thymeleafのテンプレート解決の仕組み
Thymeleafでth:replaceやth:insertにテンプレート名を書くと、内部的には次のように解決されます。
リゾルバのprefix + テンプレート名 + リゾルバのsuffix
Spring Bootのデフォルト設定では、prefixはclasspath:/templates/、suffixは.htmlです。たとえばcommon/error/SystemErrorというテンプレート名なら
classpath:/templates/common/error/SystemError.html
という1本のパスに組み上がり、これをSpringのClassPathResource経由でクラスパス上から探しに行きます。ここまでは特に問題ありません。
なぜ先頭スラッシュ規約と噛み合わなかったか
問題のプロジェクトでは、フラグメント参照を書くときに
<head th:replace="/common/headTag::headTag(~{::script})"></head>
のように、テンプレート名の先頭にスラッシュを付ける規約になっていました。「ルートからの絶対パスであることを明示する」意図の規約自体は理解できるものです。
しかしこれをそのままprefixと連結すると
classpath:/templates/ + /common/headTag + .html
= classpath:/templates//common/headTag.html ← スラッシュが2つ
となります。Spring の ClassPathResource は、先頭に付いた1つの / は取り除いてくれますが、パスの途中にある連続スラッシュ(//)は畳み込みません。実際にjshellで検証すると違いがはっきり出ます。
new ClassPathResource("/templates/common/headTag.html").exists(); // true (単一スラッシュ)
new ClassPathResource("/templates//common/headTag.html").exists(); // false (二重スラッシュ)
このテンプレートは元々アプリ本体のソースツリー(src/main/resources/templates配下)に直接置かれていたので、二重スラッシュのままでも実害はありませんでした。ローカルディレクトリ配下のファイルを開く処理は、OSやJavaのファイルAPIが冗長なスラッシュを普通に吸収してくれるからです。
問題が表面化したのは、このヘッダー・フッター・エラー画面などの共通テンプレートを別プロジェクトに切り出してjar化したタイミングでした。
ハマり1: jarとローカルディレクトリで挙動が違う理由
jarファイルの中身は、ZipEntryという「ファイル名の文字列」の集合として管理されています。ディレクトリ階層があるように見えても、実体はただの文字列一致検索です。つまり
templates/common/headTag.html ← 実際に格納されているエントリ名
templates//common/headTag.html ← 二重スラッシュで探しに行った名前
は別の文字列として扱われ、後者は「存在しない」と判定されてしまいます。
| 配置場所 | 実体 | 探索方法 | 冗長スラッシュへの耐性 |
|---|---|---|---|
ローカルディレクトリ(target/classes配下) |
ファイルシステム上のファイル | OS・ファイルAPI経由 | あり(吸収される) |
| jar内リソース | ZipEntryの文字列 | 文字列の完全一致 | なし(別物として扱われる) |
ローカルディレクトリならFileやPathがよしなに正規化してくれる部分が、jarの世界では一切救済されません。
実際に起きていたエラーはこうです。
org.thymeleaf.exceptions.TemplateInputException:
Error resolving template [/common/headTag], template might not exist or
might not be accessible by any of the configured Template Resolvers
Thymeleafのトレースログを有効にすると、もう少し実態が見えます。
Skipping template resolver "SpringResourceTemplateResolver" for template "/common/headTag"
「解決を試みて失敗した」のではなく、内部の存在チェックの時点で「解決できない」と判断されてスキップされていたということです。
ちなみに、この共通テンプレート郡はエラー画面(error/SystemErrorのようなテンプレート)も含んでいたため、被害はさらに厄介でした。何らかの例外が起きてエラー画面にフォールバックしようとした瞬間、そのエラー画面自身が内部で/common/headTagを参照していて二次的に失敗するため、本来見えるはずの元のエラー内容すら握りつぶされて見えなくなっていました。
ハマり2: 環境によって再現有無が割れた話
原因の見立てが付いた段階で、念のため他のメンバーにも同じ手順で動作確認をお願いしたところ、4人中2人は問題なく画面が表示できてしまいました。同じコード、同じライブラリのはずなのに結果が割れる、という状況です。
条件を突き合わせていくと、共通しているのは「VS CodeのSpring Boot Dashboard拡張機能のRunでアプリを起動している」という点でした。この起動方式はmvnコマンドを直接叩くのではなく、VS CodeのJava拡張機能(内部はEclipse JDT Language Server)が計算したクラスパスでJavaプロセスを起動します。
この言語サーバーはMaven依存関係の解決処理にEclipseのm2e系の実装を内包しており、pom.xmlに書かれた依存(groupId:artifactId:version)と一致するMavenプロジェクトが同じワークスペースに開かれていると、実体のjarファイルではなく、そのプロジェクトのビルド出力ディレクトリを直接クラスパスに割り込ませることがあります。
- 共通テンプレートのプロジェクトを同じワークスペースに開いていた2人 → ディレクトリ経由の解決になり、冗長スラッシュが吸収されて問題を踏まなかった
- 開いていなかった2人(自分含む) → 素直にjar経由の解決になり、二重スラッシュで問題が再現した
つまり2人が「問題なかった」のは環境が正しかったからではなく、IDEの利便性機能の副作用で、本来の依存解決経路(jar経由)を一度も通っていなかっただけでした。
対処: リゾルバの二重スラッシュ正規化
修正は、Spring Boot標準のSpringResourceTemplateResolverをラップして、リソース名を計算した直後に連続スラッシュを畳み込むだけです。
@Configuration
public class ThymeleafConfig {
@Bean(name = "defaultTemplateResolver")
public SpringResourceTemplateResolver defaultTemplateResolver(
ApplicationContext applicationContext, ThymeleafProperties properties) {
SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver() {
@Override
protected ITemplateResource computeTemplateResource(
IEngineConfiguration configuration, String ownerTemplate, String template,
String resourceName, String characterEncoding,
Map<String, Object> templateResolutionAttributes) {
String normalized = resourceName.replaceAll("/{2,}", "/");
return super.computeTemplateResource(configuration, ownerTemplate, template,
normalized, characterEncoding, templateResolutionAttributes);
}
};
resolver.setApplicationContext(applicationContext);
resolver.setPrefix(properties.getPrefix());
resolver.setSuffix(properties.getSuffix());
resolver.setTemplateMode(properties.getMode());
resolver.setCharacterEncoding(properties.getEncoding().name());
resolver.setCacheable(properties.isCache());
resolver.setCheckExistence(properties.isCheckTemplate());
return resolver;
}
}
ポイントは2つです。
- Bean名をわざと
defaultTemplateResolverにしている点。Spring Bootの自動設定は@ConditionalOnMissingBean(name = "defaultTemplateResolver")という名前ベースの条件でリゾルバを登録しているため、同名Beanを1つ定義するだけで標準の自動設定を丸ごと差し替えられます -
computeTemplateResourceをオーバーライドして、算出済みリソース名の連続スラッシュを畳み込んでいる点。テンプレート名やprefixのどちらに原因があっても関係なく、最後の一箇所で正規化するので副作用が少なく済みます
この修正1ファイルで、先頭スラッシュ付きの参照を使っている数百ファイル規模の既存テンプレートを一切変更せずに解決できました。
反省: なぜ調査が長引いたか
二重スラッシュそのものにはわりと早い段階でたどり着けていました。時間がかかったのは、その後の「一部の環境では再現しない」という事実への向き合い方でした。
ここは今回Claude Codeと一緒に調査していたからこその難しさでもありました。二重スラッシュの特定は、ソースコード・ログ・クラスパスの中で完結する話だったので、渡してしまえばあっという間でした。一方「環境によって再現しない」の原因究明では、Eclipseのm2eが怪しいのでは、jarが古いままなのでは、といった仮説は次々出てくるものの、どれも決定打にはならず外れていきます。最終的に効いたのは、自分が他のメンバーに直接聞いて回って持ち帰った「共通テンプレートのプロジェクトを同じワークスペースに開いているかどうか」という1行の情報でした。これを渡した瞬間、推論は一気に収束しました。
AIは渡された情報の中でしか推論できません。何が原因になり得るか自分でも分かっていない段階では、「何を渡せば解けるか」を精査すること自体が難しく、結局は人間が現場に出て、実行方法や拡張機能の使い方といったコード上に現れない情報を拾ってくるしかありませんでした。今回の調査で意識したい役割分担はこんな感じです。
- コード・ログ・クラスパスなど、リポジトリの中で完結する情報はAIに投げて掘ってもらうのが速い
- 「誰が」「どの拡張機能で」「どうやって」動かしたか、というコードに現れない人間の作業手順は、人間が明示的に聞き取ってAIに渡すしかない
- AIが出す仮説(今回でいうm2e説やjarの古さ説)は、外れていても無駄にはならない。「次に何を確認すべきか」のチェックリストとして使う
再現有無が割れた時点で「相手の環境が正しい」「自分の環境がおかしいのでは」と決め打ちしてしまいがちですが、実際に効いていたのはIDEの実行方式の違いという、コードにもライブラリにも現れない変数でした。「動作確認できました」という報告は、どの依存解決経路(jar経由か、ワークスペース内の別プロジェクト経由か)を通った結果なのかとセットでないと、比較材料として成立しないのだと痛感しました。
ライブラリとして切り出したプロジェクトは、本番やCIでは必ずjar経由で参照されます。ローカルの検証だけ「たまたま」別プロジェクトの参照に化けているとしたら、それはむしろ検証になっていません。ライブラリ化した以上、消費側と同じワークスペースには置かない・置くとしても実行方式を意識して記録する、というのが今回の反省点です。
まとめ
- Thymeleafの
prefix + テンプレート名は単純連結。先頭スラッシュ付きのテンプレート名はprefixの末尾スラッシュと衝突して二重スラッシュになる - 二重スラッシュはローカルディレクトリでは実害がないが、jar内リソースはZipEntry名の厳密一致のため解決に失敗する
-
computeTemplateResourceをオーバーライドして正規化する1クラスの追加で、既存テンプレートを無改修のまま解決できた - 「環境によって再現しない」バグに当たったときは、実行方式(IDEの依存解決経路)まで含めて条件を揃えてから比較する
ライブラリはjarとして参照されて初めてライブラリです。ローカルの動作確認がその前提を満たしているか、次に疑うクセをつけたいと思います。
最後に、GMOコネクトではサービス開発支援や技術支援をはじめ、
幅広い支援を行っておりますので、何かありましたらお気軽にお問合せください。