LoginSignup
7
14

More than 5 years have passed since last update.

1エンドポイントでJSON,Yaml,CSVに対応する

Last updated at Posted at 2017-02-25

エンドポイント

ある1つのエンドポイントがあったとして、このREST-APIのレスポンスのContent−Typeを拡張子で切り替えてみようと思います。

期待する結果

デフォルト or JSON

Request
GET /items or /items.json
---
Response
Content-Tyep: application/json;charset=UTF-8

Yaml

Request
GET /items.yaml
---
Response
Content-Tyep: application/yaml;charset=UTF-8

CSV

Request
GET /items.csv
---
Response
Content-Tyep: text/csv;charset=UTF-8

コントローラ

用意するのはこのコントローラ1つ。
REST-APIのコントローラとしては何のリソースを返すかだけの問題なので、これで十分なはず。
Content-Typeに合わせてどう変換するかはSpring-MVCに於いてコントローラの仕事じゃないはず。

@GetMapping("/items")
List<Item> getItems(HttpServletResponse response) {
return List.of(Item.builder()
  .id(1234L)
  .name("手提げバッグ")
  .category(Category.builder()
    .id(345)
    .name("小物")
    .parent(Category.builder()
      .id(345)
      .name("バッグ")
      .build())
    .build())
  .build());

Yamlを追加してみる

SpringBoot使ってたら、このままでもJSONだけなら返る。

$ curl -i http://localhost:8080/items 
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
[ {
  "id" : 1234,
  "name" : "手提げバッグ",
  "category" : {
    "id" : 345,
    "name" : "小物",
    "parent" : {
      "id" : 345,
      "name" : "バッグ",
      "parent" : null
    }
  }
} ]

これにYamlで返るように設定を追加してみる。

application.yaml
spring:
  mvc:
    contentnegotiation:
      media-types:
        json: application/json
        yaml: application/yaml
      favor-path-extension: true
    pathmatch:
      use-registered-suffix-pattern: true

これで拡張子が.jsonならapplication/jsonに。
.yamlならapplication/yamlにレスポンスのContent-Typeが変わるはず。
CharsetはデフォルトUTF-8なので気にしない。

でもこれだけだとContent-Typeがapplication/yamlに変わるだけで、実際にYaml形式のレスポンスには変換してくれない。

なので、Yaml用のHttpMessageConverterを追加してみる。

pom.xmlにjackson-dataformat-yamlを追加する。

pom.xml
<dependency>
  <groupId>com.fasterxml.jackson.dataformat</groupId>
  <artifactId>jackson-dataformat-yaml</artifactId>
</dependency>

バージョンはspring-boot-starter-parentに任せるので指定不要。

次にBean設定。
JSONで使われているMappingJackson2HttpMessageConverterObjectMappperYamlFactoryを指定するだけ。簡単。

Config.java
@Bean
public MappingJackson2HttpMessageConverter yamlConverter() {
var yamlConverter = new MappingJackson2HttpMessageConverter(
  new ObjectMapper(new YAMLFactory()));
yamlConverter.setSupportedMediaTypes(List.of(MediaType.parseMediaType("application/yaml")));
return yamlConverter;
}

結果

$ curl -i http://localhost:8080/items.yaml
HTTP/1.1 200
Content-Type: application/yaml;charset=UTF-8
---
- id: 1234
  name: "手提げバッグ"
  category:
    id: 345
    name: "小物"
    parent:
      id: 345
      name: "バッグ"
      parent: null

JSONでも問題ない?

$ curl -i http://localhost:8080/items
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
[ {
  "id" : 1234,
  "name" : "手提げバッグ",
  "category" : {
    "id" : 345,
    "name" : "小物",
    "parent" : {
      "id" : 345,
      "name" : "バッグ",
      "parent" : null
    }
  }
} ]

問題ない。

CSVを追加してみる

まずはContentNegotiationの設定。

application.yaml
spring:
  mvc:
    contentnegotiation:
      media-types:
        json: application/json
        yaml: application/yaml
        csv: text/csv
      favor-path-extension: true
    pathmatch:
      use-registered-suffix-pattern: true

次にライブラリ追加。
これもYamlと同じようにJacksonにライブラリが無いか探してみる。

あった。

jackson-dataformat-csv

pom.xmlに追加する。

pom.xml
<dependency>
  <groupId>com.fasterxml.jackson.dataformat</groupId>
  <artifactId>jackson-dataformat-csv</artifactId>
</dependency>

これもYaml同様spring-boot-starter-parentにバージョン管理は任せる。

次にHttpMessageConverterの設定。
これもCsvFactoryを設定して・・・エラーになった。。

そうだよ。そんな簡単なはず無いんだよ。
CSVなんて1行データでそもそもネスト階層のデータなんて表現できないんだから。

だから、まずはネスト階層のデータをどう1行データにマッピングするかを考えなきゃいけない。
HttpMessageConverterの設定までの道のりは遠い。

CSVマッピングのルール定義

コントローラが返すList<Item>Itemはこんな構造。

Item.java
@Data
@Builder
public class Item {
  private Long id;
  private String name;
  private Category category;
}

ネスト階層のデータも持つようにCategoryを入れている。

Category.java
@Data
@Builder
public class Category {
  private Integer id;
  private String name;

  @Nullable
  private Category parent;
}

Categoryはネストできるようになっていて、常に「親」カテゴリを知っている状態。
親がいなければトップ階層のカテゴリといった具合。
実際こんな構造のデータはまぁ最近は見かけないが、今回の検証用。

これをこんな形のCSVにしようと思う。

商品ID 商品名 大カテゴリ 中カテゴリ
1234 手提げバッグ 小物 バッグ

じゃあ、理想の形をJacksonのMix-Inで表現してみる。

@JsonProperty("商品ID")
private Long id;

@JsonProperty("商品名")
private String name;

@JsonProperty("大カテゴリ")
private String largeCategoryName;

@JsonProperty("中カテゴリ")
private String middleCategoryName;

でも、このMix-InをItemのMix-Inとして登録してもlargeCategoryNameなんか無ぇよってエラーになる。

じゃあ、Itemをこの理想の形に変換してからJacksonにシリアライズしてもらえればエラーにならないはず。

そこで、自前の変換ロジックをObjectMapperに設定して変換してみる。

まずは独自の変換ルール用にアノテーション追加。

ItemFlatMixin.java
@Data
@JsonPropertyOrder({ "商品ID", "商品名", "大カテゴリ", "中カテゴリ" })
// ネスト階層を表現できるオブジェクトをフラットにするためのアノテーション
@JsonFlatMixinFor(Item.class)
public class ItemFlatMixin {
  @JsonProperty("商品ID")
  private Long id;

  @JsonProperty("商品名")
  private String name;

  @JsonProperty("大カテゴリ")
  // フラットにする際に元の型の値へのアクセスするためのパス
  @JsonMixinProperty("category.parent.name")
  private String largeCategoryName;

  @JsonProperty("中カテゴリ")
  @JsonMixinProperty("category.name")
  private String middleCategoryName;
}

Mix-Inなのに何で具象クラスなんだっていうツッコミはちょっと待って下さい。

次はSerializer

FlatSerializer.java
@RequiredArgsConstructor
public class FlatSerializer extends JsonSerializer<Object> {

  private final Class<?> flatType;

  @Override
  public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers)
    throws IOException, JsonProcessingException {
    var targetWrapper = this.wrap(value);
    var mixinWrapper = this.wrap(BeanUtils.instantiateClass(this.flatType));

    // ItemFlatMixinのフィールドをグルッと回して対応する値をItemから取得して自身にセットする
    ReflectionUtils.doWithFields(this.flatType, field -> {
      var targetPath = this.targetPath(field);
      if (targetWrapper.isReadableProperty(targetPath) == false) {
        return;
      }
      if (mixinWrapper.isWritableProperty(field.getName()) == false) {
        return;
      }
      mixinWrapper.setPropertyValue(field.getName(), targetWrapper.getPropertyValue(targetPath));
    });
    gen.writeObject(mixinWrapper.getWrappedInstance());
  }

  private BeanWrapper wrap(Object obj) {
    var wrapper = PropertyAccessorFactory.forBeanPropertyAccess(obj);
    // JsonMixinProperty#valueの値はこの機能を使うため。
    wrapper.setAutoGrowNestedPaths(true);
    return wrapper;
  }

  private String targetPath(Field field) {
    var property = field.getAnnotation(JsonMixinProperty.class);
    if (property == null) {
      return field.getName();
    }
    return property.value();
  }

}

このSerializerをどうするんだ?flatTypeにはどうやって値をセットすんだ?
で、SimpleSerializersを使う。

SampleSerializers.java
@RequiredArgsConstructor
public class SampleSerializers extends SimpleSerializers {

  // 全てのPOJOとフラットな型への変換マッピング
  private final Map<Class<?>, Class<?>> flatterMap;

  // シリアライズ時にシリアライザを探す処理が走るので、その際に対象の型からシリアライザを動的生成する
  @Override
  public JsonSerializer<?> findSerializer(SerializationConfig config, JavaType type, BeanDescription beanDesc) {
    var rawClass = type.getRawClass();
    var flatClass = this.flatterMap.entrySet().stream()
      .filter(entry -> entry.getKey().isAssignableFrom(rawClass))
      .findFirst()
      .map(Entry::getValue)
      .orElse(null);

    if (flatClass != null) {
      return new FlatSerializer(flatClass);
    }

    return super.findSerializer(config, type, beanDesc);
  }
}

スーパークラスの実装はシリアライザをキャッシュしているので、そのあたりは本実装する際はよしなに実装する。
キャッシュは使った方が良い。

そして、このSimpleSerializersObjectMapperに登録する。
CSVの場合はObjectMapperではなくCsvMapper

Config.java
@Bean
public CsvMapper csvMapper() {
var csvMapper = new CsvMapper();
csvMapper.registerModule(new SimpleModule() {
  @Override
  public void setupModule(SetupContext context) {
    context.addSerializers(new SampleSerializers(Config.this.flatterMap()));
  }
});

return csvMapper;
}

private Map<Class<?>, Class<?>> flatterMap() {
return new Reflections("jp.uich.databind.mixin")
  .getTypesAnnotatedWith(JsonFlatMixinFor.class)
  .stream()
  .collect(Collectors.toMap(
    type -> type.getAnnotation(JsonFlatMixinFor.class).value(), Function.identity()));
}

ここで使っているReflectionsorg.reflectionsreflections
SpringBootの特定バージョン以上の場合、GuavaでパッケージスキャンするとExecutableJarにした際にうまくいきません。
これは本題ではないので、理由は割愛します。

次は HttpMessageConverter の設定。

Config.java
@Bean
public MappingJackson2HttpMessageConverter csvConverter() {
  var csvConverter = new MappingJackson2HttpMessageConverter(this.csvMapper());
  csvConverter.setSupportedMediaTypes(Collections.singletonList(MediaType.parseMediaType("text/csv")));
  return csvConverter;
}

として終われば良いんだけど、そうはいかない。
CsvSchema の設定をしないといけない。
こればかりはCsvMapper側でどう頑張っても解決できないのでHttpMessageConverterに手を入れる必要がある。

ということで、AbstractJackson2HttpMessageConverterを継承して実装してみる。

MappingJackson2CsvHttpMessageConverter.java
@Override
protected void writeInternal(Object object, Type type, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
var csvMapper = (CsvMapper) this.getObjectMapper();

// レスポンスの文字コード
// #getJsonEncoding(MediaType)は使わない。Shift_JISに対応していないから。
var actualCharset = Optional.ofNullable(outputMessage.getHeaders().getContentType())
  // レスポンスヘッダに指定されていたらそれを使用する
  .map(contentType -> contentType.getCharset())
  // そうでなければデフォルトのCharset
  .orElseGet(this::getDefaultCharset);

var generator = csvMapper.getFactory().createGenerator(
  new OutputStreamWriter(outputMessage.getBody(), actualCharset));

try {
  // CSVのスキーマ(1行目のヘッダー)を生成するために型を特定する
  // 型 → シリアライズルールを定義している型 → ItemFlatMixin.java
  var objectWriter = Optional.ofNullable(type)
    .map(csvMapper::schemaFor)
    .map(CsvSchema::withHeader)
    .map(csvMapper::writer)
    .orElseGet(csvMapper::writer);

  objectWriter.writeValue(generator, object);
  generator.flush();

} catch (JsonProcessingException ex) {
  throw new HttpMessageNotWritableException("Could not write content: " + ex.getMessage(), ex);
}

これでOK!と思って実行してみたらエラー。
そらそうだ。スキーマを特定しようとしている型はItem
CsvMapperItemのシリアライズルールにItemFlatMixinが使われることを知らない。

なので、Mix-Inとして登録してみる。

Config.java
@Bean
CsvMapper csvMapper() {
var csvMapper = new CsvMapper();
// これを追加
csvMapper.setMixIns(this.flatterMap());

csvMapper.registerModule(new SimpleModule() {
  @Override
  public void setupModule(SetupContext context) {
    context.addSerializers(new SampleSerializers(Config.this.flatterMap()));
  }
});

return csvMapper;
}

とやってみたが、エラーになる。
まぁ、これもそうなんだよ。 ItemItemFlatMixinはフィールドの内容が違うからObjectMapperはスキーマ判定する際に

Item? -> ItemFlatMixin使おう -> ん?何かフィールド違わないか? -> エラー!!!!

ってなる。
なので、スキーマの型を特定するところに少し手を加えてみる。

MappingJackson2CsvHttpMessageConverter.java
private Class<?> getSchemaType(Type type) {
  if (type == null) {
    return null;
  }
  var javaType = this.getJavaType(type, null);
  Class<?> rowClass;

  if (javaType.isCollectionLikeType()) {
    rowClass = Optional.ofNullable(javaType.getContentType())
      .map(JavaType::getRawClass)
      .orElse(null);

    if (rowClass == null) {
      return javaType.getRawClass();
    }
  } else {
    rowClass = javaType.getRawClass();
  }

  // Item -> ItemFlatMixinに変換
  var classForSchema = this.getObjectMapper().findMixInClassFor(rowClass);
  if (classForSchema == null) {
    return rowClass;
  }

  return classForSchema;
}

で、さっきのMappingJackson2CsvHttpMessageConverterの該当行を次のように書き換える。

MappingJackson2CsvHttpMessageConverter.java
var objectWriter = Optional.ofNullable(this.getSchemaType(type))
  .map(csvMapper::schemaFor)
  .map(CsvSchema::withHeader)
  .map(csvMapper::writer)
  .orElseGet(csvMapper::writer);

このgetSchemaTypeから返る型はItemFlatMixinとなり、このクラスはこのままシリアライズ時のルールを持っているのでObjectMapperはMix-Inを探さずに(探すとは思うが見つからない)そのままItemFlatMixinのシリアライズルールに則りスキーマを出力する。

これでMappingJackson2CsvHttpMessageConverterをBean登録すれば準備完了。

Config.java
@Bean
MappingJackson2CsvHttpMessageConverter csvConverter() {
  var converter = new MappingJackson2CsvHttpMessageConverter(this.csvMapper());
  converter.setSupportedMediaTypes(List.of(MediaType.parseMediaType("text/csv")));
  return converter;
}

結果確認

$ curl -i http://localhost:8080/items.csv
HTTP/1.1 200
Content-Type: text/csv;charset=UTF-8
商品ID,商品名,大カテゴリ,中カテゴリ
1234,手提げバッグ,バッグ,小物

OK。

JSONやYamlも問題ない?

$ curl -i http://localhost:8080/items.json
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
[ {
  "id" : 1234,
  "name" : "手提げバッグ",
  "category" : {
    "id" : 345,
    "name" : "小物",
    "parent" : {
      "id" : 345,
      "name" : "バッグ",
      "parent" : null
    }
  }
} ]
$ curl -i http://localhost:8080/items.yaml
HTTP/1.1 200
Content-Type: application/yaml;charset=UTF-8
---
- id: 1234
  name: "手提げバッグ"
  category:
    id: 345
    name: "小物"
    parent:
      id: 345
      name: "バッグ"
      parent: null
$ curl -i http://localhost:8080/items
HTTP/1.1 200
Content-Type: text/csv;charset=UTF-8
商品ID,商品名,大カテゴリ,中カテゴリ
1234,手提げバッグ,バッグ,小物

あら?デフォルトがCSVになってしまった。
デフォルトはJSONのままにしたい。

HttpMessageConverter@Orderで調整してみたけどダメだった。
で結局解決したのはこれ↓

Config.java
@Bean
HttpMessageConverters converters() {
  return new HttpMessageConverters(false, List.of(
    this.jsonConverter(),
    this.yamlConverter(),
    this.csvConverter()));
}

まとめ

  • シリアライズ処理はJacksonで一本化可能
  • 今回の検証でのMixinの使い方は気持ち悪い。フラットにする方とJacksonで使用するMix-Inは分けた方が良いかも

今回のコードもGitHubにアップしてます。
CSVでダウンロードする際に文字コードをShift_JISに変更する処理も入れていたりしますので興味のある方はどうぞ。

追記

2019-02-22

  • JDK11
  • Spring Boot@2.1.2
7
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
14