これは、なにをしたくて書いたもの?
JSONの読み書きにはJacksonをよく使いますが、マッピング先のクラスのプロパティに、そのまま設定できない値を指定した場合
(たとえば数字ではない文字列をint
のフィールドに設定しようとする)、どうなるんだっけ?というのをメモしておこうと。
結果は予想できるのですが、「そうだったよね?」的な気分になるので。
環境
今回の環境は、こちら。
$ java --version openjdk 11.0.11 2021-04-20 OpenJDK Runtime Environment (build 11.0.11+9-Ubuntu-0ubuntu2.20.04) OpenJDK 64-Bit Server VM (build 11.0.11+9-Ubuntu-0ubuntu2.20.04, mixed mode, sharing) $ mvn --version Apache Maven 3.8.1 (05c21c65bdfed0f71a2f2ada8b84da59348c4c5d) Maven home: $HOME/.sdkman/candidates/maven/current Java version: 11.0.11, vendor: Ubuntu, runtime: /usr/lib/jvm/java-11-openjdk-amd64 Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "5.4.0-73-generic", arch: "amd64", family: "unix"
準備
Maven依存関係は、こちら。
<dependencies> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.12.3</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.dataformat</groupId> <artifactId>jackson-dataformat-csv</artifactId> <version>2.12.3</version> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-api</artifactId> <version>5.7.2</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> <version>5.7.2</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.19.0</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <artifactId>maven-surefire-plugin</artifactId> <version>2.22.2</version> </plugin> </plugins> </build>
Jackson Databindと、Jackson Text Dataformats ModuleのCSVを利用します。
お題
- オブジェクトにマッピングできるデータを与えて、パースする
- オブジェクトにマッピングできないものを含むデータを与えて、パースする
- オブジェクトにマッピングできないものを含むデータを与えて、パース時に変換する
確認はテストコードで行います。
テストコードの雛形
テストコードの雛形はこちら。
src/test/java/org/littlewings/jackson/JacksonDeserializeTest.java
package org.littlewings.jackson; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.fasterxml.jackson.dataformat.csv.CsvMapper; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; public class JacksonDeserializeTest { // ここに、テストを書く }
この中に、テストを書いていきます。
JSON
最初は、JSONで試していきましょう。
src/test/java/org/littlewings/jackson/Person.java
package org.littlewings.jackson; public class Person { String lastName; String firstName; int age; // getter/setterは省略 }
テストコード。オブジェクトにそのままマッピングできるデータを渡しているので、まあふつうです。
@Test public void parseJson() throws JsonProcessingException { ObjectMapper mapper = new ObjectMapper(); Person katsuo = mapper.readValue("{\"lastName\": \"磯野\", \"firstName\": \"カツオ\", \"age\": 11}", Person.class); assertThat(katsuo.getLastName()).isEqualTo("磯野"); assertThat(katsuo.getFirstName()).isEqualTo("カツオ"); assertThat(katsuo.getAge()).isEqualTo(11); Person tarao = mapper.readValue("{\"lastName\": \"フグ田\", \"firstName\": \"タラオ\", \"age\": 3}", Person.class); assertThat(tarao.getLastName()).isEqualTo("フグ田"); assertThat(tarao.getFirstName()).isEqualTo("タラオ"); assertThat(tarao.getAge()).isEqualTo(3); }
次に、age
に数字に変換できない値を指定してみましょう。
@Test public void parseJsonInvalidType() { ObjectMapper mapper = new ObjectMapper(); assertThatThrownBy(() -> mapper.readValue("{\"lastName\": \"磯野\", \"firstName\": \"カツオ\", \"age\": \"aaa\"}", Person.class)) .isInstanceOf(InvalidFormatException.class) .hasMessageContaining("Cannot deserialize value of type `int` from String \"aaa\": not a valid `int` value\n" + " at [Source: (String)\"{\"lastName\": \"磯野\", \"firstName\": \"カツオ\", \"age\": \"aaa\"}\"; line: 1, column: 47] (through reference chain: org.littlewings.jackson.Person[\"age\"])"); assertThatThrownBy(() -> mapper.readValue("{\"lastName\": \"フグ田\", \"firstName\": \"タラオ\", \"age\": \"bbb\"}", Person.class)) .isInstanceOf(InvalidFormatException.class) .hasMessageContaining("Cannot deserialize value of type `int` from String \"bbb\": not a valid `int` value\n" + " at [Source: (String)\"{\"lastName\": \"フグ田\", \"firstName\": \"タラオ\", \"age\": \"bbb\"}\"; line: 1, column: 48] (through reference chain: org.littlewings.jackson.Person[\"age\"])"); }
これは、オブジェクトに変換する際に失敗します。まあ、そうなりますよね。
ちなみに、数値に変換さえできれば、型まで合わせる必要はなさそうです。
@Test public void parseJson2() throws JsonProcessingException { ObjectMapper mapper = new ObjectMapper(); Person katsuo = mapper.readValue("{\"lastName\": \"磯野\", \"firstName\": \"カツオ\", \"age\": \"11\"}", Person.class); assertThat(katsuo.getLastName()).isEqualTo("磯野"); assertThat(katsuo.getFirstName()).isEqualTo("カツオ"); assertThat(katsuo.getAge()).isEqualTo(11); Person tarao = mapper.readValue("{\"lastName\": \"フグ田\", \"firstName\": \"タラオ\", \"age\": \"3\"}", Person.class); assertThat(tarao.getLastName()).isEqualTo("フグ田"); assertThat(tarao.getFirstName()).isEqualTo("タラオ"); assertThat(tarao.getAge()).isEqualTo(3); }
で、こういう場合にどうにかする方法は?というと、@JsonDeserialize
アノテーションとConverter
インターフェースを
使えば良さそうです。
Databind annotations · FasterXML/jackson-databind Wiki · GitHub
JsonDeserialize (jackson-databind 2.12.0 API)
Converter (jackson-databind 2.12.0 API)
Converter
インターフェースは、こちらをそのまま使うのではなくStdConverter
クラスを使えば良さそうですが。
StdConverter (jackson-databind 2.12.0 API)
こちらを使って、String
をInteger#parseInt
でパースできない場合は、-1
に一律してしまうようなConverter
を作ってみます。
※継承するのはStdConverter
クラスです
src/test/java/org/littlewings/jackson/LooseStringToIntConverter.java
package org.littlewings.jackson; import com.fasterxml.jackson.databind.util.StdConverter; public class LooseStringToIntConverter extends StdConverter<String, Integer> { @Override public Integer convert(String value) { try { return Integer.parseInt(value); } catch (NumberFormatException e) { return -1; } } }
作成したConverter
を@JsonDeserialize
アノテーションのconverter
属性に指定して、適用したいフィールドに設定します。
src/test/java/org/littlewings/jackson/PersonWithConverter.java
package org.littlewings.jackson; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; public class PersonWithConverter { String lastName; String firstName; @JsonDeserialize(converter = LooseStringToIntConverter.class) int age; // getter/setterは省略 }
こうすると、int
に変換できないString
の値が含まれていてもオブジェクトにマッピングできるようになり、Converter
が
使われたことが確認できます。
@Test public void parseJsonInvalidTypePass() throws JsonProcessingException { ObjectMapper mapper = new ObjectMapper(); PersonWithConverter katsuo = mapper.readValue("{\"lastName\": \"磯野\", \"firstName\": \"カツオ\", \"age\": 11}", PersonWithConverter.class); assertThat(katsuo.getLastName()).isEqualTo("磯野"); assertThat(katsuo.getFirstName()).isEqualTo("カツオ"); assertThat(katsuo.getAge()).isEqualTo(11); PersonWithConverter tarao = mapper.readValue("{\"lastName\": \"フグ田\", \"firstName\": \"タラオ\", \"age\": \"bbb\"}", PersonWithConverter.class); assertThat(tarao.getLastName()).isEqualTo("フグ田"); assertThat(tarao.getFirstName()).isEqualTo("タラオ"); assertThat(tarao.getAge()).isEqualTo(-1); }
といっても、ここまで極端な使い方はしないと思いますが…。
CSV
CSVの場合も、同じ結果になります。
マッピング先のクラスの定義。
src/test/java/org/littlewings/jackson/CsvPerson.java
package org.littlewings.jackson; import com.fasterxml.jackson.annotation.JsonPropertyOrder; @JsonPropertyOrder({"lastName", "firstName", "age"}) public class CsvPerson { String lastName; String firstName; int age; // getter/setterは省略 }
最初は、オブジェクトにそのままマッピングできるデータを渡して確認。
@Test public void parseCsv() throws JsonProcessingException { CsvMapper mapper = new CsvMapper(); ObjectReader reader = mapper.readerWithSchemaFor(CsvPerson.class); CsvPerson katsuo = reader.readValue("磯野,カツオ,11"); assertThat(katsuo.getLastName()).isEqualTo("磯野"); assertThat(katsuo.getFirstName()).isEqualTo("カツオ"); assertThat(katsuo.getAge()).isEqualTo(11); CsvPerson tarao = reader.readValue("フグ田,タラオ,3"); assertThat(tarao.getLastName()).isEqualTo("フグ田"); assertThat(tarao.getFirstName()).isEqualTo("タラオ"); assertThat(tarao.getAge()).isEqualTo(3); }
続いて、int
に変換できないString
の値をage
に指定すると、マッピングに失敗します。
@Test public void parseCsvInvalidType() throws JsonProcessingException { CsvMapper mapper = new CsvMapper(); ObjectReader reader = mapper.readerWithSchemaFor(CsvPerson.class); assertThatThrownBy(() -> reader.readValue("磯野,カツオ,aaa")) .isInstanceOf(InvalidFormatException.class) .hasMessageContaining("Cannot deserialize value of type `int` from String \"aaa\": not a valid `int` value\n" + " at [Source: UNKNOWN; line: 1, column: 8] (through reference chain: org.littlewings.jackson.CsvPerson[\"age\"])"); assertThatThrownBy(() -> reader.readValue("フグ田,タラオ,bbb")) .isInstanceOf(InvalidFormatException.class) .hasMessageContaining("Cannot deserialize value of type `int` from String \"bbb\": not a valid `int` value\n" + " at [Source: UNKNOWN; line: 1, column: 9] (through reference chain: org.littlewings.jackson.CsvPerson[\"age\"])"); }
こちらも、先ほど作成したConverter
を使って動作を変更することができます。
src/test/java/org/littlewings/jackson/CsvPersonWithConverter.java
package org.littlewings.jackson; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; @JsonPropertyOrder({"lastName", "firstName", "age"}) public class CsvPersonWithConverter { String lastName; String firstName; @JsonDeserialize(converter = LooseStringToIntConverter.class) int age; // getter/setterは省略 }
これで、int
に変換できない値をage
に渡しても、マッピングできるようになりました。
@Test public void parseCsvInvalidTypePass() throws JsonProcessingException { CsvMapper mapper = new CsvMapper(); ObjectReader reader = mapper.readerWithSchemaFor(CsvPersonWithConverter.class); CsvPersonWithConverter katsuo = reader.readValue("磯野,カツオ,11"); assertThat(katsuo.getLastName()).isEqualTo("磯野"); assertThat(katsuo.getFirstName()).isEqualTo("カツオ"); assertThat(katsuo.getAge()).isEqualTo(11); CsvPersonWithConverter tarao = reader.readValue("フグ田,タラオ,bbb"); assertThat(tarao.getLastName()).isEqualTo("フグ田"); assertThat(tarao.getFirstName()).isEqualTo("タラオ"); assertThat(tarao.getAge()).isEqualTo(-1); }
まとめ
こういうケースの時は、オブジェクトにマッピングできなくて失敗しますよね、と思うものの、いざ「どうでしたっけ?」と
なると自信がなくなったりするので、あらためて確認&メモとして。
ついでに、Converter
についても書いておきました。