標準ライブラリだけで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でコードを書くことで大分楽が出来るな〜と改めて思いました。