Groovyを使うと超お手軽にWeb開発を始められます。・・・といっても、ちょっとしたスタブとか開発中のツール類専用です。
Jettyを組み込んでGroovyスクリプトで現在ディレクトリをさっくり公開しておしまい、な3分クッキングレベルですが、Groovyが提供しているGroovyServletとTemplateServletのお陰で、スクリプト感覚でJava/Groovy Servletプログラミングを楽しめます。
GroovyServlet, TemplateServlet共に、リクエストされる度に評価・実行されるため、書き換えたらすぐにブラウザから確認できます。コンテナの再起動など一切不要です。ただその代わり、毎回評価・実行されるためにパフォーマンスは悪いはずです。
ですので、プロダクトコード用というよりは実験や内部で使うツール類をさくっとでっち上げたい時に使う、そんな使い方になると思います。
※Windows上で実験する人、またはGroovyServletを使うと1度目は正常に動作するのに2回目以降のアクセスで404になってしまう人は、「GroovyServlet経由で".groovy"にアクセスした時に、2回目以降で"Script not found, sending 404."とスタックトレースが発生する場合の回避策」も参照してください。(2012-10-08追記)
2012-09-30時点でのJetty8最新版を使います・・・が、なんかorg.eclipse.jetty.orbitの依存解決でエラーが出るので、手動で調整する必要があります。
ひとまずGrapeで手動で"org.eclipse.jetty.aggregate"から"jetty-all"をインストールします。
$ grape install org.eclipse.jetty.aggregate jetty-all 8.1.7.v20120910
・・・が、本記事を書いてる時点だと絶対に org.eclipse.jetty.orbit経由でのjavax.servlet-3.0.0.v201112011016.jarがDLできず、失敗します。
なぜか javax.servlet-3.0.0.v201112011016.orbitをDLしようとするんです・・・。
ivyのxmlファイルで"ext"部分が"jar"じゃなくて"orbit"になってしまってるんですよね。というわけでこれを手で修正します。
"~/.groovy/grape/org.eclipse.jetty.orbit/javax.servlet/ivy-3.0.0.v201112011016.xml":
<artifact name="javax.servlet" type="orbit" ext="orbit" conf="master"/> ->次のようにext="jar"に修正します。 <artifact name="javax.servlet" type="orbit" ext="jar" conf="master"/>
これで改めて "grape install" すれば多分正常にJettyの8.1.7最新版をインストールできるはずです。
今回の実験環境です。
Mac OS X 10.7.5 JDK 1.6.0_35 Groovy Version: 1.8.6 JVM: 1.6.0_35 Vendor: Apple Inc. OS: Mac OS X
j1/j1.groovy:
import org.eclipse.jetty.server.* import org.eclipse.jetty.servlet.* import groovy.servlet.* @Grab(group='org.eclipse.jetty.aggregate', module='jetty-all', version='8.1.7.v20120910') def server = new Server(8080) def context = new ServletContextHandler(server, '/', ServletContextHandler.SESSIONS) context.resourceBase = '.' context.addServlet(GroovyServlet, "*.groovy") context.addServlet(TemplateServlet, "*.gsp") server.start() server.join()
実行:
$ cd j1/ $ groovy j1.groovy
以下のファイルはUTF-8で保存してます。
GroovyServletの使用例:
j1/g1.groovy:
println """ <html> <head><title>test</title></head> <body> hello, Jettyの組み込みテストです。 ${application.getServerInfo()} </body> </html> """
TemplateServletの使用例:
j1/g2.gsp:
<html> <head><title>TemplateServlet</title> </head> <body> TemplateServletの組み込みテストです。<br> <% println params %> <% 3.times { %> Hello World!<br> <% } %> </body> </html>
"g1.groovy"は文字化けしなかったけど、"g2.gsp"の日本語部分は文字化けてしまいました。また後日調べます。
WEB-INF/のディレクトリ構成や、web.xml無しで始められるのが良い点です。
代わりに、".html"などの静的ファイルの公開も自前でaddServlet()などする必要があります。
静的ファイルやライブラリjarファイルの呼び出しが出てきたら、次に紹介するweb.xml付きのパターンにした方が楽ちんです。
j2/j2.groovy:
import org.eclipse.jetty.server.* import org.eclipse.jetty.webapp.* import groovy.servlet.* @Grab(group='org.eclipse.jetty.aggregate', module='jetty-all', version='8.1.7.v20120910') def server = new Server(8080) def webapp = new WebAppContext('.', '/') server.handler = webapp server.start() server.join()
j2/WEB-INF/web.xml:
<web-app> <servlet> <servlet-name>Groovlet</servlet-name> <servlet-class>groovy.servlet.GroovyServlet</servlet-class> </servlet> <servlet> <servlet-name>Template</servlet-name> <servlet-class>groovy.servlet.TemplateServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>Groovlet</servlet-name> <url-pattern>*.groovy</url-pattern> </servlet-mapping> <servlet-mapping> <servlet-name>Template</servlet-name> <url-pattern>*.gsp</url-pattern> </servlet-mapping> </web-app>
j1からg1.groovyやg2.gspなどをコピペしてきます。
j2/ j2.groovy g1.groovy g2.gsp WEB-INF/web.xml
実行:
$ cd j2/ $ groovy j2.groovy
web.xml有り版の場合はWebアプリとしてdeployされるためか、静的コンテンツなども通常のWebアプリと同じお作法で配置し、公開できます。
最初、Windows上で実験してたんです。
"g1.groovy"への1度目のアクセスは正常に動作します。
ところが、2回目以降のアクセスではなぜか以下の様なスタックトレースが発生し、404が返されてしまいます。
java.io.IOException: ファイル名、ディレクトリ名、またはボリューム ラベルの構文が間違っています。 at java.io.WinNTFileSystem.canonicalize0(Native Method) at java.io.Win32FileSystem.canonicalize(Win32FileSystem.java:414) at java.io.File.getCanonicalPath(File.java:589) at org.eclipse.jetty.util.resource.FileResource.getAlias(FileResource.java:191) at org.eclipse.jetty.server.handler.ContextHandler.getResource(ContextHandler.java:1569) at org.eclipse.jetty.webapp.WebAppContext.getResource(WebAppContext.java:346) at org.eclipse.jetty.webapp.WebAppContext$Context.getResource(WebAppContext.java:1248) at groovy.servlet.AbstractHttpServlet.getResourceConnection(AbstractHttpServlet.java:182) at groovy.util.GroovyScriptEngine.isSourceNewer(GroovyScriptEngine.java:564) at groovy.util.GroovyScriptEngine.loadScriptByName(GroovyScriptEngine.java:496) at groovy.util.GroovyScriptEngine.createScript(GroovyScriptEngine.java:549) at groovy.util.GroovyScriptEngine.run(GroovyScriptEngine.java:536) at groovy.servlet.GroovyServlet$1.call(GroovyServlet.java:120) at org.codehaus.groovy.runtime.GroovyCategorySupport$ThreadCategoryInfo.use(GroovyCategorySupport.java:109) at org.codehaus.groovy.runtime.GroovyCategorySupport$ThreadCategoryInfo.access$400(GroovyCategorySupport.java:65) at org.codehaus.groovy.runtime.GroovyCategorySupport.use(GroovyCategorySupport.java:249) at groovy.servlet.GroovyServlet.service(GroovyServlet.java:129) at javax.servlet.http.HttpServlet.service(HttpServlet.java:802) at org.eclipse.jetty.servlet.ServletHolder.handle(ServletHolder.java:648)
TemplateServlet("g2.gsp")は影響を受けず、何度アクセスしても正常に動作してくれます。GroovyServletの場合だけこうなってしまうという謎・・・。
今回の記事ではまずした準備で書いた org.eclipse.jetty.orbit 経由の javax.servlet のインストールで1時間以上嵌ってしまい、さらにこの謎のスタックトレースで2時間ほど嵌りました。最終的に "java.io.WinNTFileSystem.canonicalize0" でWindows上でのファイル名の形式がおかしくなってるのかも・・・ならばUnix系のMacなら、とMacで試してみたらあっさりと動いてしまいました。
(上記スタックトレースの原因と回避策について以下に追記しました:2012-10-08)
非常に原因を掴みづらいトラブルですが、GroovyServlet経由で".groovy"にアクセスした時、1回目のアクセスは正常に動作するのに、2回目以降のアクセスで404発生、JettyなどServletContainer側で例外が発生しスタックトレースと"Script not found, sending 404."というエラーメッセージが出力される場合があります。
その場合、以下の様な方法で404とスタックトレースを回避することが可能です。(Groovy 1.8.8 + Jetty 8, Win7SP1, JDK1.7で確認)
詳細は後述しますが、groovy.servlet.AbstractHttpServletで実装されているgetResourceConnection()に、1行だけ追加したServletクラスを追加します。
今回は以下の様な構成にしました。
WEB-INF/ web.xml classes/ groovy/ servlet/ MyGroovyServlet.groovy
MyGroovyServlet.groovy:
package groovy.servlet import groovy.servlet.* public class MyGroovyServlet extends GroovyServlet { public URLConnection getResourceConnection(String name) throws ResourceException { if (name.startsWith("file:")) name = name.replaceFirst("file:", "") return super.getResourceConnection(name) } }
groovycでコンパイルしておきます。
$ cd WEB-INF/classes/ $ groovyc groovy/servlet/MyGroovyServlet.groovy
WEB-INF/web.xml:
<web-app> <!-- ... --> <servlet> <servlet-name>Groovlet</servlet-name> <!-- 自分でカスタマイズしたServletクラスを指定してます。 --> <servlet-class>groovy.servlet.MyGroovyServlet</servlet-class> </servlet> <!-- ... --> <servlet-mapping> <servlet-name>Groovlet</servlet-name> <url-pattern>*.groovy</url-pattern> </servlet-mapping> <!-- ... --> </web-app>
これで、2回目以降のアクセスでも正常に動作するGroovyServletでお手軽プログラミングが楽しめるようになります。
GroovyScriptEngineのキャッシュ更新チェック時に、すでに"file:"スキーマのURL展開されたものに対してServletContext.getResource()を呼んでしまっていることが原因です。
まず今回のトラブルに関連する箇所に絞って、GroovyServletが".groovy"ファイルを実行する仕組みを簡単に解説します。
GroovyServletはGroovyScriptEngineで指定されたgroovyファイルを実行しています。この時、リクエストの度に毎回GroovyScriptEngineを初期化するのではなく、Servletのinit()中に初期化したGroovyScriptEngineを使いまわしています。
GroovyScriptEngineでは、一度実行したスクリプトについては内部でキャッシュしています。同じスクリプト名を実行するとき、GroovyScriptEngineは指定されたスクリプト名の"リソース"の最終更新日を調べ、再コンパイルが必要か判断しています。
GroovyScriptEngineではgroovy.util.ResourceConnectorのgetResourceConnection()を介して「スクリプト名」から実際のスクリプトを取り出しています。この時、実際のスクリプトはURLConnectionの形で取り出され、スクリプトのキャッシュはURLConnection.getURL().toExternalForm()の戻り値をキーとしてキャッシュされる・・・ようです。(すみません、ここちょっと不確かで、GroovyScriptEngine中でのscriptCache.get()メソッドの呼び出しは全てURL.toExternalForm()の値を引数にしているんですが、scriptCache.put()のキー値がどういう表現になるのか確認しきれていません)
ファイルシステム上のスクリプト名を取り出す場合は、「スクリプト名」ほぼイコール「ファイル名」でほぼそのままURLに変換できます。
しかしServletContainer上で指定された場合、「スクリプト名」は "/foo/bar/baz.groovy" のようなURLパス名で指定されてきます。これを、ServletContextのgetResource()を使って、実際に展開されているファイル名に変換する必要があります。
この変換処理を実装しているのが、groovy.servlet.AbstractHttpServletで実装されているgetResourceConnection()メソッドになります(AbstractHttpServletはResourceConnectorを実装しています)。
実際にローカルファイルシステム上でのファイルパスをURLで取り出しtoExternalForm()に変換すると、以下のようになります。
Windows: file:C:¥foo¥bar¥baz.groovy Mac: file:/foo/bar/baz.groovy
ここまでを踏まえて、デフォルトの実装のGroovyServletがURLで指定された".groovy"をどう処理するか1回目と2回目で見ていきます。
1回目:
2回目:
この2回目以降のisSourceNewer()内のgetResourceConnection()の呼び出しが問題で、Macの場合は2回目以降では
"/foo/bar/baz.groovy"
がgetResourceConnection()に渡されてきます。この形式であれば、AbstraceHttpServlet内ではServletContextのgetRealPath("/")で親ディレクトリを取り出し、"/baz.groovy"まで戻した上で再度 ServletContext.getResource("/baz.groovy")で正しいファイルパスのURLConnectionを返すことが出来ます。つまりAbstractHttpServletのデフォルト実装で正常に期待通りの動作をしてくれます。
ところがWindowsの場合、なぜか2回目以降では
"file:C:¥foo¥bar¥baz.groovy"
というのが渡されてきてしまいます。これでは、AbstractHttpServletのデフォルト実装では元の"/baz.groovy"に戻せません。よって、これがそのまま ServletContext.getResource("file:C:¥foo¥bar¥baz.groovy")の呼び出しになってしまい、不正なファイルパスとして例外が発生し、以下のようなスタックトレースになってしまったわけです。
java.io.IOException: ファイル名、ディレクトリ名、またはボリューム ラベルの構文が間違っています。 at java.io.WinNTFileSystem.canonicalize0(Native Method) ... at org.eclipse.jetty.webapp.WebAppContext$Context.getResource(WebAppContext.java:1248) at groovy.servlet.AbstractHttpServlet.getResourceConnection(AbstractHttpServlet.java:182) (ここでちょうど、isSourceNewerからAbstractHttpServletのgetResourceConnection()が呼ばれてます) at groovy.util.GroovyScriptEngine.isSourceNewer(GroovyScriptEngine.java:564) at groovy.util.GroovyScriptEngine.loadScriptByName(GroovyScriptEngine.java:496) at groovy.util.GroovyScriptEngine.createScript(GroovyScriptEngine.java:549) at groovy.util.GroovyScriptEngine.run(GroovyScriptEngine.java:536) ...
ここまで判明し、さてどうやってこの問題を回避するか、となるわけですが、たかがお手軽WebプログラミングをしたいがためだけにGroovyScriptEngineまで手を出す余裕はありません。
「その場限りの」アドホック対応として、getResourceConnection()に"file:"始まりの文字列が渡されてきたら先頭の"file:"を空文字列に置換してから処理するように修正します。これが回避策で示したMyGroovyServletのソースコードで、先頭の"file:"を空にしてから親の(=AbstractHttpServletの)本来のgetResourceConnection()の実装に渡しています。
回避策自体の本質はたったの1行なのですが、なぜその1行でOKなのか、という部分がServlet処理と言うよりはGroovyのScript処理の内部実装の話になってしまい、調査に時間を取られてしまいました。
Jetty組み込み関連:
意外に苦労した点としては、GroovyやJetty組み込み関連のWikiページ資料などで使われているJettyのバージョンが古く(Jetty6や7)、Jetty8だとクラス名やパッケージ階層が(今回のような超基本的かつ単純なクラスでさえも)変更されてしまっていたため、いちいちJetty8のJavaDoc上で探し直して、引数なども再確認が必要だったのが手間でした。