JerseyでFreeMarkerを使うには(その1 jersey-freemarkerモジュールがダメな件)

FreeMarkerJava製のテンプレートエンジンです。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)をforeachで回します。
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>メモ&lt;script&gt;alert('今時XSS脆弱性とかあり得ないだろ常考')&lt;/script&gt;&lt;td&gt;タグ埋め込み&lt;/td&gt;&lt;td&gt;メモ
        </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値が&#65533;?って&#65533;&#65533;?&#65533;&#65533;とExceptionとなる&#65533;?でチェ&#65533;&#65533;?&#65533;&#65533;-->
          <td>&#65533;j&#65533;&#65533;&#65533;[&#65533;&#65533;&#65533;E&#65533;f&#65533;E&#65533;&#65533;&#65533;&#65533;&#65533;v</td>
        <!-- null値が&#65533;?って&#65533;&#65533;?&#65533;&#65533;とExceptionとなる&#65533;?でチェ&#65533;&#65533;?&#65533;&#65533;-->
          <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)


取り敢えず問題点は

で、jersey-freemarkerのソースを見てみると・・・見事なまでにデフォルトエンコーディングを指定して無い。テンプレートファイル側でエンコード形式を指定できるようですが、後々テンプレートファイルが複数になった時に結構面倒そうです。

そこで、自前でjerse-freemarkerと同様のコンポーネントを作ってみようかということで、次回へ続く。