JSON Patch(RFC6902) with Spring MVC

イントロダクション

ウェブアプリケーションは多くのケースで以下のようにサーバと同期します。
  • ボタンクリック時に画面で更新された値をサーバに送り、サーバから必要な情報を取得する
  • スクロール時に表示に必要な情報を取得する
  • ページ遷移時に次のページに必要な情報を取得する
このように特定のイベント実行時だけサーバと同期するとき問題になることがあるのが、データサイズが大きいと通信に時間がかかるということです。
更新されたフィールドの数が多く何層にもネストしているようなModelをやりとりするケースでは1回の通信に対する待ち時間が長いと実用には耐えられません。
特にモバイル端末のように制限された環境ではなんらかの対策は必須です。
これを解決するには、
  • 最小単位のデータを常にサーバに送る
    1項目単位で変更をサーバに送り、ユーザのセッション単位にサーバ側にクライアントと同期されたデータを一時的に保持しておく。そして特定のボタンクリック時にサーバ側にある画面の情報をコミット(永続化, or etc...)する。
  • 前回サーバ通信時との差分だけをサーバに送る
    直近にサーバ通信した際のデータをクライアント側に保持しておき、特定のイベント実行時にその前回のデータと変更後のデータとの差分だけを送信する。
この記事では後者(前回サーバ通信時との差分だけをサーバに送る)に対する1つの回答として JSON Patch を使います。

JSON Patch(RFC6902) とは

JSON形式を使ってPatchを表現した JSON PatchというものがRFC(RFC6902)にあります。
これは、差分適用のオペレーション(addやreplaceなど)と、差分をHTTP PATCHメソッドを使って送信するものです。
クライアントからは、下記のようなJSONを送ります (下記は属性の追加例で、path, valueが追加データ)。
{ "op": "add", "path": "/baz/bat", "value": "qux" }

Javaでは現状だとJersey(JAX-RSのリファレンス実装)には実装されています。
ですが、Javaで大規模なWeb applicationを開発する場合、Spring MVCが使われることが多く、そのSpring MVCでJSONを扱う場合には、Springがデフォルトで提供しているJacksonを使用したJSON Deserializer/Serializerを使うケースがほとんどでしょう。
そこで今回は Spring MVC(+ Jackson) にJSON Patchを組込みたいと思います。

JSON PatchをSpring MVC frameworkで使えるようにする

JSON Patchが扱えるようにSpring MVC + Jacksonの拡張します。

I/Fを決める

まずは使う側がどのように使用(実装)したいかを決めましょう。
SpringではControllerは以下のように定義します
@RequestMapping(value = "/memo/{id}", method = RequestMethod.PATCH, produces = "application/json-patch+json")
@ResponseBody
public ? patchMemo(@PathVariable final long id, @RequestBody ? memo) {
  ...
  return memo;
}
引数と戻り値にはなにを指定するのが良いでしょうか。
まず引数には、
部分的に値を保持しているものが渡ってくるのが良さそうです。
以下の様なI/Fを持つPartialModel型を作りましょう。
PartialModel.java
package diff;

@Partial
public interface PartialModel<T> {

  public T apply(T original) throws IllegalPatchException;
}
Partial.java
(あとで使うJacksonの拡張のためにアノテーションを用意します)
package diff;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Partial {

}
これは以下のような使われ方を想定しています。
public ? patchMemo(@PathVariable final long id, @RequestBody PartialModel<Memo> partialMemo) {

  // 永続化されたメモをどこからか取ってくる
  Memo original = repository.getMemo(id);

  // 引数の部分メモに永続化されていたメモを適用する
  Memo current = partialMemo.apply(original);

  // 更新されたメモを永続化する
  repository.updateMemo(current);

  ....
そして戻り値は、
クライアント側に差分だけを返せるるように更新前と更新後の値を戻します。
以下のように更新前と更新後の値を保持できるDifference型を作ります。
  ...
  return Difference.of(original, current);
}
Difference.java
package diff;

@Subtractable
public class Difference<T> {

  public final T original;
  public final T updated;

  public Difference(T original, T updated) {
    this.original = original;
    this.updated = updated;
  }

  public static <T> Difference<T> of(T original, T updated) {
    return new Difference<T>(original, updated);
  }
}
Subtractable.java
(あとで使うJacksonの拡張のためにアノテーションを用意します)
package diff;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Subtractable {

}
最終的にControllerのメソッドはこうなりました。
@RequestMapping(value = "/memo/{id}", method = RequestMethod.PATCH, produces = "application/json-patch+json")
@ResponseBody
public Difference<Memo> patchMemo(@PathVariable final long id, @RequestBody PartialModel<Memo> partialMemo) {

  // 永続化されたメモをどこからか取ってくる
  Memo original = repository.getMemo(id);

  // 引数の部分メモに永続化されていたメモを適用する
  Memo current = partialMemo.apply(original);

  // 最新のMemoに対する処理
  repository.updateMemo(current);
  ...

  // クライアントには差分だけ返す
  return Difference.of(original, current);
}
I/Fが決まったので、
これに合わせてSpring frameworkの拡張をしましょう。

Spring WEB MVC Frameworkを拡張する

まずは、
HttpMessageConverterへJSONをやりとりするためのMessageConverterを設定するために、以下のようにSpringへJSONを扱うための設定を追加します。
@Configuration
public class SampleJsonConfig extends WebMvcConfigurerAdapter {

  @Override
  public List extends HttpMessageConverter> createHttpMessageConverter() {

    // JSON(application/json)用Message converter
    // ('application/json', 'application/*+json'のときにこれが使用される)
    MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
    ...
    return Arrays.asList(converter);
  }
}
これだけだとRFC6902には対応できていないので、MappingJackson2HttpMessageConverterがデフォルトで使用するObjectMapper(*)を拡張します。
(*)Jacksonはcom.fasterxml.jackson.databind.ObjectMapperがJavaのObjectのSerialize/Deserialize処理を提供しています。
以下のようにObjectMapperを継承しJson patch用の処理を追加します。
public class SampleObjectMapper extends ObjectMapper {

  public SampleObjectMapper() {

    // DEFAULT_ANNOTATION_INTROSPECTORはJacksonがデフォルトで提供するannotation処理機能
    AnnotationIntrospector introspector = AnnotationIntrospector.pair(new PartialJsonAnnotationIntrospector(this), DEFAULT_ANNOTATION_INTROSPECTOR);

    setAnnotationIntrospector(introspector);
  }
}
Jacksonのデフォルト機能を簡単に拡張するには、annotation introspectorを追加します。これはannotationベースでダイナミックに処理を追加するための機構です。新しく作る PartialJsonAnnotationIntrospector を上のように追加することで簡単にJacksonを拡張することができます。
次にこのPartialJsonAnnotationIntrospectorの実装をしましょう。
ここには以下のようにDeserializeとSerializeの手段を書きます。
class PartialJsonAnnotationIntrospector extends NopAnnotationIntrospector {

  private final ObjectMapper mapper;

  PartialJsonAnnotationIntrospector(ObjectMapper mapper) {
    this.mapper = mapper;
  }

  @Override
  public boolean isAnnotationBundle(Annotation ann) {
    return false;
  }

  @Override
  public Object findDeserializer(Annotated a) {
    // a = Deserialize対象のClass

    // PartialというannotationがあればJSON patch専用のDeserializerを返す
    Partial ann = a.getAnnotation(Partial.class);
    if (ann != null) {
      return new PartialJsonDeserializer(this.mapper);
    }
    return null;
  }

  @Override
  public Object findSerializer(Annotated a) {
    // a = Serialize対象のClass

    // SubtractableというannotationがあればJSON patch専用のDeserializerを返す
    Subtractable ann = a.getAnnotation(Subtractable.class);
    if (ann != null) {
      return new PartialJsonSerializer(this.mapper);
    }
    return null;
  }
}
あとは、
JSON patch専用のDeserializer, Serializerの実装をしたらおしまいです。
一からJSON patchのDeserializer, Serializerを作るのは本題ではありませんので、簡単のために今回この部分にはありもののライブラリを利用することにします。
以下がDeserializerの実装です。
import java.io.IOException;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

final class PartialJsonDeserializer extends JsonDeserializer<Object> {

  private final ObjectMapper mapper;

  public PartialJsonDeserializer(ObjectMapper mapper) {
    this.mapper = mapper;
  }

  @Override
  public Object deserialize(JsonParser jp, DeserializationContext ctxt)
      throws IOException, JsonProcessingException {
    return new PartialJson<Object>(this.mapper, (JsonNode) jp.readValueAsTree());
  }
}
Deserizeメソッドが戻しているPartialJsonクラスは以下のようにします。
import java.io.IOException;

import diff.IllegalPatchException;
import diff.PartialModel;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.fge.jsonpatch.JsonPatch;
import com.github.fge.jsonpatch.JsonPatchException;

public class PartialJson<T> implements PartialModel<T> {
  private final ObjectMapper mapper;
  private final JsonNode node;

  public PartialJson(ObjectMapper mapper, JsonNode node) {
    this.mapper = mapper;
    this.node = node;
  }

  @Override
  @SuppressWarnings("unchecked")
  public T apply(T original) throws IllegalPatchException {
    try {
      JsonPatch patch = JsonPatch.fromJson((JsonNode) this.node);
      JsonNode patched = patch.apply(mapper.valueToTree(original));
      return mapper.treeToValue(patched, (Class<T>)original.getClass());
    } catch (IllegalArgumentException | JsonPatchException | IOException e) {
      throw new IllegalPatchException(e);
    }
  }
}
そして、Serializerです。
import java.io.IOException;

import diff.Difference;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.github.fge.jsonpatch.diff.JsonDiff;

final class PartialJsonSerializer extends JsonSerializer<Difference> {

  private final ObjectMapper mapper;

  protected PartialJsonSerializer(ObjectMapper mapper) {
    this.mapper = mapper;
  }

  @Override
  public void serialize(Difference value, JsonGenerator jgen,
      SerializerProvider provider) throws IOException,
      JsonProcessingException {
    provider.defaultSerializeValue(JsonDiff.asJson(
        mapper.valueToTree(value.original),
        mapper.valueToTree(value.updated)), jgen);
  }
}
これでSpringの拡張は完了です。
使ったライブラリ
Spring 4.0.x
Jackson 2.2.x
json-patch 1.7 https://github.com/fge/json-patch

使ってみる

最後に、上のコードを組み込んでサーバを起動したのち、HTTPでアクセスしてみましょう。
$ curl http://localhost:8080/memo/1 -X PATCH -H "Content-Type: application/json-patch+json" -H "Accept: application/json-patch+json" -d '[ { "op": "replace", "path": "/id", "value": 2 } ]'
サーバに送った差分が適用され、クライアントに適用するための新しい差分が返ってきました。
[{"op":"replace","path":"/id","value":"2"}]

Leave a Reply

Popular Posts