標準ライブラリだけで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.htmlやhttp://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スクリプト内で参照可能な変数を渡してあげることが出来ます。
今回はpath
とnow
という名前で変数を渡してあげています。
実際に実行される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.groovy
とtest.css
を用意しておけば、http://localhost:8081/index.groovyにてその実行結果を確認できます。
まとめ
いかがだったでしょうか。
3回にわたってApache GroovyでWebサーバ自体を自分で実装してきました。
当然全くセキュリティ、パフォーマンス、正しいHTTP仕様を考慮していませんが、ざっくりこういった形でWebサーバが動いているのだな、ということが理解できたのではと思います。