はじめに
お勉強には車輪の再発明が有効だと思っているので、Javaでごくごく簡単なHTTPサーバを作ってみてHTTPについて説明してみる。
Socketのlisten
まずはTCPのコネクションを受け付けないと始まらない。ポート番号は通常80だが、ここでは8080を使っている。
実際のコードはこんな感じ。(ここでは main を作っていないが、mainから serverMain を呼び出す感じになる)
ちなみにhttpsにするなら、ここでSSLServerSocketを使えば良い。(サーバ証明書が必要)
import java.net.ServerSocket;
import java.net.Socket;
.
.
.
public class HttpTestServer
{
private void serverMain()
{
ServerSocket listen_sock = null;
try {
listen_sock = new ServerSocket(8080);
while (true) {
Socket accept_sock = listen_sock.accept();
new RequestThread(accept_sock);
}
}
catch (Exception e) {
Log.write("appMain exception: " + e.toString());
}
try {
if (null != listen_sock) {
listen_sock.close();
}
}
catch (Exception e) {
Log.write("appMain close error: " + e.toString());
}
}
}
やっていることは単純で、ServerSocketで8080をlistenし、クライアントからの通信が来たらRequestThreadのスレッドを起動して、そっちに処理させているだけ。
パフォーマンスを気にするならThreadPoolを使う。
HTTPの通信
別スレッド(ここではRequestThread)で実際のHTTP通信を行う。
HTTPでは、まずクライアントからHTTPのヘッダが送られてきて、空行を挟んでリクエストボディが送られてくる。(リクエストボディは無いことがある)
というわけで、RequestThreadの中身を説明する。まずはコンストラクタ。ついでに中で使っている closeSocket();
public class RequestThread extends Thread
{
private Socket clientSocket = null;
private InputStream receiveStream;
private OutputStream sendStream;
// コンストラクタで、acceptしたSocketを受け取って、start(); でスレッドの動作開始
public RequestThread(Socket client_sock)
{
clientSocket = client_sock;
try {
receiveStream = clientSocket.getInputStream();
sendStream = clientSocket.getOutputStream();
}
catch (Exception e) {
Log.write("Stream exception: " + e.toString());
closeSocket();
return;
}
start();
}
// 通信終了時にSocketを閉じる
private void closeSocket()
{
if (null != clientSocket) {
try {
clientSocket.close();
clientSocket = null;
}
catch (Exception e) {
Log.write("closeSocket exception: " + e.toString());
clientSocket = null;
}
}
}
}
次にスレッドの本体となるrun()の中身。
// ヘッダにつながってきたボディを格納するためのバッファ
private ByteArrayOutputStream bodyBuffer = null;
// リクエストヘッダにある、リクエストされたコンテンツの情報
private String requestHost = null;
private String requestFileName = null;
// Keep-aliveが終わってcloseするためのフラグ
private boolean closeConnection = false;
@Override
public void run()
{
while (true) {
// まずヘッダを受信、一緒にボディの一部も受信する可能性がある、受信した場合はbodyBufferに残す
String req_header = receiveHeader();
if (null == req_header) {
sendError();
break;
}
// ヘッダを解析
statusCode = 0;
parseHeader(req_header);
if (0 != statusCode) {
sendError();
break;
}
// リクエストボディがあれば読み込み
if (0 < bodyLen) {
receiveBody();
}
if (0 != statusCode) {
sendError();
break;
}
// リクエストされたファイルを読み込み
Log.write("Request host: " + requestHost);
Log.write("Request path: " + requestFileName);
readResponseData();
if (0 != statusCode) {
sendError();
break;
}
// 読み込んだファイルを送信
sendResponseData();
// Connection: close 指定の場合はここでcloseするために抜ける
if (closeConnection) {
break;
}
}
closeSocket();
}
HTTP 1.1では Connection: keep-alive が標準で、一つのコネクションでHTTPのリクエスト・レスポンスが複数回繰り返される。
このため、whileでループして、ループを抜けたところでcloseSocket();して終了となる。
HTTPリクエストヘッダ
リクエストヘッダは最初にクライアントから送られてくるので、サーバとしてはこれを受信する。
ヘッダは空行(改行が二つ連続)で終了するので、連続した改行を検索して、見つかったらそこまでがヘッダ。続けてリクエストボディ(POSTするときのデータ)がくることがあるので、受信した分は全てヘッダだと思ってはいけない。
RFCではヘッダの長さに制限は無いが、実際のサーバでは8kBytesなど、制限があるらしい。ここでは最大256kBytesにしている。多少パフォーマンスが落ちても良ければ ByteArrayOutputStream などを使うと、メモリがある限り受信できる。
private byte[] headerReadBuf = new byte[1024 * 256];
private int headerEndSearch = 0;
private int bodyLen = 0;
private int statusCode = 0;
private String receiveHeader()
{
try {
int cur_read = 0;
headerEndSearch = 0;
bodyLen = 0;
while (true) {
// まずは受信、前のデータにつながるようにする
int read_size = receiveStream.read(headerReadBuf, cur_read, headerReadBuf.length - cur_read);
if (read_size < 0) {
// まだヘッダ終端が見つかっていないのに受信終了したらエラー
Log.write("receiveHeader header error: len = -1");
statusCode = 400; // Bad Request
return null;
}
cur_read += read_size;
if (headerReadBuf.length <= cur_read) {
// ヘッダ大きすぎエラー
Log.write("receiveHeader: header too large");
statusCode = 431; // Request Header Fields Too Large
return null;
}
// ヘッダの終わり(\r\n\r\n)を探す、次回が探すときは最後から3文字前までで、最後に見つけた \r から探す
for (int i = headerEndSearch; i < cur_read - 3; i++) {
if (headerReadBuf[i] == '\r' && headerReadBuf[i + 1] == '\n' && headerReadBuf[i + 2] == '\r' && headerReadBuf[i + 3] == '\n') {
// ヘッダに繋げてリクエストボディまで受信している可能性があるため、ここでチェック
int body_start = i + 4;
bodyLen = cur_read - body_start;
if (0 < bodyLen) {
bodyBuffer = new ByteArrayOutputStream();
bodyBuffer.write(headerReadBuf, body_start, bodyLen);
}
return new String(headerReadBuf, 0, body_start, StandardCharsets.UTF_8);
}
else if (headerReadBuf[i] == '\r') {
headerEndSearch = i;
}
}
}
}
catch (Exception e) {
Log.write("receiveHeader exception: " + e.toString());
statusCode = 400; // Bad Request
return null;
}
}
1回のInputStream.readでは全部が読み取れない可能性があることに注意。また、改行は\r\nなので、改行二つは4バイト必要。
この4バイトの途中で受信が切れている可能性もある。なので、読み取った分を繋げて\r\n\r\nを検索し、無かったら次は今見つかっている最後の\rから検索する。この最後の\rの位置をheaderEndSearchに保存している。
(\r\n\r で切れている時は、最後の\rから検索すると見つからなくなるので注意。ここでは for で長さ-3までで検索しているので大丈夫)
ヘッダの解析
ヘッダの最初の行がリクエストラインと呼ばれていて、リクエストするコンテンツの名前(パス)になっている。
例えば、http://example.com/test.html なら、example.com:80 に connect して、/test.html を要求するということになる。
リクエストラインは例えばこんな感じ。(改行の\r\nはあえて見えるように書いてある)
GET /index.html HTTP/1.1\r\n
最初のGETがメソッド、一般的にはGETかPOST。HEADとかもあるが、とりあえず今回は気にしない。空白文字で区切って、次がコンテンツの指定、最後がHTTPのバージョン。今回はとりあえず1.1の話だけ。
今は簡単なサーバなので、ヘッダの中からHost(virtual host などで使用する、サーバのホスト名)とContent-Length(リクエストボディのバイト数)、Connection(このコネクションでの通信を継続するかどうかなど、今回はcloseを検出するだけ)を抽出する。
コードはこんな感じ。
// リクエストヘッダのHostで指定されているホスト(ここでは特に使わない)
private String requestHost = null;
// リクエストされたファイル名
private String requestFileName = null;
private void parseHeader(String header)
{
closeConnection = false;
// Request ヘッダの解析
String[] lines = header.split("\n");
if (lines.length < 1) {
statusCode = 400;
return;
}
// まずはリクエストライン
parseRequest(lines[0].trim());
if (0 != statusCode) {
return;
}
// ヘッダの残りから、Host, Content-Length を取得する
try {
for (int i = 1; i < lines.length; i++) {
// 他のヘッダは 名前: 値 で行ごとになっている
String l = lines[i].trim();
if (l.isEmpty()) continue;
String[] l_elms = l.split(":");
// 大抵のクライアントは Host: Content-Length: Connection: のように大文字・小文字が混ざっているので、とりあえず全部小文字にして比較
String header_check = l_elms[0].trim().toLowerCase();
if (header_check.startsWith("host")) {
requestHost = l_elms[1].trim();
}
else if (header_check.startsWith("content-length")) {
bodyLen = Integer.parseInt(l_elms[1].trim());
}
else if (header_check.startsWith("connection")) {
// 今回はcloseを検出するだけ
if (l_elms[1].toLowerCase().contains("close")) {
closeConnection = true;
}
}
}
}
catch (Exception e) {
statusCode = 400;
Log.write("parseHeader exception: " + e.toString());
}
}
// リクエストラインからコンテンツのパス部分を取り出す
private void parseRequest(String req_line)
{
// 空白区切りなので、" "で分割
String[] elms = req_line.split(" ");
if (3 != elms.length) {
// 3分割できなければエラーにしてしまう
statusCode = 400;
return;
}
// 2番目がパス
String req_file = elms[1].trim();
if (req_file.endsWith("/")) {
// "/" で終わっているパスならデフォルトのindex.htmlにする
requestFileName = req_file + "index.html";
}
else {
requestFileName = req_file;
}
}
リクエストボディの受信
今回はあくまでお勉強用なので、POSTの処理をちゃんと実装したりしないが(CGIのようなプログラムの実行機能が無いので、POSTの意味は無い)、一応ボディを受信するところは作る。
ヘッダを解析して、Content-Lengthが来ているはず(来てなければボディが無いので、受信しなくて良い)なので、それで指定されたバイト数分受信すれば良い。
ヘッダにつながって、先に受信されている分があるときは、bodyBufferに入っている。この分は先に受信しているということになる。
private void receiveBody()
{
try {
// 先に受信した分も受信済みとしてカウント
int recv_len = 0;
if (null != bodyBuffer) {
recv_len = bodyBuffer.size();
}
// 受信バッファ、ループで受信するので適当なサイズで良い
byte[] recv_buf = new byte[1024 * 4];
// Content-Length で指定されたバイト数がbodyLenに入っているので、そのバイト数受信するまで繰り返し
// 本当は全てをメモリに置くのではなく、ループ内でファイルに書き出すとかしないと、巨大ファイルのアップロードに対応できない
while (recv_len < bodyLen) {
int recv_size = receiveStream.read(recv_buf);
if (0 < recv_size) {
bodyBuffer.write(recv_buf, 0, recv_size);
recv_len += recv_size;
}
else if (recv_size < 0) {
// 途中までしか受信できなければエラー(多分Socketが切れているので、エラーは返せないが、一応400エラーにしている)
Log.write("receiveBody error: socket closed");
statusCode = 400;
break;
}
}
// 今回はリクエストボディを使わないので、とりあえず文字列とみなしてログに出すだけにしている
requestBody = new String(bodyBuffer.toByteArray(), StandardCharsets.UTF_8);
Log.write("Receive body: " + requestBody);
}
catch (Exception e) {
Log.write("receiveBody exception: " + e.toString());
statusCode = 400;
}
}
リクエストされたファイルの読み込み
指定されたファイルを読み込むが、公開しているディレクトリの中にあるファイルしか読み込まないように注意する。
特に /../ を入れてリクエストするパストラバーサル攻撃に注意。
private String webBaseDirectory;
private byte[] responseBody = null;
private void readResponseData()
{
try {
// ファイルは全て webBaseDirectory 以下にあるとする
String fname = webBaseDirectory + requestFileName;
File read_file = new File(fname);
// ファイルが存在しなければ404
if (!read_file.exists()) {
statusCode = 404;
return;
}
// 正規化してパストラバーサル攻撃に対応
String can_path = read_file.getCanonicalPath();
Log.write("canonical path: " + can_path);
// 正規化した結果、webBaseDirectory外だったら403
if (!can_path.startsWith(webBaseDirectory)) {
statusCode = 403;
return;
}
// ファイルサイズのチェック、ここは試験用サーバなのであまり大きいサイズは送らないようにしている
// ちゃんとやるなら、部分的に読み込んで送信を繰り返すとかしないとメモリが足りない
long fsize = read_file.length();
if (1024 * 1024 * 32 <= fsize) {
statusCode = 413; // Payload Too Large
return;
}
responseBody = new byte[(int)fsize];
FileInputStream fis = new FileInputStream(read_file);
int cur_read = 0;
while (cur_read < responseBody.length) {
int read_size = fis.read(responseBody, cur_read, responseBody.length - cur_read);
if (0 < read_size) {
cur_read += read_size;
}
else if (read_size < 0) {
// 読み込めなかったら403
statusCode = 403;
break;
}
}
fis.close();
}
catch (Exception e) {
statusCode = 404;
Log.write("readResponseData exception: " + e.toString());
}
}
レスポンスヘッダの作成とレスポンスの送信
あとはクライアントにリクエストされたコンテンツを送信すれば良い。
クライアントに送るレスポンスも、ヘッダとボディに分かれていて、ヘッダの終わりは空行になる。
private void sendResponseData()
{
try {
// ここに来たらファイルは全部読み込めているので、OKにする
StringBuilder header = new StringBuilder("HTTP/1.1 200 OK\r\n");
header.append("Server: httptestsv\r\n");
// あくまで簡易なサーバなので、ファイルはtext/html固定にしてしまう
header.append("Content-Type: text/html\r\n");
// データの長さは読み込んだバイト列の長さ(=ファイルサイズ)
header.append("Content-Length: ");
header.append(Integer.toString(responseBody.length));
// ヘッダの終わりは空行なので、\r\n\r\n
header.append("\r\n\r\n");
// 作ったヘッダを送る
sendStream.write(header.toString().getBytes(StandardCharsets.US_ASCII));
// 続けてデータを送る
sendStream.write(responseBody);
}
catch (Exception e) {
// ここまで来て例外はサーバ側の問題か、Socketが切れたか
// とりあえずサーバのエラーにしておく
statusCode = 500;
Log.write("sendResponseData exception: " + e.toString());
}
}
エラーの処理
エラーが起きた時はstatusCodeに番号を入れるようにしている。このエラーを送信するところはこんな感じ。
// ステータスコードに対応したメッセージを取得、本当はもっと色々あるけど、使うものだけ
private String getStatusMessage()
{
switch (statusCode) {
case 200:
return "OK";
case 400:
return "Bad Request";
case 403:
return "Forbidden";
case 404:
return "Not Found";
case 413:
return "Payload Too Large";
case 431:
return "Request Header Fields Too Large";
default:
return "Server Error";
}
}
private void sendError()
{
try {
String err_mes = getStatusMessage();
String stat_str = String.format("HTTP/1.1 %d %s\r\n", statusCode, err_mes);
StringBuilder send_data = new StringBuilder(stat_str);
send_data.append("Connection: close\r\n");
send_data.append("Content-Type: text/plain\r\n");
// エンコード後のバイト数を調べるため、getBytesしてから長さを取得、全角文字が入っていると文字数!=バイト数なので
byte[] body_data = err_mes.getBytes(StandardCharsets.UTF_8);
send_data.append("Content-Length: " + body_data.length);
send_data.append("\r\n\r\n");
send_data.append(err_mes);
sendStream.write(send_data.toString().getBytes(StandardCharsets.UTF_8));
}
catch (Exception e) {
Log.write("sendError exception: " + e.toString());
}
}