Webサーバを作る

レスポンスヘッダを取捨選択する

前回、 手作りのTCPクライアントでApacheを叩いてみたところ、 以下のレスポンスが得られました。

  1: HTTP/1.1 200 OK
  2: Date: Tue, 30 Jul 2013 17:47:09 GMT
  3: Server: Apache/2.4.6 (Win32)
  4: Last-Modified: Mon, 11 Jun 2007 11:53:14 GMT
  5: ETag: "2e-432a0069dbe80"
  6: Accept-Ranges: bytes
  7: Content-Length: 46
  8: Keep-Alive: timeout=5, max=100
  9: Connection: Keep-Alive
 10: Content-Type: text/html
 11: 
 12: <html><body><h1>It works!</h1></body></html>

ここではWebサーバを作ろうとしているわけですから、 上記のようなレスポンスを返さなければなりません。

ヘッダの中には意味のわからないものもあるかと思います。 ただし、これらのすべてが必須であるというわけでもありません。

HTTP1.1の仕様を規定している RFC2616を 横目で見ながら、今作ろうとしているへなちょこな Webサーバでどこまで返すかを決めましょう。

ステータスライン(1行目)
これは、常識的に考えて必要ですよね。
Date:
日付です。 RFCを見ると、ステータスとして200を返すのであれば、 サーバに時計がついている限り返さなければいけないようです。 時計のないサーバのふりをしてもよいのですが、 難しい話でもなし、返すことにしましょう。 この日付はグリニッジ標準時(GMT)です(HTTP上ではUTCと変わりません)。
Server:
サーバの名前です。 必須でもなんでもないですが、せっかくなので書いておきましょう。 簡単ですし。
Last-Modified:
RFCによれば「可能であればいつでも Last-Modified を送るべきである。」 とのことですが、「べきである」レベルの話なので無視します。
Etag:
わけがわからないものが出ていますが、 RFCによれば「エンティティタグは、 同じリソースからの他のエンティティとの比較に使う事ができる」 というレベルの話なので無視します。
Accept-Ranges:
RFCによれば「送る事ができる」レベルの話ですので無視します。
Content-Length:
これはボディの長さを示します。あったほうがよさそうにも見えますが、 RFCによれば(レスポンスの場合は)「使うべきである」レベルなので ここでは無視します。
Keep-Alive:
これは、HTTP1.1から導入された機能です。
通常、HTMLのページを1ページ読み込むときには、 その中にたくさん画像が貼ってあったりしますから、 たくさんのHTTPリクエストが発生します。 HTTP1.0の頃は、そのたくさんのリクエストの度にTCPの接続からやり直していましたが、 Keep-Alive:を使用するといったん作ったTCPの接続を(一定時間) 使いまわすようになります。 しかし、いきなりバージョン1.1の機能を作ることもないでしょうし、 今回はこのヘッダもつけません。
Connection:
RFCによれば「HTTP/1.1 では、 送信者がレスポンスを完了した後に接続を切断するという事を合図する "close"接続オプションを定義する。」とありますので、 Keep-Aliveを使わないのであればこれはつけたほうがよさそうです (なくてもFirefoxでは動きましたが……)。
Content-Type:
RFCによれば「エンティティボディを含む HTTP/1.1 メッセージは、 いつでもそのボディのメディアタイプを定義するための Content-Typeヘッダフィールドを含むべきである。 」とあります。 なくてもよいのかもしれませんが、 まずはHTMLを返すのであればtext/htmlと書いておけばよいので、 これは付けることにします。

ひとつのHTMLファイルを返す

上記のようなヘッダを返せるよう修正したサーバが以下(Server02.java)です。

import java.io.*;
import java.net.*;
import java.util.*;
import java.text.*;

public class Server02 {
    private static final String DOCUMENT_ROOT = "C:\\maebashi\\homepage";

    // InputStreamからのバイト列を、行単位で読み込むユーティティメソッド
    private static String readLine(InputStream input) throws Exception {
        int ch;
        String ret = "";
        while ((ch = input.read()) != -1) {
            if (ch == '\r') {
                // 何もしない
            } else if (ch == '\n') {
                break;
            } else {
                ret += (char)ch;
            }
        }
        if (ch == -1) {
            return null;
        } else {
            return ret;
        }
    }

    // 1行の文字列を、バイト列としてOutputStreamに書き込む
    // ユーティリティメソッド
    private static void writeLine(OutputStream output, String str)
        throws  Exception {
        for (char ch : str.toCharArray()) {
            output.write((int)ch);
        }
        output.write((int)'\r');
        output.write((int)'\n');
    }

    // 現在時刻から、HTTP標準に合わせてフォーマットされた日付文字列を返す
    private static String getDateStringUtc() {
        Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
        DateFormat df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss",
                                             Locale.US);
        df.setTimeZone(cal.getTimeZone());
        return df.format(cal.getTime()) + " GMT";
    }

    public static void main(String[] argv) throws Exception {
        try (ServerSocket server = new ServerSocket(8001)) {
            Socket socket = server.accept();
            InputStream input = socket.getInputStream();

            String line;
            String path = null;
            while ((line = readLine(input)) != null) {
                if (line == "")
                    break;
                if (line.startsWith("GET")) {
                    path = line.split(" ")[1];
                }
            }
            OutputStream output = socket.getOutputStream();
            // レスポンスヘッダを返す
            writeLine(output, "HTTP/1.1 200 OK");
            writeLine(output, "Date: " + getDateStringUtc());
            writeLine(output, "Server: Sever02.java");
            writeLine(output, "Connection: close");
            writeLine(output, "Content-type: text/html");
            writeLine(output, "");

            // レスポンスボディを返す
            try (FileInputStream fis
                 = new FileInputStream(DOCUMENT_ROOT + path);) {
                int ch;
                while ((ch = fis.read()) != -1) {
                    output.write(ch);
                }
            }
            socket.close();
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }
}

7行目で定数DOCUMENT_ROOTを設定しています。 ここではC:\maebashi\homepageを指定していますが、 これは私が自分のWebサイトのHTMLをローカルで持っているフォルダです。 適宜HTMLを用意して、そのフォルダを設定してください。

このサーバ(Server02)では、クライアントとの通信を、 InputStreamおよびOutputStreamを使ってバイト単位で行います。 それを行単位の入出力にするためのユーティリティメソッドとして、 readLine(), writeLine()を作成しています ※1。 また、現在時刻からレスポンスヘッダ用の日付文字列を作成する getDateStringUtc()も作成しています。

56行目から62行目までのループで、HTTPリクエストを、 ヘッダが終わる空行まで受け取っています。 その中で、GETで始まるリクエストラインを空白でsplit()し、 取得するファイルのパスを取得しています(60行目)。 ここでは「/index.html」のような文字列がpathに取得できることになります。

65行目から69行目までで、ステータスラインとレスポンスヘッダを返しています。 70行目がレスポンスヘッダの終了の空行です。

76行目からのループで、DOCUMENT_ROOTにpathを連結したパスから 1バイトずつ読み込み、クライアントに返送しています。 HTTPのリクエストラインで与えられるパスの区切り文字はスラッシュ(/)で、 Windowsの区切り文字であるバックスラッシュ(\)とは異なりますが Windowsは両方に対応しているので大丈夫です。

Server02を起動してからブラウザで 「http://localhost:8001/index.html」を参照すると、 こんな感じでHTMLを参照することができました。

見ての通り、画像は取得できていませんし、CSSも効いていません。 画像やCSSは、ブラウザが、本体のHTMLを取得してから別途取りに行きますが、 このサーバは1回しかTCP接続を受け付けていないので当然です。

マルチスレッド化とContent-Type

ここまでで、ひとつのHTMLファイルを返し、ブラウザで表示することができました。 ただし、画像やCSSが取得できていません。

また、そもそも作りたいのはWebサーバなのですから、 ひとつのブラウザから1回限りHTMLが取得できてもだめで、 たくさんの接続先からの接続を並行して受け付けられなければなりません。

そこで、あとやるべきことは以下の2点です。

これに対応したプログラムが以下です。 それなりの大きさになってきたので、クラスもふたつに分けました。

まずはmain()メソッドを含むMain.javaです。

import java.io.*;
import java.net.*;
import java.util.*;

public class Main {
    public static void main(String[] argv) throws Exception {
        try (ServerSocket server = new ServerSocket(8001)) {
            for (;;) {
                Socket socket = server.accept();

                ServerThread serverThread = new ServerThread(socket);
                Thread thread = new Thread(serverThread);
                thread.start();
            }
        }
    }
}

Main.javaではTCP接続を待ち受け、accept()したら ServerThreadにそのソケットを渡して別スレッドで起動しています。 accept()は無限ループの中にいますから、ServerThreadを起動したら、 また次の接続の受付に入ります。 このようにすると、ひとつのポート番号で、 同時に複数のソケットを生成し、独立して通信が可能です。

以下がServerThreadのソース(ServerThread.java)です。

import java.io.*;
import java.net.*;
import java.util.*;
import java.text.*;

public class ServerThread implements Runnable {
    private static final String DOCUMENT_ROOT = "C:\\maebashi\\homepage";
    private Socket socket;

    // InputStreamからのバイト列を、行単位で読み込むユーティティメソッド
    private static String readLine(InputStream input) throws Exception {
        int ch;
        String ret = "";
        while ((ch = input.read()) != -1) {
            if (ch == '\r') {
                // 何もしない
            } else if (ch == '\n') {
                break;
            } else {
                ret += (char)ch;
            }
        }
        if (ch == -1) {
            return null;
        } else {
            return ret;
        }
    }

    // 1行の文字列を、バイト列としてOutputStreamに書き込む
    // ユーティリティメソッド
    private static void writeLine(OutputStream output, String str)
        throws  Exception {
        for (char ch : str.toCharArray()) {
            output.write((int)ch);
        }
        output.write((int)'\r');
        output.write((int)'\n');
    }

    // 現在時刻から、HTTP標準に合わせてフォーマットされた日付文字列を返す
    private static String getDateStringUtc() {
        Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
        DateFormat df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss",
                                             Locale.US);
        df.setTimeZone(cal.getTimeZone());
        return df.format(cal.getTime()) + " GMT";
    }

    // 拡張子とContent-Typeの対応表
    private static final HashMap<String, String> contentTypeMap =
        new HashMap<String, String>() {{
            put("html", "text/html");
            put("htm", "text/html");
            put("txt", "text/plain");
            put("css", "text/css");
            put("png", "image/png");
            put("jpg", "image/jpeg");
            put("jpeg", "image/jpeg");
            put("gif", "image/gif");
        }
    };
    // 拡張子を受け取りContent-Typeを返す
    private static String getContentType(String ext) {
        String ret = contentTypeMap.get(ext.toLowerCase());
        if (ret == null) {
            return "application/octet-stream";
        } else {
            return ret;
        }
    }

    @Override
    public void run() {
        OutputStream output;
        try {
            InputStream input = socket.getInputStream();

            String line;
            String path = null;
            String ext = null;
            while ((line = readLine(input)) != null) {
                if (line == "")
                    break;
                if (line.startsWith("GET")) {
                    path = line.split(" ")[1];
                    String[] tmp = path.split("\\.");
                    ext = tmp[tmp.length - 1];
                }
            }
            output = socket.getOutputStream();
            // レスポンスヘッダを返す
            writeLine(output, "HTTP/1.1 200 OK");
            writeLine(output, "Date: " + getDateStringUtc());
            writeLine(output, "Server: Sever03.java");
            writeLine(output, "Connection: close");
            writeLine(output, "Content-type: " + getContentType(ext));
            writeLine(output, "");

            // レスポンスボディを返す
            try (FileInputStream fis
                 = new FileInputStream(DOCUMENT_ROOT + path);) {
                int ch;
                while ((ch = fis.read()) != -1) {
                    output.write(ch);
                }
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        } finally {
            try {
                socket.close();
            } catch (Exception ex) {
                ex.printStackTrace();
            }
        }
    }

    ServerThread(Socket socket) {
        this.socket = socket;
    }
}

ServerThread.javaがServer02.javaと異なるのは、 97行目で、返却するファイルの拡張子に応じたContent-Type を指定していることです。 拡張子とContent-Typeの対応表が51行目からのHashMapです。

このプログラムを起動しブラウザからアクセスすると、 以下のように画像もCSSも有効になりました。

ローカルにあるHTMLに関しては、ちゃんとリンクも効いており、 クリックすると遷移できます。

バッファリングもなしに1バイトずつ送っているせいか、 画像の表示などかなり遅いですが、 このプログラムの目的はWebサーバの基本的な原理を説明することですから、 ここでは気にしないことにします。

今後のこと

この記事は、わかっている人からすれば「何をそんな当たり前のことを」 というレベルの話かと思います。

しかし、現状、その当たり前の知識を入門者が得るまでには、 結構回り道をしなければならないように思いますし、 実際にアプリケーションを作るとき、 サーバとクライアントの通信が実際にどうなっているかの イメージができていないと結局困ります。 そういう人の助けになれば幸いです。

今後ですが、まず現状のサーバは、 ファイルがなくても404さえ返さないとてつもない手抜きなので、 そういったこまごまとしたところをまず直します。

その後は、POSTを受け付けるようにして掲示板のひとつも作ってみるとか、 Cookieを送れるようにしてみるとか考えられますが、 いかんせん私の時間が……

何も保証はできませんが、気長にお待ちくださいませ。


前のページ | 次のページ | ひとつ上のページに戻る | トップページに戻る