Shoeisha Technology Media

CodeZine(コードジン)

特集ページ一覧

ServerSocketを利用した簡易HTTPサーバーの自作

Javaによる簡易HTTPサーバーの作成 第1回

  • LINEで送る
  • このエントリーをはてなブックマークに追加
2005/09/30 12:00

ServerSocketを利用すると、パフォーマンスを無視すれば簡単にサーバーアプリケーションを作ることができます。本記事では、単純なHTTPサーバーの実装を例にして、ServerSocketの利用方法を解説します。

はじめに

 今回から数回に渡ってJavaを利用した簡単なHTTPサーバーの作り方を解説します。第1回目に当たる本記事では、java.net.ServerSocketの使い方について説明します。

対象読者

 本記事は、Javaプログラミングの初級者から中級者を対象に、ネットワークプログラミングの基礎を解説します。また、本記事の読者は、ソケットプログラミングの基礎的な用語(ソケットポート、アドレスなど)についての知識を持っていることを前提しています。

必要な環境

 本記事のソースをビルド/実行するには、J2SE 1.4 以上を利用してください。ソースファイルアーカイブは、直接NetBeans 4.1のプロジェクトとして開けるように構成してありますが、NetBeans 4.1を利用しなくてもコマンドラインからJDKおよびAntを利用してビルドできるようになっています。

 なお、ソースファイルアーカイブに格納してあるコンパイル済みクラスファイルはJ2SE 1.4.2_08を利用してビルドしてあります。

 参考までに筆者が利用した本記事のテスト環境は以下のものです。

OSJ2SEAntJUnitIDE
OS X 10.4.21.5.0_021.6.23.8.1NetBeans4.1J
Windows 20001.4.2_081.6.23.8.1NetBeans4.1J
Windows XP1.5.0_041.6.23.8.1NetBeans4.1J

NetBeans 4.1からの利用

 メニューから[ファイル]→[プロジェクトを開く]を順に選択し、ソースファイルアーカイブを展開したディレクトリの「httpserver」ディレクトリを選択して、[プロジェクトフォルダを開く]をクリックしてください。

 プロジェクトが読み込まれたら、プロジェクトのコンテキストメニュー(ショートカットメニュー)から[プロパティ]をクリックし、[プロジェクトプロパティ]の[プロジェクトの実行]タブを選択して、[作業用ディレクトリ]に展開したディレクトリの「httpserver\html」を設定してください。

 以降、プロジェクトのコンテキストメニューから実行、デバッグ、テスト実行が可能となります。

ファイル構成

 ダウンロードしたファイルはzipで圧縮してあります。展開すると「httpserver」というディレクトリを頂点としたディレクトリ階層ができます。すぐに実行できるようにコンパイル済みのクラスファイルも添付してあります(J2SE 1.4.2_08を利用)。また、ソースファイルはすべてシフトJISでエンコードしています。

「httpserver」ディレクトリ

  • 「build.xml」
  • antのビルドスクリプトです。NetBeans 4.1が生成したファイルですが、コマンドラインからantを利用して操作することも可能です。

 主なターゲットに以下のものがあります。

  • clean
  • ビルドしたクラスファイルを削除。
  • compile
  • 「src」ディレクトリ下のソースファイルをコンパイル。
  • test
  • 「src」および「test」ディレクトリ下のソースファイルをコンパイルしてユニットテストを実行。なお、testターゲットを実行する場合には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] ------------- ---------------- ---------------
    ...
    
  • run
  • 「src」ディレクトリ下のソースファイルをコンパイルし実行。application.argsプロパティで引数を指定することができます。

「httpserver\html」ディレクトリ

 テスト用のHTMLを格納してあります。「build.xml」のrunを実行した場合のワークディレクトリはここを設定しています。

「httpserver\nbproject」ディレクトリ

 NetBeans 4.1のプロジェクト設定ファイルが格納されています。

「httpserver\src\com\example\http」ディレクトリ

  • 「HttpServer.java」
  • 本記事で取り上げる簡易HTTPサーバーのソースファイルです。

「httpserver\test\com\example\http」ディレクトリ

  • 「HttpServerTest.java」
  • 簡易HTTPサーバーのテストプログラムのソースファイルです。

実行方法

 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」を表示します(下図参照)。

「html\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#acceptSocketExceptionで中断された場合です。

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文字で処理できるため、ここではPrintWriterSocketから取得した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のほうが便利かも知れません。

FirefoxはContent-Typeに従うがIEはpngとして表示
FirefoxはContent-Typeに従うがIEはpngとして表示

まとめ

 ServerSocketを利用することで、パフォーマンスを無視すれば簡単にサーバーアプリケーションを作ることができます。本記事では、単純なHTTPサーバーの実装を使って、ServerSocketの利用方法を解説しました。

 利用手順は以下の通りです。

  1. ServerSocketを生成する。
  2. bindメソッドでクライアントからのリクエストを受け付けるアドレス/ポート/バックログを設定する。
  3. acceptメソッドを呼び出してクライアントからのコネクションを受け付ける。
  4. acceptメソッドが返したSocketを利用してクライアントと通信する。

参考資料



  • LINEで送る
  • このエントリーをはてなブックマークに追加

著者プロフィール

  • arton(アートン)

    専門は業界特化型のミドルウェアやフレームワークとそれを利用するアプリケーションの開発。需要に応じてメインフレームクラスから携帯端末までダウンサイジングしたりアップサイジングしたりしながらオブジェクトを連携させていくという変化に富んだ開発者人生を歩んでいる。著書に『Ruby③ オブジェクト指向とはじめ...

バックナンバー

連載:Javaによる簡易HTTPサーバーの作成
All contents copyright © 2005-2018 Shoeisha Co., Ltd. All rights reserved. ver.1.5