CLOVER🍀

That was when it all began.

Jacksonで、オブジェクトをシリアライズする際にnullのプロパティを出力しないようにする

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

Jacksonを使ってオブジェクトをJSONにシリアライズする際に、nullのプロパティの出力は抑制したくなることがあります。

JSONにした時に、以下のような状態を

{
  "property1": "value1",
  "property2": null
}

こうする、という話ですね。

{
  "property1": "value1"
}

このやり方を忘れて毎回調べている気がするので、メモしておこうかなと。

ふつうにJacksonを使った場合、Spring Bootを使った場合、Quarkusを使った場合でそれぞれメモしていきます。

方法

大きく、2つの方法があります。

前者は対象となるプロパティを持つクラス、もしくはプロパティ自体に付与し、後者はデフォルトの設定として振る舞います。

Spring BootやQuarkusは、ObjectMapper#setSerializationInclusionを設定ファイルで行えるようになっています。

今回は、それぞれをJackson 2.14.1で試していこうかなと思います。Spring Boot 3.0.1、Quarkus 2.15.3.Finalでも含まれているJacksonの
バージョンは2.14.1になっています。

環境

今回の環境は、こちら。

$ java --version
openjdk 17.0.5 2022-10-18
OpenJDK Runtime Environment (build 17.0.5+8-Ubuntu-2ubuntu122.04)
OpenJDK 64-Bit Server VM (build 17.0.5+8-Ubuntu-2ubuntu122.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.7 (b89d5959fcde851dcb1c8946a785a163f14e1e29)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.5, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.15.0-58-generic", arch: "amd64", family: "unix"

お題

書籍をお題にして、以下のようなクラスを対象にしたいと思います。

src/main/java/org/littlewings/jackson/nonnull/Book.java

package org.littlewings.jackson.nonnull;

import java.util.Arrays;
import java.util.List;

public class Book {
    String isbn;
    String title;
    Integer price;
    Category category;
    List<Category> categories;

    public static Book create(String isbn, String title, Integer price, Category... categories) {
        Book book = new Book();
        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);

        book.setCategory(categories.length > 0 ? categories[0] : null);
        book.setCategories(categories != null ? Arrays.asList(categories) : null);

        return book;
    }

    // getter/setterは省略
}

ネストしたプロパティおよびコレクションについても確認したかったので、カテゴリーを持つことにして、ちょっとわかりにくいですが
カテゴリーの最初のひとつを単独のプロパティで持つことにしました。

    Category category;
    List<Category> categories;

カテゴリー側はこんな感じにします。

src/main/java/org/littlewings/jackson/nonnull/Category.java

package org.littlewings.jackson.nonnull;

public class Category {
    Integer id;
    String name;

    public static Category create(Integer id, String name) {
        Category category = new Category();
        category.setId(id);
        category.setName(name);

        return category;
    }

    // getter/setterは省略
}

Jackson単体で試してみる

まずは、Jackson単体で試します。

確認はテストコードで行いましょう。

準備

Maven依存関係など。

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.14.1</version>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.9.2</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.24.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
        </plugins>
    </build>

テストコードの雛形をこんな感じで作成。

src/test/java/org/littlewings/jackson/nonnull/JacksonIncludeNonNullTest.java

package org.littlewings.jackson.nonnull;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;

import static org.assertj.core.api.Assertions.assertThat;

public class JacksonIncludeNonNullTest {

    // ここにテストを書く!!
}
まずはふつうに

とりあえず、すべてのプロパティが非nullになるようにしてみます。

    @Test
    void simply(TestInfo testInfo) throws JsonProcessingException {
        Book book =
                Book.create(
                        " 978-4621303252",
                        "Effective Java 第3版",
                        4400,
                        Category.create(1, "java"),
                        Category.create(2, "programming")
                );

        ObjectMapper mapper = new ObjectMapper();

        String json = mapper.writeValueAsString(book);

        System.out.printf("%s: json = %s%n", testInfo.getDisplayName(), json);
        assertThat(json)
                .isEqualTo("{\"isbn\":\" 978-4621303252\",\"title\":\"Effective Java 第3版\",\"price\":4400,\"category\":{\"id\":1,\"name\":\"java\"},\"categories\":[{\"id\":1,\"name\":\"java\"},{\"id\":2,\"name\":\"programming\"}]}");
    }

System.out.printfしている部分の結果。

simply(TestInfo): json = {"isbn":" 978-4621303252","title":"Effective Java 第3版","price":4400,"category":{"id":1,"name":"java"},"categories":[{"id":1,"name":"java"},{"id":2,"name":"programming"}]}

整形後。

{
  "isbn": " 978-4621303252",
  "title": "Effective Java 第3版",
  "price": 4400,
  "category": {
    "id": 1,
    "name": "java"
  },
  "categories": [
    {
      "id": 1,
      "name": "java"
    },
    {
      "id": 2,
      "name": "programming"
    }
  ]
}
nullを含めてみる

次に、nullを含めてみます。

    @Test
    void withNull(TestInfo testInfo) throws JsonProcessingException {
        Book book1 =
                Book.create(
                        " 978-4621303252",
                        null,
                        null,
                        Category.create(null, "java"),
                        Category.create(2, null)
                );
        Book book2 =
                Book.create(
                        " 978-4621303252",
                        null,
                        null
                );

        ObjectMapper mapper = new ObjectMapper();

        String json1 = mapper.writeValueAsString(book1);
        String json2 = mapper.writeValueAsString(book2);

        System.out.printf("%s: json1 = %s%n", testInfo.getDisplayName(), json1);
        assertThat(json1)
                .isEqualTo("{\"isbn\":\" 978-4621303252\",\"title\":null,\"price\":null,\"category\":{\"id\":null,\"name\":\"java\"},\"categories\":[{\"id\":null,\"name\":\"java\"},{\"id\":2,\"name\":null}]}");

        System.out.printf("%s: json2 = %s%n", testInfo.getDisplayName(), json2);
        assertThat(json2)
                .isEqualTo("{\"isbn\":\" 978-4621303252\",\"title\":null,\"price\":null,\"category\":null,\"categories\":[]}");
    }

適当にいくつかプロパティをnullにしておきます。

        Book book1 =
                Book.create(
                        " 978-4621303252",
                        null,
                        null,
                        Category.create(null, "java"),
                        Category.create(2, null)
                );
        Book book2 =
                Book.create(
                        " 978-4621303252",
                        null,
                        null
                );

標準出力の書き出し結果。

withNull(TestInfo): json1 = {"isbn":" 978-4621303252","title":null,"price":null,"category":{"id":null,"name":"java"},"categories":[{"id":null,"name":"java"},{"id":2,"name":null}]}
withNull(TestInfo): json2 = {"isbn":" 978-4621303252","title":null,"price":null,"category":null,"categories":[]}

整形後。

## json1

{
  "isbn": " 978-4621303252",
  "title": null,
  "price": null,
  "category": {
    "id": null,
    "name": "java"
  },
  "categories": [
    {
      "id": null,
      "name": "java"
    },
    {
      "id": 2,
      "name": null
    }
  ]
}



## json2

{
  "isbn": " 978-4621303252",
  "title": null,
  "price": null,
  "category": null,
  "categories": []
}

nullがそのまま出力されますね。なお、コレクションにnullを指定すると空のコレクションになりましたね。

@JsonInclude(JsonInclude.Include.NON_NULL)を使う

では、@JsonInclude(JsonInclude.Include.NON_NULL)を使ってみましょう。

src/main/java/org/littlewings/jackson/nonnull/BookNonNull.java

package org.littlewings.jackson.nonnull;

import java.util.Arrays;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_NULL)
public class BookNonNull {
    String isbn;
    String title;
    Integer price;
    Category category;
    List<Category> categories;

    public static BookNonNull create(String isbn, String title, Integer price, Category... categories) {
        BookNonNull book = new BookNonNull();
        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);

        book.setCategory(categories.length > 0 ? categories[0] : null);
        book.setCategories(categories != null ? Arrays.asList(categories) : null);

        return book;
    }

    // getter/setterは省略
}

クラスに@JsonInclude(JsonInclude.Include.NON_NULL)を付与。

@JsonInclude(JsonInclude.Include.NON_NULL)
public class BookNonNull {

テストコード。

    @Test
    void withNullExclude(TestInfo testInfo) throws JsonProcessingException {
        BookNonNull book1 =
                BookNonNull.create(
                        " 978-4621303252",
                        null,
                        null,
                        Category.create(null, "java"),
                        Category.create(2, null)
                );
        BookNonNull book2 =
                BookNonNull.create(
                        " 978-4621303252",
                        null,
                        null
                );

        ObjectMapper mapper = new ObjectMapper();

        String json1 = mapper.writeValueAsString(book1);
        String json2 = mapper.writeValueAsString(book2);

        System.out.printf("%s: json1 = %s%n", testInfo.getDisplayName(), json1);
        assertThat(json1)
                .isEqualTo("{\"isbn\":\" 978-4621303252\",\"category\":{\"id\":null,\"name\":\"java\"},\"categories\":[{\"id\":null,\"name\":\"java\"},{\"id\":2,\"name\":null}]}");

        System.out.printf("%s: json2 = %s%n", testInfo.getDisplayName(), json2);
        assertThat(json2)
                .isEqualTo("{\"isbn\":\" 978-4621303252\",\"categories\":[]}");
    }

標準出力の書き出し結果。

withNullExclude(TestInfo): json1 = {"isbn":" 978-4621303252","category":{"id":null,"name":"java"},"categories":[{"id":null,"name":"java"},{"id":2,"name":null}]}
withNullExclude(TestInfo): json2 = {"isbn":" 978-4621303252","categories":[]}

整形後。

## json1

{
  "isbn": " 978-4621303252",
  "category": {
    "id": null,
    "name": "java"
  },
  "categories": [
    {
      "id": null,
      "name": "java"
    },
    {
      "id": 2,
      "name": null
    }
  ]
}


## json2

{
  "isbn": " 978-4621303252",
  "categories": []
}

おや?と思うかもしれませんが、ネストしたプロパティ内のプロパティがnullだったり、コレクション内のプロパティがnullだったりすると
そのまま出力されています。

このようなケースに対しては、単にプロパティを保持したクラスに@JsonInclude(JsonInclude.Include.NON_NULL)を付与しただけでは
効果がありません。

ネストしたクラスにも@JsonInclude(JsonInclude.Include.NON_NULL)を付与する

ネストしたプロパティのnullも除外するようにしていきましょう。

カテゴリー用のクラスにも@JsonInclude(JsonInclude.Include.NON_NULL)を付与。

src/main/java/org/littlewings/jackson/nonnull/CategoryNonNull.java

package org.littlewings.jackson.nonnull;

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_NULL)
public class CategoryNonNull {
    Integer id;
    String name;

    public static CategoryNonNull create(Integer id, String name) {
        CategoryNonNull category = new CategoryNonNull();
        category.setId(id);
        category.setName(name);

        return category;
    }

    // getter/setterは省略
}

作成したクラスを使うように、書籍側のクラスも作成。

src/main/java/org/littlewings/jackson/nonnull/BookNonNullNested.java

package org.littlewings.jackson.nonnull;

import java.util.Arrays;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_NULL)
public class BookNonNullNested {
    String isbn;
    String title;
    Integer price;
    CategoryNonNull category;
    List<CategoryNonNull> categories;

    public static BookNonNullNested create(String isbn, String title, Integer price, CategoryNonNull... categories) {
        BookNonNullNested book = new BookNonNullNested();
        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);

        book.setCategory(categories.length > 0 ? categories[0] : null);
        book.setCategories(categories != null ? Arrays.asList(categories) : null);

        return book;
    }

    // getter/setterは省略
}

テストコード。

    @Test
    void withNullExcludeIncludeNested(TestInfo testInfo) throws JsonProcessingException {
        BookNonNullNested book1 =
                BookNonNullNested.create(
                        " 978-4621303252",
                        null,
                        null,
                        CategoryNonNull.create(null, "java"),
                        CategoryNonNull.create(2, null)
                );
        BookNonNullNested book2 =
                BookNonNullNested.create(
                        " 978-4621303252",
                        null,
                        null
                );

        ObjectMapper mapper = new ObjectMapper();

        String json1 = mapper.writeValueAsString(book1);
        String json2 = mapper.writeValueAsString(book2);

        System.out.printf("%s: json1 = %s%n", testInfo.getDisplayName(), json1);
        assertThat(json1)
                .isEqualTo("{\"isbn\":\" 978-4621303252\",\"category\":{\"name\":\"java\"},\"categories\":[{\"name\":\"java\"},{\"id\":2}]}");

        System.out.printf("%s: json2 = %s%n", testInfo.getDisplayName(), json2);
        assertThat(json2)
                .isEqualTo("{\"isbn\":\" 978-4621303252\",\"categories\":[]}");
    }

標準出力に書き出された内容。

withNullExcludeIncludeNested(TestInfo): json1 = {"isbn":" 978-4621303252","category":{"name":"java"},"categories":[{"name":"java"},{"id":2}]}
withNullExcludeIncludeNested(TestInfo): json2 = {"isbn":" 978-4621303252","categories":[]}

整形後。

## json1

{
  "isbn": " 978-4621303252",
  "category": {
    "name": "java"
  },
  "categories": [
    {
      "name": "java"
    },
    {
      "id": 2
    }
  ]
}


## json2

{
  "isbn": " 978-4621303252",
  "categories": []
}

これで、ネストしたプロパティからもnullがなくなりました。

空のコレクションを出力しないようにする

ところで、空のコレクションも出力しないようにする場合はどうしたらいいのでしょうか?

{
  "isbn": " 978-4621303252",
  "categories": []
}

nullの出力抑止と組み合わせると、こんな感じですね。

@JsonInclude(
        value = JsonInclude.Include.NON_EMPTY,
        content = JsonInclude.Include.NON_NULL
)
public class BookNonNullNested {

特定のプロパティのみ設定する場合はこうなります。

    @JsonInclude(
            value = JsonInclude.Include.NON_EMPTY,
            content = JsonInclude.Include.NON_NULL
    )
    List<CategoryNonNull> categories;

これは、JsonIncludeアノテーションのJavadocに説明があります。

Note that the main inclusion criteria (one annotated with value()) is checked on Java object level, for the annotated type, and NOT on JSON output -- so even with JsonInclude.Include.NON_NULL it is possible that JSON null values are output, if object reference in question is not null. An example is AtomicReference instance constructed to reference null value: such a value would be serialized as JSON null, and not filtered out.

To base inclusion on value of contained value(s), you will typically also need to specify content() annotation; for example, specifying only value() as JsonInclude.Include.NON_EMPTY for a {link java.util.Map} would exclude Maps with no values, but would include Maps with null values. To exclude Map with only null value, you would use both annotations like so:

public class Bean {
    @JsonInclude(value=Include.NON_EMPTY, content=Include.NON_NULL)
   public Map<String,String> entries;
}

JsonInclude (Jackson-annotations 2.14.0 API)

設定後の出力結果(整形後)はこうですね。

{
  "isbn": " 978-4621303252"
}
デフォルトでnullを出力しないように設定する

ここまで@JsonIncludeアノテーションで設定してきましたが、個別に付与するのが面倒だと思うこともあるかなと。

これは、ObjectMapper#setSerializationInclusionにJsonInclude.Include.NON_NULLを指定することで実現できます。

    @Test
    void defaultIncludeNonNull(TestInfo testInfo) throws JsonProcessingException {
        Book book1 =
                Book.create(
                        " 978-4621303252",
                        null,
                        null,
                        Category.create(null, "java"),
                        Category.create(2, null)
                );
        Book book2 =
                Book.create(
                        " 978-4621303252",
                        null,
                        null
                );

        ObjectMapper mapper =
                new ObjectMapper()
                        .setSerializationInclusion(JsonInclude.Include.NON_NULL);

        String json1 = mapper.writeValueAsString(book1);
        String json2 = mapper.writeValueAsString(book2);

        System.out.printf("%s: json1 = %s%n", testInfo.getDisplayName(), json1);
        assertThat(json1)
                .isEqualTo("{\"isbn\":\" 978-4621303252\",\"category\":{\"name\":\"java\"},\"categories\":[{\"name\":\"java\"},{\"id\":2}]}");

        System.out.printf("%s: json2 = %s%n", testInfo.getDisplayName(), json2);
        assertThat(json2)
                .isEqualTo("{\"isbn\":\" 978-4621303252\",\"categories\":[]}");
    }

先ほどやっていた、空のコレクションの出力を抑制する場合はJsonInclude.Include.NON_EMPTYを追加します。

    @Test
    void defaultIncludeNonNullAndNonEmpty(TestInfo testInfo) throws JsonProcessingException {
        Book book1 =
                Book.create(
                        " 978-4621303252",
                        null,
                        null,
                        Category.create(null, "java"),
                        Category.create(2, null)
                );
        Book book2 =
                Book.create(
                        " 978-4621303252",
                        null,
                        null
                );

        ObjectMapper mapper =
                new ObjectMapper()
                        .setSerializationInclusion(JsonInclude.Include.NON_NULL)
                        .setSerializationInclusion(JsonInclude.Include.NON_EMPTY);

        String json1 = mapper.writeValueAsString(book1);
        String json2 = mapper.writeValueAsString(book2);

        System.out.printf("%s: json1 = %s%n", testInfo.getDisplayName(), json1);
        assertThat(json1)
                .isEqualTo("{\"isbn\":\" 978-4621303252\",\"category\":{\"name\":\"java\"},\"categories\":[{\"name\":\"java\"},{\"id\":2}]}");

        System.out.printf("%s: json2 = %s%n", testInfo.getDisplayName(), json2);
        assertThat(json2)
                .isEqualTo("{\"isbn\":\" 978-4621303252\"}");
    }

ここまで押さえておけばOKかなと。

Spring Bootのプロパティで設定する

次は、Spring Bootのプロパティで設定してみます。ここからは@JsonIncludeアノテーションを個別に付与するのではなく、
ObjectMapper#setSerializationInclusionをフレームワークの機能で設定する方法を見ていこうと思います。

Spring Bootプロジェクトを作成。

$ curl -s https://start.spring.io/starter.tgz \
  -d bootVersion=3.0.1 \
  -d javaVersion=17 \
  -d type=maven-project \
  -d name=spring-boot-jackson-include-non-null \
  -d groupId=org.littlewings \
  -d artifactId=spring-boot-jackson-include-non-null \
  -d version=0.0.1-SNAPSHOT \
  -d packageName=org.littlewings.jackson.nonnull \
  -d dependencies=web \
  -d baseDir=spring-boot-jackson-include-non-null | tar zxvf -

プロジェクト内へ移動。

$ cd spring-boot-jackson-include-non-null

Maven依存関係など。

        <properties>
                <java.version>17</java.version>
        </properties>
        <dependencies>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-web</artifactId>
                </dependency>

                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-test</artifactId>
                        <scope>test</scope>
                </dependency>
        </dependencies>

        <build>
                <plugins>
                        <plugin>
                                <groupId>org.springframework.boot</groupId>
                                <artifactId>spring-boot-maven-plugin</artifactId>
                        </plugin>
                </plugins>
        </build>

ここに、テスト用にREST Assuredを追加しておきました。

     <dependency>
            <groupId>io.rest-assured</groupId>
            <artifactId>rest-assured</artifactId>
            <version>5.3.0</version>
            <scope>test</scope>
        </dependency>

自動生成されたソースコードは、削除しておきます。

$ rm src/main/java/org/littlewings/jackson/nonnull/SpringBootJacksonIncludeNonNullApplication.java src/test/java/org/littlewings/jackson/nonnull/SpringBootJacksonIncludeNonNullApplicationTests.java

このプロジェクトに、先ほどのBookクラスとCategoryクラスを追加しておきます。

$ cp /path/to/src/main/java/org/littlewings/jackson/nonnull/{Book.java,Category.java} src/main/java/org/littlewings/jackson/nonnull

@JsonIncludeが「付与されていない」方のクラスですね。

public class Book {


public class Category {

mainメソッドを持ったクラスに、RestControllerも付けておきます。

src/main/java/org/littlewings/jackson/nonnull/App.java

package org.littlewings.jackson.nonnull;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@SpringBootApplication
public class App {
    public static void main(String... args) {
        SpringApplication.run(App.class, args);
    }

    @GetMapping("/simply")
    public Book simply() {
        return Book.create(
                " 978-4621303252",
                "Effective Java 第3版",
                4400,
                Category.create(1, "java"),
                Category.create(2, "programming")
        );
    }

    @GetMapping("/with-null")
    public Book withNull() {
        return Book.create(
                " 978-4621303252",
                null,
                null,
                Category.create(null, "java"),
                Category.create(2, null)
        );
    }

    @GetMapping("/with-null-empty-collection")
    public Book withNullAndEmptyCollection() {
        return Book.create(
                " 978-4621303252",
                null,
                null
        );
    }
}

レスポンスの内容は、Jackson単体でテストしていた時と同じものです。

application.propertiesには、以下の内容を定義します。

src/main/resources/application.properties

spring.jackson.default-property-inclusion=non_null

これで、ObjectMapper#serializationInclusionにJsonInclude.Includeの値を指定していることになります。

Spring Bootのプロパティを見ているとspring.jackson.serialization.*なのでは?と一瞬思いたくなりますが、こちらは違いますね。

Jackson on/off features that affect the way Java objects are serialized.

Application Properties / JSON Properties / spring.jackson.serialization.*

spring.jackson.default-property-inclusionの説明をちゃんと見ると、JsonInclude.Includeの値を指定するプロパティであることが書かれています。

Application Properties / JSON Properties / spring.jackson.default-property-inclusion

Controls the inclusion of properties during serialization. Configured with one of the values in Jackson's JsonInclude.Include enumeration.

ソースコード上でも確認。

               if (this.jacksonProperties.getDefaultPropertyInclusion() != null) {
                    builder.serializationInclusion(this.jacksonProperties.getDefaultPropertyInclusion());
                }

https://github.com/spring-projects/spring-boot/blob/v3.0.1/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfiguration.java#L203-L205

では、テストコードで確認してみます。

src/test/java/org/littlewings/jackson/nonnull/JacksonTest.java

package org.littlewings.jackson.nonnull;

import java.net.URI;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;

import static io.restassured.RestAssured.given;
import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class JacksonTest {
    @LocalServerPort
    int port;


    @Test
    void simply() {
        String body =
                given()
                        .get(URI.create("http://localhost:" + port + "/simply"))
                        .asString();
        assertThat(body).isEqualTo("{\"isbn\":\" 978-4621303252\",\"title\":\"Effective Java 第3版\",\"price\":4400,\"category\":{\"id\":1,\"name\":\"java\"},\"categories\":[{\"id\":1,\"name\":\"java\"},{\"id\":2,\"name\":\"programming\"}]}");
    }

    @Test
    void withNull() {
        String body =
                given()
                        .get(URI.create("http://localhost:" + port + "/with-null"))
                        .asString();
        assertThat(body).isEqualTo("{\"isbn\":\" 978-4621303252\",\"category\":{\"name\":\"java\"},\"categories\":[{\"name\":\"java\"},{\"id\":2}]}");
    }

    @Test
    void withNullAndEmptyCollection() {
        String body =
                given()
                        .get(URI.create("http://localhost:" + port + "/with-null-empty-collection"))
                        .asString();
        assertThat(body).isEqualTo("{\"isbn\":\" 978-4621303252\",\"categories\":[]}");
    }
}

nullの項目がすべてなくなっています。OKですね。

当然ですが、以下のように設定を削除すると

#spring.jackson.default-property-inclusion=non_null

値がnullのプロパティも含まれるようになります。

{"isbn":" 978-4621303252","title":null,"price":null,"category":{"id":null,"name":"java"},"categories":[{"id":null,"name":"java"},{"id":2,"name":null}]}


{"isbn":" 978-4621303252","title":null,"price":null,"category":null,"categories":[]}

ところで、先ほどJackson単体の時に行っていたJsonInclude.IncludeのNON_NULLとNON_EMPTYを同時に指定するようなことは、
設定ではできなさそうですね。

https://github.com/spring-projects/spring-boot/blob/v3.0.1/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonProperties.java#L89-L93

この場合はJackson2ObjectMapperBuilderCustomizerでなんとかするのかな、と思います。

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
        return jackson2ObjectMapperBuilder -> {
            jackson2ObjectMapperBuilder.serializationInclusion(JsonInclude.Include.NON_NULL);
            jackson2ObjectMapperBuilder.serializationInclusion(JsonInclude.Include.NON_EMPTY);
        };
    }

Quarkusのプロパティで設定する

最後はQuarkusです。こちらも@JsonIncludeアノテーションを個別に付与するのではなく、ObjectMapper#setSerializationInclusionを
フレームワークの機能で設定する方法を見ていこうと思います。

Quarkusプロジェクトを作成。

$ mvn io.quarkus.platform:quarkus-maven-plugin:2.15.3.Final:create \
    -DprojectGroupId=org.littlewings \
    -DprojectArtifactId=quarkus-jackson-include-non-null \
    -DprojectVersion=0.0.1-SNAPSHOT \
    -Dextensions='resteasy-reactive,resteasy-reactive-jackson' \
    -DnoCode

RESTEasyは、なんとなくReactiveにしています。

プロジェクト内に移動。

$ cd quarkus-jackson-include-non-null

Maven依存関係など。

  <properties>
    <compiler-plugin.version>3.10.1</compiler-plugin.version>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
    <quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
    <quarkus.platform.version>2.15.3.Final</quarkus.platform.version>
    <skipITs>true</skipITs>
    <surefire-plugin.version>3.0.0-M7</surefire-plugin.version>
  </properties>
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>${quarkus.platform.group-id}</groupId>
        <artifactId>${quarkus.platform.artifact-id}</artifactId>
        <version>${quarkus.platform.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-resteasy-reactive</artifactId>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-resteasy-reactive-jackson</artifactId>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-arc</artifactId>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-junit5</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

ここにREST Assuredも追加しておきます。

    <dependency>
      <groupId>io.rest-assured</groupId>
      <artifactId>rest-assured</artifactId>
      <scope>test</scope>
    </dependency>

このプロジェクトに、先ほどのBookクラスとCategoryクラスを追加しておきます。

$ cp /path/to/src/main/java/org/littlewings/jackson/nonnull/{Book.java,Category.java} src/main/java/org/littlewings/jackson/nonnull

@JsonIncludeが「付与されていない」方のクラスですね。

public class Book {


public class Category {

JAX-RSリソースクラスを作成。

src/main/java/org/littlewings/jackson/nonnull/BookResource.java

package org.littlewings.jackson.nonnull;

import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

import io.smallrye.mutiny.Uni;

@Path("")
public class BookResource {
    @GET
    @Path("simply")
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<Book> simply() {
        return Uni
                .createFrom()
                .item(
                        Book.create(
                                " 978-4621303252",
                                "Effective Java 第3版",
                                4400,
                                Category.create(1, "java"),
                                Category.create(2, "programming")
                        )
                );
    }

    @GET
    @Path("with-null")
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<Book> withNull() {
        return Uni
                .createFrom()
                .item(
                        Book.create(
                                " 978-4621303252",
                                null,
                                null,
                                Category.create(null, "java"),
                                Category.create(2, null)
                        )
                );
    }

    @GET
    @Path("with-null-empty-collection")
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<Book> withNullAndEmptyCollection() {
        return Uni
                .createFrom()
                .item(
                        Book.create(
                                " 978-4621303252",
                                null,
                                null
                        )
                );
    }
}

RESTEasy Reactiveを使っているところ以外は、Spring Bootの時とそう変わりません。

application.propertiesには、以下の内容を書いておきます。

src/main/resources/application.properties

quarkus.jackson.serialization-inclusion=non-null

こちらのプロパティですね。

All configuration options / Jackson / quarkus.jackson.serialization-inclusion

Quarkusの場合、指定できる値が書かれているのでわかりやすいかなと思います。

always, non-null, non-absent, non-empty, non-default, custom, use-defaults

テスト。

src/test/java/org/littlewings/jackson/nonnull/BookResourceTest.java

package org.littlewings.jackson.nonnull;

import io.quarkus.test.common.http.TestHTTPEndpoint;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;

import static io.restassured.RestAssured.given;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;

@QuarkusTest
@TestHTTPEndpoint(BookResource.class)
class BookResourceTest {
    @Test
    void simply() {
        String body =
                given()
                        .get("/simply")
                        .asString();
        assertThat(body, is("{\"isbn\":\" 978-4621303252\",\"title\":\"Effective Java 第3版\",\"price\":4400,\"category\":{\"id\":1,\"name\":\"java\"},\"categories\":[{\"id\":1,\"name\":\"java\"},{\"id\":2,\"name\":\"programming\"}]}"));
    }

    @Test
    void withNull() {
        String body =
                given()
                        .get("/with-null")
                        .asString();
        assertThat(body, is("{\"isbn\":\" 978-4621303252\",\"category\":{\"name\":\"java\"},\"categories\":[{\"name\":\"java\"},{\"id\":2}]}"));
    }

    @Test
    void withNullAndEmptyCollection() {
        String body =
                given()
                        .get("/with-null-empty-collection")
                        .asString();
        assertThat(body, is("{\"isbn\":\" 978-4621303252\",\"categories\":[]}"));
    }
}

なお、quarkus.jackson.serialization-inclusionの指定を削除すると

src/main/resources/application.properties

#quarkus.jackson.serialization-inclusion=non-null

このテストはやはり失敗します。

{\"isbn\":\" 978-4621303252\",\"title\":null,\"price\":null,\"category\":{\"id\":null,\"name\":\"java\"},\"categories\":[{\"id\":null,\"name\":\"java\"},{\"id\":2,\"name\":null}]}


{\"isbn\":\" 978-4621303252\",\"title\":null,\"price\":null,\"category\":null,\"categories\":[]}

Quarkusの場合も、JsonInclude.IncludeのNON_NULLとNON_EMPTYを同時に指定するようなことはプロパティ指定では
できなさそうです。

        if (serializationInclusion != null) {
            objectMapper.setSerializationInclusion(serializationInclusion);
        }

https://github.com/quarkusio/quarkus/blob/2.15.3.Final/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/ObjectMapperProducer.java#L52-L54

これを実現したい場合は、ObjectMapperCustomizerを使えばよさそうです。

Writing JSON REST Services / Creating your first JSON REST service / Configuring JSON support / Jackson

こんな感じですね。

src/main/java/org/littlewings/jackson/nonnull/ObjectMapperCustomizerImpl.java

package org.littlewings.jackson.nonnull;

import javax.enterprise.context.ApplicationScoped;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.jackson.ObjectMapperCustomizer;

@ApplicationScoped
public class ObjectMapperCustomizerImpl implements ObjectMapperCustomizer {
    @Override
    public void customize(ObjectMapper objectMapper) {
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY);
    }
}

なんとなく、@Singletonではなく@ApplicationScopedにしておきました…。

Quarkusのテストでも似たようなことをしていました。

    @Override
    public void customize(ObjectMapper objectMapper) {
        objectMapper.configure(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES, false)
                .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
                .configure(SerializationFeature.WRITE_DATE_KEYS_AS_TIMESTAMPS, false)
                .setSerializationInclusion(JsonInclude.Include.NON_NULL)
                .setSerializationInclusion(JsonInclude.Include.NON_ABSENT);
    }

https://github.com/quarkusio/quarkus/blob/2.15.3.Final/integration-tests/jackson/src/main/java/io/quarkus/it/jackson/MyObjectMapperCustomizer.java#L15-L22

こんなところでしょうか。

まとめ

Jacksonを使ってオブジェクトをシリアライズする際に、nullのプロパティを出力内容に含めない方法を確認していってみました。

Jackson単体、Spring Boot、Quarkusを対象に。

@JsonIncludeでの指定はけっこう簡単に見つかるのですが、Spring Bootでの指定がわかりにくい&調べにくい感じがしたので
メモを兼ねてという感じで。

付随的に、いろいろと勉強になりましたが。

複数の宛先に対する簡単なTCPプロキシサーバーをsocat+Pythonで書く

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

時々、TCPでの通信をローカルポートから別の宛先に転送したくなることがあります。いわゆるプロキシです。

個人的に、こういう時にはsocatをよく使っているのですが。

socatでTCPプロキシサーバーを立てる - CLOVER🍀

転送先が増えてくると、これを複数書くのが面倒になってきます。そして、シェルスクリプトだとちょっと苦しい感じがします。
なので、スクリプトでも書こうかなと。

お題

今回は、以下のお題で書いてみようかなと思います。

  • 作成のスクリプト内で、複数の宛先に対するTCP通信の転送定義を書けるようにする
  • TCPプロキシの機能は、socatをプロセス起動で使う
  • 作成するスクリプトは、実装する言語の標準機能で行う

個人的に、こういうものはコンパイルする言語ではなくてスクリプト系の言語で書きたいと思っているのと、その環境に持っていったら
すぐに使える感じにしたいので、あまりライブラリなどは入れたくありません。
なので、実装する言語もちょっと迷うのですが、今回はPythonにすることにしました。

まあ、socatはインストールしなくてはいけないんじゃないの?という話はありますが。

こういう時に書くスクリプトはPerlかどうかで迷うのですが、今はPythonかな、と…。

環境

今回の環境は、こちら。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 22.04.1 LTS
Release:        22.04
Codename:       jammy


$ uname -srvmpio
Linux 5.15.0-58-generic #64-Ubuntu SMP Thu Jan 5 11:43:13 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux

Pythonとsocat。

$ python3 -V
Python 3.10.6


$ socat -v
2023/01/15 15:01:59 socat[2943] E exactly 2 addresses required (there are 0); use option "-h" for help

作成したスクリプト

こんな感じにしました。

start-tcp-proxies.py

#!/usr/bin/python3

from subprocess import Popen

target_host = '192.168.0.6'

port_mappings: dict = {
    "8080": f'{target_host}:8080',
    "3306": f'{target_host}:3306',
}

def start_proxy(from_port: str, to_address: str):
    process: Popen = Popen(['socat', f'tcp-listen:{from_port},fork,reuseaddr', f'tcp-connect:{to_address}'])
    return process

def start_proxies(port_mappings: dict):
    processes:list = []
    for from_port, to_address in port_mappings.items():
        processes.append(start_proxy(from_port, to_address))
        print(f'bind local-port {from_port} -> to {to_address}')

    return processes

def wait_processes(processes: list):
    for process in processes:
        process: Popen = process
        process.wait()

def terminate_processes(processes: list):
    for process in processes:
        process: Popen = process
        process.terminate()


if __name__ == '__main__':
    try:
        proxy_processes: list = start_proxies(port_mappings)
        wait_processes(proxy_processes)
    except KeyboardInterrupt as err:
        pass
    finally:
        terminate_processes(proxy_processes)

Popenを使って、socatを指定した数だけ立ち上げます。

subprocess --- サブプロセス管理 / Popen コンストラクター

def start_proxy(from_port: str, to_address: str):
    process: Popen = Popen(['socat', f'tcp-listen:{from_port},fork,reuseaddr', f'tcp-connect:{to_address}'])
    return process

コマンド実行時に完了は待たず、あとでまとめて待ち合わせを行います。

def wait_processes(processes: list):
    for process in processes:
        process: Popen = process
        process.wait()

基本的には、Ctrl-cで止めるかなと思いますが。

あとはローカルポートと転送先をdictで定義します。

target_host = '192.168.0.6'

port_mappings: dict = {
    "8080": f'{target_host}:8080',
    "3306": f'{target_host}:3306',
}

今回は、転送先にWildFlyとMySQLを起動しておきました。

接続先を変更したい場合は、このdictを変更します。

確認

起動。

$ python3 /usr/local/bin/start-tcp-proxies.py
bind local-port 8080 -> to 192.168.0.6:8080
bind local-port 3306 -> to 192.168.0.6:3306

確認。

$ curl -I localhost:8080
HTTP/1.1 200 OK
Connection: keep-alive
Last-Modified: Fri, 16 Dec 2022 00:28:16 GMT
Content-Length: 1504
Content-Type: text/html
Accept-Ranges: bytes
Date: Sun, 15 Jan 2023 06:32:05 GMT


$ curl telnet://localhost:3306 --output -
J
8.0.31

OKですね。

/usr/local/binに置いて実行権限を付けて起動してもいいかもしれません。

$ sudo cp start-tcp-proxies.py /usr/local/bin/start-tcp-proxies
$ sudo chmod a+x /usr/local/bin/start-tcp-proxies


$ start-tcp-proxies
bind local-port 8080 -> to 192.168.0.6:8080
bind local-port 3306 -> to 192.168.0.6:3306

こんな感じで。