jacksonその2:TreeModel

jacksonについて調べたときのメモその2です。

環境は前回の記事と同じですので省略します。

https://baubaubau.hatenablog.com/entry/2024/02/04/145625

TreeModelというものがどうやらあるらしいので触ってみましょう。

https://github.com/FasterXML/jackson-databind?tab=readme-ov-file#tree-model

一応wikiにもページがありましたが中身が空っぽのようです。

https://github.com/FasterXML/jackson-databind/wiki/JacksonTreeModel

TreeModel

jacksonを使うとJavaのオブジェクトとJSONまたは、ListやMapなどのコレクションなどとJSONとの間で相互変換ができますが、JSONをトラバーサルするのには向いていません。ということでTreeModelというものがあるぞ、というみたいです。

ObjectMapperをインスタンス化し、

ObjectMapper objectMapper = new ObjectMapper();

readTreeでJSON文字列を読み込みます。するとJSONのルート要素を表現するJsonNodeが返ってくるようです。

JsonNode root;

root = objectMapper.readTree("""
    {
        "NB001": {
            "profile": {
                "firstName": "book",
                "lastName": "store",
                "age": 25
            },
            "type": [
                "software engineer"
            ]
        }
    }
    """);

トラバーサルして値を読み込むにはgetを使い目的のプロパティまで掘っていけば良さそう。

値の型に応じてasなんちゃらメソッドを呼び出せば対応するJavaの値で取得できます。

String firstName = root.get("NB001").get("profile").get("firstName").asText();
assertEquals("book", firstName);

int age = root.get("NB001").get("profile").get("age").asInt();
assertEquals(25, age);

値がない場合、結果はnullになりました。

JsonNode notExist = root.get("notExist");
assertNull(notExist);

値と型が適切ではない場合も試してみました。

どうやら変換できるものは頑張ってくれますが、無理なものは無意味な値で取得されるようです。てっきり例外が発生するかと思いましたがそうではないみたい。

int firstName = root.get("NB001").get("profile").get("firstName").asInt();
assertEquals(0, firstName); // 本当は "book"

String age = root.get("NB001").get("profile").get("age").asText();
assertEquals("25", age); // 数値の25が文字列に変換された

値を取得する前に対象がどんな型なのかを確認するには、isなんちゃらのメソッドを使えば良さそうです。

JsonNode firstNameNode = root.get("NB001").get("profile").get("firstName");
assertTrue(firstNameNode.isTextual());
assertFalse(firstNameNode.isInt());

JsonNode ageNode = root.get("NB001").get("profile").get("age");
assertTrue(ageNode.isInt());
assertFalse(ageNode.isTextual());

トラバーサるしつつ、JSONの一部分をオブジェクトへマッピングできるようです。

次のようなクラスを定義し、

public record Profile(String firstName, String lastName, int age) {
}

treeToValueで読み込みます。

Profile profile = objectMapper.treeToValue(root.get("NB001").get("profile"), Profile.class);
assertEquals(new Profile("book", "store", 25), profile);

そういえば、jacksonはネストされた構造のJSONを読み込むとどうなるのでしょうか?

こういうクラスを用意し、

record ProfileNestedJson(String firstName, String lastName, int age, ProfileNestedJsonAddress address) {
}
                                                                                                        
record ProfileNestedJsonAddress(String country, String countryCode) {
}

以下のように読み込めました。

JsonNode root = objectMapper.readTree("""
        {
            "NB001": {
                "profile": {
                    "firstName": "book",
                    "lastName": "store",
                    "age": 25,
                    "address": {
                        "country": "Japan",
                        "countryCode": "JPN"
                    }
                },
                "type": [
                    "software engineer"
                ]
            }
        }
        """);

var profileNestedJson = objectMapper.treeToValue(root.get("NB001").get("profile"), ProfileNestedJson.class);
                                                                                                                          
assertEquals(new ProfileNestedJson("book", "store", 25, new ProfileNestedJsonAddress("Japan", "JPN")), profileNestedJson);

Javaのクラスにはないプロパティを持つJSONはどうなるのでしょうか?

次のようにpostalCodeを追加してJSONを読み込むと例外が発生しました。てっきり無視されるかと思いましたが。

JsonNode root = objectMapper.readTree("""
        {
            "NB001": {
                "profile": {
                    "firstName": "book",
                    "lastName": "store",
                    "age": 25,
                    "address": {
                        "country": "Japan",
                        "countryCode": "JPN",
                        "postalCode": "000-000"
                    }
                },
                "type": [
                    "software engineer"
                ]
            }
        }
        """);
var profileNestedJson = objectMapper.treeToValue(root.get("NB001").get("profile"), ProfileNestedJson.class);
                                                                                                                          
assertEquals(new ProfileNestedJson("book", "store", 25, new ProfileNestedJsonAddress("Japan", "JPN")), profileNestedJson);

例外は次の通り。丁寧にignorableじゃないぞと書かれています。

com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "postalCode" (class org.example.TreeModelTest$TreeToValueTest$ProfileNestedJsonAddress), not marked as ignorable (2 known properties: "countryCode", "country"])
 at [Source: UNKNOWN; byte offset: #UNKNOWN] (through reference chain: org.example.TreeModelTest$TreeToValueTest$ProfileNestedJson["address"]->org.example.TreeModelTest$TreeToValueTest$ProfileNestedJsonAddress["postalCode"])

    at com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException.from(UnrecognizedPropertyException.java:61)
    at com.fasterxml.jackson.databind.DeserializationContext.handleUnknownProperty(DeserializationContext.java:1153)
    at com.fasterxml.jackson.databind.deser.std.StdDeserializer.handleUnknownProperty(StdDeserializer.java:2224)
    at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.handleUnknownProperty(BeanDeserializerBase.java:1719)
    at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.handleUnknownVanilla(BeanDeserializerBase.java:1697)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:279)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:464)
    at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1419)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:348)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:185)
    at com.fasterxml.jackson.databind.deser.SettableBeanProperty.deserialize(SettableBeanProperty.java:545)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeWithErrorWrapping(BeanDeserializer.java:571)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:440)
    at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1419)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:348)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:185)
    at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:342)
    at com.fasterxml.jackson.databind.ObjectMapper._readValue(ObjectMapper.java:4875)
    at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3033)
    at com.fasterxml.jackson.databind.ObjectMapper.treeToValue(ObjectMapper.java:3497)
    at org.example.TreeModelTest$TreeToValueTest.treeToValueNestedJsonAdditionalProperties(TreeModelTest.java:157)
    at java.base/java.lang.reflect.Method.invoke(Method.java:568)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)

どうやら @JsonIgnoreProperties で無視するプロパティを指定する必要があるとのこと。

https://github.com/FasterXML/jackson-databind?tab=readme-ov-file#annotations-ignoring-properties

というわけで次のようにしたら動きました。

record ProfileNestedJsonAdditionalProperties(String firstName,
                                             String lastName,
                                             int age,
                                             ProfileNestedJsonAdditionalPropertiesAddress address) {
}
                                                                                                    
@JsonIgnoreProperties("postalCode")
record ProfileNestedJsonAdditionalPropertiesAddress(String country, String countryCode) {
}

JsonNode root = objectMapper.readTree("""
        {
            "NB001": {
                "profile": {
                    "firstName": "book",
                    "lastName": "store",
                    "age": 25,
                    "address": {
                        "country": "Japan",
                        "countryCode": "JPN",
                        "postalCode": "000-000"
                    }
                },
                "type": [
                    "software engineer"
                ]
            }
        }
        """);
var profileNestedJson = objectMapper.treeToValue(root.get("NB001").get("profile"), ProfileNestedJsonAdditionalProperties.class);
                                                                                                                                
assertEquals(new ProfileNestedJsonAdditionalProperties("book", "store", 25,
        new ProfileNestedJsonAdditionalPropertiesAddress("Japan", "JPN")), profileNestedJson);