Javaは大規模データ収集の主力です。成熟したHTTPクライアント、実スレッド、そして長時間稼働するクローラーを可視化できるJVMツール群が揃っています。residential proxyを、多くのチームが使う2つのクライアント、OkHttpとApache HttpClientのどちらかに組み込むのは簡単です。難しいのは、各ライブラリが異なる方法で処理する細部です。プロキシ認証の渡し方、静かにスループットを絞るコネクションプールのデフォルト値、そしてプーリングが機能するかどうかを左右するレスポンス消費のルールです。
これはPythonでのresidential proxy、Playwrightでの利用、Goでの利用と同じシリーズのJava編です。両クライアント向けの動作コードに加え、Java特有の落とし穴を扱います。
以下すべて、Shifterのresidential gatewayを使用します。エンドポイントはp.shifter.io:443の一つだけで、ターゲティングはすべてユーザー名にエンコードされます。別のプロバイダに切り替える場合はホストと認証情報を差し替えるだけで、構造は同じです。
ゲートウェイモデルを一言で
プロキシのユーザー名には認証情報とターゲティング情報の両方が含まれます。国やセッションを変更するのにエンドポイントを切り替えるのではなく、ユーザー名の文字列を変更します。
customer-USERNAME-country-us-sid-abc123-ttl-600country-usは米国をターゲットにし、sidはセッションを固定し、ttlはそのIPをN秒間保持します。sid/ttlを省略すると、新しい接続ごとにローテーションします。パスワードは固定です。前もって指摘しておくべき注意点があります。両方のJavaクライアントで、プロキシの認証情報はプロキシのURLに含めません。専用の認証メカニズムを通して渡します。これが最もよくある間違いです。
OkHttp
OkHttpはアドレス用にProxyオブジェクトを、認証情報用に**別途proxyAuthenticator**を受け取ります。URLにuser:pass@をエンコードしようとしないでください。OkHttpはそれを読み取りません。
import okhttp3.*;import java.net.InetSocketAddress;import java.net.Proxy;import java.io.IOException;
public class ProxyExample { public static void main(String[] args) throws IOException { String user = System.getenv("SHIFTER_USER") + "-country-us"; String pass = System.getenv("SHIFTER_PASS");
OkHttpClient client = new OkHttpClient.Builder() .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("p.shifter.io", 443))) .proxyAuthenticator((route, response) -> { // Called when the proxy returns 407. Attach Proxy-Authorization. String credential = Credentials.basic(user, pass); return response.request().newBuilder() .header("Proxy-Authorization", credential) .build(); }) .build();
Request request = new Request.Builder().url("https://api.ipify.org").build(); try (Response response = client.newCall(request).execute()) { // try-with-resources closes the body System.out.println(response.body().string()); // a US residential IP } }}心に留めておくべきことが2つあります。user文字列にはターゲティングのフラグ(-country-us)が含まれます。ジオ情報はここにあるからです。そしてResponseを囲むtry-with-resourcesは単なるスタイルの選択ではありません。これがレスポンスボディを閉じ、それによってコネクションがプールに戻されます。
クライアントを再利用する。 OkHttpClientは一度作成して共有するよう設計されています。コネクションプールとスレッドディスパッチャを保持し、スレッドセーフです。リクエストごとに1つ作成すると、プーリングを無駄にしリソースをリークします。リクエストごとに識別情報を変えるには、newBuilder()で派生させます。これは基となるプールとディスパッチャを共有します。
// One base client, shared. Per-identity variants reuse its pool + dispatcher.OkHttpClient forGeo(OkHttpClient base, String country, String sid) { String user = System.getenv("SHIFTER_USER") + "-country-" + country + (sid != null ? "-sid-" + sid + "-ttl-600" : ""); String pass = System.getenv("SHIFTER_PASS"); return base.newBuilder() .proxyAuthenticator((route, resp) -> resp.request().newBuilder() .header("Proxy-Authorization", Credentials.basic(user, pass)) .build()) .build();}論理的な作業単位ごとに独自のsidを与え、フローの途中ではなく作業単位間でローテーションします(違いについてはsticky vs rotatingで扱っています)。
Apache HttpClient (5.x)
Apache HttpClientは、リクエスト設定またはルートプランナーを通じてプロキシを渡し、プロキシホストにスコープされたCredentialsProviderを通じて認証情報を渡します。
import org.apache.hc.client5.http.classic.methods.HttpGet;import org.apache.hc.client5.http.impl.classic.*;import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;import org.apache.hc.client5.http.auth.*;import org.apache.hc.client5.http.config.RequestConfig;import org.apache.hc.core5.http.HttpHost;import org.apache.hc.core5.util.Timeout;
public class ApacheProxyExample { public static void main(String[] args) throws Exception { HttpHost proxy = new HttpHost("http", "p.shifter.io", 443); String user = System.getenv("SHIFTER_USER") + "-country-us"; char[] pass = System.getenv("SHIFTER_PASS").toCharArray();
BasicCredentialsProvider creds = new BasicCredentialsProvider(); creds.setCredentials(new AuthScope(proxy), new UsernamePasswordCredentials(user, pass));
// Pool: raise per-route from the default of 5 (see gotcha below). PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); cm.setMaxTotal(200); cm.setDefaultMaxPerRoute(50);
RequestConfig config = RequestConfig.custom() .setProxy(proxy) .setConnectTimeout(Timeout.ofSeconds(10)) // connect phase .setResponseTimeout(Timeout.ofSeconds(30)) // response phase .build();
try (CloseableHttpClient client = HttpClients.custom() .setConnectionManager(cm) .setDefaultCredentialsProvider(creds) .setDefaultRequestConfig(config) .build()) {
HttpGet get = new HttpGet("https://api.ipify.org"); // try-with-resources on the response consumes + releases the connection. try (var response = client.execute(get)) { System.out.println(new String(response.getEntity().getContent().readAllBytes())); } } }}コネクションとレスポンスのタイムアウトが別々になっている点に注目してください。この接続対レスポンスの分離が、何かがハングしたときにタイムアウトを診断可能にする決め手です。
落とし穴1:Apacheのデフォルトの最大ルートあたり接続数は5
これはGoのMaxIdleConnsPerHostの罠のJava版で、強烈に効いてきます。PoolingHttpClientConnectionManagerは古いビルドでデフォルトでルートあたり2接続、合計20、5.xではルートあたりの上限が低くなっています。1つのホストに対して50スレッドを実行すると、そのほとんどが接続が空くのを待ってブロックされ、それはまさにプロキシが遅いように見えます。
プールを少なくともホストあたりの並行数以上に設定してください。
cm.setMaxTotal(200);cm.setDefaultMaxPerRoute(50); // >= your per-host concurrencyスレッドをいくら増やしてもクローラーのスループットが頭打ちになる場合、まず確認すべきはこのデフォルト値です。
落とし穴2:エンティティを消費しなければ、コネクションは決して戻らない
両クライアントともコネクションをプーリングし、どちらもレスポンスが完全に消費され、閉じられたときにのみコネクションをプールに戻します。Apache HttpClientでは、消費されていないエンティティはコネクションをチェックアウトしたままにします。これを十分な数繰り返すと、技術的には何もリークしていないのにプールが枯渇します。
レスポンスにtry-with-resourcesを使うか(上記の通り)、明示的に消費してください。
import org.apache.hc.core5.http.io.entity.EntityUtils;// ...EntityUtils.consume(response.getEntity()); // drains + frees the connection罠は早期終了です。エンティティを消費せずに非200ステータスで処理を打ち切ると、コネクションが取り残されます。ブロックの多いスクレイパーでは、それがトラフィックの大半を占め、プールは静かに死んでいきます。
落とし穴3:常にクライアントを再利用する
OkHttpClientとCloseableHttpClientはどちらも重量級で、スレッドセーフであり、アプリケーション全体で共有されることを想定しています。コネクションプールを保有しており、リクエストごとに新しいクライアントを作ると、毎回プロキシ経由でフルのTCP + TLSハンドシェイクを行うことになります。この負荷こそ、レイテンシガイドが排除しようとしているものです。起動時に1つ構築して注入し、識別情報を変える必要があるときだけリクエストスコープの派生を作ってください。
ジオとセッションのローテーション
ターゲティング情報はユーザー名に格納されているため、異なる識別情報は異なる認証情報文字列になります。
OkHttp: 識別情報ごとにnewBuilder()でクライアントを派生させます(上記参照)。共有プールを再利用するため効率的なままです。
Apache HttpClient: その識別情報用のCredentialsProviderを持つリクエストごとのHttpClientContextをアタッチすることで、クライアントを再構築せずに1つのクライアントで多数の識別情報を扱えます。
HttpClientContext ctx = HttpClientContext.create();BasicCredentialsProvider perCall = new BasicCredentialsProvider();perCall.setCredentials(new AuthScope(proxy), new UsernamePasswordCredentials(userFor("de", "job-42"), pass));ctx.setCredentialsProvider(perCall);client.execute(get, ctx, resp -> { /* handle */ return null; });1つの作業単位の中でローテーションするのではなく、論理的な作業単位間でローテーションし、load balancing postで説明されている方法で作業を識別情報にマッピングしてください。
並行性、ホスト単位で
ターゲットホストごとに並行性を制限し、1つのグローバルな上限に頼らないようにします。そうすれば壊れやすいターゲットを叩きすぎることも、寛容なターゲットが飢えることもありません。ホストごとのSemaphoreが最も単純な表現です。
Map<String, Semaphore> limits = Map.of( "tough-site.example", new Semaphore(4), "open-site.example", new Semaphore(32));
void fetch(String host, Runnable work) throws InterruptedException { Semaphore sem = limits.get(host); sem.acquire(); try { work.run(); } finally { sem.release(); }}ターゲットの許容範囲を超えると、スレッドを増やしてもスループットではなくブロックが増えるだけです(how to avoid getting blocked)。プールのルートあたりの上限とホストごとのセマフォは合わせてサイズを決めてください。
実際にプロキシ経由になっているか確認する
ベンチマークやその他のデバッグを行う前に、まず出口IPを確認してください。
Request req = new Request.Builder().url("http://ip-api.com/json").build();try (Response r = client.newCall(req).execute()) { System.out.println(r.body().string()); // expect a residential IP in the targeted country}自分自身のIPが表示される場合は、クライアントがプロキシを使っていないということです。ハングする場合は、ローカルの出口がブロックされているということです。どちらもtimeout diagnostic guideで扱っています。
よくある質問
なぜJavaでプロキシにhttp://user:pass@hostが使えないのですか?
OkHttpもApache HttpClientも、URLからインラインのプロキシ認証情報を読み取りません。OkHttpはProxy-Authorizationを設定するproxyAuthenticatorを使い、ApacheはプロキシホストにスコープされたCredentialsProviderを使います。ゲートウェイはターゲティング情報をユーザー名にエンコードしているため、そのユーザー名はURLではなくauthenticator/credentialsに渡されます。
1つのクライアントしか使っていないのに、Javaのスクレイパーが負荷下で停止します。なぜですか?
最も可能性が高いのは、Apacheプールのルートあたりの上限(デフォルトで低い)がスループットを絞っているか、レスポンスエンティティを消費していないためコネクションが決してプールに戻らないことです。setDefaultMaxPerRouteを自分の並行数に合わせて上げ、すべてのレスポンスをtry-with-resourcesで消費してください。
JavaでリクエストごとにIPをローテーションするにはどうすればよいですか?
プロキシのユーザー名を変えてください。OkHttpでは、newBuilder()で識別情報ごとのクライアントを派生させます(プールを共有します)。Apache HttpClientでは、その識別情報用のCredentialsProviderを持つリクエストごとのHttpClientContextを渡します。どちらも1つの共有クライアントで多数の識別情報を扱えます。
接続タイムアウトとレスポンスタイムアウトは別々に設定すべきですか? はい。接続タイムアウトとレスポンス/ソケットタイムアウトを分けることで、遅い接続(自分側の問題か一致するIPがない)と遅いレスポンス(ターゲット側の問題)を区別できます。これはタイムアウトを診断する際の重要な判断材料です。1つの大まかなタイムアウトでは、どのフェーズで失敗したのかが分かりません。
スクレイピングにはOkHttpとApache HttpClientのどちらがよいですか? どちらもうまく機能します。OkHttpは軽量でAPIがすっきりしています。Apache HttpClientはより設定可能で歴史も長いです。使い勝手や既存の依存関係で選んでください。プロキシのセットアップと上記の落とし穴はどちらにも当てはまります。
結論
Javaとresidential proxyの組み合わせは、各クライアントのルールを守れば堅実に機能します。プロキシの認証情報は適切なメカニズム(OkHttpのproxyAuthenticator、ApacheのCredentialsProvider)を通して渡し、URLにインラインで含めないこと。長寿命のクライアントを1つ共有し、そこから識別情報のバリエーションを派生させること。Apacheのルートあたりのプール上限を自分の並行数に合わせて上げること。常にレスポンスを消費して閉じ、コネクションがプールに戻るようにすること。接続タイムアウトとレスポンスタイムアウトを分けること。ジオやセッションを変えるにはプロキシのユーザー名を変え、並行性はホスト単位で制限してください。
これらを正しく行えば、両クライアントともJVMにふさわしい性能を発揮します。residential gatewayを指定して使い、プールの品質がそもそも何回リトライすることになるかを左右することを忘れないでください(IP reputation)。pricing pageにはGB単位のプランがあり、自分のターゲットで試すことができます。