saba1024のブログ

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

標準ライブラリだけでApache GroovyでWebサーバを実装(2) - Groovyっぽく

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

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

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

初めに

今回は、標準ライブラリだけでGroovyでWebサーバを実装(1) - 基礎 のコードをGroovyっぽくして、ドキュメントルートも指定できるようしていきます。
さらに、クライアントに返すHTMLも固定ではなく、指定できるようにしたドキュメントルート配下のHTMLファイルを返すようにします。
それではまずは全体ソースを。

import java.text.SimpleDateFormat

class Server03 {
    Socket socket
    HttpRequestLine httpRequestLine
    Map httpRequestHeader = [:]
    String httpRequestBody
    File documentRoot

    static main (args) {
        // CliBuilderを使ってコマンドライン引数を解析
        def cli = new CliBuilder(usage: "Server03 [options]")
        cli.h(longOpt: "help", "Show this help")
        cli.d(longOpt: "documentroot", args: 1, required: false, "Specify a DocumentRoot of this web server")
        def options = cli.parse(args)
        if (options.h) {
            cli.usage()
            return
        }

        ServerSocket server = new ServerSocket(8081)

        for (;;) {
            // GroovyのServerSocketは、acceptにtrueを渡してあげると自動的に別スレッドでクロージャの中身を実行してくれるように拡張されている。
            server.accept(true) { Socket socket ->
                File documentRoot = options.d ? new File(options.d) : new File(getClass().protectionDomain.codeSource.location.path).parentFile
                new Server03(socket, documentRoot)
            }
        }
        server.close()
    }

    def Server03(Socket socket, File documentRoot) {
        this.socket = socket
        this.documentRoot = documentRoot
        try {
            // GroovyはSocketから同時にInputStreamとOutputStreamを取得できる
            socket.withStreams {InputStream input, OutputStream output ->
                readHttpRequest(input)
                writeHttpResponse(output)
            }
            socket.close()
        } catch (Exception e) {
            e.printStackTrace()
        }
    }

    def readHttpRequest(InputStream input) {
        BufferedReader reader = input.newReader()

        // 行単位の読み込みに変えたので、簡単に各HTTPリクエストの内容を解析できる
        String line
        // HTTPリクエストの1行目は特殊なので別途処理
        httpRequestLine = new HttpRequestLine(reader.readLine())

        // リクエストヘッダーの解析
        while( !(line = reader.readLine()).isEmpty() ) {
            def(type, value) = line.split(": ")
            httpRequestHeader.put(type?.trim(), value?.trim())
        }

        // リクエストボディの解析
        if(httpRequestHeader.get('Content-Length', 0).toInteger() > 0) {
            char[] buf = new char[httpRequestHeader.get('Content-Length') as Integer]
            reader.read(buf)
            httpRequestBody = buf.toString()
        }
    }

    // https://docs.oracle.com/javase/jp/8/docs/api/java/io/OutputStream.html
    def writeHttpResponse(OutputStream output) {
        // Date:の仕様については以下のRFCを参照
        // https://tools.ietf.org/html/rfc2616#section-14.18
        // https://tools.ietf.org/html/rfc2616#section-3.3.1
        SimpleDateFormat sdf = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.ENGLISH)
        sdf.setTimeZone(TimeZone.getTimeZone("GMT"))
        String now = "${sdf.format(new Date().time)} 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 << new File("${this.documentRoot.path}/${httpRequestLine.path}").text

        // GroovyJDKで追加されてるメソッド。終わる前にoutputStreamはcloseされる。
        output.setBytes(sb.toString().bytes)
    }
}

class HttpRequestLine {
    String vlaue
    String method
    String path
    String protocol

    HttpRequestLine(String v) {
        def(method, path, protocol) = v.split(" ")
            this.method = method
            this.path = path == "/" ? "index.html" : path[1 .. -1]
            this.protocol = protocol
        }
}

だいぶ雰囲気が変わったのではないでしょうか?
このソースをServer03.groovyという名前などで保存して、groovyコマンドで実行すればWebサーバが起動します。
このGroovyスクリプトを配置した同じ場所に、HTMLファイルを置いて置いておけば、http://localhost:8081/index.htmlというように普通にアクセスできるようになっています。
今回はドキュメントルートを指定できるようにしたので、その指定方法や動作などは以下をご覧ください。

それぞれの部分の解説

メインの処理

class Server03 {
    Socket socket
    HttpRequestLine httpRequestLine
    Map httpRequestHeader = [:]
    String httpRequestBody
    File documentRoot

    static main (args) {
        // CliBuilderを使ってコマンドライン引数を解析
        def cli = new CliBuilder(usage: "Server03 [options]")
        cli.h(longOpt: "help", "Show this help")
        cli.d(longOpt: "documentroot", args: 1, required: false, "Specify a DocumentRoot of this web server")
        def options = cli.parse(args)
        if (options.h) {
            cli.usage()
            return
        }

        ServerSocket server = new ServerSocket(8081)

        for (;;) {
            // GroovyのServerSocketは、acceptにtrueを渡してあげると自動的に別スレッドでクロージャの中身を実行してくれるように拡張されている。
            server.accept(true) { Socket socket ->
                File documentRoot = options.d ? new File(options.d) : new File(getClass().protectionDomain.codeSource.location.path).parentFile
                new Server03(socket, documentRoot)
            }
        }
        server.close()
    }

    def Server03(Socket socket, File documentRoot) {
        this.socket = socket
        this.documentRoot = documentRoot
        try {
            // GroovyはSocketから同時にInputStreamとOutputStreamを取得できる
            socket.withStreams {InputStream input, OutputStream output ->
                readHttpRequest(input)
                writeHttpResponse(output)
            }
            socket.close()
        } catch (Exception e) {
            e.printStackTrace()
        }
    }
    ...省略...
}

かなり前回とは異なっています。
まず、classがRunnableインタフェースを実装していません。
これは、Apache GroovyがJava標準のServerSocketを拡張してくれていて、第1引数にtrueを渡すことで、第2引数を渡すクロージャを別のスレッドで自動的に実行してくれる為、態々自分でRunnableを実装する必要が無くなったためです。

さらに、コンストラクタ内でSocket#withStreams()を利用することで、渡すクロージャにInputStreamとOutputStreamを同時に渡してくれる親切設計になっています。

また、今回はドキュメントルートを指定できるようにしたので、この部分を拡張しています。
具体的にはまずCliBuilderを利用して、-d、もしくは--documentrootオプションにドキュメントルートを指定できるようにしました。
以下の部分です。

def cli = new CliBuilder(usage: "Server03 [options]")
cli.h(longOpt: "help", "Show this help")
cli.d(longOpt: "documentroot", args: 1, required: false, "Specify a DocumentRoot of this web server")

もしドキュメントルートが指定されなかった場合、このGroovyスクリプトが配置されているディレクトをドキュメントルートにするようにしました。

File documentRoot = options.d ? new File(options.d) : new File(getClass().protectionDomain.codeSource.location.path).parentFile

ちょっと長ったらしいですが、このようにして自分自身(Groovyを実行したディレクトリではなくて、Groovyスクリプトが居る場所)のパスを取得することが出来ます。
本当はmainメソッドの中で実行してしまっても良いようなものですが、そうするとcodeSourceがnullを返してくるので、クロージャの中で定義しました。(他に良い方法が有るはず)

もしgroovy Server03.groovy -d /tmpと実行すると、/tmpがドキュメントルートになるので、例えば/tmp/hoge.htmlを作成しておけば、http://localhost:8081/hoge.htmlでアクセスできるようになります。

HTTPリクエストの処理

class Server03 {
    ...省略...
    def readHttpRequest(InputStream input) {
        BufferedReader reader = input.newReader()

        // 行単位の読み込みに変えたので、簡単に各HTTPリクエストの内容を解析できる
        String line
        // HTTPリクエストの1行目は特殊なので別途処理
        httpRequestLine = new HttpRequestLine(reader.readLine())

        // リクエストヘッダーの解析
        while( !(line = reader.readLine()).isEmpty() ) {
            def(type, value) = line.split(": ")
            httpRequestHeader.put(type?.trim(), value?.trim())
        }

        // リクエストボディの解析
        if(httpRequestHeader.get('Content-Length', 0).toInteger() > 0) {
            char[] buf = new char[httpRequestHeader.get('Content-Length') as Integer]
            reader.read(buf)
            httpRequestBody = buf.toString()
        }
    }
    ...省略...
}

class HttpRequestLine {
    String vlaue
    String method
    String path
    String protocol

    HttpRequestLine(String v) {
        def(method, path, protocol) = v.split(" ")
            this.method = method
            this.path = path == "/" ? "index.html" : path[1 .. -1]
            this.protocol = protocol
        }
}

前回のものと比べると大分コード量が少なくなりました。
GroovyがInputStream#newSteram()を用意してくれていて、これがBufferedReaderインスタンスを返してくれるのでこれを利用しない手はありません。
さらに、BufferedReader#readLine()でHTTPリクエストヘッダーを簡単に1行ずつ読み込めるようになったので、もし読み込んだ行が空っぽ(改行一つだけ)の場合、それをHTTPリクエストヘッダーの終わりと判断できるので自作のQueueも必要無くなりました。
HTTPリクエストボディに関しては、Content-Lengthで指定されたバイト数分読み込みしてあげたいので、そのサイズ分のchar[]配列を生成して、BufferedReader.read(buf[])に渡して一気に読み込んでいます。

さて、基本的にHTTPリクエストヘッダーは、各行がCRLFで区切られて、それぞれの行は項目名:値のような形式になっています。
しかし、1行目だけは特別で、そのルールに該当しません。(POST /index.html HTTP/1.1のような形式になっています。)
また、その最初の行の値を利用してアクセスしたいリソースを特定する必要も有りますので、この部分は専用のHttpRequestLineクラスを用意してそこで管理するようにしました。

HTTPレスポンスの処理

    // https://docs.oracle.com/javase/jp/8/docs/api/java/io/OutputStream.html
    def writeHttpResponse(OutputStream output) {
        // Date:の仕様については以下のRFCを参照
        // https://tools.ietf.org/html/rfc2616#section-14.18
        // https://tools.ietf.org/html/rfc2616#section-3.3.1
        SimpleDateFormat sdf = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.ENGLISH)
        sdf.setTimeZone(TimeZone.getTimeZone("GMT"))
        String now = "${sdf.format(new Date().time)} 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 << new File("${this.documentRoot.path}/${httpRequestLine.path}").text

        // GroovyJDKで追加されてるメソッド。終わる前にoutputStreamはcloseされる。
        output.setBytes(sb.toString().bytes)
    }

こちらに関しては大きくは変わっていませんが、

sb << new File("${this.documentRoot.path}/${httpRequestLine.path}").text

この部分で、ユーザがURLに指定したリソース(現状HTML限定)のテキストを返すようにしています。
これよって、例えばgroovy Server03.groovyと実行しているのであれば、Server03.groovyと同じディレクトリにあるHTMLファイルにhttp://localhost:8081/index.htmlとしてアクセスできるようになりました。

また、OutputStream#setBytes()というGroovyのメソッドを利用すれば、自分でclose()を書く必要はありません。

まとめ

いかがだったでしょうか。GroovyがJDKを拡張してくれているおかげで、大分スッキリコードが書けるようになりました。
while文を使っていたり、まだまだJavaの匂いは残っていますが、同じファイル内に複数のclassを定義できるなど、やはりGroovyでコードを書くことで大分楽が出来るな〜と改めて思いました。