Java ネットワークとプロキシ

1) はじめに

現在のネットワーク環境、特に企業のネットワーク環境では、アプリケーション開発者はシステム管理者と同じほど頻繁にプロキシを操作する必要があります。アプリケーションがシステムのデフォルト設定を使用すべき場合もあれば、プロキシを経由する情報を厳密に制御したい場合もあります。また、大半のアプリケーションは、大半のブラウザと同様に、ある時点でプロキシ設定用の GUI を提供してユーザーに決定を任せます。

どちらの場合でも、Java のような開発プラットフォームは、これらのプロキシに対応する強力かつ柔軟な機構を提供する必要があります。残念なことに、この分野における Java プラットフォームの柔軟性は、最近までそれほど高いものではありませんでした。しかし、この短所を克服する新しい API が J2SE 5.0 で導入されたことにより、状況は大きく変化しました。ここでは、引き続き有効なものや新しいものを含めて、そういったすべての API と機構について詳しく説明します。

2) システムプロパティー

J2SE 1.4 まで、システムプロパティーは Java ネットワーク API 内部でプロトコルハンドラ用のプロキシサーバーを設定する唯一の方法でした。しかも、これらプロパティーの名前がリリースごとに変化してきたこと、および一部のプロパティーは互換性を維持する目的でサポートされていても旧式になっているために、事態はさらに複雑になっています。

システムプロパティーを使用する上での主な制限事項は、切り替えが「全部かゼロか」になっていることです。このため、特定のプロトコル用にプロキシが設定されると、そのプロトコルに対応したすべての接続が影響を受けます。これは、VM 全体にわたる動作です。

システムプロパティーの設定方法は、次の 2 つに大別できます。

ここで、プロキシの設定に使用できるプロパティをプロトコルごとに見てみることにしましょう。すべてのプロキシは、ホスト名とポート番号で定義されます。後者はオプションです。指定しない場合は、標準のデフォルトポートが使用されます。

2.1) HTTP

HTTP プロトコルハンドラが使用するプロキシを指定するため、次の 3 つのプロパティーを設定できます。

GetURL クラスの main メソッドの実行を試みている場合の例をいくつか見てみましょう。

$ java -Dhttp.proxyHost=webcache.mydomain.com GetURL

すべての HTTP 接続は、ポート 80 上で待機している webcache.mydomain.com のプロキシサーバーを経由して接続されます (ポートの指定は行っていないため、デフォルトポートが使用される)。

$ java -Dhttp.proxyHost=webcache.mydomain.com -Dhttp.proxyPort=8080
-Dhttp.noProxyHosts=”localhost|host.mydomain.com” GetURL

2 番目の例でも、プロキシサーバーは引き続き webcache.mydomain.com 上に存在しますが、待機するポートが 8080 になります。また、localhost または host.mydonain.com への接続時には、プロキシは使用されません。

すでに説明したように、これらのオプションを使って呼び出された VM の寿命全体で、すべての HTTP 接続がこれらの設定の影響を受けます。ただし、System.setProperty() メソッドを使用すれば、動作を若干動的にできます。

次のコードにその方法を示します。

//Set the http proxy to webcache.mydomain.com:8080

System.setProperty("http.proxyHost", "webcache.mydomain.com");
System.setPropery("http.proxyPort", "8080");

// Next connection will be through proxy.
URL url = new URL("http://java.sun.com/");
InputStream in = url.openStream();

// Now, let's 'unset' the proxy.
System.setProperty("http.proxyHost", null);

// From now on http connections will be done directly.

これは、多少煩雑でもほぼ良好に動作します。ただし、アプリケーションがマルチスレッドに対応している場合は、扱いにくくなる場合があります。システムプロパティーは「VM 全体」の設定であるため、すべてのスレッドが影響を受けることに留意してください。このため、あるスレッド内のコードの副作用として、その他のスレッド内のコードが動作不能になる可能性があります。

2.2) HTTPS

HTTPS (HTTP over SSL) プロトコルハンドラは、独自のプロパティーセットを保持します。

これらの動作は対応する HTTP の動作と正確に同じであることが推測できるため、詳細には触れません。ただし、この場合、デフォルトのポート番号は 443 になります。また、HTTPS プロトコルハンドラが使用する「非プロキシホスト」リストのデフォルトポート番号は、HTTP ハンドラ (http.nonProxyHosts) と同じになります。

2.3) FTP

FTP プロトコルハンドラの設定は、HTTP の場合と同じ規則に従います。唯一の相違点は、各プロパティー名の接頭辞が 「http.」ではなく「ftp.」になることです。

このため、システムプロパティーは次のようになります。

ここで、「非プロキシホスト」リストに対して別個のプロパティーが存在することに留意してください。また、HTTP の場合と同様、デフォルトのポート番号は 80 になります。プロキシを経由する場合、FTP プロトコルハンドラは実際には HTTP を使ってプロキシサーバーにコマンドを発行することに留意してください。デフォルトのポート番号が同じなのは、このためです。

簡潔な例を見てみることにしましょう。

$ java -Dhttp.proxyHost=webcache.mydomain.com
-Dhttp.proxyPort=8080 -Dftp.proxyHost=webcache.mydomain.com -Dftp.proxyPort=8080 GetURL

この例では、HTTP と FTP の両方のプロトコルハンドラが、webcache.mydomain.com:8080 にある同じプロキシサーバーを使用します。

2.4) SOCKS

RFC 1928 に定義されているように、SOCKS プロトコルは、クライアントサーバーアプリケーションが TCP と UDP の両方のレベルでファイアウォールを安全にトラバースするためのフレームワークを提供します。この点では、このプロトコルは、より高レベルのプロキシ (HTTP または FTP 固有のプロキシ) よりも高い汎用性を持ちます。J2SE 5.0 は、クライアント TCP ソケット用の SOCKS をサポートします。

SOCKS 関連のシステムプロパティーには、次の 2 つがあります。

ここでは、接頭辞のあとにドット (「.」) を付けないことに留意してください。これには、歴史的な理由と共に、下位互換性を維持する目的があります。この方法で SOCKS プロキシが指定されると、すべての TCP 接続がこのプロキシを介して試みられます。

例:

$ java -DsocksProxyHost=socks.mydomain.com GetURL

この場合、コードの実行中に、外向きの TCP ソケットが、socks.mydomain.com:1080 で SOCKS プロキシサーバーを経由します。

では、SOCKS プロキシと HTTP プロキシの両方が定義されている場合はどうなるでしょうか。その場合は、HTTP や FTP など、より高レベルのプロトコルの設定が SOCKS 設定に優先されます。このため、この場合は、HTTP 接続が確立されると SOCKS プロキシの設定は無視され、HTTP プロキシへの連絡が行われます。例を見てみましょう。

$ java -Dhttp.proxyHost=webcache.mydomain.com -Dhttp.proxyPort=8080
-DsocksProxyHost=socks.mydomain.com GetURL

ここでは、HTTP 設定が優先されるため、HTTP URL は webcache.mydomain.com:8080 を経由します。では、FTP URL についてはどうでしょうか。FTP には特定のプロキシ設定が割り当てられておらず、FTP は TCP の最上位に位置するため、SOCKS プロキシサーバー (socks.mydomsain.com:1080) 経由で FTP 接続が試みられます。FTP プロキシが指定されている場合は、そのプロキシが代わりに使用されます。

3) Proxy クラス

すでに説明したとおり、システムプロパティーは強力ですが、柔軟性に欠けています。その「全部かゼロか」という動作は、大半の開発者には厳しすぎる制限に感じられます。このため、J2SE 5.0 では、プロキシ設定に基づく接続を可能にする、より柔軟性の高い新規 API の導入が決定されました。

この新規 API のコアは、プロキシ定義を表す Proxy クラスです。通常、プロキシ定義にはタイプ (HTTP、SOCKS) およびソケットアドレスが含まれます。J2SE 5.0 では、次の 3 つのタイプを指定可能です。

このため、HTTP プロキシオブジェクトを作成するには、次の呼び出しを実行します。

SocketAddress addr = new
InetSocketAddress("webcache.mydomain.com", 8080);
Proxy proxy = new Proxy(Proxy.Type.HTTP, addr);

この新規プロキシオブジェクトはプロキシ定義を表すだけで、それ以上の意味はないことに留意してください。このオブジェクトをどのように使用したらよいでしょうか。URL クラスに追加された新しい openConnection() メソッドで、引数として Proxy を指定します。この場合の動作は、指定したプロキシ経由で接続を強制的に確立し、前述のシステムプロパティーを含む、その他の設定すべてを無視することを除き、引数を指定せずに openConnection() を実行した場合と同じです。

このため、次のコードを追加して前述の例を完了させることができます。

URL url = new URL("http://java.sun.com/");
URConnection conn = url.openConnection(proxy);

使い方は、このように簡単です。

イントラネット上の URL など、特定の URL への直接接続を指定する場合にも、同じ機構を使用できます。このような場合に、DIRECT タイプが役立ちます。ただし、DIRECT タイプでは、プロキシインスタンスを作成する必要はありません。静的メンバー NO_PROXY を使用するだけです。

URL url2 = new URL("http://infos.mydomain.com/");
URLConnection conn2 = url2.openConnection(Proxy.NO_PROXY);

これにより、その他のプロキシ設定を無視して、特定の URL を直接接続で確実に取得できます。これは、便利な方法です。

URLConnection に SOCKS プロキシを強制的に経由させることもできます。

SocketAddress addr = new InetSocketAddress("socks.mydomain.com", 1080);
Proxy proxy = new Proxy(Proxy.Type.SOCKS, addr);
URL url = new URL("ftp://ftp.gnu.org/README");
URLConnection conn = url.openConnection(proxy);

この FTP 接続は、指定した SOCKS プロキシを介して試みられます。見ておわかりのように、これは極めて直接的な方法です。

最後に、新たに導入されたソケットコンストラクタを使って、個別の TCP ソケット用のプロキシを指定することもできます。説明する順番は最後になりますが、これも重要な方法です。

SocketAddress addr = new InetSocketAddress("socks.mydomain.com", 1080);
Proxy proxy = new Proxy(Proxy.Type.SOCKS, addr);
Socket socket = new Socket(proxy);
InetSocketAddress dest = new InetSocketAddress("server.foo.com", 1234);
socket.connect(dest);

ここでは、ソケットは、指定した SOCKS プロキシ経由で、宛先アドレス (server.foo.com:1234) への接続を試みます。

URL の場合は、グローバルな設定の影響を受けることなく、同じ機構を使って直接 (プロキシを経由しない) 接続を確実に試みることができます。

Socket socket = new Socket(Proxy.NO_PROXY);
socket.connect(new InetAddress("localhost", 1234));

J2SE 5.0 では、この新規コンストラクタが受け付けるプロキシは、SOCKS または DIRECT (NO_PROXY インスタンス) の 2 種類だけです。

4) ProxySelector

これまでの説明からおわかりのように、J2SE 5.0 では、開発者はプロキシを強力かつ柔軟に制御できます。それでも、プロキシ間で負荷分散を実行したり、宛先に応じてプロキシを変更したりするなど、これまで説明された API では実現が難しい状況で、使用するプロキシを動的に決定したい場面に遭遇することがあります。このような場合に、ProxySelector が役立ちます。

簡潔に説明すると、ProxySelector は、指定された URL で使用すべきプロキシが存在する場合に、そのことをプロトコルハンドラに伝えるコードです。たとえば、次の例を考えてみましょう。

URL url = new URL("http://java.sun.com/index.html");
URLConnection conn = url.openConnection();
InputStream in = conn.getInputStream();

この時点で、HTTP プロトコルが呼び出され、proxySelector へのクエリーを実行します。クエリーの内容は、次のようなものになります。

ハンドラ: やあ、java.sun.com までたどり着きたいんだけど、プロキシを使うべきかな ?
ProxySelector: どのプロトコルを使うつもりなんだい ?
ハンドラ: もちろん、HTTP さ。
ProxySelector: ポートはデフォルトポートかい ?
ハンドラ: 確認してみるからちょっと待って....そのとおり、デフォルトポートだ。
ProxySelector: わかった。じゃあ、プロキシには webcache.mydomain.com を使って。ポートは 8080 でね。
ハンドラ: ありがとう。<休止> ねえ、webcache.mydomain.com:8080 が応答しないみたいなんだけど。ほかの方法はないの ?
ProxySelector: それは困ったね。OK、じゃあ webcache2.mydomain.com をポート 8080 で試してみて。
ハンドラ: 了解。今度はうまくいっているみたいだ。ありがとう。
ProxySelector: お安いご用さ。じゃあね。

もちろん、この会話はいくらか脚色してありますが、やり取りの内容は理解できるでしょう。

ProxySelector のもっともすばらしい点は、プラグインが可能であることです。このため、デフォルトの ProxySelector では望みの機能を得られない場合、代わりの ProxySelector を記述してそれをプラグインとして使用できます。

では、ProxySelector とはどのようなものでしょうか。クラス定義を見てみることにしましょう。

public abstract class ProxySelector {
	public static ProxySelector getDefault();
	public static void setDefault(ProxySelector ps);
	public abstract List<Proxy> select(URI uri);
	public abstract void connectFailed(URI uri,
		SocketAddress sa, IOException ioe);
}

ここからわかるように、ProxySelector はデフォルト実装の設定用と取得用の 2 つの静的メソッドを持つ抽象クラスです。使用するプロキシを決定するため、またはプロキシへの到達が不可能のようであることを通知するため、プロトコルハンドラにより 2 つのインスタンスメソッドが使用されます。独自の ProxySelector を提供する場合に実行すべきことは、このクラスを拡張し、これら 2 つのインスタンスメソッドの実装を提供してから、新規クラスのインスタンスを引数として ProxySelector.setDefault() を呼び出すことです。この時点で、プロトコルハンドラは、HTTP や FTP と同様に、新規 ProxySelector へのクエリーを実行して使用するプロキシを決定します。

この種の ProxySelector の記述方法の詳細を説明する前に、デフォルトの ProxySelector について説明しましょう。J2SE 5.0 の提供するデフォルト実装では、下位互換性が維持されています。つまり、デフォルトの ProxySelector は、前述のシステムプロパティーをチェックして使用するプロキシを決定します。ただし、新しいオプション機能も存在します。最近の Windows システムや Gnome 2.x プラットフォームでは、デフォルトの ProxySelector に対してシステムのプロキシ設定の使用を指示できます (最近のバージョンの Windows および Gnome 2.x では、ユーザーインタフェースからプロキシをグローバルに設定することが可能)。システムプロパティー java.net.useSystemProxies が true に設定されている場合 (デフォルトでは互換性維持の目的で false に設定されている)、デフォルトの ProxySelector はこれらの設定の使用を試みます。このシステムプロパティーは、コマンド行で設定することも、JRE インストールファイル lib/net.properties を編集して設定することもできます。このような方法で、所定のシステムでシステムプロパティーを一度だけ変更する必要があります。

それでは、新規 ProxySelector を記述およびインストールする方法について説明しましょう。

実現したいことは、次のとおりです。デフォルトの ProxySelector の動作にはかなり満足していますが、HTTP および HTTPS については変更が必要です。ネットワーク上には、これらのプロトコルに対応したプロキシが複数存在するため、アプリケーションがこれらのプロキシを順番に試みるようにしたいと考えています。つまり、最初のプロキシが応答しない場合に、2 番目のプロキシの使用を試み、2 番目のプロキシが応答しない場合には 3 番目のプロキシの使用を試みる、という具合です。さらに、あるプロキシが頻繁に失敗するようであれば、それをリストから削除して最適化を行います。

実行する作業は、java.net.ProxySelector をサブクラス化すること、および select() メソッドと connectFailed() メソッドの実装を提供することだけです。

宛先への接続を試みる前に、プロトコルハンドラにより select() メソッドが呼び出されます。渡される引数は、リソース (プロトコル、ホスト、およびポート番号) が記述された URI です。その後、このメソッドにより、プロキシのリストが返されます。次に例を示します。

URL url = new URL("http://java.sun.com/index.html");
InputStream in = url.openStream();

このコードにより、次の擬似呼び出しがプロトコルハンドラ内でトリガーされます。

List<Proxy> l = ProxySelector.getDefault().select(new URI("http://java.sun.com/"));

この実装で行うべきことは、URI からのプロトコルが本当に HTTP (または HTTPS) であるかを確認し、そのとおりであればプロキシのリストを返し、そうでなければデフォルトプロキシに委譲することだけです。この場合、使用するプロトコルがデフォルトになるため、コンストラクタ内に以前のデフォルトへの参照を格納する必要があります。

このため、コードの先頭部分は次のようになります。

public class MyProxySelector extends ProxySelector {
	ProxySelector defsel = null;
	MyProxySelector(ProxySelector def) {
		defsel = def;
	}
	
	public java.util.List<Proxy> select(URI uri) {
		if (uri == null) {
			throw new IllegalArgumentException("URI can't be null.");
		}
		String protocol = uri.getScheme();
		if ("http".equalsIgnoreCase(protocol) ||
			"https".equalsIgnoreCase(protocol)) {
			ArrayList<Proxy> l = new ArrayList<Proxy>();
			// Populate the ArrayList with proxies
			return l;
		}
		if (defsel != null) {
			return defsel.select(uri);
		} else {
			ArrayList<Proxy> l = new ArrayList<Proxy>();
			l.add(Proxy.NO_PROXY);
			return l;
		}
	}
}

最初に、コンストラクタが以前のデフォルトセレクタへの参照を維持することに留意してください。次に、仕様に準拠するために、select() メソッド内の不正な引数をチェックすることに注目してください。最後に、以前のデフォルトが存在する場合、必要に応じてそれを保留することに注目します。ArrayList の生成方法については、格別興味を引くものではないため、この例では詳しく説明しません。関心がある場合には、付録にコード全体が記載されているので、それを参照してください。

見てわかるとおり、connectFailed() メソッドの実装が含まれていないため、このクラスは完全なものではありません。次の手順で、このメソッドを実装します。

select() メソッドから返されたいずれかのプロキシへの接続が失敗した場合は、connectFailed() メソッドがプロトコルハンドラにより常に呼び出されます。渡される引数は、ハンドラが到達を試みた URI (select() の呼び出し時に使用されたもの)、ハンドラがアクセスを試みたプロキシの SocketAddress、プロキシへの接続を試みたときにスローされた IOException の 3 つです。この情報を使って、次の操作を実行します。このプロキシがリスト内に存在し、かつ 3 回以上失敗した場合は、リストから削除して以降は使用されないようにします。コードは次のようになります。

public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
	if (uri == null || sa == null || ioe == null) {
		throw new IllegalArgumentException("Arguments can't be null.");
	}
	InnerProxy p = proxies.get(sa); 
	if (p != null) {
		if (p.failed() >= 3)
			proxies.remove(sa);
	} else {
		if (defsel != null)
			defsel.connectFailed(uri, sa, ioe);
	}
}

とても簡単ですね。再び、引数の有効性を確認する必要があります (これも仕様に準拠するため)。ここで考慮するのは SocketAddress だけです。SocketAddress がリスト内のプロキシのいずれかであればそれを処理し、そうでなければデフォルトセレクタとして再度保留します。

これで実装がほぼ完成しました。アプリケーション内でさらに実行することは、登録だけです。それを行いましょう。

public static void main(String[] args) {
	MyProxySelector ps = new MyProxySelector(ProxySelector.getDefault());
	ProxySelector.setDefault(ps);
	// rest of the application
}

わかりやすくするために、いくらか簡略化して記述しています。特に、例外のキャッチについてあまり記述していないことにお気づきでしょう。自分でコードを記述する際には、省略してある部分を補ってください。

基盤となるプラットフォームやコンテナ (Web ブラウザなど) との統合性を改善するため、Java Plugin と Java Webstart の両方でデフォルトの ProxySelector をカスタムの ProxySelector に置き換えていることに留意してください。このため、ProxySelector を操作する際には、デフォルトの ProxySelector は、通常、基盤となるプラットフォームおよび JVM 実装に固有のものであることを念頭に置く必要があります。このため、カスタムの ProxySelector を提供する場合、前述の例で示したように以前の ProxySelector への参照を保持して、必要に応じて使用するのは良い方法です。

5) 結論

ここまでの内容から、J2SE 5.0 ではプロキシを操作する方法が非常に多く用意されていることがわかります。システムプロキシ設定を使用する非常に簡単な方法から、ProxySelector を変更する非常に柔軟性の高いもの (熟練した開発者専用) まで提供されています。Proxy クラスを接続ごとに選択することも可能です。

付録

ここでは、本書で開発してきた ProxySelector のソース全体を示します。これは、教育を目的とするものであり、簡潔性を意図して記述されていることを念頭に置いてください。

import java.net.*;
import java.util.List;
import java.util.ArrayList;
import java.util.HashMap;
import java.io.IOException;

public class MyProxySelector extends ProxySelector {
	// Keep a reference on the previous default
    ProxySelector defsel = null;
	
	/*
	 * Inner class representing a Proxy and a few extra data
	 */
	class InnerProxy {
    	Proxy proxy;
		SocketAddress addr;
		// How many times did we fail to reach this proxy?
		int failedCount = 0;
		
		InnerProxy(InetSocketAddress a) {
			addr = a;
			proxy = new Proxy(Proxy.Type.HTTP, a);
		}
		
		SocketAddress address() {
			return addr;
		}
		
		Proxy toProxy() {
			return proxy;
		}
		
		int failed() {
			return ++failedCount;
		}
	}
	
	/*
	 * A list of proxies, indexed by their address.
	 */
	HashMap<SocketAddress, InnerProxy> proxies = new HashMap<SocketAddress, InnerProxy>();

	MyProxySelector(ProxySelector def) {
	  // Save the previous default
	  defsel = def;
	  
	  // Populate the HashMap (List of proxies)
	  InnerProxy i = new InnerProxy(new InetSocketAddress("webcache1.mydomain.com", 8080));
	  proxies.put(i.address(), i);
	  i = new InnerProxy(new InetSocketAddress("webcache2.mydomain.com", 8080));
	  proxies.put(i.address(), i);
	  i = new InnerProxy(new InetSocketAddress("webcache3.mydomain.com", 8080));
	  proxies.put(i.address(), i);
	  }
	  
	  /*
	   * This is the method that the handlers will call.
	   * Returns a List of proxy.
	   */
	  public java.util.List<Proxy> select(URI uri) {
		// Let's stick to the specs. 
		if (uri == null) {
			throw new IllegalArgumentException("URI can't be null.");
		}
		
		/*
		 * If it's a http (or https) URL, then we use our own
		 * list.
		 */
		String protocol = uri.getScheme();
		if ("http".equalsIgnoreCase(protocol) ||
			"https".equalsIgnoreCase(protocol)) {
			ArrayList<Proxy> l = new ArrayList<Proxy>();
			for (InnerProxy p : proxies.values()) {
			  l.add(p.toProxy());
			}
			return l;
		}
		
		/*
		 * Not HTTP or HTTPS (could be SOCKS or FTP)
		 * defer to the default selector.
		 */
		if (defsel != null) {
			return defsel.select(uri);
		} else {
			ArrayList<Proxy> l = new ArrayList<Proxy>();
			l.add(Proxy.NO_PROXY);
			return l;
		}
	}
	
	/*
	 * Method called by the handlers when it failed to connect
	 * to one of the proxies returned by select().
	 */
	public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
		// Let's stick to the specs again.
		if (uri == null || sa == null || ioe == null) {
			throw new IllegalArgumentException("Arguments can't be null.");
		}
		
		/*
		 * Let's lookup for the proxy 
		 */
		InnerProxy p = proxies.get(sa); 
			if (p != null) {
				/*
				 * It's one of ours, if it failed more than 3 times
				 * let's remove it from the list.
				 */
				if (p.failed() >= 3)
					proxies.remove(sa);
			} else {
				/*
				 * Not one of ours, let's delegate to the default.
				 */
				if (defsel != null)
				  defsel.connectFailed(uri, sa, ioe);
			}
     }
}

フィードバックの送付先: jean-christophe.collet@sun.com