saba1024のブログ

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

標準ライブラリだけでApache GroovyでWebサーバを実装(3) - 動的な値を返す

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

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

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

初めに

今回、HTML以外のリソース(jpgとかCSSとか)も返せるようにし、さらにまるで古き良きApache/PHPスクリプトのように、お手軽にGroovyスクリプトから動的なHTMLを返せるようにしていきます。

さすがにこれぐらいの量になってくるとファイルを分けたほうがいいですが、1ファイルぽっきり&&No外部ライブラリ!というApache Groovyの素敵さを表現すべく1ファイルで引き続き書きました。

それでは今回もまずは全体のソースです。

#!/home/koji/.sdkman/candidates/groovy/current/bin/groovy
import java.text.SimpleDateFormat

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

    static main (args) {
        // CliBuilderを使ってコマンドライン引数を解析
        def cli = new CliBuilder(usage: "Server05 [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 Server05(socket, documentRoot)
            }
        }
        server.close()
    }

    def Server05(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
        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) {

        // パスから、OS上のファイルを読み込み(とうぜんセキュリティーとかもっとしっかり考慮してね!)
        File file = new File("${this.documentRoot.path}/${httpRequestLine.path}")
        HttpStatusEnum httpStatus = file.exists() ? HttpStatusEnum.OK : HttpStatusEnum.NotFound
        ContentTypeEnum content = ContentTypeEnum.findByExtension(httpRequestLine)

        def sb = new StringBuffer()
        sb << "HTTP/1.1 ${httpStatus}\r\n"
        sb << "Date: ${Server05.now()}\r\n"
        sb << "Server: apache-groovy-server\r\n"
        sb << "Connection: close\r\n"
        sb << "Content-type: ${content.contentType}\r\n"
        sb << "\r\n"

        output.write(sb.toString().bytes)
        output.write(content.doSomething(httpRequestLine, documentRoot))
        output.flush()
    }

    static String now() {
        // 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"))
        "${sdf.format(new Date().time)} GMT"
    }
}

/**
 * このHTTPサーバが扱えるファイルと拡張子の一覧。
 */
enum ContentTypeEnum  {
    HTML(["html", "htm"], "text/html"),
    ICO(["ico"], "image/x-icon"),
    CSS(["css"], "text/css"),
    JPG(["jpg", "jpeg"], "image/jpeg"),
    GROOVY(["groovy"], "text/html; charset='UTF-8'") {

        // .groovyファイルへのアクセスの場合、当然単純にファイルの中身を表示するだけじゃダメ!
        // なのでdoSomethingメソッドをoverwrite
        byte[] doSomething(HttpRequestLine httpRequestLine, File documentRoot) {
            File file = new File("${documentRoot.path}/${httpRequestLine.path}")
            if(!file.exists()) {
                return "${HttpStatusEnum.NotFound}".bytes
            }

            // Groovyスクリプトを読み込んでGroovyから実行!
            GroovyScriptEngine gse = new GroovyScriptEngine(documentRoot.path)
            Binding binding = new Binding()
            binding.setVariable("params", [path: httpRequestLine.path, now: new Date()])
            String result = gse.run(httpRequestLine.path, binding) as String
            result.bytes
        }
    }

    List<String>extensions
    String contentType

    ContentTypeEnum(List<String> extensions, String contentType) {
        this.extensions = extensions
        this.contentType = contentType
    }

    static findByExtension(String extension) {
        values().find {
            extension.toLowerCase() in it.extensions
        }
    }
    static findByExtension(HttpRequestLine httpRequestLine) {
       findByExtension(httpRequestLine.path.split("\\.").last()) ?: ContentTypeEnum.HTML
    }

    /**
     * default action for each requested resource(file)
     */
    byte[] doSomething(HttpRequestLine httpRequestLine, File documentRoot) {
        File file = new File("${documentRoot.path}/${httpRequestLine.path}")
        if(!file.exists()) {
            return "${HttpStatusEnum.NotFound}".bytes
        }
        file.bytes
    }
}

/**
 * このHTTPサーバが返すStatusCode。当然本来はもっとある。
 */
enum HttpStatusEnum {
    OK(200, "OK"),
    Forbidden(403, "Forbidden"),
    NotFound(404, "Not Found")

    Integer code
    String message

    HttpStatusEnum (Integer code, String message){
        this.code = code
        this.message = message
    }

    String toString() {
        "${this.code} ${this.message}"
    }
}

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
    }
}

ちょっと長いですね。
今回もSerever05.groovyというファイル名で保存しておいて、groovyコマンドで実行すればWebサーバが起動します。
前回実装したように、起動時に-dでドキュメントルートをしていた場合はそのパス、指定しなければこのGroovyスクリプトが有る場所にHTMLファイル等を置いておけば、http://localhost:8081/index.htmlhttp://localhost:8081/hoge.jpgという感じにアクセスできるようになっています。

それぞれの部分の解説

メインの処理

前回から特に変わっていないので省略

HTTPリクエストの処理

前回から特に変わっていないので省略

HTTPレスポンスの処理

    // https://docs.oracle.com/javase/jp/8/docs/api/java/io/OutputStream.html
    def writeHttpResponse(OutputStream output) {

        // パスから、OS上のファイルを読み込み(とうぜんセキュリティーとかもっとしっかり考慮してね!)
        File file = new File("${this.documentRoot.path}/${httpRequestLine.path}")
        HttpStatusEnum httpStatus = file.exists() ? HttpStatusEnum.OK : HttpStatusEnum.NotFound
        ContentTypeEnum content = ContentTypeEnum.findByExtension(httpRequestLine)

        def sb = new StringBuffer()
        sb << "HTTP/1.1 ${httpStatus}\r\n"
        sb << "Date: ${Server05.now()}\r\n"
        sb << "Server: apache-groovy-server\r\n"
        sb << "Connection: close\r\n"
        sb << "Content-type: ${content.contentType}\r\n"
        sb << "\r\n"

        output.write(sb.toString().bytes)
        output.write(content.doSomething(httpRequestLine, documentRoot))
        output.flush()
    }

    static String now() {
        // 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"))
        "${sdf.format(new Date().time)} GMT"
    }

実際にアクセスされたリソース(HTMLとかJPGとか)や、ファイルが存在しない場合など、状況に寄って当然HTTPステータスコードが変わってきます。
そこで、まず要求されたリソースが物理的にドキュメントルート以下に存在するのかをチェックしてあげるようにしました。

        File file = new File("${this.documentRoot.path}/${httpRequestLine.path}")
        HttpStatusEnum httpStatus = file.exists() ? HttpStatusEnum.OK : HttpStatusEnum.NotFound
...省略...
        sb << "HTTP/1.1 ${httpStatus}\r\n"

ここで利用しているHttpStatusEnumは自分で用意したものです。 特に何の変哲もないただのEnumです。

enum HttpStatusEnum {
    OK(200, "OK"),
    Forbidden(403, "Forbidden"),
    NotFound(404, "Not Found")

    Integer code
    String message

    HttpStatusEnum (Integer code, String message){
        this.code = code
        this.message = message
    }

    String toString() {
        "${this.code} ${this.message}"
    }
}

toString()を実装することで、HTTPレスポンスヘッダー内に変数を埋め込んであげれば正しい値が出力されるようにしています。

そして、実際にリソースを読み込んでクライアントに返す部分です。

ContentTypeEnum content = ContentTypeEnum.findByExtension(httpRequestLine)
...省略
sb << "Content-type: ${content.contentType}\r\n"
...省略
output.write(content.doSomething(httpRequestLine, documentRoot))

ココでもEnumを用意して、処理の切り分け、実行まで行っています。
今までは単純にHTMLファイルを表示するだけだったのでoutput(OutputStream)にはStringを渡しておけば良かったのですが、今回JPGなどのバイナリファイルも返せるようにしたいので、OutputStream.write(byte[])に、読み込んだリソースのバイト配列を渡してあげるようにしました。
これでテキスト(HTMLとか)であろうがバイナリ(JPGとか)であろうが統一的にクライアントにリソースを返すことが出来ます。

拡張子によって処理を切り替える

/**
 * このHTTPサーバが扱えるファイルと拡張子の一覧。
 */
enum ContentTypeEnum  {
    HTML(["html", "htm"], "text/html"),
    ICO(["ico"], "image/x-icon"),
    CSS(["css"], "text/css"),
    JPG(["jpg", "jpeg"], "image/jpeg"),
    GROOVY(["groovy"], "text/html; charset='UTF-8'") {

        // .groovyファイルへのアクセスの場合、当然単純にファイルの中身を表示するだけじゃダメ!
        // なのでdoSomethingメソッドをoverwrite
        byte[] doSomething(HttpRequestLine httpRequestLine, File documentRoot) {
            File file = new File("${documentRoot.path}/${httpRequestLine.path}")
            if(!file.exists()) {
                return "${HttpStatusEnum.NotFound}".bytes
            }

            // Exexutable Groovy script like a old school PHP/Apache under the DocumentRoot
            GroovyScriptEngine gse = new GroovyScriptEngine(documentRoot.path)
            Binding binding = new Binding()
            binding.setVariable("params", [path: httpRequestLine.path, now: new Date()])
            String result = gse.run(httpRequestLine.path, binding) as String
            result.bytes
        }
    }

    List<String>extensions
    String contentType

    ContentTypeEnum(List<String> extensions, String contentType) {
        this.extensions = extensions
        this.contentType = contentType
    }

    static findByExtension(String extension) {
        values().find {
            extension.toLowerCase() in it.extensions
        }
    }
    static findByExtension(HttpRequestLine httpRequestLine) {
       findByExtension(httpRequestLine.path.split("\\.").last()) ?: ContentTypeEnum.HTML
    }

    /**
     * default action for each requested resource(file)
     */
    byte[] doSomething(HttpRequestLine httpRequestLine, File documentRoot) {
        File file = new File("${documentRoot.path}/${httpRequestLine.path}")
        if(!file.exists()) {
            return "${HttpStatusEnum.NotFound}".bytes
        }
        file.bytes
    }
}

このEnumでは、拡張子とそのContentTypeの組み合わせを保持しています。
findByExtension()で、拡張子から該当するEnumの値を取得できます。
そしてbyte[]を返すdoSomething()で、基本的には単純にユーザがアクセスしたいリソースのFileインスタンスのByte配列をreturnするだけです。
ただし!当然Groovyスクリプト(.groovy)にアクセスしてきた場合はその実行結果を返したいですよね。
ということで、このEnumが用意しているデフォルトの動作doSomethingを、拡張子groovyにアクセスが来た場合に変更するようにします。

GroovyからGroovyスクリプトを実行する

    GROOVY(["groovy"], "text/html; charset='UTF-8'") {

        // .groovyファイルへのアクセスの場合、当然単純にファイルの中身を表示するだけじゃダメ!
        // なのでdoSomethingメソッドをoverwrite
        byte[] doSomething(HttpRequestLine httpRequestLine, File documentRoot) {
            File file = new File("${documentRoot.path}/${httpRequestLine.path}")
            if(!file.exists()) {
                return "${HttpStatusEnum.NotFound}".bytes
            }

            // Groovyスクリプトを読み込んでGroovyから実行!
            GroovyScriptEngine gse = new GroovyScriptEngine(documentRoot.path)
            Binding binding = new Binding()
            binding.setVariable("params", [path: httpRequestLine.path, now: new Date()])
            String result = gse.run(httpRequestLine.path, binding) as String
            result.bytes
        }
    }

最初の方のファイルのチェックは同じです。
それ以降のGroovyScriptEngineの生成からがGroovyスクリプト用の独自の処理です。
Groovyは、GroovyからGroovyスクリプトを実行する方法を幾つか持っています。
その中から今回は、ファイルをGroovyスクリプトとして実行して、その結果を取得することが出来るこのGroovyScriptEngineを使ってみました。
GroovyScriptEngine#run()の第1引数に実際に実行したいGroovyスクリプト、省略可能な第2引数にはBindingインスタンスを渡してあげることで、実行されるGroovyスクリプト内で参照可能な変数を渡してあげることが出来ます。
今回はpathnowという名前で変数を渡してあげています。

実際に実行されるGrovyスクリプトCSS

上記GroovyScriptEngine#run()で実際に読み込まれるGroovyスクリプトを以下のように記述できます。
単純に実行結果をStringとして返せば良いので、Groovyの便利なGStringを埋め込み変数を使ってみました。

println "これはコンソールに出力されるよ"
"""
<html>
<head>
    <link rel="stylesheet" type="text/css" href="/test.css">
    <title>index.groovy</title>
</head>
<bod>
<h1>Groovyのバージョンは。。。${GroovySystem.version}</h1>
アクセスした日付は:${params.now}<br>
パスは:${params.path}<br>
${(1..10).findAll{it % 2 == 0}.collect {it * 2}}<br>
<img src="/test.jpg">
</body>
</html>
"""

また、CSSもクライアントに返せるようにしたので、実際に使ってみました。

img {
    width: 100px;
    height: 100px;
}

これで、groovy Server05.groovyを起動して、Server05.groovyがある場所と同じ場所にに上記index.groovytest.cssを用意しておけば、http://localhost:8081/index.groovyにてその実行結果を確認できます。

まとめ

いかがだったでしょうか。
3回にわたってApache GroovyでWebサーバ自体を自分で実装してきました。
当然全くセキュリティ、パフォーマンス、正しいHTTP仕様を考慮していませんが、ざっくりこういった形でWebサーバが動いているのだな、ということが理解できたのではと思います。