JerseyでFreeMarkerを使うには(その1 jersey-freemarkerモジュールがダメな件)
FreeMarkerはJava製のテンプレートエンジンです。Javaのテンプレートエンジンというとapache velocityがメジャーですが、記述の仕方はFreeMarkerの方がすっきりしています。
また、jerseyの公式がmavenリポジトリに公開しているjersey-freemarkerモジュールを使うとjerseyと楽に連携できるとのことなので、使ってみようと思ったら色々とはまったのでメモ。
とりあえず作ってみる。
maven2を導入した環境で、Webアプリ用のアーキタイプを作成、以下のpom.xmlを定義。
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>example</groupId> <artifactId>JerseyFreemarkerExample</artifactId> <version>0.0.1-SNAPSHOT</version> <packaging>war</packaging> <dependencies> <dependency> <groupId>com.sun.jersey</groupId> <artifactId>jersey-server</artifactId> <version>1.13</version> </dependency> <dependency> <groupId>com.sun.jersey</groupId> <artifactId>jersey-json</artifactId> <version>1.13</version> </dependency> <dependency> <groupId>com.sun.jersey.contribs</groupId> <artifactId>jersey-freemarker</artifactId> <version>1.14</version> </dependency> <dependency> <groupId>servletapi</groupId> <artifactId>servletapi</artifactId> <version>2.4</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>2.0.2</version> <configuration> <source>1.6</source> <target>1.6</target> <encoding>UTF-8</encoding> </configuration> </plugin> <plugin> <groupId>org.mortbay.jetty</groupId> <artifactId>maven-jetty-plugin</artifactId> <configuration> <contextPath>/Sample</contextPath> <scanIntervalSeconds>10</scanIntervalSeconds> <connectors> <connector implementation="org.mortbay.jetty.nio.SelectChannelConnector"> <port>8080</port> <maxIdleTime>60000</maxIdleTime> </connector> </connectors> </configuration> </plugin> </plugins> <finalName>Sample</finalName> </build> </project>
web.xmlはこんな感じ
web.xml
<?xml version="1.0" encoding="UTF-8"?> <web-app id="Sample" version="2.4" xmlns="http://java.sun.com/xml/ns/j2ee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd"> <display-name>Example</display-name> <servlet> <servlet-name>Example Web Application</servlet-name> <servlet-class>com.sun.jersey.spi.container.servlet.ServletContainer</servlet-class> <init-param> <param-name>com.sun.jersey.config.property.packages</param-name> <param-value>example.service</param-value> </init-param> <init-param> <param-name>com.sun.jersey.api.json.POJOMappingFeature</param-name> <param-value>true</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>Example Web Application</servlet-name> <url-pattern>/*</url-pattern> </servlet-mapping> </web-app>
とりあえずデータ定義クラス.
package example.beans; import java.util.ArrayList; import java.util.List; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlRootElement; @XmlRootElement(name = "persons") @XmlAccessorType(XmlAccessType.FIELD) public class Persons { public Persons(){} @XmlElement(name = "person") private List<Person> persons = new ArrayList<Person>(); public List<Person> getPersons() { return persons; } public void setPersons(List<Person> persons) { this.persons = persons; } public void addPerson(Person person){ this.persons.add(person); } }
package example.beans; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlRootElement; @XmlRootElement(name = "person") @XmlAccessorType(XmlAccessType.FIELD) public class Person { public Person(){} @XmlElement private int code; @XmlElement private String homepage; @XmlElement private String name; @XmlElement private String memo; public int getCode() { return code; } public void setCode(int code) { this.code = code; } public String getHomepage() { return homepage; } public void setHomepage(String homepage) { this.homepage = homepage; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getMemo() { return memo; } public void setMemo(String memo) { this.memo = memo; } }
Restサービスクラスと、モックデータ定義。
list.xmlへアクセスするとxmlデータ、list.jsonへアクセスするとjsonデータ、list.htmlへアクセスするとFreeMarkerで生成されたhtmlデータが戻るというソースです。
package example.service; import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.Produces; import javax.ws.rs.core.MediaType; import com.sun.jersey.api.view.Viewable; import com.sun.jersey.spi.resource.Singleton; import example.beans.Person; import example.beans.Persons; @Singleton @Path("person") public class RestService { private Persons mockData = new Persons(); public RestService(){ Person person1 = new Person(); person1.setCode(100000001); person1.setName("ニュー速・デ・やる夫"); person1.setHomepage("やる夫.html"); person1.setMemo(null); Person person2 = new Person(); person2.setCode(100000002); person2.setName("VIP・デ・やらない夫"); person2.setHomepage(null); person2.setMemo("メモ<script>alert('今時XSS脆弱性とかあり得ないだろ常考')</script><td>タグ埋め込み</td><td>メモ"); mockData.addPerson(person1); mockData.addPerson(person2); } @GET @Produces(MediaType.APPLICATION_XML) @Path("list.xml") public Persons getXMLList(){ return mockData; } @GET @Produces(MediaType.APPLICATION_JSON) @Path("list.json") public Persons getJSONList(){ return mockData; } @GET @Produces(MediaType.TEXT_HTML) @Path("list.html") public Viewable getListHtml(){ /* * 第1引数はtemplateファイル名 * 第2引数にオブジェクト * */ return new Viewable("/template.ftl", mockData); } }
最後に、テンプレートファイル(template.ftl)をクラスパス直下に配置。ファイルエンコーディングはもちろんUTF-8。
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"> <html> <head> <meta http-equiv="Content-Language" content="ja" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <title>Welcome!</title> </head> <body> <table> <tr> <th>コード</th> <th>名前</th> <th>ページ</th> <th>メモ</th> </tr> <#list getPersons() as person> <tr> <td>${person.getCode()?c}</td> <!-- null値が入っているとExceptionとなるのでチェック--> <td> <#if person.getName()??> ${person.getName()?html} </#if> </td> <td> <!-- null値が入っているとExceptionとなるのでチェック--> <#if person.getHomepage()??> <a href="http://example.com/${person.getHomepage()?url}" > ${person.getHomepage()?html} </a> </#if> </td> <td> <!-- null値が入っているとExceptionとなるのでチェック--> <#if person.getMemo()??> ${person.getMemo()?html} </#if> </td> </tr> </#list> </table> </body> </html>
テンプレートの書き方メモ
- 繰り返し要素の扱い
<#list getPersons() as person>
暗黙的に用意されているルート要素(Personsクラスのインスタンス)から"getPersons()"メソッドを呼び出し、その結果リスト(java.util.List
javaコード的には
for(Person person: persons){ //ホニャララ }
と同じです。int型のindexを使ってリストを回すには一工夫必要となります。
- 数値の扱い
${person.getCode()?c}
getCodeの戻り値(int型)を埋め込みます。最後の"?c"を抜かすと、勝手に桁区切りが入るので注意。
- 文字列の扱いとビルドインエスケープ機構
<#if person.getName()??>
${person.getName()?html}
getNameの戻り値を埋め込みます。戻り値がnull値の場合に例外が出るのでNullチェックを行います。
"<#if チェック対象??>"という構文でNullチェックを行えますが、マニュアルに一切記述が無いのが不安。
また、末尾の"?html"で埋め込み文字列をhtmlエスケープします。詳細はこちら。URLエンコードやjavascriptエンコードも可能。
ビルド
こんな感じのプロジェクトを作成し、プロジェクトルートフォルダから以下のコマンドでビルド。
mvn clean package -Dmaven.test.skip=true
以下のコマンドでさっくりjettyを起動。
mvn jetty:run
表示結果
テンプレート
まずはテンプレートと関係のない通常のJerseyの動作を確認。
http://localhost:8080/Sample/person/list.xmlへのアクセス。
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <persons> <person> <code>100000001</code> <name>ニュー速・デ・やる夫</name> <homepage>http://example.com/やる夫.html</homepage> </person> <person> <code>100000002</code> <name>VIP・デ・やらない夫</name> <memo>メモ<script>alert('今時XSS脆弱性とかあり得ないだろ常考')</script><td>タグ埋め込み</td><td>メモ </memo> </person> </persons>
http://localhost:8080/Sample/person/list.jsonへのアクセス。
{"person":[ { "code":100000001, "name":"ニュー速・デ・やる夫", "homepage":"http://example.com/やる夫.html" }, { "code":100000002, "name":"VIP・デ・やらない夫", "memo":"メモ<script>alert('今時XSS脆弱性とかあり得ないだろ常考')</script><td>タグ埋め込み</td><td>メモ" } ]}
特に問題なし。タグをエスケープしたりとか相変わらずjerseyは空気を読むヤツだ。
テンプレート
http://localhost:8080/Sample/person/list.htmlへのアクセスした結果がこちら。
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"> <html> <head> <meta http-equiv="Content-Language" content="ja" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <title>Welcome!</title> </head> <body> <table> <tr> <th>コード</th> <th>名前</th> <th>ページ</th> <th>メモ</th> </tr> <tr> <td>100000001</td> <!-- null値が�?って��?��とExceptionとなる�?でチェ��?��--> <td>�j���[���E�f�E�����v</td> <!-- null値が�?って��?��とExceptionとなる�?でチェ��?��--> <td> To do URL encoding, the framework that encloses FreeMarker must specify the output encoding or the URL encoding charset, so ask the programmers to fix it. Or, as a last chance, you can set the url_encoding_charset setting in the template, e.g. <#setting url_escaping_charset='ISO-8859-1'>, or give the charset explicitly to the buit-in, e.g. foo?url('ISO-8859-1'). The problematic instruction: ---------- ==> ${person.getUrl()?url} [on line 25, column 15 in template.ftl] ---------- Java backtrace for programmers: ---------- freemarker.template.TemplateModelException: To do URL encoding, the framework that encloses FreeMarker must specify the output encoding or the URL encoding charset, so ask the programmers to fix it. Or, as a last chance, you can set the url_encoding_charset setting in the template, e.g. <#setting url_escaping_charset='ISO-8859-1'>, or give the charset explicitly to the buit-in, e.g. foo?url('ISO-8859-1'). at freemarker.core.BuiltIn$urlBI$urlBIResult.getAsString(BuiltIn.java:617)
取り敢えず問題点は
- 文字化け(テンプレートのソースはUTF-8、かつhtmlのcharsetもUTF-8を指定)
- URLエンコード実行時に、エンコード用文字コードとしてISO-8859-1が使われているのが原因で例外発生
で、jersey-freemarkerのソースを見てみると・・・見事なまでにデフォルトエンコーディングを指定して無い。テンプレートファイル側でエンコード形式を指定できるようですが、後々テンプレートファイルが複数になった時に結構面倒そうです。