1クール続けるブログ

とりあえず1クール続けるエンジニアの備忘録

Accept-CharsetとContent-Typeの不一致でJerseyは404を返す

かなりハマってしまったので残しておきたいと思います。
とあるWebクライアントから通信を行った際に、ChromeFirefoxなどのモダンブラウザではステータスコード 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叩くと再現が出来た!

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のエンコーダーを内包するので事前にネゴシエーションする必要はなくなったからだそう、なるほどだ。

developer.mozilla.org

Charsetの指定は、IANAで管理されている。このIANAで指定/推奨されている値になっていれば不一致は起こらなかったはず…! ちなみに本来、このヘッダは無視されるか、ネゴシエーションに失敗した場合には406 Not Acceptableを返すのだそう。

www.iana.org

レスポンスヘッダ: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管理になっています。

github.com