エンドポイント
ある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で返るように設定を追加してみる。
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
を追加する。
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
バージョンはspring-boot-starter-parent
に任せるので指定不要。
次にBean設定。
JSONで使われているMappingJackson2HttpMessageConverter
のObjectMappper
にYamlFactory
を指定するだけ。簡単。
@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の設定。
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に追加する。
<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
はこんな構造。
@Data
@Builder
public class Item {
private Long id;
private String name;
private Category category;
}
ネスト階層のデータも持つようにCategory
を入れている。
@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
に設定して変換してみる。
まずは独自の変換ルール用にアノテーション追加。
@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
。
@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
を使う。
@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);
}
}
スーパークラスの実装はシリアライザをキャッシュしているので、そのあたりは本実装する際はよしなに実装する。
キャッシュは使った方が良い。
そして、このSimpleSerializers
をObjectMapper
に登録する。
CSVの場合はObjectMapper
ではなくCsvMapper
。
@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()));
}
ここで使っているReflections
はorg.reflections
のreflections
。
SpringBootの特定バージョン以上の場合、Guava
でパッケージスキャンするとExecutableJarにした際にうまくいきません。
これは本題ではないので、理由は割愛します。
次は HttpMessageConverter
の設定。
@Bean
public MappingJackson2HttpMessageConverter csvConverter() {
var csvConverter = new MappingJackson2HttpMessageConverter(this.csvMapper());
csvConverter.setSupportedMediaTypes(Collections.singletonList(MediaType.parseMediaType("text/csv")));
return csvConverter;
}
として終われば良いんだけど、そうはいかない。
CsvSchema
の設定をしないといけない。
こればかりはCsvMapper
側でどう頑張っても解決できないのでHttpMessageConverter
に手を入れる必要がある。
ということで、AbstractJackson2HttpMessageConverter
を継承して実装してみる。
@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
。
CsvMapper
はItem
のシリアライズルールにItemFlatMixin
が使われることを知らない。
なので、Mix-Inとして登録してみる。
@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;
}
とやってみたが、エラーになる。
まぁ、これもそうなんだよ。 Item
とItemFlatMixin
はフィールドの内容が違うからObjectMapper
はスキーマ判定する際に
Item? -> ItemFlatMixin使おう -> ん?何かフィールド違わないか? -> エラー!!!!
ってなる。
なので、スキーマの型を特定するところに少し手を加えてみる。
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
の該当行を次のように書き換える。
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登録すれば準備完了。
@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
で調整してみたけどダメだった。
で結局解決したのはこれ↓
@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