CLOVER🍀

That was when it all began.

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

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

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

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

対象は、JSONCSVでやってみます。

環境

今回の環境は、こちら。

$ 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

お題

JSONCSVそれぞれで、以下のことをやってみます。

  • オブジェクトにマッピングできるデータを与えて、パースする
  • オブジェクトにマッピングできないものを含むデータを与えて、パースする
  • オブジェクトにマッピングできないものを含むデータを与えて、パース時に変換する

確認はテストコードで行います。

テストコードの雛形

テストコードの雛形はこちら。

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)

こちらを使って、StringInteger#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についても書いておきました。

Linuxで、ランダム(/dev/random、/dev/urandom)に関する情報を見る

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

Linuxでの乱数生成では、/dev/randomもしくは/dev/urandomという疑似デバイスファイル(キャラクタデバイスファイル)が
使用されます。

ここで、エントロピープールがどうの、という話をよく見るわけですが、このあたりのドキュメントって見たことがないな、と
思いまして。

ちょっと調べてみようかな、と。

環境

今回の確認環境は、こちらです。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.2 LTS
Release:    20.04
Codename:   focal


$ uname -srvmpio
Linux 5.4.0-73-generic #82-Ubuntu SMP Wed Apr 14 17:39:42 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

Ubuntu Linux 20.04 LTSで、カーネルは5.4です。

/dev/randomと/dev/urandom

そもそも、/dev/random/dev/urandomについて。

こちらについては、manを見るのがよいでしょう。

random(4): kernel random number source devices - Linux man page

urandom(4): kernel random number source devices - Linux man page

randomurandomでページがあるのですが、内容が同じですね。

Man page of RANDOM

Man page of RANDOM

Ubuntu Linuxのドキュメントの日本語訳がしっかりしているので、こちらを見るとよいかもです。

(Linux 1.3.30 から提供されている) /dev/random 、 /dev/urandom キャラクタースペシャルファイルは カーネル乱数ジェネレーターへのインターフェースを提供する。

なので、catで読み出せます。コンソールが埋まりますけど。

$ cat /dev/random
$ cat /dev/urandom

エントロピープールは、入力デバイスなどの環境ノイズから生成されるようです。

乱数ジェネレーターはデバイスドライバやその他の源からの環境ノイズを エントロピープールへ集める。 また、ジェネレーターはエントロピープール内のノイズのビット数の推定値を保持する。このエントロピープールから乱数が生成される。

このため、Linuxの起動直後のユーザーがあまり操作していない状態でのエントロピープールは、予測可能になりやすい状態に
あると言えるようです。

よく言われる、/dev/random/dev/urandomの違い。

読み込みが行われると、 /dev/random デバイスエントロピープールのノイズビットの数の推定値のうち、 ランダムバイトのみを返す。 /dev/random はワンタイムパッド (one-time pad) や鍵の生成のような 非常に高い品質を持った無作為性が必要になる用途に向いているだろう。 エントロピープールが空の時は、/dev/random からの読み出しは、 更なる環境ノイズが得られるまで、ブロックされる。

/dev/urandom デバイスから読み出しでは、 エントロピーがより高くなるのを待つためのブロックは行われない。 十分なエントロピーがない場合、 要求されたバイトを作成するのに疑似乱数生成器が使用される。 その結果、 この場合の返り値はこのドライバで使われているアルゴリズムに基づく暗号攻撃に対して、 論理的には弱くなることになる。 この攻撃をどのように行うかという事については、現在研究論文などの 形で入手できる資料はない、しかし、そのような攻撃は論理的に存在可能である。 もし、この事が心配なら、(/dev/urandom ではなく) /dev/random を利用すればいい。

乱数生成には、エントロピープールが必要になります。/dev/randomは乱数を返しますがエントロピープールが空になると
ブロックし、/dev/urandomエントロピープールが空になると疑似乱数生成を行うもののブロックしない、という性質に
なります、と。

どちらを使うか、というのは一般には/dev/urandomで十分で、鍵などを作成する場合は/dev/random、という使い分けに
なります。

/dev/random と /dev/urandom のどちらを使うべきか迷った場合、たいていは /dev/urandom の方を使いたいと思っているはずだろう。 一般に、長期に渡って使われる GPG/SSL/SSH のキー以外の全てのものに /dev/urandom を使用すべきである。

Ubuntu Manpage: random, urandom - カーネル乱数ソースデバイス

Ubuntu Manpage: random, urandom - カーネル乱数ソースデバイス

/procやsysctlから見るランダムに関する情報

/dev/randomおよび/dev/urandomのmanページを見ていると、/procディレクトリのファイルの説明が出てきます。

ちなみに、カーネルのドキュメントには5.9から載ったみたいです。

Documentation for /proc/sys/kernel/ / random

/proc/sys/kernel/ディレクトリには、ランダムに関する情報を確認できるファイルがあります。全部で7つですね。

  • boot_id … UUIDが取得できるが、最初に取得した値から変化しない
  • entropy_avail … 現在利用可能なエントロピー数(ビット単位)
  • poolsizeエントロピープールのサイズ(ビット単位)
  • urandom_min_reseed_secs … (廃止)
  • uuid … 読み込む度にUUIDが生成される
  • write_wakeup_thresholdエントロピー数がこの値を下回ると、/dev/randomへの書き込みのためのプロセスが起動する(ビット単位)
  • read_wakeup_threshold/dev/randomを呼び出して休止しているプロセスを起こすために必要な、エントロピー数(ビット単位)

変更できるのは、write_wakeup_thresholdread_wakeup_thresholdくらいで、あとは読み取り専用みたいです。

現在の値を確認してみましょう。

まずは、/procファイルシステムentropy_availを見てみましょう。

$ cat /proc/sys/kernel/random/entropy_avail
1712

少し時間を置いて見ると、値が増えます。poolsizeが増えた場合は、利用可能なエントロピーが増えたということを意味しますね。

$ cat /proc/sys/kernel/random/entropy_avail
1720

uuidについては、参照する度に値が変わります。

$ cat /proc/sys/kernel/random/uuid
be0a15c4-c32f-48c8-94af-6a0009aa4fed

$ cat /proc/sys/kernel/random/uuid
4337ab9e-137d-4b7c-8b1a-0bcedd2aa58d

sysctlでも確認できます。これなら、一気に見れますね。

$ sudo sysctl -a | fgrep kernel.random.
kernel.random.boot_id = b13979d5-7e52-45ab-a9e4-14d475cab5b3
kernel.random.entropy_avail = 1757
kernel.random.poolsize = 4096
kernel.random.read_wakeup_threshold = 64
kernel.random.urandom_min_reseed_secs = 60
kernel.random.uuid = 6fbdb4d3-29a3-4842-ac21-ff76fc5f32ec
kernel.random.write_wakeup_threshold = 1024

sysctlで見てもkernel.random.uuidは呼び出す度に値が変わりますし、kernel.random.poolsizeは呼び出した時点で
値が増えていたりします。

$ sudo sysctl -a | fgrep kernel.random.
kernel.random.boot_id = b13979d5-7e52-45ab-a9e4-14d475cab5b3
kernel.random.entropy_avail = 1766
kernel.random.poolsize = 4096
kernel.random.read_wakeup_threshold = 64
kernel.random.urandom_min_reseed_secs = 60
kernel.random.uuid = bd6fdfb2-5c0f-4a02-a675-919585c341de
kernel.random.write_wakeup_threshold = 1024

エントロピープールを消費してみる

ここで、エントロピープールを消費してみましょう。

以下のコマンドを実行している間に

$ cat /dev/random | hexdump
$ cat /dev/urandom | hexdump

別のターミナルでkernel.random.*を確認してみます。

$ watch 'sudo sysctl -a | fgrep kernel.random.'

まずは、/dev/randomから。

$ cat /dev/random | hexdump

実行するとひたすら乱数が表示され続けるので載せませんが、headで切るとこんな感じですね。

$ cat /dev/random | hexdump | head -n 10
0000000 9dd4 8cf2 c40e 9bd5 df2c 8fed fb03 1bdc
0000010 bf56 db83 4e9f e88b 5230 6175 ff41 3824
0000020 ab8e 7df8 1f17 b32b eb1f c56e bfdd 5f90
0000030 55f5 87d5 c953 e0da d60a de10 2aa2 9ea4
0000040 e7fe 7da5 8429 10f9 d39a 8f13 3e4d 4c92
0000050 accf 7e91 9022 5323 82e4 5b0b 965f 270a
0000060 406d 3447 b790 d2fc 2413 c9f6 583f 6d95
0000070 0453 01c9 9e40 5a8e e837 ea29 0077 acaa
0000080 2525 ec19 0ccb 6ee0 10c3 f8a8 3ef2 838d
0000090 30a1 26e0 c5dd fa57 0960 c153 47a4 4790

エントロピープールの状態がこうだったとして

$ sudo sysctl -a | fgrep kernel.random.
kernel.random.boot_id = d6f78aae-e986-47ad-ac06-1c5b5dba07ff
kernel.random.entropy_avail = 2334
kernel.random.poolsize = 4096
kernel.random.read_wakeup_threshold = 64
kernel.random.urandom_min_reseed_secs = 60
kernel.random.uuid = 0ea5219b-30fc-4400-be99-f58a292f98ef
kernel.random.write_wakeup_threshold = 1024

以下のコマンドを実行すると

$ cat /dev/random | hexdump

kernel.random.entropy_availの値が増減するのが確認できると思います。

が、わかりやすくブロックしません…。
kernel.random.read_wakeup_thresholdを1024とかにしてもうまくいきませんでした…

こちらについては、kernel.random.entropy_availの値がほぼ下がりません…。

$ cat /dev/urandom | hexdump

/dev/randomの読み出しがブロックするところまで確認したかったのですが…情報はある程度見ることは
できましたし、今回はここまでにしましょうか。