[.NET 7] System.Text.Json 動態決定屬性是否序列化 - 高級打字員

  • 462
  • 0
  • C#
  • 2023-04-29

Serialize property by input string using System.Text.Json

前言

最近工作上,需要開發功能類似OData Web API 中的$select選項,讓用戶端自行決定從伺服器回傳Json哪些物件屬性。
https://learn.microsoft.com/zh-tw/aspnet/web-api/overview/odata-support-in-aspnet-web-api/using-select-expand-and-value

Json.Net 可以參考以下文章:
Json.NET-動態決定屬性是否序列化 - 黑暗執行緒
System.Text.Json: In .NET 7, how can I determine the JsonPropertyInfo created for a specific member, so I can customize the serialization of that member?

本文介紹的是System.Text.Json的作法

內文

實務工作上我要輸出的Json內容有可能這樣 ↓ (注意products集合裡的物件屬性數量)

{
    "status": 200,
    "error_msg": "",
    "products": [
        {
            "modelName": "CMT-SBT300Test",
            "baseModelName": "CMT-SBT300WBTT",
            "SerialNo": "5100045aaa",
            "purchasedDate": "2000-01-01"
        },
        {
            "modelName": "CMT-SBT30Test",
            "baseModelName": "CMT-SBT300TTBBCC",
            "SerialNo": "5101000bbb",
            "purchasedDate": "2020-01-01"
        } 
    ]
}

products集合裡的物件屬性也有可能減少成這樣 ↓

{
    "status": 200,
    "error_msg": "",
    "products": [
        {
            "modelName": "CMT-SBT300Test",
            "purchasedDate": "2000-01-01"
        },
        {
            "modelName": "CMT-SBT30Test",
            "purchasedDate": "2020-01-01"
        } 
    ]
}

以下提供上述案例的實作方式,說明在程式註解裡

※ 2023-04-27 追記:[.NET] System.Text.Json 序列化&反序列化使用不同的屬性名稱 - 高級打字員
↑ 這裡有更完整功能的範例程式碼

※以下是.Net Framework 4.8 的 Console專案,.Net Core應該大同小異,不過貌似TypeInfoResolverDefaultJsonTypeInfoResolver這兩個類別在.NET 7+ 才有

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Text.Encodings.Web;

namespace ConsoleApp_JsonConvertTest
{
    /// <summary>
    /// 最終要回傳被序列化的物件
    /// </summary>
    public class MyResponseContent
    {
        /// <summary>
        /// http 回應代碼
        /// </summary>
        public int status { get; set; } = 0;
        /// <summary>
        /// 錯誤訊息
        /// </summary>
        public string error_msg { get; set; } = "";
        /// <summary>
        /// ★List集合裡的物件屬性,最終序列化之後有時候多有時候少,所以宣告為object較有彈性
        /// </summary>
        public List<object> products { get; set; } = new List<object>();
    }//end class MyResponseContent
    public class Product
    {
        public string modelName { get; set; }
        public string baseModelName { get; set; }
        [JsonPropertyName("SerialNo")]
        public string MySerialNo { get; set; }
        [JsonPropertyName("purchasedDate")]
        public string buy_date { get; set; }

    }//end class Product
    
    /// <summary> 
    /// [.NET] System.Text.Json 動態決定屬性是否序列化 - 高級打字員
    /// https://www.dotblogs.com.tw/shadow/2023/04/25/211222
    /// </summary>
    public class MyJsonModifier
    {
        /// <summary>
        /// 要序列化的屬性名稱,集合內容值以[JsonPropertyName]名稱為主儲存,其次為原始屬性名稱
        /// </summary>
        public List<string> SerializeJsonPropertyNames { get; set; } = new List<string>();

        /// <summary>
        /// 序列化部份屬性
        /// </summary>
        /// <param name="jti"></param>
        public void SerializePartialProperty(JsonTypeInfo jti)
        {
            foreach (JsonPropertyInfo prop in jti.Properties)
            {
            //↓若屬性有套用 [JsonPropertyName] Attribute者,則優先取得 JsonPropertyName Attribute 的名稱;若沒套用,則直接取得原始屬性名稱。
                string jsonPropertyName = prop.Name;
                //此屬性是否序列化
                prop.ShouldSerialize = (object myObject, object propType) =>
                {
                    bool result = true;//預設序列化目前的Property
                    if (this._serializePropertieNames.Count > 0)//有指定要序列化屬性的白名單
                    {   //有在白名單內的JsonPropertyName才要序列化
                        result = this.SerializeJsonPropertyNames.Contains(jsonPropertyName, StringComparer.OrdinalIgnoreCase);//字串比對忽略大小寫
                    }//end if 
                    return result;
                };
            }//end foreach 

        }//end SerializePartialProperty()
    }

    public class Program
    {
        private static MyJsonModifier myJsonModifier = new MyJsonModifier();
        /// <summary>
        /// 序列化的設定
        /// </summary>
        public static readonly JsonSerializerOptions jso = new JsonSerializerOptions()
        {
            //排版整齊
            WriteIndented = true,
            TypeInfoResolver = new DefaultJsonTypeInfoResolver()
            {
            /* ★參考文章「自訂 JSON 合約」:
            https://learn.microsoft.com/zh-tw/dotnet/standard/serialization/system-text-json/custom-contracts 
            */
                Modifiers = { myJsonModifier.SerializePartialProperty }
            },
            /*
             * 解決中文亂碼
             * 如何使用 自訂字元編碼 System.Text.Json:https://learn.microsoft.com/zh-tw/dotnet/standard/serialization/system-text-json/character-encoding
             1.JavaScriptEncoder.Create(UnicodeRanges.All),正常顯示中文但會編碼不安全的符號字串
             2.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,不會編碼XSS符號字串也可正常顯示中文,但留意前端若直接輸出至網頁上會有資安漏洞
             */
            //如果要回傳的屬性值當中就是會有奇怪的符號,例如:「<>+」。才用這個↓
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping 

        };
        static void Main(string[] args)
        {
            //回傳給用戶端的物件
            var objResponseContent = new MyResponseContent()
            {
                status = Convert.ToInt32(System.Net.HttpStatusCode.OK),
                error_msg = "",
            };

            //假裝從DB撈出所有資料行(屬性)的資料,塞什麼值不是重點
            var dbListData = new List<object>();
            dbListData.Add(new Product() { modelName = "CMT-SBT300Test", baseModelName = "CMT-SBT300WBTT", 
                                           MySerialNo = "5100045aaa", buy_date = "2000-01-01" });
            dbListData.Add(new Product() { modelName = "CMT-SBT30Test", baseModelName = "CMT-SBT300TTBBCC", 
                                           MySerialNo = "5101000bbb", buy_date = "2020-01-01" });

             //↓假裝用戶端指定要序列化的物件屬性才2個(以小寫逗號區隔)
             //★有標記JsonPropertyName就填JsonPropertyName名稱
             string productPropertyNames = "modelName,purchasedDate";
             //↓亂輸入的字串不會報錯,因為程式找不到對應名稱的物件屬性可序列化就會略過
            //string productPropertyNames = "modelName,purchasedDate,亂輸入word";
             myJsonModifier.SerializeJsonPropertyNames = productPropertyNames.Split(new string[] { "," }, StringSplitOptions.RemoveEmptyEntries).ToList();

  
            //★先序列化products集合,以減少集合裡物件屬性
            string strList = JsonSerializer.Serialize(dbListData, jso);

            //上述json字串再反序列化為 List<object> 型別,待會指派給 回傳結果物件(objResponseContent)
            List<object> list2 = JsonSerializer.Deserialize<List<object>>(strList);
            objResponseContent.products = list2;
            //清除序列化指定屬性白名單,避免影響最終輸出結果
            myJsonModifier.SerializeJsonPropertyNames.Clear();
            //最終結果序列化
            string strResult = JsonSerializer.Serialize(objResponseContent,jso);
            Console.WriteLine(strResult);//輸出json字串

        }//end Main()

    }//end class Program

}//end namespace 

↓ 執行結果

以上是參考 MSDN 文章改寫出來
「自訂 JSON 合約」:https://learn.microsoft.com/zh-tw/dotnet/standard/serialization/system-text-json/custom-contracts 

另一實作方法為繼承 DefaultJsonTypeInfoResolver 類別,覆寫 GetTypeInfo() 方法,如下(執行結果一模一樣),挑自己喜歡的Coding Style來用就好

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Text.Encodings.Web;
using System.Text.Unicode;
using System.Data.SqlClient;
using System.Runtime.CompilerServices;

namespace ConsoleApp_JsonConvertTest
{
    /// <summary>
    /// 最終要回傳被序列化的物件
    /// </summary>
    public class MyResponseContent
    {
        /// <summary>
        /// http 回應代碼
        /// </summary>
        public int status { get; set; } = 0;
        /// <summary>
        /// 錯誤訊息
        /// </summary>
        public string error_msg { get; set; } = "";
        /// <summary>
        /// ★List集合裡的物件屬性,最終序列化之後有時候多有時候少,所以宣告為object較有彈性
        /// </summary>
        public List<object> products { get; set; } = new List<object>();
    }//end class MyResponseContent
    public class Product
    {
        public string modelName { get; set; }
        public string baseModelName { get; set; }
        [JsonPropertyName("SerialNo")]
        public string MySerialNo { get; set; }
        [JsonPropertyName("purchasedDate")]
        public string buy_date { get; set; }

    }//end class Product

    #region System.Text.Json版本↓
    //★繼承 DefaultJsonTypeInfoResolver 類別,覆寫 GetTypeInfo() 方法
    public class MyContractResolver : DefaultJsonTypeInfoResolver 
    {
        /// <summary>
        /// 要序列化的屬性名稱,集合內容值以[JsonPropertyName]名稱為主儲存,其次為原始屬性名稱
        /// </summary>
        
       public List<string> SerializeJsonPropertyNames { get; set; } = new List<string>();
        public override JsonTypeInfo GetTypeInfo(Type t, JsonSerializerOptions o)
        {
            JsonTypeInfo jti = base.GetTypeInfo(t, o);
            foreach (JsonPropertyInfo prop in jti.Properties)
            {
             //↓若屬性有套用 [JsonPropertyName] Attribute者,則優先取得 JsonPropertyName Attribute 的名稱;若沒套用,則直接取得原始屬性名稱。
                string jsonPropertyName = prop.Name;
                
                prop.ShouldSerialize = (object myObject, object propType) =>
                {
                    bool result = true;//預設序列化目前的Property屬性
                    if (this.SerializeJsonPropertyNames.Count > 0)//有指定要序列化屬性的白名單
                    {//有在白名單內的JsonPropertyName才要序列化
                        result = this.SerializeJsonPropertyNames.Contains(jsonPropertyName, StringComparer.OrdinalIgnoreCase);//字串比對忽略大小寫
                    }//end if 
                    return result;
                };
            }//end foreach 
            return jti;
        }//end GetTypeInfo()

    }//end class 
    #endregion

    public class Program
    {
        /// <summary>
        /// 序列化的設定
        /// </summary>
        public static readonly JsonSerializerOptions jso = new JsonSerializerOptions()
        {
            //排版整齊
            WriteIndented = true,
            TypeInfoResolver = new MyContractResolver(),//★改成自己的 ContractResolver
            /*
             * 解決中文亂碼
             * 如何使用 自訂字元編碼 System.Text.Json:https://learn.microsoft.com/zh-tw/dotnet/standard/serialization/system-text-json/character-encoding
             1.JavaScriptEncoder.Create(UnicodeRanges.All),正常顯示中文但會編碼不安全的符號字串
             2.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,不會編碼XSS符號字串也可正常顯示中文,但留意前端若直接輸出至網頁上會有資安漏洞
             */
            //如果要回傳的屬性值當中就是會有奇怪的符號,例如:「<>+」。才用這個↓
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping 

        };
        static void Main(string[] args)
        {
            //回傳給用戶端的物件
            var objResponseContent = new MyResponseContent()
            {
                status = Convert.ToInt32(System.Net.HttpStatusCode.OK),
                error_msg = "",
            };

            //假裝從DB撈出所有資料行(屬性)的資料,塞什麼值不是重點
            var dbListData = new List<object>();
            dbListData.Add(new Product() { modelName = "CMT-SBT300Test", baseModelName = "CMT-SBT300WBTT", MySerialNo = "5100045aaa", buy_date = "2000-01-01" });
            dbListData.Add(new Product() { modelName = "CMT-SBT30Test", baseModelName = "CMT-SBT300TTBBCC", MySerialNo = "5101000bbb", buy_date = "2020-01-01" });

            //假裝用戶端指定要序列化的物件屬性才2個↓
            //★有標記JsonPropertyName就填JsonPropertyName名稱
             string productPropertyNames = "modelName,purchasedDate";
            //★把 TypeInfoResolver 挖出來轉型成自己的 ContractResolver
            var myResolver = (MyContractResolver)jso.TypeInfoResolver;
            myResolver.SerializeJsonPropertyNames= productPropertyNames.Split(new string[] { "," }, StringSplitOptions.RemoveEmptyEntries).ToList();

            //★先序列化products集合,以減少集合裡物件屬性
            string strList = JsonSerializer.Serialize(dbListData, jso);

            //上述json字串再反序列化為 List<object> 型別,待會指派給 回傳結果物件(objResponseContent)
            List<object> list2 = JsonSerializer.Deserialize<List<object>>(strList);
            objResponseContent.products = list2;
            //清除序列化指定屬性的白名單,避免影響最終輸出結果
            myResolver.SerializeJsonPropertyNames.Clear();
            //最終結果序列化
            string strResult = JsonSerializer.Serialize(objResponseContent,jso);
            Console.WriteLine(strResult);//輸出json字串

        }//end Main()

    }//end class Program
     
}//end namespace

補充:留意以下程式碼在執行過一次的序列化or反序列化之後會報錯,別妄想二次指派 DefaultJsonTypeInfoResolver物件給TypeInfoResolver

所以程式設計上,我不會傳遞參數SerializePropertieNamesnew DefaultJsonTypeInfoResolver()的建構式,
而是把SerializePropertieNames做成公開的Property可以反覆存取修改值。

//↓會發生例外狀況: 
//System.InvalidOperationException: This JsonSerializerOptions instance is read-only or has already been used in serialization or deserialization.
jso.TypeInfoResolver = new DefaultJsonTypeInfoResolver();

 

參考文章

System.Text.Json與 比較 Newtonsoft.Json ,並移轉至System.Text.Json
※MSDN官方文章,主要看 DefaultContractResolver 要改寫為 DefaultJsonTypeInfoResolver 類別

System.Text.Json improvements in .NET 7
※給出大方向、繼承類別的靈感

How to Exclude Properties From JSON Serialization in C#
※其它解決方案