Accept-CharsetとContent-Typeの不一致でJerseyは404を返す
かなりハマってしまったので残しておきたいと思います。
とあるWebクライアントから通信を行った際に、ChromeやFirefoxなどのモダンブラウザではステータスコード 200 OK
を返していたリクエストパスで、ステータスコード 404 NotFound
を返す事象と遭遇しました。使用していたフレームワークはJerseyで、前段にWebサーバとしてApacheを利用していました。
原因調査
今回、対象のWebクライアントが手元にない状態でした。リモートでの在宅勤務ということもあり、手元に持ってくることも難しい状況です。そのため少し泥臭くサーバサイドから調査を行っています。下記のように調査を進めていきました。
- tomcatのアクセスログを出力し、「tomcatまで通信は来ているか?」「tomcatの時点で返却されているステータスコードは何か?」を確認
- Javaアプリケーションでどこまで処理が到達しているかをデバッグ出力で確認
WriterInterceptor
まで到達しているので、レスポンスの書き込み時に404が返却されていることがわかる- JerseyのFilter系を理解するときには毎回、こちらの資料にお世話になっている、とっても分かりやすい
- リクエストのヘッダ差分を確認
- 直上の調査結果からJersey側で処理している層で404を返却していることがわかったので、
X-
で始まる独自定義ヘッダでない部分から見ていく - 他のWebクライアントから送信していないヘッダ
accept-charset
が見つかる、以前にReal World HTTPを読んだ際に「モダンブラウザではほとんど送信していない」とあったので臭いなと感じる accept-charset
を404のリクエストと同じように設定しcurl
叩くと再現が出来た!
- 直上の調査結果からJersey側で処理している層で404を返却していることがわかったので、
accept-charset
で指定している文字コードと、Content-Typeで指定している文字コードは同じだが、それぞれUPPRER_CASEとlower_caseとなっており厳密には異なっていた。その事実から、Content-Typeで指定している文字コード以外の文字コードをaccept-charset
ヘッダに設定してリクエストを投げると100%再現することができ、Accept-CharsetとContent-Typeの不一致が原因であることが分かりました。
Accept-CharsetとContent-Type
一旦立ち止まり、それぞれについて調べてみる
リクエストヘッダ:Accept-Charset
Accept-Encoding
ヘッダなどと同じコンテントネゴシエーションのためのもの。MDNを観て分かる通り、モダンブラウザでは送信していないです。どのブラウザも全Charsetのエンコーダーを内包するので事前にネゴシエーションする必要はなくなったからだそう、なるほどだ。
Charsetの指定は、IANAで管理されている。このIANAで指定/推奨されている値になっていれば不一致は起こらなかったはず…!
ちなみに本来、このヘッダは無視されるか、ネゴシエーションに失敗した場合には406 Not Acceptable
を返すのだそう。
レスポンスヘッダ:Content-Type
このヘッダには、MIMEタイプとキャラクターセットの宣言が含まれます。WEBブラウザがどのようにファイルの種類、文字コードを区別し読み込むために必要なものです。HTMLの場合はドキュメント内に記述することも可能。ローカルに保存して再表示することもあるので、併用するべきとのこと。
Jerseyではリクエストハンドラとなるメソッドに付与する@Produces()
で指定する。
Jersey はどこで404を返したのか
例外発生箇所は下記です。templateが解決できなかったとして404 NotFound
としています。
同じリクエストパスで他のWebクライアントからのリクエストはtemplateが解決できているので、不思議です。もう少し深堀りしていきます。
jersey/ViewableMessageBodyWriter.java at master · jersey/jersey · GitHub
if (resolvedViewable == null) { final String message = LocalizationMessages.TEMPLATE_NAME_COULD_NOT_BE_RESOLVED(viewable.getTemplateName()); throw new WebApplicationException(new ProcessingException(message), Response.Status.NOT_FOUND); }
templateProcessorsとmediaTypesという2重ループの中でViewが解決されるのですが、404 NotFound
になるときにはmediaTypesが要素数が0で返り、Viewが解決できていないということになります。
そのmediaTypesを生成しているのが下記メソッドです。この処理で呼ばれているselectVariants
が肝になります。
jersey/VariantSelector.java at master · jersey/jersey · GitHub
public static List<Variant> selectVariants(final InboundMessageContext context, final List<Variant> variants, final Ref<String> varyHeaderValue) { LinkedList<VariantHolder> vhs = getVariantHolderList(variants); final Set<String> vary = new HashSet<>(); vhs = selectVariants(vhs, context.getQualifiedAcceptableMediaTypes(), MEDIA_TYPE_DC, vary); vhs = selectVariants(vhs, context.getQualifiedAcceptableLanguages(), LANGUAGE_TAG_DC, vary); vhs = selectVariants(vhs, context.getQualifiedAcceptCharset(), CHARSET_DC, vary); // ここの処理でvhsの要素が0になる vhs = selectVariants(vhs, context.getQualifiedAcceptEncoding(), ENCODING_DC, vary); if (vhs.isEmpty()) { return Collections.emptyList(); } else { // 省略 } }
selectVariants
の処理が下記です。dimensionChecker.isCompatible(a, d))
というメソッドで、Accpet-CharsetのヘッダValueとContent-Typeで指定しているcharsetが一致しているかを確認しています。
jersey/VariantSelector.java at master · jersey/jersey · GitHub
for (final T a : acceptableValues) { // 省略 while (iv.hasNext()) { final VariantHolder v = iv.next(); // Get the dimension value of the variant to check final U d = dimensionChecker.getDimension(v); // dに文字コードが入る、例えば "utf-8" if (d != null) { vary.add(dimensionChecker.getVaryHeaderValue()); // Check if the acceptable entry is compatable with // the dimension value final int qs = dimensionChecker.getQualitySource(v, d); if (qs >= cqs && dimensionChecker.isCompatible(a, d)) { // ここで判断 // 省略 } } } }
dimensionChecker.isCompatible(a, d)
の処理は実際どんな感じかというと、下記のようになっており単純なString.equals
を使用しているだけです。そのため大文字/小文字を厳密に判定する形となっています。
jersey/Token.java at master · jersey/jersey · GitHub
public final boolean isCompatible(String token) { if (this.token.equals("*")) { return true; } return this.token.equals(token); }
まとめ
ググっても全然出てこなかったので調査がとても大変だった。フレームワーク側の挙動を見るのかなり大変だ。
Jerseyのバージョンが2.11
で結構古めだったので起こってしまったのかもなあとも思います。
貼ったJerseyのリンクは旧リポジトリのほうで、現在は以下のようにEclipse Foundataion管理になっています。