CLOVER🍀

That was when it all began.

Jacksonで、入力として型変換できない値を渡したら?

これは、なにをしたくて書いたもの?

JSONの読み書きにはJacksonをよく使いますが、マッピング先のクラスのプロパティに、そのまま設定できない値を指定した場合
(たとえば数字ではない文字列をintのフィールドに設定しようとする)、どうなるんだっけ?というのをメモしておこうと。

結果は予想できるのですが、「そうだったよね?」的な気分になるので。

対象は、JSONとCSVでやってみます。

環境

今回の環境は、こちら。

$ 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を利用します。

GitHub - FasterXML/jackson-databind: General data-binding package for Jackson (2.x): works on streaming API (core) implementation(s)

jackson-dataformats-text/csv at jackson-dataformats-text-2.12.3 · FasterXML/jackson-dataformats-text · GitHub

お題

JSON、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で試していきましょう。

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についても書いておきました。