saba1024のブログ

どうでも良い思いついた事とかプログラミング関係のメモとか書いていきます。

標準ライブラリだけでApache GroovyでWebサーバを実装(1) - 基礎

これはG* Advent Calendar 2017の2日目の記事です。

この記事は、以下の3つの投稿で成り立っています。

標準ライブラリだけでGroovyでWebサーバを実装(1) - 基礎
標準ライブラリだけでGroovyでWebサーバを実装(2) - Groovyっぽく
標準ライブラリだけでGroovyでWebサーバを実装(3) - 動的な値を返す

初めに

Go言語やRust等、システムよりのプログラミングに利用しやすい言語の勢いが出てきている昨今、Webサーバ自体を実装するというサンプルをよく目にするようになりました。
今回、Apache Groovyで自分でもWebサーバを実装してみました。

あくまでWebサーバの動作をざっくり理解するためのものなのでセキュリティーなどは一切考慮していません。

先ずは全体ソースをご覧ください。 なお、今回は最も基本的な部分を理解するための初回なので、ほぼほぼJavaのコードになります。

import java.text.SimpleDateFormat
class Server02 implements Runnable {

    Socket socket

    static main (args) {
        ServerSocket server = new ServerSocket(8081)

        for (;;) {
            // クライアントからの接続を待つ
            Socket socket = server.accept()

            // クライアントから接続されたら、別スレッドでその要求を処理する。
            Server02 server02 = new Server02(socket)
            Thread thread = new Thread(server02)
            thread.start()
        }
        server.close()
    }

    Server02(Socket socket) {
        this.socket = socket
    }

    void run () {
        try {
            InputStream input = socket.getInputStream()
            readHttpRequest(input)

            OutputStream output = socket.getOutputStream()
            writeHttpResponse(output)

            socket.close()
        } catch (Exception e) {
            e.printStackTrace()
        }
    }

    def createQueue (Integer size) {
        List q = []
        return { value ->
            if (q.size() >= size) {
                q = q.tail() + value
            } else {
                q << value
            }
            q
        }
    }

    // 全てはintでやりとりされる
    // https://docs.oracle.com/javase/jp/8/docs/api/java/io/InputStream.html
    def readHttpRequest(InputStream input) {
        Integer ch
        Integer contentLength = 0
        List<Integer> requestHeader = []

        // KeepAlive(HTTP1.1)の場合、そもそもストリームの終わりがないので、2回連続CRLFが現れたらヘッダーの終わりと判断する。
        def queue = createQueue(4)
        for(;;) {
            // InputStream#read() returns integer. but this value is between 0 and 255. also it is 1 Byte.
            ch = input.read()
            if (ch == -1 || queue(ch) == [13, 10, 13, 10]) {
                break
            } else {
                requestHeader.add(ch)
            }
        }

        // Content-typeが指定されているのであれば、その分Bodyが有るはずなので読み込み
        List headerLines = new String(requestHeader as byte[]).split("\r\n")
        String contentLengthString = headerLines.find {it.startsWith("Content-Length")}
        if (contentLengthString) {
            contentLength = contentLengthString.split(":")[1].trim() as Integer
        }

        // InputStream#read()は1バイトずつ読み込んでくれるので、Content-Length回readを実行すればOK
        // and already ended header. so you can read from continued.
        List<Integer> requestBody = []
        if (contentLength > 0) {
            (0 ..< contentLength).each {
                requestBody.add(input.read())
            }
        }

        println new String(requestHeader as byte[])
        println "-" * 30
        println new String(requestBody as byte[])
    }

    // https://docs.oracle.com/javase/jp/8/docs/api/java/io/OutputStream.html
    def writeHttpResponse(OutputStream output) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss")
        sdf.setTimeZone(TimeZone.getTimeZone("GMT"))
        String now = "${sdf.format(new Date())} GMT"

        def sb = new StringBuffer()
        sb << "HTTP/1.1 200 OK\r\n"
        sb << "Date: ${now}\r\n"
        sb << "Server: apache-groovy-server\r\n"
        sb << "Connection: close\r\n"
        sb << "Content-type: text/html\r\n"
        sb << "\r\n"
        sb << "<html><head><title></title><body>hello world<br>${now}</body></html>"

        sb.toString().each {
            output.write((int)it)
        }
        output.flush()
    }
}

これをServer02.groovyというようなファイル名で保存してgroovyコマンドで実行すればもうオリジナルのWebサーバの完成です!

それぞれの部分解説

メインの処理

class Server02 implements Runnable {

    Socket socket

    static main (args) {
        ServerSocket server = new ServerSocket(8081)

        for (;;) {
            // クライアントからの接続を待つ
            Socket socket = server.accept()

            // クライアントから接続されたら、別スレッドでその要求を処理する。
            Server02 server02 = new Server02(socket)
            Thread thread = new Thread(server02)
            thread.start()
        }
        server.close()
    }

    Server02(Socket socket) {
        this.socket = socket
    }

    void run () {
        try {
            InputStream input = socket.getInputStream()
            readHttpRequest(input)

            OutputStream output = socket.getOutputStream()
            writeHttpResponse(output)

            socket.close()
        } catch (Exception e) {
            e.printStackTrace()
        }
    }
    ...省略...
}

上記のコードが、実際にGroovyでサーバを8081番ポートで起動して、ユーザからのリクエストを待つ処理になります。
クライアントからのリクエストの度に、それぞれ別スレッドでServer02#run()が実行されます。
ServerSocketインスタンスで、サーバを起動してはいますが、この時点では別にWebサーバでもないし、FTPサーバでもDBサーバでもありません。
単純に指定したポートでクライアントからの通信を待ち受けるプログラムになります。
このプログラムが、クライアントから送られてくるバイト列を解析して、処理して、クライアントにその結果を返す、という一連の流れをHTTPというルールに則って行うことで初めてWebサーバとなります。

では、続いてこのプログラムをHTTPサーバにするための2大項目をそれぞれ見ていきます。

HTTPリクエストの処理

ユーザから送られてくるバイト列を、HTTPのルールに則って解析します。

    def createQueue (Integer size) {
        List q = []
        return { value ->
            if (q.size() >= size) {
                q = q.tail() + value
            } else {
                q << value
            }
            q
        }
    }

    def readHttpRequest(InputStream input) {
        Integer ch
        Integer contentLength = 0
        List<Integer> requestHeader = []

        // KeepAlive(HTTP1.1)の場合、そもそもストリームの終わりがないので、2回連続CRLFが現れたらヘッダーの終わりと判断する。
        def queue = createQueue(4)
        for(;;) {
            // InputStream#read() returns integer. but this value is between 0 and 255. also it is 1 Byte.
            ch = input.read()
            if (ch == -1 || queue(ch) == [13, 10, 13, 10]) {
                break
            } else {
                requestHeader.add(ch)
            }
        }

        // Content-typeが指定されているのであれば、その分Bodyが有るはずなので読み込み
        List headerLines = new String(requestHeader as byte[]).split("\r\n")
        String contentLengthString = headerLines.find {it.startsWith("Content-Length")}
        if (contentLengthString) {
            contentLength = contentLengthString.split(":")[1].trim() as Integer
        }

        // InputStream#read()は1バイトずつ読み込んでくれるので、Content-Length回readを実行すればOK
        // and already ended header. so you can read from continued.
        List<Integer> requestBody = []
        if (contentLength > 0) {
            (0 ..< contentLength).each {
                requestBody.add(input.read())
            }
        }

        println new String(requestHeader as byte[])
        println "-" * 30
        println new String(requestBody as byte[])
    }

HTTPリクエスト自体はシンプルなもので、以下のような物がクライアントから送られてきます。
curl http://localhost:8081 --data "{a:b}" --data "message:どうですか"

POST / HTTP/1.1
User-Agent: curl/7.35.0
Host: localhost:8081
Accept: */*
Content-Length: 29
Content-Type: application/x-www-form-urlencoded

------------------------------
{a:b}&message:どうですか

これは実際にこのプログラムを起動して、curlコマンドでアクセスした時に表示される内容です。
今回は最も低レベルなInputStream#read()を使って、クライアントから送られてくるリクエスト(HTTPリクエスト)を1バイトずつ読み込んで処理しています。

HTTPリクエストヘッダー

注意する点としては、ブラウザなどのクライアントが、keepAliveをオンとしてアクセスしてくると、InputStream#read()は終わらない(-1を返さない)ので、無限ループになってしまう点です。

HTTPの仕様として各ヘッダー行はCRLFで終わり、HTTPリクエストヘッダーは空行(CRLFのみ)で終わる、と決められているので、InputStream#read()で読み込んだ直近の4Byte(本当はIntegerだけど0-255の値なので実質Byte)を自作のQueueに貯めこんでいます。
もし直近の4ByteがCRLFCRLFの場合、それはHTTPリクエストヘッダーの終わりとういことなので、ヘッダーの読み込みを終了するようにしています。

HTTPリクエストボディ

もしHTTPリクエストヘッダー内にContent−Lengthが指定されている場合、HTTPリクエストボディが存在します。
InputStreamインスタンスはread()でどこまで読み込まれたかを保持していますので、引き続きInputStream#read()Cotnent-Length分実行してあげることで、HTTPリクエストボディ部分が取得できます。

しかし1点注意しなければならないことが有ります。マルチバイト文字です。
ASCIIであれば、1Byteずつデータを取り出して、それを1文字として扱っても問題ありませんが、日本語を始めマルチバイト文字が送られてくる可能性のあるHTTリクエストボディはそれでは不味いです。
そのため、InputStream#read()で取得したByteデータはそのまま即1文字として処理せずに、Integerのリスト等に貯めこんでおいて、あとで一気にString型に変換して上げる必要が有ります。
GroovyでもJavaのプリミティブ型の配列(byte[])にasで簡単に変換することが出来ます。
(ちなみに私のこの環境はLinuxですので、文字コードUTF-8としてnew String()されています。)

HTTPレスポンスの処理

さて、クライアントからのリクエストを解析したら当然最後にはクライアントに何かデータを返して上げる必要が有ります。
Webサーバなので、当然HTMLですね!

    // https://docs.oracle.com/javase/jp/8/docs/api/java/io/OutputStream.html
    def writeHttpResponse(OutputStream output) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss")
        sdf.setTimeZone(TimeZone.getTimeZone("GMT"))
        String now = "${sdf.format(new Date())} GMT"

        def sb = new StringBuffer()
        sb << "HTTP/1.1 200 OK\r\n"
        sb << "Date: ${now}\r\n"
        sb << "Server: apache-groovy-server\r\n"
        sb << "Connection: close\r\n"
        sb << "Content-type: text/html\r\n"
        sb << "\r\n"
        sb << "<html><head><title></title><body>hello world<br>${now}</body></html>"

        sb.toString().each {
            output.write((int)it)
        }
        output.flush()
    }

やっていることは言ったってシンプルで、クライアントに返したい情報をHTTPレスポンスのルールに則ってStringで記述しておいて、それを1文字ずつIntegerに変換してOutputStream#write(int)に渡してあげているだけです。
なお、これはあくまで最低限ブラウザがHTTPレスポンスを一応HTMLとして解釈して表示してくれる最低限のデータを指定しているだけなので、実際のHTTP1.1のルール通りにはなっていません。

まとめ

いかがだったでしょうか。ただHTTPサーバを立てるだけなのに結構大変ですね。
さらに本来は正しいHTTPの仕様に準拠する必要も有りますしセキュリティーの問題も有ります。
ただ、やはりこのように自分でソケットサーバ/HTTPサーバを実装してみることで色々理解が進みますね。