[食譜好菜] Json.NET 處理多型的反序列化

多型在像是 C#、爪哇(用中文應該就不會被告了)這種強型別的物件導向程式語言,要將一個抽象類別的實例存到關聯式資料庫或是做序列化/反序列化,都需要另外處理,關於多型跟關聯式資料庫的對應方法我已經寫在這篇文章裡面,這篇要來寫寫多型如何做 JSON 的序列化/反序列化。

假設我定義了一個 Food 抽象類別,派生了三個子類分別是:DessertDryGoodsDelicatessen

一般解法

用 Json.NET 做多型的序列化只要在序列化的時候,指定 JsonSerializerSettingsTypeNameHandling 屬性為 TypeNameHandling.Objects

這麼做就會在序列化的結果多一個 $type 屬性,內容就是該 Type 的 AssemblyQualifiedName,預設 Json.NET 會移除掉一些 Assembly 的詳細內容。

所以,可想而知當要反序列化的時候,$type 也需要包含進來, 而且要是正確的內容才有辦法反序列化回來。

自訂 SerializationBinder

一般解法有一些限制,如果需要在不同專案之間序列化/反序列化,就要參考相同的 Assembly,而且當資料要存到資料庫去的時候,一般不會將 AssemblyQualifiedName 完整地塞進資料庫去,但是用來識別派生類的欄位還是會有的,現在假設 Food 在資料庫內的資料是這個樣子。

有一個 ShoppingCart 類別,裡面有一個 Foods 屬性,型別是 List<Food>

public class ShoppingCart
{
    public int Id { get; set; }

    public List<Food> Foods { get; set; }

    public DateTime CreatedTime { get; set; }
}

然後 ShoppingCart 在資料庫內存放的資料長這樣

我需要從資料庫中一個查詢就能取得 Id 為 1 的 ShoppingCart,並且連同購物車內的所有食物商品也一併取得,因此我利用 SQL Server 的 FOR JSON PATH 語法直接將 Foods 拼成 JSON 回傳。

SELECT
    sc.Id
   ,sc.CreatedTime
   ,(SELECT
            f.Discriminator AS '$type'
           ,f.Id
           ,f.[Name]
           ,f.ShelfLife_Months AS 'ShelfLife.Months'
           ,f.ShelfLife_Days AS 'ShelfLife.Days'
           ,f.Dessert_Calorie AS Calorie
           ,f.DryGoods_CountryOfOrigin AS CountryOfOrigin
           ,f.Delicatessen_Chef AS Chef
        FROM Food f WITH (NOLOCK)
        INNER JOIN (SELECT
                [value]
            FROM STRING_SPLIT(sc.Foods, ',')) sp
            ON f.Id = CAST(sp.[value] AS INT)
        FOR JSON PATH)
    AS Foods
FROM ShoppingCart sc WITH (NOLOCK)
WHERE sc.Id = 1

我識別派生類別的方式是看 Discriminator 欄位的值,配合 Json.NET 的要求,需要將識別的欄位名稱改為 $type,底下為執行結果。

再來我們要實作 ISerializationBinder 介面,自訂 $type 的對應規則。

public class DerivedTypesBinder<TBase> : ISerializationBinder
{
    private readonly Dictionary<string, Type> derivedTypes;

    public DerivedTypesBinder()
    {
        var baseType = typeof(TBase);

        this.derivedTypes = Assembly.GetAssembly(baseType)
            .GetTypes()
            .Where(t => t.IsSubclassOf(baseType))
            .ToDictionary(x => x.Name, x => x);
    }

    public Type BindToType(string assemblyName, string typeName)
    {
        return this.derivedTypes[typeName];
    }

    public void BindToName(Type serializedType, out string assemblyName, out string typeName)
    {
        assemblyName = null;
        typeName = serializedType.Name;
    }
}

使用方式很簡單,在反序列化時除了指定 JsonSerializerSettings 的 TypeNameHandling 屬性為 TypeNameHandling.Objects 之外,還需要指定 SerializationBinder 屬性。

最後搭配我先前文章提到 Dapper 的 TypeHandler 來使出組合技

public class JsonObjectTypeHandler<T> : SqlMapper.TypeHandler<T>
{
    private readonly JsonSerializerSettings serializerSettings;

    public JsonObjectTypeHandler(ISerializationBinder serializationBinder = null)
    {
        if (serializationBinder != null)
        {
            this.serializerSettings = new JsonSerializerSettings
                                      {
                                          TypeNameHandling = TypeNameHandling.Objects,
                                          SerializationBinder = serializationBinder
                                      };
        }
    }

    public override void SetValue(IDbDataParameter parameter, T value)
    {
        parameter.Value = (value == null) ? (object)DBNull.Value : JsonConvert.SerializeObject(value);
        parameter.DbType = DbType.String;
    }

    public override T Parse(object value)
    {
        return this.serializerSettings != null
                   ? JsonConvert.DeserializeObject<T>((string)value, this.serializerSettings)
                   : JsonConvert.DeserializeObject<T>((string)value);
    }
}

拜工具進步所賜,現在處理這種多型資料比起以前要容易得多了,希望本篇文章對大家在處理多型資料上有幫助。

參考資料

C# 指南ASP.NET 教學ASP.NET MVC 指引
Azure SQL Database 教學SQL Server 教學Xamarin.Forms 教學