home ホーム search 検索 -  login ログイン  | reload edit datainfo version cmd icon diff delete  | help ヘルプ

Groovy/超お手軽Web開発(Groovy-1.8 + Jetty8, 201209版)

Groovy/超お手軽Web開発(Groovy-1.8 + Jetty8, 201209版)

Groovy / 超お手軽Web開発(Groovy-1.8 + Jetty8, 201209版)
id: 1103 所有者: msakamoto-sf    作成日: 2012-10-01 00:32:32
カテゴリ: Groovy 

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

web.xml無し版

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付きのパターンにした方が楽ちんです。

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環境での正体不明のトラブル

最初、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"にアクセスした時に、2回目以降で"Script not found, sending 404."とスタックトレースが発生する場合の回避策

非常に原因を掴みづらいトラブルですが、GroovyServlet経由で".groovy"にアクセスした時、1回目のアクセスは正常に動作するのに、2回目以降のアクセスで404発生、JettyなどServletContainer側で例外が発生しスタックトレースと"Script not found, sending 404."というエラーメッセージが出力される場合があります。
その場合、以下の様な方法で404とスタックトレースを回避することが可能です。(Groovy 1.8.8 + Jetty 8, Win7SP1, JDK1.7で確認)

回避策:groovy.servlet.GroovyServletを継承した独自のServletクラスでgetResourceConnection()をカスタマイズする。

詳細は後述しますが、groovy.servlet.AbstractHttpServletで実装されているgetResourceConnection()に、1行だけ追加したServletクラスを追加します。

  1. 手っ取り早くgroovy.servlet.GroovySerlvetを継承しただけの空っぽのクラスをJavaでもGroovyでも良いので作成。
  2. groovy.servlet.AbstractHttpServletのgetResourceConnection()メソッドのシグネチャをコピペしてくる。
  3. getResourceConnection()では最初に次の1行を実行したら、すぐに親クラス(super)のgetResourceConnection()に処理を委譲する。: if (name.startsWith("file:")) name = name.replaceFirst("file:", "");
  4. web.xmlでgroovy.servlet.GroovyServletではなく、今回作成したServletクラスで".groovy"を処理させる。

今回は以下の様な構成にしました。

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でお手軽プログラミングが楽しめるようになります。

2回目以降のアクセスでトラブルになる原因

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回目:

  1. URLで指定されたURLをGroovyScriptEngineに渡す。
  2. GroovyScriptEngineはResourceConnectorのgetResourceConnection()を呼び出す。
  3. この時呼び出されるResourceConnectorはGroovyServlet、ひいてはAbstractHttpServletのインスタンスになる。(GroovyServletがinit()中でGroovyServletEngineのコンストラクタで、ResourceConnectorとして自分自身のthisを渡している)
  4. AbstractHttpServletのgetResourceConnection()により、URLからServletContext.getResource()により実際のファイルパスのURLに変換される。
  5. GroovyScriptEngine内でisSourceNewer()によりキャッシュが無いかチェックされるが、1回目では当然キャッシュされていないので、そのままキャッシュにputされる。

2回目:

  1. isSourceNewer()までは同じ。
  2. isSourceNewer()内でスクリプトのキャッシュについて最終更新日を確認するが、この時キャッシュのスクリプト名について再度ResourceConnectorのgetResourceConnection()を呼び出す。

この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上で探し直して、引数なども再確認が必要だったのが手間でした。



プレーンテキスト形式でダウンロード
現在のバージョン : 1
更新者: msakamoto-sf
更新日: 2012-10-08 21:44:30
md5:2cac7b6f5824346d50e0b1c20493b118
sha1:1e14645ec3735f9c4014f6345c2c0b7bfe74dd4b
コメント
コメントを投稿するにはログインして下さい。