LoginSignup
2
2

More than 3 years have passed since last update.

Javaでデータオブジェクトの内容をJSONに変換したい でも循環参照が…

Posted at

この記事で「データオブジェクト」とは何?

データを格納するために定義したJavaのクラスの事を指します。「privateメンバ変数 + getter&setter」か、publicメンバ変数を持つだけのクラスです。
web APIや、JPAでデータベースからデータを取ってくる時なんかによく作りますね。「DTO」って呼ぶ場合もあります。

環境

  • JDK : Amazon Corretto 1.8.0.265
  • Apache commons-lang3 : 3.11

やりたいこと

こういうデータオブジェクトがあったとして、ここに格納されたデータをログとかに出力したい時ってありますよね。

    public class TestDto
    {
        public String stringvar;
        public Integer integervar;
        public int intvar;
        public TestDto2 testDto2;
        public BigDecimal decimal;
        public java.util.Date date;
    }

    public class TestDto2
    {
        public String string2;
        public Integer integer2;
        public int int2;
        public TestDto testDto;
    }

そんな時、↓みたいにJSONで出力できればすごく見やすい。


{
    "stringvar": "aaa",
    "integervar": 1,
    "intvar": 2,
    "testDto2": {
        "string2": "CCC",
        "integer2": 2,
        "int2": 0,
        "testDto": null
    },
    "decimal": null,
    "date": "2020-08-12T00:00:00.000+0900"
}

reflectionToStringで変換する

Apache CommonsのToStringBuilder#reflectionToStringは、昔からおなじみですね。reflectionを使って、データオブジェクトの内容を文字列化してくれる便利メソッドです。
実はコレ、第2引数に出力フォーマットが指定でき、JSONもちゃんと用意されています。

ToStringBuilder.reflectionToString(dto, ToStringStyle.JSON_STYLE)

↓出力結果(別途pretty-print済)


{
    "stringvar": "aaa",
    "integervar": 1,
    "intvar": 2,
    "testDto2": "jp.example.common.dto.TestDto2@ed17bee",
    "decimal": null,
    "date": "Wed Aug 12 00:00:00 JST 2020"
}

うー、惜しい…
入れ子になったデータオブジェクトは、"testDto2": "jp.example.common.dto.TestDto2@ed17bee",といった、クラス名とハッシュ値だけが表示され、データを表示する事ができません。
ToStringBuilder#reflectionToStringは、各メンバの内容をtoString()を使って文字列化しているので、今回のように親クラスを指定していない場合、Object#toStringが使われ、↑のような出力結果になるのですね。

DTOの基底クラスを作ってみる

それならば、↓のようなデータオブジェクトの基底クラスを作ってみよう。

import org.apache.commons.lang3.builder.ToStringBuilder;

public class CommonDto implements Serializable
{
    @Override
    public String toString()
    {
        return ToStringBuilder.reflectionToString(this, ToStringStyle.JSON_STYLE);
    }

そして、データオブジェクトは必ず↑のクラスを継承するようにします。

    public class TestDto extends CommonDto
    {
        public String stringvar;
        public Integer integervar;
        public int intvar;
        public TestDto2 testDto2;
        public BigDecimal decimal;
        public java.util.Date date;
    }

    public class TestDto2 extends CommonDto
    {
        public String string2;
        public Integer integer2;
        public int int2;
        public TestDto testDto;
    }

これなら、toString()するだけでJSONで出力する事ができます。

dto.toString();

↓出力結果(別途pretty-print済)


{
    "stringvar": "aaa",
    "integervar": 1,
    "intvar": 2,
    "testDto2": {
        "string2": "CCC",
        "integer2": 2,
        "int2": 0,
        "testDto": null
    },
    "decimal": null,
    "date": "Wed Aug 12 00:00:00 JST 2020"
}

循環参照に注意!

これで完璧にできた感じがするけど、1つ落とし穴があります。
データオブジェクトの中のメンバが、自分の親を参照している場合。素直に参照をたどっていくと、ぐるぐると永遠に参照できてしまうのですね。

実際、↑の例ではnullになっているtestDto2.testDtoの参照先を親にしてみると…


{
    "stringvar": "aaa",
    "integervar": 1,
    "intvar": 2,
    "testDto2": {
        "string2": "CCC",
        "integer2": 2,
        "int2": 0,
        "testDto": {
            "stringvar": "aaa",
            "integervar": 1,
            "intvar": 2,
            "testDto2": {
                "string2": "CCC",
                "integer2": 2,
                "int2": 0,
                "testDto": {
                    "stringvar": "aaa",
                    "integervar": 1,
                    "intvar": 2,
                    "testDto2": {
                        "string2": "CCC",
                        "integer2": 2,
                        "int2": 0,
                        "testDto": {
            :

こんなふうに無限ループしてしまう。
こんな参照のしかたは、通常しないと思いますが、JPAでこういうEntityを作って相互に関連させると、まさにこういうオブジェクトを返してきます。
Stack Overflowなんか見ても、この問題で困っている人がちらほら見られます。意外に遭遇しやすい落とし穴みたい。

循環参照にも対応する

今回の目的はログ出力なので、循環参照してようがなんだろうが、気にせず使えるようにしたいと思います。

(例えば、目的がREST APIのレスポンスだったら、JPAから取得したオブジェクトをそのままレスポンスする事はないと思うので、循環参照問題は気にしなくて良いと思います。)

ToStringStyle.JSON_STYLEを使っている限り、循環参照には対応しきれないように思ったので、今回はToStringStyle.JSON_STYLEによく似たオリジナルのクラスを作る事で対応しようと思います。

ToStringStyle.JSON_STYLEの実体は、ToStringStyleのインナークラスとして定義されているJsonToStringStyleなので、まずはこれを丸々コピーして自分のプロジェクトに保存します。
↓こんな感じ(クラス名以外はJsonToStringStyleと同じです)

    public class OriginalJsonToStringStyle extends ToStringStyle {

        private static final long serialVersionUID = 1L;

        private static final String FIELD_NAME_QUOTE = "\"";

        /**
         * <p>
         * Constructor.
         * </p>
         *
         * <p>
         * Use the static constant rather than instantiating.
         * </p>
         */
        OriginalJsonToStringStyle() {
            super();

            this.setUseClassName(false);
            this.setUseIdentityHashCode(false);
  :

そして、↓のメソッドだけ中身を書き換えます。
実は、元々ToStringStyleには循環参照を適切に処理する仕組みが入っているのですが、↓のメソッドでの処理がちょっとマズくて、その処理をスルーしてしまうのです。そのせいで無限ループしていたのですね。

        @Override
        protected void appendDetail(final StringBuffer buffer, final String fieldName, final Object value) {

            if (value == null) {
                appendNullText(buffer, fieldName);
                return;
            }

            if (value instanceof String || value instanceof Character) {
                appendValueAsString(buffer, value.toString());
                return;
            }

            if (value instanceof Number || value instanceof Boolean) {
                buffer.append(value);
                return;
            }

            final String valueAsString = value.toString();
            if (isJsonObject(valueAsString) || isJsonArray(valueAsString)) {
                buffer.append(value);
                return;
            }

            appendDetail(buffer, fieldName, valueAsString);
        }

 ↓こんな感じに変えました

        @Override
        protected void appendDetail(final StringBuffer buffer, final String fieldName, final Object value) {

            if (value == null) {
                appendNullText(buffer, fieldName);
                return;
            }

            if (value instanceof String || value instanceof Character) {
                appendValueAsString(buffer, value.toString());
                return;
            }

            if(value instanceof java.util.Date)
            {
                appendValueAsDate(buffer, (java.util.Date)value);
                return;
            }

            buffer.append(value);
        }

        //ついでにjava.util.DateをISO8601拡張形式で出力するように
        protected void appendValueAsDate(StringBuffer buffer, java.util.Date value) {
            DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
            buffer.append("\"" + df.format(value) + "\"");
        }

これで循環参照を適切に察知できるようになったので、さらに下記のメソッドを追加します。
循環参照しているオブジェクトの場合、↑で書き換えたような、通常の文字列化メソッドが使われず、このメソッドで処理されるのです。
出力内容は何でも良いので、オーバーライド元のメソッドと同じくObjectUtils#identityToStringの結果。ただし、JSON形式を崩さないように、前後にダブルクォーテーションを付与するようにしました。

        @Override
        protected void appendCyclicObject(final StringBuffer buffer, final String fieldName, final Object value) {
            buffer.append(FIELD_NAME_QUOTE);
            ObjectUtils.identityToString(buffer, value);
            buffer.append(FIELD_NAME_QUOTE);
         }

↓出力結果(別途pretty-print済)

{
    "stringvar": "aaa",
    "integervar": 1,
    "intvar": 2,
    "testDto2": {
        "int2": 0,
        "integer2": 2,
        "string2": "CCC",
        "testDto": {
            "stringvar": "aaa",
            "integervar": 1,
            "intvar": 2,
            "date": "2020-08-12T00:00:00.000+0900",
            "decimal": null,
            "testDto2": "jp.example.common.dto.TestDto2@5577140b"
        }
    }
    "date": "2020-08-12T00:00:00.000+0900",
    "decimal": null
}

いい感じ!
循環参照している部分は、一部重複して出力されていますが、"jp.example.common.dto.TestDto2@5577140b"という表記に置き換わって、無限ループに陥らずに済みました!

まとめ

データオブジェクトをJSONに変換するのは、ToStringBuilder#reflectionToStringで可能です。
ただし、JPAを使っている場合等でオブジェクトが循環参照している場合は、特別な処理クラスを作ってあげないとダメでした。

なお、JSON変換といえば、ToStringBuilder以外にもGSONやJackson(ObjectMapper)もありますが、やはり循環参照しているオブジェクトはダメっぽいです。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2