前回、 手作りの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サーバでどこまで返すかを決めましょう。
上記のようなヘッダを返せるよう修正したサーバが以下(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接続を受け付けていないので当然です。
ここまでで、ひとつの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を送れるようにしてみるとか考えられますが、 いかんせん私の時間が……
何も保証はできませんが、気長にお待ちくださいませ。