はじめに
今回から数回に渡ってJavaを利用した簡単なHTTPサーバーの作り方を解説します。第1回目に当たる本記事では、java.net.ServerSocket
の使い方について説明します。
対象読者
本記事は、Javaプログラミングの初級者から中級者を対象に、ネットワークプログラミングの基礎を解説します。また、本記事の読者は、ソケットプログラミングの基礎的な用語(ソケット、ポート、アドレスなど)についての知識を持っていることを前提しています。
必要な環境
本記事のソースをビルド/実行するには、J2SE 1.4 以上を利用してください。ソースファイルアーカイブは、直接NetBeans 4.1のプロジェクトとして開けるように構成してありますが、NetBeans 4.1を利用しなくてもコマンドラインからJDKおよびAntを利用してビルドできるようになっています。
なお、ソースファイルアーカイブに格納してあるコンパイル済みクラスファイルはJ2SE 1.4.2_08を利用してビルドしてあります。
参考までに筆者が利用した本記事のテスト環境は以下のものです。
OS | J2SE | Ant | JUnit | IDE |
OS X 10.4.2 | 1.5.0_02 | 1.6.2 | 3.8.1 | NetBeans4.1J |
Windows 2000 | 1.4.2_08 | 1.6.2 | 3.8.1 | NetBeans4.1J |
Windows XP | 1.5.0_04 | 1.6.2 | 3.8.1 | NetBeans4.1J |
NetBeans 4.1からの利用
メニューから[ファイル]→[プロジェクトを開く]を順に選択し、ソースファイルアーカイブを展開したディレクトリの「httpserver」ディレクトリを選択して、[プロジェクトフォルダを開く]をクリックしてください。
プロジェクトが読み込まれたら、プロジェクトのコンテキストメニュー(ショートカットメニュー)から[プロパティ]をクリックし、[プロジェクトプロパティ]の[プロジェクトの実行]タブを選択して、[作業用ディレクトリ]に展開したディレクトリの「httpserver\html」を設定してください。
以降、プロジェクトのコンテキストメニューから実行、デバッグ、テスト実行が可能となります。
ファイル構成
ダウンロードしたファイルはzipで圧縮してあります。展開すると「httpserver」というディレクトリを頂点としたディレクトリ階層ができます。すぐに実行できるようにコンパイル済みのクラスファイルも添付してあります(J2SE 1.4.2_08を利用)。また、ソースファイルはすべてシフトJISでエンコードしています。
「httpserver」ディレクトリ
- 「build.xml」
主なターゲットに以下のものがあります。
- clean
- compile
- test
- run
libs.junit.classpath
プロパティで「junit.jar」を指定する必要があります。C:\test\httpserver>ant test -Dlibs.junit.classpath= %JUNIT_HOME%\junit.jar Buildfile: build.xml -pre-init: ... -do-test-run: [junit] done [junit] Testsuite: com.example.http.HttpServerTest [junit] Tests run: 2, Failures: 0, Errors: 0, Time elapsed: 0.375 sec [junit] ------------- Standard Output --------------- [junit] done [junit] ------------- ---------------- --------------- ...
application.args
プロパティで引数を指定することができます。「httpserver\html」ディレクトリ
テスト用のHTMLを格納してあります。「build.xml」のrunを実行した場合のワークディレクトリはここを設定しています。
「httpserver\nbproject」ディレクトリ
NetBeans 4.1のプロジェクト設定ファイルが格納されています。
「httpserver\src\com\example\http」ディレクトリ
- 「HttpServer.java」
「httpserver\test\com\example\http」ディレクトリ
- 「HttpServerTest.java」
実行方法
NetBeans 4.1のプロジェクトのコンテキストメニューから「プロジェクトを実行」をクリックするか、またはコマンドラインから以下のように実行します。
C:\test\httpserver>ant run -Dapplication.args=-d Buildfile: build.xml ... compile: run:
実行を開始するとポート8800番でサービスを開始します。
Webブラウザで、「http://localhost:8800/index.html」を開くと、「html」ディレクトリに格納されている「index.html」を表示します(下図参照)。
なお、実行時に-d
を指定(ant利用時は-Dapplication.args=-d
と指定)すると、デバッグモードで実行され、標準出力にクライアントからのリクエスト情報などを出力します。
終了するには、Webブラウザで「http://localhost:8800/quit」を開くか、または「index.html」の「終了」をクリックしてください。
解説
ServerSocketの作成
サーバーアプリケーションを作成するには、java.net.ServerSocket
を利用します。
/** * 簡易HTTPサーバーのインスタンスを生成する。 * * @param port サーバーが利用するポート番号 */ public HttpServer(int port) throws IOException { serverSocket = new ServerSocket(); serverSocket.setReuseAddress(true); serverSocket.bind(new InetSocketAddress(port)); }
HttpServer
クラスはコンストラクタで、指定されたポートにバインドされたServerSocket
を作成します。
ServerSocket
のコンストラクタには、リストで利用している無引数のものの他に、ポート番号や、バックログ(接続要求を受付済み、かつサーバーアプリケーションによるソケット取得待ち状態のクライアント数)、InetAddress
を指定できるものがあります。これらはいずれも生成後にバインド(ソケットにアドレスやポートを割り当てる処理)を実行しますが、ここではsetReuseAddress
メソッド(バインド後に呼び出した場合の動作は不定です)を利用するためにバインドされない無引数コンストラクタを呼び出しています。
setReuseAddress
メソッドにtrue
を指定して呼び出すと、指定したポート番号がTIME_WAIT
状態(切断状態を認識していないクライアントから再送された切断要求によって、別のクライアントとの通信が切断されることを回避するために設けられたソケットの待機状態)であっても、該当アドレスを強制的に再利用することができます。デバッグなどの必要性から短い間隔で同一ポート番号を利用するServerSocket
を作成する場合には、アドレス使用中例外となることを回避するために直接コンストラクタでバインドせずに(引数付きコンストラクタを呼び出すとその時点でバインドされるため、後からsetReuseAddress
メソッドを呼び出しても効果がありません)、無引数コンストラクタを呼び出してServerSocket
を作成してください。
無引数コンストラクタを利用してソケットを作成した場合、明示的にbind
メソッドを呼んでバインドを実行します。なお、ここではバックログを指定していないため、規定値(SunのJ2SE 5の場合は50)が利用されます。
なお、bind
メソッド(およびコンストラクタ)にはポート番号やバックログの他にInetAddress
を引数に指定できるものがあります。InetAddress
の指定は複数のネットワークアドレスを持つホストでは意味がありますが、ここではアドレスを1個しか持たないコンピュータの利用を前提して、特に指定していません。
バックログについて
バックログが多いと、それだけクライアントはリクエストが処理されるまで待つことが可能となります。バックログ分のリクエストがキューされると、サーバーはそれ以上の接続要求に対して接続そのものを拒否するからです。たとえば、本記事のHttpServerは、クライアントからのリクエストを逐次処理するため、1つのクライアントへのサービスを実行している間は、他のクライアントからのリクエストはバックログとしてキューされることになります。このようなサーバーの作り方では、同時に複数のクライアントからのリクエストがある場合にバックログが十分にないと、クライアントは即時に接続エラーとなってしまいます。
しかしだからと言って、バックログは多ければ良いというものでもありません。クライアントが待ち状態になるよりも次のアクションを取れる方が望ましい場合には、逆に少ない数を設定することもあります。プログラムtoプログラムの通信で、インタラクティブな処理のようにユーザーが能動的にあきらめる(たとえばブラウザの中止ボタンを押すなど)ことができない場合には、サーバービジーとして次のアクションをクライアントプログラムが取れるように、バックログを少なめに設定するほうが良いでしょう。
accept処理
クライアントからのリクエストメッセージの読み取りやレスポンスメッセージの書き出しはServerSocket
のインスタンスに対してではなく、個々のクライアントとのコネクションを示すSocket
クラスのインスタンスに対して行います。
ServerSocket
からSocket
を取得するのがServerSocket#accept
メソッドです。
/** * クライアントからのリクエストを受け付ける。 */ Socket accept() throws IOException { try { return serverSocket.accept(); } catch (SocketException e) { System.out.println("done"); } return null; }
ServerSocket#accept
メソッドは、クライアントからの接続要求を受け付けると新たに作成したSocket
のインスタンスを返します。この時、元のServerSocket
は引き続き他のクライアントからの接続要求を受け付けるため、アプリケーションは継続してサービスを実行できます。しかし別のクライアントからのリクエストを連続して処理するには、クライアントごとに複数のスレッドを利用するか、または非ブロッキングモードソケットを利用して、リクエストメッセージの受信やレスポンスメッセージの送信時に実行しているスレッドがIO待ちによって停止することがないよう考慮しなければなりません。ただし第1回の本記事では、上記の考慮はせずに単純にクライアントからのリクエストを逐次処理するよう実装しています。
なお、ServerSocket#accept
メソッドを呼び出すと、ServerSocket#setSoTimeout
で指定した時間が経過するか(本記事の実装のように無指定の場合は無限の待ち時間となります)、またはクライアントからのリクエストを受信するまで待ち状態となります。複数のスレッドでサーバーアプリケーションを構成する場合には、サーバーを停止するために別のスレッドからServerSocket#close
を呼び出すことができます。ServerSocket#close
を呼び出した時点でServerSocket#accept
を実行しているスレッドに対しては、リストで示されているようにSocketException
がスローされます。
次のリストは、この動作を利用してHttpServerTestプログラムがサーバースレッドの停止テストを行っている個所です。
assertTrue(t.isAlive()); server.close(); Thread.yield(); assertFalse(t.isAlive());
ここでt
で示されているのがHttpServer
のスレッドです。Thread#isAlive
が真の時点ではServerSocket#accept
を呼び出し中と判断できます。そこでHttpServer#close
(このメソッドの中でServerSocket#close
を呼びます)を呼び出してからスレッドを切り替えて、サーバースレッドに終了処理を実行させます。そして次にスレッドに制御が戻ってきた時点でサーバーのスレッドが終了していることを検証します。なお、タイミングによってはこのテストは失敗する可能性があります。その場合にはThread.yield
ではなくThread.sleep(100)
などに変える必要があるでしょう。
メイン処理
HttpServer#service
メソッドが、サーバー処理の中心となります。このメソッドで、クライアントからのリクエストメッセージを受け取りレスポンスメッセージを返します。既に述べたように、この実装ではクライアントからのリクエストを逐次処理します。
/** * サーバー処理を実行する。 * クライアントから"/quit"ファイルを要求されるか、 * {@link #close}を呼ばれるまで制御は戻らない。 */ public void service() throws IOException { assert serverSocket != null; for (Socket sock = accept(); sock != null; sock = accept()) { try { Request req = new Request(sock); if (req.path.equals("/quit")) { response(200, "OK", sock.getOutputStream()); break; } else { response(req, sock.getOutputStream()); } } catch (BadRequestException e) { if (debug) { e.printStackTrace(); } response(sock.getOutputStream(), e); } finally { sock.close(); } } }
Socket
取得後に作成しているRequest
クラスは、HTTPプロトコルのリクエストメッセージを解析して、クライアントからの要求をアプリケーション的に処理しやすく変換するクラスです。
次にクライアントから要求されたファイルが「/quit」なら、レスポンスメッセージをクライアントへ返した後にaccept
ループを抜けて処理を終了します。そうでなければレスポンス処理を実行します。
なお、ループの終了条件のsock != null
が偽になるのは、既に見たように他のスレッドからServerSocket#close
を呼び出すことでServerSocket#accept
がSocketException
で中断された場合です。
HTTPリクエスト解析
HTTPリクエストの解析はRequest
クラスのコンストラクタで実行します。なお、この実装ではクライアントからのコンテントボディは処理しません。
Request(Socket sock) throws IOException { in = sock.getInputStream(); header(); if (debug) { System.out.println(this); for (int i = 0; i < metadata.length; i++) { System.out.println(metadata[i]); } } }
ソケットからインバウンドメッセージを読み込むには、Socket#getInputStream
メソッドで取り出したInputStream
を利用します。
void header() throws IOException { byte[] buff = new byte[2000]; for (int i = 0; ; i++) { int c = in.read(); if (c < 0) { throw new BadRequestException("header too short:" + new String(buff, 0, i), "header too short", HttpURLConnection.HTTP_BAD_REQUEST); } buff[i] = (byte)c; if (i > 3 && buff[i - 3] == '\r' && buff[i - 2] == '\n' && buff[i - 1] == '\r' && buff[i] == '\n') { createHeader(buff, i - 4); break; } else if (i == buff.length - 1) { if (i > MAX_HEADER_SIZE) { throw new BadRequestException("header too long:" + new String(buff, 0, 256), "header too long", HttpURLConnection.HTTP_BAD_REQUEST); } byte[] nbuff = new byte[buff.length * 2]; System.arraycopy(buff, 0, nbuff, 0, i + 1); buff = nbuff; } } }
HTTPのリクエストメッセージは、「リクエスト行+メッセージヘッダ行(繰り返し)+空行+メッセージボディ」という形式です。リクエスト行およびメッセージヘッダの各行はCR/LFで終了します。
ここでは、この行末に注目して連続してCR/LF/CR/LFが出現する個所をチェックすることでリクエスト行とメッセージヘッダ行を切り出しています。
なお、異常なリクエストを検出するためにリクエストヘッダの上限をMAX_HEADER_SIZE
定数で制限し、それを越える場合にはエラーとします。この時、最初から上限分のバッファを確保するのは無駄なので標準的なリクエストヘッダが収まる範囲として2000バイト(この値は適当に割り当てた値です。本当に無駄なメモリーブロックの割り当てを回避するには標準的なブラウザのヘッダ長の平均を求めるべきです)を初回分として割り当てています。
void createHeader(byte[] buff, int len) { for (int i = 0; i < len; i++) { if (i > 2 && buff[i - 1] == '\r' && buff[i] == '\n') { Matcher m = COMMAND.matcher(new String(buff, 0, i - 1)); if (m.matches()) { method = m.group(1); path = m.group(2); version = m.group(3); } else { throw new BadRequestException( new String(buff, 0, i + 1), "header too long", HttpURLConnection.HTTP_BAD_REQUEST); } metadata = new String(buff, i + 1, len - i) .split("\\r\\n"); break; } } }
リクエスト行は
static final Pattern COMMAND = Pattern.compile("^(\\w+)\\s+(.+?)\\s+HTTP/([\\d.]+)$");
という正規表現にマッチします。この正規表現は
行の先頭 → 1文字以上の単語構成文字の繰り返し → 1文字以上の空白の繰り返し → 1文字以上のいろいろな文字の繰り返し → 1文字以上の空白の繰り返し → 「HTTP/」という並び → 数字または.の、1文字以上の繰り返し → 直後に行末
という意味です。
各カッコは順にメソッド、リクエストURI、HTTPバージョンを示しています。なお、各要素の構成文字はより厳密に決定可能ですが、ここでは上記のような大雑把な正規表現を利用しています。
createHeader
メソッドでは、COMMAND
定数に保持した正規表現を利用してメソッド(変数名はmethod
)、URI(変数名はpath
)、バージョン番号(変数名はversion
)をリクエスト行から取得します。
その後、String#split
メソッドを利用してメッセージヘッダ行を行単位に分割し、配列として保持します。
レスポンス処理1(ファイルチェック)
クライアントから要求されたファイルの存在チェックをします。
/** * クライアントからのリクエストを処理する。 */ void response(Request req, OutputStream out) throws IOException { if (req.method.equals("GET")) { File f = new File(".", req.path); if (f.getAbsolutePath().indexOf("..") < 0 && f.isFile()) { response(f, out); } else { if (debug) { System.out.println(f.getAbsolutePath() + " is not found."); } throw new BadRequestException(req.path + " not found", HttpURLConnection.HTTP_NOT_FOUND); } } else { throw new BadRequestException("unknown method:" + req.method, HttpURLConnection.HTTP_BAD_METHOD); } }
なお、ここでは仮想的に設定したルートディレクトリを越えてアクセスができないように、リクエストURIに「..」 が含まれないかをチェックし、もし含まれていたらエラーとしています。HTTPサーバーは情報漏洩が起きないように、公開する範囲として仮想的なディレクトリツリーを内部に持ちます。しかし、これを正しく処理しないと、公開するつもりのないファイルをクライアントへ与えてしまう危険性があります。
たとえば、URLの「/」をファイルシステムの「/var/http」に設定しているHTTPサーバーに対して、クライアントが「/../../etc/passwd」と要求した場合に、それを直接ファイルシステムにマッピングするとパスワードファイルをクライアントへ送ってしまうことになります。実際にはHTTPサーバが公開している仮想ディレクトリツリーの内部に収まる範囲であれば「..」の入力を認めるべきですが、ここでは実装を単純にするために一律にエラーとします。
レスポンス処理2(レスポンス出力)
HTTPレスポンスは、「ステータス行+メッセージヘッダ行(繰り返し)+空行+メッセージボディ」という形式です。
HttpServer#responseSuccess
メソッドは、ステータス行とメッセージヘッダ行を出力します。
/** * クライアントへOK応答を返す。 * @param len コンテンツ長 * @param type コンテンツのMIMEタイプ */ void responseSuccess(int len, String type, OutputStream out) throws IOException { PrintWriter prn = new PrintWriter(out); prn.print("HTTP/1.1 200 OK\r\n"); prn.print("Connection: close\r\n"); prn.print("Content-Length: "); prn.print(len); prn.print("\r\n"); prn.print("Content-Type: "); prn.print(type); prn.print("\r\n\r\n"); prn.flush(); }
ヘッダはASCII文字で処理できるため、ここではPrintWriter
をSocket
から取得したOutputStream
に被せて利用しています。バッファ上に出力内容が残らないように最後にPrintWriter#flush
を呼び出します。また、PrintWriter#close
を呼び出すと以後の出力ができなくなる可能性があるため呼びません。
responseSuccess
メソッドではファイルが存在した場合のOKレスポンスを生成します。メッセージボディの長さを示すContent-Length
フィールドや、メッセージボディの内容を示すContent-Type
(この実装では「text/html」を固定で利用)を作成しています。また、クライアントからのリクエストを都度処理できるようにConnection
フィールドにはclose
を指定し、次のリクエストには別の接続を利用するように求めています。
この最後の処理は、初期のHTTPの実装のしやすさの要因であり、初期のHTTPのパフォーマンス問題の原因でもあります。一般にTCPのコネクションは生成と終結ともにシステムに負荷がかかります。生成時のプロトコルシーケンスを悪用した攻撃(synフラッドと呼ばれます)を避けるためのタイマーによる監視や、終結時の再送回避のためのWAIT動作(タイマーによる負荷だけではなく、利用可能なポート資源の一時的な減少をもたらします)のいずれも1つのコネクションで複数のリクエスト/レスポンスを行えるようにすることで減らすことができます。このため、HTTP 1.1のデフォルト動作の規定ではリクエスト―レスポンスの都度コネクションを切断しないように定められています。
ただし、今回の実装では処理を単純にするために、1回のリクエスト―レスポンスでコネクションを切断しています。そのためConnection
フィールドでclose
を指定します。
メッセージボディはヘッダと異なり、Content-Length
フィールドで指定した長さを満たし、Content-Type
フィールドで指定した内容であれば、どのようなデータでも送信できます。このため、PrintWriter
を利用して出力したHttpServer#responseSuccess
と異なり、HttpServer#response(File, OutputStream)
メソッドでは、OutputStream
を直接利用してレスポンスを送ります。
/** * クライアントへ指定されたファイルを返送する。 */ void response(File f, OutputStream out) throws IOException { responseSuccess((int)f.length(), "text/html", out); BufferedInputStream bi = new BufferedInputStream(new FileInputStream(f)); try { for (int c = bi.read(); c >= 0; c = bi.read()) { out.write(c); } } finally { bi.close(); } }
なお、この実装ではContent-Type
として「text/html」を固定で利用していますが、「httpserver/html」ディレクトリへ適当なファイルをコピーし、ブラウザから要求すればそのファイルを送信します。下図はpngファイルをブラウザに表示させたところです。IEはContent-Type
を信じずにファイルの内容を見て処理するため本来のファイルとして画像が表示されますが、FirefoxはContent-Type
に従うためテキストが表示されています。無料のサーバーなどでユーザーがContent-Type
を設定できない場合にはIEの動作は好ましいとも言えますが、自動的に内容を判定することによって問題が引き起こされることもあるため、IEの実装は危険性が高いとも思います。
とは言え、とりあえず今回のHttpServerの実装のテストにはIEのほうが便利かも知れません。
まとめ
ServerSocket
を利用することで、パフォーマンスを無視すれば簡単にサーバーアプリケーションを作ることができます。本記事では、単純なHTTPサーバーの実装を使って、ServerSocket
の利用方法を解説しました。
利用手順は以下の通りです。
ServerSocket
を生成する。bind
メソッドでクライアントからのリクエストを受け付けるアドレス/ポート/バックログを設定する。accept
メソッドを呼び出してクライアントからのコネクションを受け付ける。accept
メソッドが返したSocket
を利用してクライアントと通信する。