ぺーぺーSEのブログ

備忘録・メモ用サイト。

Jacksonが/(スラッシュ)をエスケープしてくれない件

RFC的にはJSON中の文字列で「/」(スラッシュ)を返却する際にはエスケープ「\/」しないといけないけど、Jacksonはやってくれなかった。
実際にはjersey-json-1.17.1を使用しているときにはまった。
jersey-json-1.17.1を使用するとJSON-オブジェクト変換してくれるエンティティ・プロバイダとしてorg.codehaus.jackson.jaxrs.JacksonJsonProviderが採用される。

サンプルアプリ

「/」(スラッシュ)をエスケープしてくれないサンプルアプリ。
Tomcatで実行すること前提で作ってる。

■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>org.sample</groupId>
  <artifactId>JerseyHelloWorld</artifactId>
  <version>1.0.0</version>
  <packaging>war</packaging>

  <properties>

    <!-- Generic properties -->
    <jdk.version>1.7</jdk.version>
    <encoding>UTF-8</encoding>

    <!-- Jersey -->
    <jersey.version>1.17.1</jersey.version>

  </properties>

  <build>
    <finalName>JerseyHelloWorld</finalName>
    <plugins>
      <!-- compiler -->
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <configuration>
          <source>${jdk.version}</source>
          <target>${jdk.version}</target>
          <encoding>${encoding}</encoding>
        </configuration>
      </plugin>
    </plugins>
  </build>

  <dependencies>

    <!-- Jersey -->
    <dependency>
      <groupId>com.sun.jersey</groupId>
      <artifactId>jersey-core</artifactId>
      <version>${jersey.version}</version>
    </dependency>
    <dependency>
      <groupId>com.sun.jersey</groupId>
      <artifactId>jersey-servlet</artifactId>
      <version>${jersey.version}</version>
    </dependency>
    <dependency>
      <groupId>com.sun.jersey</groupId>
      <artifactId>jersey-json</artifactId>
      <version>${jersey.version}</version>
    </dependency>

  </dependencies>
</project>



■リソースクラス(org.sample.resource.SampleResource)

package org.sample.resource;

import java.util.LinkedHashMap;
import java.util.Map;

import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

@Path("/sample")
public class SampleResource {
  
  @GET
  @Path("url")
  @Produces({ MediaType.APPLICATION_JSON })
  public Map<String, String> getUrl() {
    Map<String, String> map = new LinkedHashMap<>();
    map.put("page-name", "HOGE PAGE");
    map.put("url", "http://hoge.com");
    return map;
  }
}



■アプリケーションクラス(org.sample.application.SampleApplication)

package org.sample.application;

import java.util.HashSet;
import java.util.Set;

import javax.ws.rs.ApplicationPath;
import javax.ws.rs.core.Application;

import org.sample.resource.SampleResource;

@ApplicationPath("")
public class SampleApplication extends Application {
  
  @Override
  public Set<Class<?>> getClasses() {
    Set<Class<?>> s = new HashSet<Class<?>>();
    s.add(SampleResource.class);
    s.add(org.codehaus.jackson.jaxrs.JacksonJsonProvider.class);
    return s;
  }

}



■web.xml

<!DOCTYPE web-app PUBLIC
 "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
 "http://java.sun.com/dtd/web-app_2_3.dtd" >
<web-app>

  <display-name>Archetype Created Web Application</display-name>

  <servlet>
    <servlet-name>Jersey REST Service</servlet-name>
    <servlet-class>com.sun.jersey.spi.container.servlet.ServletContainer</servlet-class>
    <init-param>
      <param-name>javax.ws.rs.Application</param-name>
      <param-value>org.sample.application.SampleApplication</param-value>
    </init-param>
  </servlet>

  <servlet-mapping>
    <servlet-name>Jersey REST Service</servlet-name>
    <url-pattern>/*</url-pattern>
  </servlet-mapping>

</web-app>

このサンプルアプリに対して
GET http://localhost:8080/JerseyHelloWorld/sample/url
を実行すると

{"page-name":"HOGE PAGE","url":"http://hoge.com"}

が返却される。

改修方法

上記のサンプルアプリではJackson1.9.2が使用されている。
Jackson1.8からorg.codehaus.jackson.io.CharacterEscapesというクラスが追加されており、エスケープ対象の文字を拡張することができる。
拡張方法は下記の通り。


■org.sample.jackson.escape.CustomCharacterEscapes

package org.sample.jackson.escape;

import org.codehaus.jackson.SerializableString;
import org.codehaus.jackson.io.CharacterEscapes;

public class CustomCharacterEscapes extends CharacterEscapes {

  private final int[] asciiEscapes;

  public CustomCharacterEscapes() {
    int[] esc = CharacterEscapes.standardAsciiEscapesForJSON();
    esc['/'] = CharacterEscapes.ESCAPE_CUSTOM;
    asciiEscapes = esc;
  }

  @Override
  public int[] getEscapeCodesForAscii() {
    return asciiEscapes;
  }

  @Override
  public SerializableString getEscapeSequence(int ch) {
    return new SerializableString() {

      @Override
      public String getValue() {
        return "\\/";
      }

      @Override
      public int charLength() {
        return 2;
      }

      @Override
      public char[] asQuotedChars() {
        return new char[] { '\\', '/' };
      }

      @Override
      public byte[] asUnquotedUTF8() {
        return new byte[] { '\\', '/' };
      }

      @Override
      public byte[] asQuotedUTF8() {
        return new byte[] { '\\', '/' };
      }
    };
  }
}

このクラスを下記のようにorg.codehaus.jackson.map.ObjectMapperに設定すればエスケープ対象を拡張できる。

ObjectMapper mapper = new ObjectMapper();
mapper.getJsonFactory().setCharacterEscapes(new CustomCharacterEscapes());



結論から書くと、上記のCustomCharacterEscapesの作成に加え、下記のクラスを作成・修正すると「/」(スラッシュ)をエスケープできるサンプルアプリになる。


■org.sample.provider.ObjectMapperContextResolver

package org.sample.provider;

import javax.ws.rs.ext.ContextResolver;
import javax.ws.rs.ext.Provider;

import org.codehaus.jackson.map.ObjectMapper;
import org.sample.jackson.escape.CustomCharacterEscapes;

@Provider
public class ObjectMapperContextResolver implements ContextResolver<ObjectMapper> {

  private ObjectMapper objectMapper;

  public ObjectMapperContextResolver() throws Exception {
    this.objectMapper = new ObjectMapper();
    this.objectMapper.getJsonFactory().setCharacterEscapes(new CustomCharacterEscapes());
  }

  public ObjectMapper getContext(Class<?> objectType) {
    return objectMapper;
  }
}



■修正版アプリケーションクラス(org.sample.application.SampleApplication)

package org.sample.application;

import java.util.HashSet;
import java.util.Set;

import javax.ws.rs.ApplicationPath;
import javax.ws.rs.core.Application;

import org.sample.provider.ObjectMapperContextResolver;
import org.sample.resource.SampleResource;

@ApplicationPath("")
public class SampleApplication extends Application {
  
  @Override
  public Set<Class<?>> getClasses() {
    Set<Class<?>> s = new HashSet<Class<?>>();
    s.add(SampleResource.class);
    s.add(org.codehaus.jackson.jaxrs.JacksonJsonProvider.class);
    s.add(ObjectMapperContextResolver.class);
    return s;
  }
}

この修正したサンプルアプリに対して
GET http://localhost:8080/JerseyHelloWorld/sample/url
を実行すると

{"page-name":"HOGE PAGE","url":"http:\/\/hoge.com"}

が返却される。

解説

javax.ws.rs.ext.ContextResolverインターフェースを実装したクラスに@Providerを付与すると任意のオブジェクト取得できるContextResolverクラスをJAX-RSランタイムに登録することができる。
ジェネリクスでContextResolverから取得したいオブジェクトの型を指定する。
この例ではObjectMapperオブジェクトの作成および登録をしている。
さらにApplicationインターフェース実装クラス(SampleApplication)に作成したObjectMapperContextResolverを追加しておく。
ここで、jackson-jaxrs-1.9.2のorg.codehaus.jackson.jaxrs.JacksonJsonProviderクラスの実装を見てみる。


■org.codehaus.jackson.jaxrs.JacksonJsonProvider

package org.codehaus.jackson.jaxrs;
// 〜省略〜
@Provider
@Consumes({MediaType.APPLICATION_JSON, "text/json"})
@Produces({MediaType.APPLICATION_JSON, "text/json"})
public class JacksonJsonProvider
    implements
        MessageBodyReader<Object>,
        MessageBodyWriter<Object>,
        Versioned // since 1.6
{
// 〜省略〜
    @Context
    protected Providers _providers; // ★①★
// 〜省略〜
    public ObjectMapper locateMapper(Class<?> type, MediaType mediaType)
    {
        // First: were we configured with a specific instance?
        ObjectMapper m = _mapperConfig.getConfiguredMapper();
        if (m == null) {
            // If not, maybe we can get one configured via context?
            if (_providers != null) {
                ContextResolver<ObjectMapper> resolver = _providers.getContextResolver(ObjectMapper.class, mediaType);// ★②★
                /* Above should work as is, but due to this bug
                 *   [https://jersey.dev.java.net/issues/show_bug.cgi?id=288]
                 * in Jersey, it doesn't. But this works until resolution of
                 * the issue:
                 */
                if (resolver == null) {
                    resolver = _providers.getContextResolver(ObjectMapper.class, null);// ★③★
                }
                if (resolver != null) {
                    m = resolver.getContext(type); // ★④★
                }
            }
            if (m == null) {
                // If not, let's get the fallback default instance
                m = _mapperConfig.getDefaultMapper();
            }
        }
        return m;
    }
// 〜省略〜
}

①の部分でフィールド変数_providersに@Contextアノテーションを付与することによってjavax.ws.rs.ext.Providersの実装クラスがインジェクションされる。
②③のように「Providers # getContextResolver」で「ObjectMapper.class」を指定することにより、先ほど実装したObjectMapperContextResolverを取得することができる。
さらに④でエスケープを拡張したObjectMapperを取得することができる。

JAX-RSっていろいろ考えられてるなぁ。
しかし、javax.ws.rs.ext.ContextResolverはリソースクラスとかエンティティプロバイダとかから欲しいオブジェクトを取得するときに作成するのかね?
だとするとCDIとの住み分けがわからん。