LoginSignup
0
0

More than 3 years have passed since last update.

JsonConverterを実装するとプロパティが自動で入出力されない件

Posted at

はじめに

Newtonsoft.JsonというC#向けのJson入出力ライブラリがありますね。
クラスのプロパティを勝手に読み込んで、Jsonファイルの属性に関連付けて相互変換をしてくれます。好き。

さて、クラスの属性設定にJsonConverterがあります。
既定の動作とは違ったシリアライズ/デシリアライズをさせるための属性指定です。

例えばこんなjsonファイルがあります。

Sample.json
{
    "variables": [
        {
            "@class": "Color",
            "clear_color": {
                "r": 255,
                "g": 0,
                "b": 0,
                "a": 255
            },
            "width": 2048,
            "height": 2048,
        },
        {
            "@class": "File",
            "path": "C:/Hoge/Huga/Piyo.bmp"
        }
    ]
}

variablesの値が2つあり、別の構造となっています。
しかしプログラム上では同じ基底クラスで扱い、@classの値に応じて別の派生クラスでインスタンス化したい、そんなケースです。

JsonConverter実装基本

これをNewtonsoft.Jsonで実装しようとした場合、次のようになります。
なお、これら、画像の初期化方法の識別として作ったものなので、そんな感じの命名にしてます。

JsonPixelInitializer.cs
using Newtonsoft.Json;

public abstract class JsonPixelInitializer
{
}

public class JsonColorRGBA
{
    [JsonProperty(PropertyName = "r")]
    public byte R { get; set; } = 0;
    [JsonProperty(PropertyName = "g")]
    public byte G { get; set; } = 0;
    [JsonProperty(PropertyName = "b")]
    public byte B { get; set; } = 0;
    [JsonProperty(PropertyName = "a")]
    public byte A { get; set; } = 0;
}

public class JsonPixelColorInitializer : JsonPixelInitializer
{
    [JsonProperty(PropertyName = "clear_color")]
    JsonColorRGBA ClearColor { get; set; } = new JsonColorRGBA();
    [JsonProperty(PropertyName = "width")]
    public int Width { get; set; } = 0;
    [JsonProperty(PropertyName = "height")]
    public int Height { get; set; } = 0;
}

public class JsonTexturePixelFileInitializer : JsonTexturePixelInitializer
{
    [JsonProperty(PropertyName = "path")]
    public string Path { get; set; } = "";
}

全プロパティにいちいちPropertyNameを指定していますが、この指定がない場合、属性名はプロパティ名と同じものとして扱われます。

これではまだ足りません。
基本の属性構造は作れましたが、デシリアライズするとき、どのクラスでインスタンス化すればいいかという情報がありません。
これを指定するのがJsonConverterの役割であり、次のように実装します。

JsonPixelInitializerConverter.cs
using Newtonsoft.Json;

class JsonPixelInitializerConverter : JsonConverter
{
    public override bool CanConvert(Type inType) => typeof(JsonPixelInitializer).IsAssignableFrom(inType);

    public override object ReadJson(JsonReader inReader, Type inObjectType, object inExistingValue, JsonSerializer inSerializer)
    {
        JObject jObject = JObject.Load(inReader);
        JsonPixelInitializer result = GenerateObject(jObject.Get<string>("@class", ""));
        if (result == null) {
            return null;
        }

        result.ReadFromJObject(jObject);
        return result;
    }

    public override void WriteJson(JsonWriter inWriter, object inValue, JsonSerializer inSerializer)
    {
        inWriter.WriteStartObject();
        inWriter.WritePropertyName("@class");

        inWriter.WriteValue(((JsonPixelInitializer)inValue).GetSubClassIdentifier());
        ((JsonPixelInitializer)inObject).WriteToWriter(inWriter);

        inWriter.WriteEndObject();
        return;
    }

    private JsonTexturePixelInitializer GenerateObject(string inSubClassIdentifier)
    {
        switch (inSubClassIdentifier) {
            case "Color":
                return new JsonTexturePixelColorInitializer();
            case "File":
                return new JsonTexturePixelFileInitializer();
            default:
                throw new JsonReaderException();
        }
    }
}

これに合わせ、JsonPixelInitializerクラスの方も改造します。

JsonPixelInitializer.cs
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

[JsonConverter(typeof(JsonTexturePixelInitializerConverter))]
public abstract class JsonPixelInitializer
{
    public abstract string GetSubClassIdentifier();
    public abstract void ReadFromJObject(JObject inObject);
    public abstract void WriteToWriter(JsonWriter inWriter);
}

public class JsonColorRGBA
{
    [JsonProperty(PropertyName = "r")]
    public byte R { get; set; } = 0;
    [JsonProperty(PropertyName = "g")]
    public byte G { get; set; } = 0;
    [JsonProperty(PropertyName = "b")]
    public byte B { get; set; } = 0;
    [JsonProperty(PropertyName = "a")]
    public byte A { get; set; } = 0;
}

public class JsonPixelColorInitializer : JsonPixelInitializer
{
    [JsonProperty(PropertyName = "clear_color")]
    JsonColorRGBA ClearColor { get; set; } = new JsonColorRGBA();
    [JsonProperty(PropertyName = "width")]
    public int Width { get; set; } = 0;
    [JsonProperty(PropertyName = "height")]
    public int Height { get; set; } = 0;

    public override string GetSubClassIdentifier() => "Color";

    public void ReadFromJObject(JObject inObject)
    { /*ここに読み込み処理を追加する*/ }
    public void WriteToWriter(JsonWriter inWriter);
    { /*ここに書き込み処理を追加する*/ }
}

public class JsonTexturePixelFileInitializer : JsonTexturePixelInitializer
{
    [JsonProperty(PropertyName = "path")]
    public string Path { get; set; } = "";

    public override string GetSubClassIdentifier() => "File";

    public void ReadFromJObject(JObject inObject)
    { /*ここに読み込み処理を追加する*/ }
    public void WriteToWriter(JsonWriter inWriter);
    { /*ここに書き込み処理を追加する*/ }
}

基本的な実装はこれでいいのですが、ちょっと問題というか不器用な点がありまして、
クラスにJsonConverterを指定すると、クラス内のプロパティがシリアライズ/デシリアライズ時に出力/入力されてくれません。
JsonConverterを指定しなかった場合には、各プロパティが勝手に出力/入力されてくれましたが、指定した場合には、(上記例で言うところの)ReadFromJObjectとWriteToWriterで各プロパティに対する処理を追加しなければいけません。
プロパティ数が1個や2個ならまあ我慢できますが(そんなん個別オブジェクトにするのも煩わしくなりますし)、プロパティ数が増えたら、ましてや階層構造が増えたら非常に面倒になります。
ので、JsonConverterを指定しない場合と同じように、勝手にプロパティをJson要素に出力/入力してくれる方法を探していました。

対策

結論から言うと、対策はできます。
特にデシリアライズは簡単なもので、ConverterのReadJsonメソッドを以下のようにJsonSerializer.Populate()で書き換えればOKです。

JsonPixelInitializerConverter.cs
public override object ReadJson(JsonReader inReader, Type inObjectType, object inExistingValue, JsonSerializer inSerializer)
{
    JObject jObject = JObject.Load(inReader);
    JsonPixelInitializer result = GenerateObject(jObject.Get<string>("@class", ""));
    if (result == null) {
        return null;
    }

    inSerializer.Populate(jObject.CreateReader(), result); // これでOK
    return result;
}

シリアライズにも同じようなメソッドがあればよかったのですが、どうも見つかりませんでした。
あるかもしれませんが、どうにもリフレクションで全プロパティ見ていく方法が確実なようです。
WriteJsonメソッドを以下のように書き換え、メソッドとクラスを1つ追加します(WriteJsonに全部書いてもいいけど)。

JsonPixelInitializerConverter.cs
public override void WriteJson(JsonWriter inWriter, object inValue, JsonSerializer inSerializer)
{
    inWriter.WriteStartObject();
    inWriter.WritePropertyName("@class");
    inWriter.WriteValue(((JsonPixelInitializer)inValue).GetSubClassIdentifier());

    foreach (var prop in CollectProperties(inValue)) {
        inWriter.WritePropertyName(prop.Name);

        if (prop.Converter != null) {
            prop.Converter.WriteJson(inWriter, prop.GetValue(inValue), inSerializer);
        } else {
            inSerializer.Serialize(inWriter, prop.GetValue(inValue));
        }
    }

    inWriter.WriteEndObject();
    return;
}

private IEnumerable<ExportTargetProperty> CollectProperties(object inValue)
{
    foreach (var prop in inValue.GetType().GetProperties()) {
        var attributes = prop.GetCustomAttributes(true);
        if (attributes.FirstOrDefault(a => a is JsonIgnoreAttribute) != null) {
            continue;
        }

        var propAtt = (JsonPropertyAttribute)attributes.FirstOrDefault(a => a is JsonPropertyAttribute);
        var convAtt = (JsonConverterAttribute)attributes.FirstOrDefault(a => a is JsonConverterAttribute);

        string name = (propAtt?.PropertyName == null) ? prop.Name : propAtt.PropertyName;
        JsonConverter converter = null;
        if (convAtt != null) {
            converter = (JsonConverter)Activator.CreateInstance(convAtt.ConverterType, convAtt.ConverterParameters);
        }

        ExportTargetProperty property = new ExportTargetProperty(prop);
        property.Name = (propAtt?.PropertyName == null) ? prop.Name : propAtt.PropertyName;
        property.Converter = convAtt == null ? null 
                : (JsonConverter)Activator.CreateInstance(convAtt.ConverterType, convAtt.ConverterParameters);

        yield return property;
    }

    yield break;
}

private class ExportTargetProperty
{
    public ExportTargetProperty(PropertyInfo inProperty)
    {
        Property = inProperty;
        return;
    }

    public string Name { get; set; } = "";
    public JsonConverter Converter { get; set; } = null;

    public void SetValue(object inObject, object inValue) => Property.SetValue(inObject, inValue);
    public object GetValue(object inObject) => Property.GetValue(inObject);

    private PropertyInfo Property { get; } = null;
}

attributes.FirstOrDefaultを何度も実行してるのが鬱陶しいのでなんとかしたいですが、まあこんな感じです。
対応している属性はJsonIgnore、JsonProperty、JsonConverterのみですが、他に必要であればまた追記しなければいけません。

さて、これで解決なわけですが、Converterが必要なクラスが増える度にこれを実装するのかと言われれば、まあ共通化したくなりますね。
というわけで最後に、Converterが必要なクラスのinterfaceおよびConverterの基底クラスを以下に書きます。

JsonConverter.cs
interface IJsonSubClassSerializable
{
    string GetSubClassIdentifier();
}

abstract class JsonConverter<T> : JsonConverter where T : IJsonSubClassSerializable
{
    public override bool CanConvert(Type inType) => typeof(T).IsAssignableFrom(inType);

    public override object ReadJson(JsonReader inReader, Type inObjectType, object inExistingValue, JsonSerializer inSerializer)
    {
        JObject jObject = JObject.Load(inReader);
        T result = GenerateObject(jObject.Get<string>("@class", ""));
        if (result == null) {
            return null;
        }

        inSerializer.Populate(jObject.CreateReader(), result);
        return result;
    }

    public override void WriteJson(JsonWriter inWriter, object inValue, JsonSerializer inSerializer)
    {
        inWriter.WriteStartObject();
        inWriter.WritePropertyName("@class");
        inWriter.WriteValue(((T)inValue).GetSubClassIdentifier());

        foreach (var prop in CollectProperties(inValue)) {
            inWriter.WritePropertyName(prop.Name);

            if (prop.Converter != null) {
                prop.Converter.WriteJson(inWriter, prop.GetValue(inValue), inSerializer);
            } else {
                inSerializer.Serialize(inWriter, prop.GetValue(inValue));
            }
        }

        inWriter.WriteEndObject();
        return;
    }

    private IEnumerable<ExportTargetProperty> CollectProperties(object inValue)
    {
        foreach (var prop in inValue.GetType().GetProperties()) {
            var attributes = prop.GetCustomAttributes(true);
            if (attributes.FirstOrDefault(a => a is JsonIgnoreAttribute) != null) {
                continue;
            }

            var propAtt = (JsonPropertyAttribute)attributes.FirstOrDefault(a => a is JsonPropertyAttribute);
            var convAtt = (JsonConverterAttribute)attributes.FirstOrDefault(a => a is JsonConverterAttribute);

            string name = (propAtt?.PropertyName == null) ? prop.Name : propAtt.PropertyName;
            JsonConverter converter = null;
            if (convAtt != null) {
                converter = (JsonConverter)Activator.CreateInstance(convAtt.ConverterType, convAtt.ConverterParameters);
            }

            ExportTargetProperty property = new ExportTargetProperty(prop);
            property.Name = (propAtt?.PropertyName == null) ? prop.Name : propAtt.PropertyName;
            property.Converter = convAtt == null ? null 
                  : (JsonConverter)Activator.CreateInstance(convAtt.ConverterType, convAtt.ConverterParameters);

            yield return property;
        }

        yield break;
    }

    protected abstract T GenerateObject(string inSubClassIdentifier);

    private class ExportTargetProperty
    {
        public ExportTargetProperty(PropertyInfo inProperty)
        {
            Property = inProperty;
            return;
        }

        public string Name { get; set; } = "";
        public JsonConverter Converter { get; set; } = null;

        public void SetValue(object inObject, object inValue) => Property.SetValue(inObject, inValue);
        public object GetValue(object inObject) => Property.GetValue(inObject);

        private PropertyInfo Property
        { get; } = null;
    }
}

JsonConveterを継承した側は、GenerateObjectを実装します。クラスの識別子が与えられるので、それに応じたインスタンスを作成すればOKです。
クラスの識別子に@classを使っていますが、@つきの属性名なんてそうそう使わないだろう、程度で採用しています。多数のオブジェクトに関わる名称となるので、それを踏まえて重複しない名称に気をつければなんでもいいと思います。

こちらに該当するソースがあるので、よければどうぞ。

0
0
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
0
0