Edited at

Kotlin でオレオレフレームワークを作ってみる ~02 - ごく簡単なウェブサーバーを実装する~


はじめに

今回は Ougi に組み込む雑なウェブサーバーを実装を予行練習します(・∀・)。

※注意!

この一連の記事で紹介するコードは動作の概念を説明するものでありセキュリティーなどは意識していません(・∀・)。

実際に運用するシステムなどに使用しないでください(・∀・)。

(そのまま使うひともいないと思いますが)


一覧


今回作るウェブサーバー


ウェブサーバーの仕様

今回作るウェブサーバーは本当に雑なもので


  • ローカルで動かしポート80番で HTTP 通信を受け付ける

  • HTTP GET が来た場合、ディレクトリの中にあるファイルを返す

  • 他は無視する

という簡単な仕様にします(・∀・)。

エラー処理などもゴッソリと省略します(・∀・)。


実際に書いてみる

まずは、基本的な部分として


  • ウェブサーバークラス

  • エントリーポイント

を実装します(・∀・)。

ウェブサーバーにはとりあえず動作するポートを渡すようにすればいいでしょう(・∀・)。

TCP/80番に固定しちゃっても良いのかも知れませんが(・∀・)。


WebServer.kt


class WebServer(private val port: Int = 80) { ... }

fun main(argv: Array<String>) {
val server = WebServer(80)
server.start()
}


特に難しいことはないと思います(・∀・)。

今回は使用しませんが、WebServer クラスには start() と stop() メソッドを実装します(・∀・)。


サーバースレッドを実装する

WebServer サーバークラスはサーバーとして動作します(・∀・)。

サーバー処理は start() で開始させます(・∀・)。

start() を呼んだら処理が返ってこないでは困りますので、サーバー処理自体は別スレッドで動作させるようにします(・∀・)。

Kotlin は thread {} で簡単に別スレッドを生成して処理を記述できます(・∀・)。

Kotlin (Java) には、ServerSocket クラスという、サーバーを記述するためのクラスが用意されています(・∀・)。

TCPポートを開いて接続の待受を行い、クライアントが接続してくればその Socket を返してくれます(・∀・)。

今回はこれを使用します(・∀・)。

処理の流れとしては


  • スレッドを開始する start() メソッドを実装


    • 別スレッドを作成して実行する

    • スレッド内で ServerSocket を使用し、接続を受け付ける

    • 接続を受け付けたら HTTP を解釈する



  • (もし)stop() メソッドが呼ばれたらサーバーを終了する

となります(・∀・)。


WebServer.kt

class WebServer(private val port: Int = 80) {

private var alive = true

// スレッドを生成して実行するメソッド
fun start() {
thread {
val server = ServerSocket(port)
server.soTimeout = 5 * 1000 // 念の為タイムアウトを5秒に設定

// stop が呼ばれ alive が false になるまで無限ループ
alive = true
while (alive) {

// ココでクライアントの接続を待つ

... // サーバー処理
// HTTP リクエストを解釈
// リクエストを処理してレスポンスを返す
}
}
}

// 実行しているスレッドを終了するメソッド
// 正確には、スレッド内の無限ループ処理を抜けるようにする
fun stop() {
alive = false
}
}


これでサーバーとして動作させるための基本的な動作は実装できました(・∀・)。

それにしても Kotlin(Java) は簡単便利に高度(?)なプログラミングが出来ますね(・∀・)。

もちろん、もし仮にこのまま実運用したら問題ありますが(・∀・)w


接続を受ける

次に、クライアントからの接続を受け付けます(・∀・)。


WebServer.kt

while (alive) {

// ココでクライアントの接続を待つ
val socket: Socket
try {
socket = server.accept()
} catch (e: SocketTimeoutException) {
continue
}
// ... 処理
}

ServerSocket::accept() メソッドはタイムアウトが設定されている場合は SocketTimeoutException を throw します(・∀・)。

なので SocketTimeoutException が飛んできたらまた接続待機を行うようにします(・∀・)。

こうしないと、接続待機中に WebServer::stop() を呼んでも ServerSocket::accept() で待ったままになってしまいます(・∀・)。


メモ

C言語 などでウェブサーバーを実装する場合、例えばソケットを使用して上記と同じ処理を行えば良です(・∀・)。

例えば bind 関数と accept 関数になります(・∀・)。

ソケットプログラミングが出来る言語なら C言語 意外もだいたい同様です(・∀・)。



接続を処理する

接続を受け付けたら送信された内容を受け取ります(・∀・)。

送信内容や返信内容は Socket が持っている InputStream / OutputStream 経由で行います(・∀・)。

要するに


  • クライアントが送信してきたデータは InputStream から読み取る

  • クライアントに返すデータは OutputStream に書き込む

とすれば良いだけです(・∀・)。

簡単ですね(・∀・)。

クライアントから要求されたデータは HTTP リクエストなハズです(・∀・)。

なので InputStream からテキストデータとして読み込んであげれば良いことになります(・∀・)。


WebServer.kt

val input = socket.getInputStream()

val output = socket.getOutputStream()

// ブラウザから http://localhost/index.html の場合
val line = read(input) // read は InputStream から文字列を1行だけ読み込むメソッド

// この時点で line には "GET /index.html HTTP/1.1" が入っている
val head = line.split(" ") // 文字列を ' ' で切り分けて配列にする
val method = head[0].toUpperCase() // GET
val uri = head[1].toLowerCase() // /index.html
val version = head[2].toLowerCase() // HTTP/1.1

// ...


のようになります(・∀・)。

read() メソッドについては


WebServer.kt

// InputStream から文字列を1行だけ読み込むメソッド

private fun read(input: InputStream): String {
// このリストに1文字ずつ入れていく
val line = mutableListOf<Byte>()
while (true) {
// ストリームから1Byte読み込む
val data = input.read()
if (data == -1) {
return "" // エラーが起きたらシラネ
}

// 1文字ずつリストに入れていく
line.add(data.toByte())

// 末尾の2バイトが "\r\n" になった場合ループを抜ける
val size = line.size
if (2 <= size) {
if (line[size - 2].toChar() == '\r' && line[size - 1].toChar() == '\n') {
break
}
}
}
// 1行分の Byte リストを Byte 配列を経由して String に変換して返す
return String(line.toByteArray())
}


のようなメソッドを用意します(・∀・)。

HTTP以外で受け付けたりしたら簡単に例外になったりフリーズしたりしますね(・∀・)w

例えば


val head = line.split(" ")


の戻り値をチェックしないで配列の添字アクセスしてるので、変な文字列をサーバーに送ってきたら簡単に IndexOutOfBoundsException とかになっちゃいます(・∀・)。

こんな危険極まりないもの、実運用しちゃダメですね(・∀・)w


HTTPリクエストを解釈してHTTPレスポンスを返す

今回の雑なウェブサーバーの仕様は


  • HTTP GET が来た場合、ディレクトリの中にあるファイルを返す

  • 他は無視する

なので、要するに HTTP メソッドが GET のときだけ処理すれば良いことになります(・∀・)。

ファイルを返すので、要するに


  • OutputStream に HTTPレスポンス の情報を書き込む

  • ファイルの内容を読み込んで OutputStream に書き込む

とすれば良いことになります(・∀・)。

コードは以下(・∀・)。


WebServer.kt


val method = head[0].toUpperCase() // GET
val uri = head[1].toLowerCase() // /index.html
val version = head[2].toLowerCase() // HTTP/1.1

// GET の時だけ動作
if (method == "GET") {

// "/index.html" の部分から "index.html" の部分だけ取り出す
// とりあえず先頭の '/' に相当する1文字をカットした File を作る
val file = File(uri.substring(1))
if (file.exists() && file.isFile) {
// 目的のファイルを読み込む
// ファイルサイズが大きいとおかしくなるかもなので、気になったら各自工夫する
val fis = FileInputStream(file)
val size = fis.available()
val buffer = ByteArray(size)
fis.read(buffer)

// まずは HTTPレスポンス のヘッダを書き込む
// これは固定値で良い
output.write("HTTP/1.1 200 OK\r\n".toByteArray())

// HTTPレスポンスのフィールドを書き込む
// ホントはもっといろいろと書くけど、とりあえずこれくらい書いておけば動く
val date = Date()
output.write("Date: $date\r\n".toByteArray())
output.write("Content-Length: $size\r\n".toByteArray())
output.write("\r\n".toByteArray()) // ヘッダフィールドが終わったことを示す空行

// 読み込んだファイル内容をボディに書き込む
output.write(buffer)
}
}


ヘッダフィールドに書き込んだ Date と Content-Length は、それぞれ


  • "Date: $date" // 日付情報

  • "Content-Length: $size" // 返却ファイルサイズ

です(・∀・)。

Date については、とりあえず適当に返してるだけ(・∀・)w

Content-Length は、無いとブラウザによっては上手く動かないので書き込んでおきます(・∀・)。

他に重要なものとしては


  • Content-Type

があります(・∀・)。

例えば


  • text/plane

  • text/html

  • image/jpg

などのように MIME-Type を指定します(・∀・)。

ブラウザはこの情報を頼りに、レスポンスの内容がHTMLなのか?TXTなのか?画像なのか?などを判断します(・∀・)。

最近のブラウザは頭がいいのでファイルの内容から勝手に判断することもありますが


  • HTMLファイルを返したのにテキストとして表示される

  • 画像を返してるのに化けた文字になっちゃって画像が表示されない

などがアレば Content-Type を指定するようにしてみると良いでしょう(・∀・)。

また、Content-Type に application/force-download を指定して実行してみるとどうなるかも実験してみると良いでしょう(・∀・)。


ほんの少しだけエラー処理

一応は上記で動作はしますが


  • 存在しないファイル(URI)を指定した

  • GET 以外を送ったりした

くらいで止まったり例外を吐いたりしてサーバーが死んでしまってはさすがに面白くありません(・∀・)。

なので


WebServer.kt


if (file.exists() && file.isFile) {
// ...
} else {
output.write("HTTP/1.1 404 Not Found\r\n".toByteArray())

output.write("Date: $date\r\n".toByteArray())
output.write("\r\n".toByteArray())

output.write("Not Found".toByteArray())
}


などのようにするといいでしょう(・∀・)。

最後のボディにエラーページのHTMLを返せばカッコいいエラーページも表示できます(・∀・)。

同様に、server.accept() 以降のサーバー処理全体を try ~ catch (e: Exception) で囲っておき、その中で


WebServer.kt


output.write("HTTP/1.1 500 Internal Server Error\r\n".toByteArray())

output.write("Date: $date\r\n".toByteArray())
output.write("\r\n".toByteArray())

output.write("Internal Server Error\r\n".toByteArray())


とでもしておけば、とりあえず簡単にクラッシュすることはなくなります(・∀・)。


最後

以上で、ウェブサーバーとして


  • ブラウザの GET 要求に応じて対象になるファイルを返す

という、ホントに最低限の実装は完了です(・∀・)。

脱・初心者クラスのプログラマならそこまで難しくはないと思います(・∀・)。

ウェブサーバーをフルスクラッチしたことのあるひとがどれくらい居るのか分かりませんが、思ったより簡単だったでしょうか(・∀・)?

それとも、難しくて面倒くさかったでしょうか(・∀・)?


次回

次回はいよいよ Ougi のウェブフレームワークとしての仕様を検討していこうと思います(・∀・)。