Java
httpclient4x

【更新中】httpclient3.x系から4.5まで上げたら大変だったので変更点まとめを書く

なにやったか自分でもわからなくなってくるので、だらだらと変更したことを書いていきます。

参考にさせていただきました。

Java apache HttpClient 4.3からの大幅なインターフェース変更に対応 (@mychaelstyle)
https://qiita.com/mychaelstyle/items/e02b3011d1e71bfa26c5
Apache httpclient の CloseableHttpClient で HTTPS なサイトへ BASIC 認証付きリクエスト (@sh_kawakami )
https://qiita.com/sh_kawakami/items/bf6d1397851cccd134b2
httpclientのインターフェースが4.3から大きく変わったみたいですよ (@sakito )
https://qiita.com/sakito/items/6366015dbbc4a88d56fc
What does setDefaultMaxPerRoute and setMaxTotal mean in HttpClient?
https://stackoverflow.com/questions/30689995/what-does-setdefaultmaxperroute-and-setmaxtotal-mean-in-httpclient の解答欄

環境

JDK 1.8.162
httpclient 4.5.4

pom.xml

pom.xml
<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
    <version>4.5.4</version>
</dependency>

素敵なDeprecatedList

こんなにDeprecatedListに助けられたことはないってぐらい見まくってる

素敵なチュートリアル

http://hc.apache.org/httpcomponents-client-ga/tutorial/html/index.html

素敵なQuickGuide

3.xから4.0に上げたときのガイドなので、4.5までとなるとまた変わってることも。
http://debuguide.blogspot.jp/2013/01/quick-guide-for-migration-of-commons.html

便利な定数一覧

コンパイルエラーをなんとかする

パッケージ変更

  • org.apache.commons.httpclient.Cookie; →org.apache.http.cookie.Cookie(interfaceなのでインスタンス作るときはBasicClientCookieを使う?)
  • org.apache.commons.httpclient.util.DateUtil; →org.apache.http.client.utils.DateUtils
  • org.apache.commons.httpclient.Header →org.apache.http.Header(interfaceなのでインスタンス作るときはBasicHeaderを使う)
  • org.apache.commons.httpclient.HeaderGroup →org.apache.http.message.HeaderGroup

標準APIに変更

  • org.apache.commons.httpclient.URI; →java.net.URI
  • org.apache.commons.httpclient.URIException; →java.net.URISyntaxException

ごっそり変わってる系

  • org.apache.commons.httpclient.HostConfiguration →org.apache.http.HttpHost
  • org.apache.commons.httpclient.protocol.SecureProtocolSocketFactory →org.apache.http.conn.ssl.SSLConnectionSocketFactory この辺に変えるのが良さそう

インスタンスの生成の仕方がそもそも変わってる

before.java
HttpClient client = new HttpClient();

Builder使おうぜ!ってことらしいです。
なにも考えなくてよければ、以下で。

after.java
HttpClient client = HttpClients.createDefault();

ただし、生成するタイミングで接続設定とかは渡さないといけないので、あまり実用的ではないかも。

methodのクラス変更

GetMethod→HttpGet
PostMethod→HttpPost
親クラスHttpMethod→HttpRequestBase
にそれぞれ変更

実行&レスポンスの取得

以前はこんな流れだったが

before.java
PostMethod post = new PostMethod(url);
post.setRequestEntity(なんかセットする);
int responseCode = client.executeMethod(post);
if (responseCode == 200) {
    Reader reader = new InputStreamReader(
            httpMethod.getResponseBodyAsStream(), httpMethod.getResponseCharSet());
    // 後続処理
} else {
  // エラー処理とか
}

だいぶ変わってこんな感じに。
でも明確になったので、わかりやすくなったかも。

after.java
HttpPost post = new HttpPost(url);
// リクエストbodyにあたるやつをセット
post.setEntity(なんかセットする);
HttpResponse response = client.execute(post);
StatusLine statusLine = response.getStatusLine();
int responseCode = statusLine.getStatusCode();
if (responseCode == HttpStatus.SC_OK) {
    Reader reader = new InputStreamReader(
            response.getEntity().getContent(), Charset.forName("UTF-8"));
    // 後続処理
} else {
    // エラー処理とか
}

ベーシック認証接続周り

今までこんな感じにしてた。

before.java
Credentials credentials = new UsernamePasswordCredentials(this.username, this.password);
AuthScope scope = new AuthScope(host, port, this.realm);
client.getState().setCredentials(scope, credentials);

CredentialsProvider使えば良いらしいです。(@sh_kawakami さんありがとうございます!)

after.java
CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(new AuthScope(baseUrl.getHost(),baseUrl.getPort()),
        new UsernamePasswordCredentials(username, password));
// インスタンス生成時にセットしてあげる
HttpClient client = HttpClients.custom().setDefaultCredentialsProvider(credentialsProvider).build();

NameValuePairのインターフェイス化

実体クラスはBasicNameValuePairになったみたい。
『=』で分割するだけっぽいから、今までと挙動が変わらないことを祈りつつ。
が、setterがなくなってる!!!
見た感じ、コンストラクタでしかsetできないようになったみたい。
それでもsetterを使いたい場合はNameValuePairを継承して独自クラスを作るしかない。

実行時にエラーになったあれこれ

デフォルトのHttpClientはHostヘッダを送らなくなった

独自に追加してあげましょう

// hostヘッダー
httpMethod.addHeader("Host", uri.getHost());

Content-LengthヘッダとResponseBodyのサイズが一致しないとConnectionClosedExceptionが発生する

レスポンスのContent-Lengthヘッダがある場合、Bodyとサイズが異なる場合に発生する。Content-Lengthヘッダがない場合は発生しない(それはそれでRFC違反な気がするけど)
例えばこんな感じのレスポンスだった時に発生。
まあ、今日日Content-Lengthは自動計算してサーバが勝手に返すと思うので、発生することは少ないと思いますけど、諸事情によりレスポンスヘッダは信用しないことにしてるので:sweat:

HTTP/1.1 200 OK
Content-Type: text/html; charset=Shift_JIS
Content-Length: 3000
<html>
<head><title>ほげ</title>
</head>
<body>test
</body>
</html>
org.apache.http.ConnectionClosedException: Premature end of Content-Length delimited message body (expected: 3000; received: 124
    at org.apache.http.impl.io.ContentLengthInputStream.read(ContentLengthInputStream.java:178)
    at org.apache.http.conn.EofSensorInputStream.read(EofSensorInputStream.java:135)
    at org.apache.http.conn.EofSensorInputStream.read(EofSensorInputStream.java:148)

原因はここ

org.apache.http.impl.io.ContentLengthInputStream.java
    /**
     * Does standard {@link InputStream#read(byte[], int, int)} behavior, but
     * also notifies the watcher when the contents have been consumed.
     *
     * @param b     The byte array to fill.
     * @param off   Start filling at this position.
     * @param len   The number of bytes to attempt to read.
     * @return The number of bytes read, or -1 if the end of content has been
     *  reached.
     *
     * @throws java.io.IOException Should an error occur on the wrapped stream.
     */
    @Override
    public int read (final byte[] b, final int off, final int len) throws java.io.IOException {
        if (closed) {
            throw new IOException("Attempted read from closed stream.");
        }

        if (pos >= contentLength) {
            return -1;
        }

        int chunk = len;
        if (pos + len > contentLength) {
            chunk = (int) (contentLength - pos);
        }
        final int count = this.in.read(b, off, chunk);
        if (count == -1 && pos < contentLength) {
            throw new ConnectionClosedException(
                    "Premature end of Content-Length delimited message body (expected: "
                    + contentLength + "; received: " + pos);
        }
        if (count > 0) {
            pos += count;
        }
        return count;
    }

対処方法は今のところ見つからず。

SSL通信時(自己証明書含む)に証明書とhostnameがマッチしないよエラー

javax.net.ssl.SSLPeerUnverifiedException: Certificate for <hostname> doesn't match any of the subject alternative names:

ホスト名の検証をデフォルトではしているみたい。
該当箇所はここ

org.apache.http.conn.ssl.SSLConnectionSocketFactory.java#verifyHostname
            if (!this.hostnameVerifier.verify(hostname, session)) {
                final Certificate[] certs = session.getPeerCertificates();
                final X509Certificate x509 = (X509Certificate) certs[0];
                final List<SubjectName> subjectAlts = DefaultHostnameVerifier.getSubjectAltNames(x509);
                throw new SSLPeerUnverifiedException("Certificate for <" + hostname + "> doesn't match any " +
                        "of the subject alternative names: " + subjectAlts);
            }

verifyがtrueを返せば良いので、NoopHostnameVerifierクラスのインスタンスを使えば良いらしい。
Apache HttpComponents/Clientで、SSL証明書の検証、ホスト名の検証を無効化する
Chapter 2. Connection management 2.7.4. Hostname verification
私の場合は、SSLContextやSSLConnectionSocketFactoryをhttpclientに直接セットするわけではなく、ConnectionManagerにセットするやり方で実装してます。今のところ。なんので以下みたいな感じで。

public static PoolingHttpClientConnectionManager createConnectionManager() {
    SSLContext sslContext = null;
    try {
        sslContext = new SSLContextBuilder().loadTrustMaterial(null, new TrustStrategy(){
            @Override
            public boolean isTrusted(X509Certificate[] chain, String authType) throws CertificateException {
                return true;
            }
        }
        ).build();
    } catch (Exception e) {
        e.printStackTrace();
    }
    SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE);
    Registry<ConnectionSocketFactory> registry =
            RegistryBuilder.<ConnectionSocketFactory>create()
                    .register("http", PlainConnectionSocketFactory.getSocketFactory())
                    .register("https", sslsf)
                    .build();
    return new PoolingHttpClientConnectionManager(registry);
}

その他参考にさせていただいたサイト様

Apache HttpClient の DefaultHttpRequestRetryHandler は ConnectTimeoutException のときはリトライしない
http://kntmr.hatenablog.com/entry/2016/12/09/150615
Use of non-ascii credentials not working in httpclient 4.3.x
https://stackoverflow.com/questions/27955067/use-of-non-ascii-credentials-not-working-in-httpclient-4-3-x