[.NET] System.Text.Json 序列化&反序列化使用不同的屬性名稱 - 高級打字員

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

Use different name for serializing and deserializing with System.Text.Json

前言

這兩篇文章遇到的問題我也踩到地雷了

Use different name for serializing and deserializing with Json.Net
System.Text.Json.Deserialize<TValue> 轉回的物件值都是 null - 亂馬客

問題描述

我有一個WebAPI回應的JSON內容如下

[
    {
        "Product1": "CMT-Test300AA",
        "buy_date": "2015-10-27",
        "Giveaway": "Y"
    },
    {
        "Product1": "CMT-Test300BB",
        "buy_date": "2020-05-02",
        "Giveaway": "N"
    }
]

但我要反序列化為物件的類別定義如下

public class MyResult
{  
        [JsonPropertyName("modelName")]
        public string Product1 { get; set; } = "";
        [JsonPropertyName("purchasedDate")]
        public string buy_date { get; set; } = "";
        public string Giveaway { get; set; } = "";
}//end class 

↓ MSDN對於JsonPropertyNameAttribute的說明

這導致我在呼叫方法System.Text.Json.JsonSerializer.Deserialize<T>(stringAPIResponse)反序列化為物件時,
由於對應找不到JsonPropertyNameAttribute的名稱modelNamepurchasedDate,反序列化後的物件雖然有拿到實例,
Product1buy_date這兩個屬性卻是預設值的空字串。
而類別的Property會加上JsonPropertyNameAttribute是因為我想要在序列化的時候,輸出和DB資料庫不同的資料行名稱給前端用戶,
所以JsonPropertyNameAttribute也不太可能拿掉。

以下考慮過幾種解法,但總覺得都怪怪的…

解法一:新增定義另一個類別來接WebAPI反序列化的物件,Property都沒有加上JsonPropertyNameAttribute
public class MyResultDeserialize
{       public string Product1 { get; set; } = ""; 
        public string buy_date { get; set; } = "";
        public string Giveaway { get; set; } = "";
}//end class 

↓ 反序列化時

string strAPIResponse = "";//API回應的json字串
var result = JsonSerializer.Deserialize<List<MyResultDeserialize>>(strAPIResponse);//改用類別 MyResultDeserialize 來接

↑ 但此解法缺點為需要維護兩個相似的類別

解法二:反序列化時,改用Newtonsoft.Json.JsonConvert.DeserializeObject()

因為Json.NET不認識JsonPropertyName("名稱")這種Attribute,它是給System.Text.Json用的,所以反序列化時物件每個屬性名稱便都對應得到API回傳JSON內容的物件屬性
↓ 例如

string strAPIResponse = "";//API回應的json字串
var result = Newtonsoft.Json.JsonConvert.DeserializeObject<List<MyResult>>(strAPIResponse);//改用 Json.NET 來反序列化

↑ 但此解法缺點為系統裡,有的使用System.Text.Json、有的地方使用 Json.NET,處理不一致很零散鎖碎。

解法三:捨棄JsonPropertyNameAttribute,改用LINQ語法解決問題

在序列化時,使用LINQ的.Select()方法做集合轉型並在匿名型別中給予不同的屬性名稱,
之後再對匿名型別的集合做序列化並輸出給前端用戶,前端便看不到DB原始資料行名稱(有達到客戶需求)。

在反序列化時,則直接使用毫無JsonPropertyNameAttribute修飾屬性的類別MyResult,如此便接得到API回傳的每個屬性。

↓ 範例如下

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Text.Json;//.Net Framework 要先從Nuget套件安裝
using System.Text.Encodings.Web;
using System.Text.Json.Serialization;

//.Net Framework 4.8 Console 專案
namespace ConsoleApp1_TestDemoJsonLINQ
{
    public class MyResult //屬性別加任何的 JsonPropertyNameAttribute
    {
        public string Product1 { get; set; } = "";
        public string buy_date { get; set; } = "";
        public string Giveaway { get; set; } = "";
    }//end class 
    public class Program
    {
        public static readonly JsonSerializerOptions jso = new JsonSerializerOptions()
        { 
            WriteIndented = true,//(序列化時使用) 排版整齊 
            //(序列化時使用) 序列化後的屬性大小寫維持原樣,若序列對象的屬性有套用 JsonPropertyNameAttribute 者為 JsonPropertyNameAttribute 名稱優先。
            PropertyNamingPolicy = null,
            PropertyNameCaseInsensitive = true,//(反序列化時使用) 不區分大小寫 
            NumberHandling = JsonNumberHandling.AllowReadingFromString,//(反序列化時使用) 允許從「字串」讀入「數值」型別的屬性  
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping//(序列化時使用) 中文不亂碼,直接輸出特殊符號,例如:「<>+」
        };
        static void Main(string[] args)
        {
            //假裝從DB抓出兩筆資料
            List<MyResult> dbList = new List<MyResult>();
            dbList.Add(new MyResult { Product1 = "CMT-Test300AA", buy_date = "2015-10-27", Giveaway = "Y" });
            dbList.Add(new MyResult { Product1 = "CMT-Test300BB", buy_date = "2020-05-02", Giveaway = "N" });
            
            //在序列化前
            //使用LINQ語法做集合轉型,並在匿名型別中給予不同的屬性名稱
            var resultList = dbList.Select(m=>new 
            {
                modelName = m.Product1,
                purchasedDate = m.buy_date,
                Giveaway = m.Giveaway
            });//End LINQ

            //序列化集合,輸出給前端用戶看
            Console.WriteLine(JsonSerializer.Serialize(resultList,jso));

            //假裝從API接到json字串
            string strJson = "";
            //API回傳JSON內容裡的每個屬性名稱都對應得到物件的屬性名稱,接回來的物件也就都拿得到值了
            List<MyResult> listResult =  JsonSerializer.Deserialize<List<MyResult>>(strJson,jso);

        }//end Main()
    }//end class Program
}

↑ 但此解法缺點在每次序列化前,都要手寫LINQ語法,一行一行地刻出匿名型別的每個屬性,要是輸出的屬性很多的話,豈不是寫到天荒地老…

最終採用的解法:繼承DefaultJsonTypeInfoResolver類別來改寫

綜合之前文章的需求:[.NET 7] System.Text.Json 動態決定屬性是否序列化 - 高級打字員
此解法的好處是定義類別寫一遍,之後Reuse就相當方便。
參考以下的範例,說明都在註解裡。

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.Net.Http;
using System.Text;
using System.Reflection;
/*
 此 Console 專案使用.Net Framework 4.8
 */
namespace ConsoleApp_JsonConvertTest
{
    public class MyJsonTypeInfoResolver : DefaultJsonTypeInfoResolver
    {
        /// <summary>
        /// 要序列化的屬性名稱,集合內容值以[JsonPropertyName]名稱為主來儲存,其次為原始屬性名稱
        /// </summary>
        public List<string> SerializeJsonPropertyNames { get; set; } = new List<string>();
        /// <summary>
        /// 序列化or反序列化時,是否使用JsonPropertyNameAttribute的屬性名稱
        /// </summary>
        public bool UseJsonPropertyName { get; set; } = true; 
        /// <summary>
        /// 呼叫 Serialize 或 Deserialize 方法之後,↓以下方法只會執行一次,之後二次呼叫Serialize 或 Deserialize 方法,不會再執行
        /// </summary>
        /// <param name="t"></param>
        /// <param name="o"></param>
        /// <returns></returns>
        public override JsonTypeInfo GetTypeInfo(Type t, JsonSerializerOptions o)
        { 
            JsonTypeInfo jti = base.GetTypeInfo(t, o);
            foreach (JsonPropertyInfo prop in jti.Properties)
            {
                //原始屬性名稱(不管是否套用 [JsonPropertyName] )
                string propUnderlyingName = (prop.AttributeProvider as MemberInfo).Name;
                //↓若屬性有套用 [JsonPropertyName] Attribute者,則優先取得 JsonPropertyName Attribute 的名稱;若沒套用,則直接取得原始屬性名稱。
                string jsonPropertyName = prop.Name;
                if (this.UseJsonPropertyName == false)
                {
                    //序列化or反序列化都使用原始屬性名稱
                    prop.Name = propUnderlyingName;

                }
                //此屬性是否序列化
                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

    #region Model
    public class MyParam
    {
        /// <summary>
        /// 會員Guid
        /// </summary>
        public string strGuid { get; set; } = "";
    }//end class MyParam
    public class MyResult
    {
        /*
          利用 JsonPropertyNameAttribute 讓用戶端看到序列化後的的Json內容裡屬性名稱被 Rename 過(而不是DB原始資料行名稱,資安比較安全)     
        */
        //掛上JsonPropertyNameAttribute後,會覆寫 JsonSerializerOptions 裡的 PropertyNamingPolicy 設定
        [JsonPropertyName("modelName")]
        public string Product1 { get; set; } = "";
        [JsonPropertyName("purchasedDate")]
        public string buy_date { get; set; } = "";
        public string GiVeAwaY { get; set; } = "";
    }//end class 
    #endregion

    public class Program
    {
        /// <summary>
        /// 共用的 JsonSerializerOptions
        /// </summary>
        public static readonly JsonSerializerOptions jso = new JsonSerializerOptions()
        { 
            WriteIndented = true,//(序列化時使用) 排版整齊
            //MSDN PropertyNamingPolicy 的說明↓
            //https://learn.microsoft.com/zh-tw/dotnet/api/system.text.json.jsonserializeroptions.propertynamingpolicy?view=net-7.0#system-text-json-jsonserializeroptions-propertynamingpolicy
            //(序列化時使用) 序列化後的屬性大小寫維持原樣,若序列對象的屬性有套用 JsonPropertyNameAttribute 者為 JsonPropertyNameAttribute 名稱優先。
            PropertyNamingPolicy = null,
            PropertyNameCaseInsensitive = true,//(反序列化時使用) 不區分大小寫 
            NumberHandling = JsonNumberHandling.AllowReadingFromString,//(反序列化時使用) 允許從「字串」讀入「數值」型別的屬性    
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping//(序列化時使用) 解決中文亂碼,直接輸出特殊符號,例如:「<>+」
            //參考MSDN官網文章:「如何使用 自訂字元編碼 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符號,也可正常顯示中文,但留意前端若直接輸出至網頁上會有資安漏洞。如果要回傳的屬性值當中就是會有奇怪的符號,例如:「<>+」。才用這個↑
            */
        };
         
        /// <summary>
        /// WebAPI網址
        /// </summary>
        private static readonly string WebApiUrl = "https://localhost:44340/XXXController/XXXAction/";

        /// <summary>
        /// 建立共用的 HttpClient
        /// 若是.NET Core,建議使用 HTTPClientFactory 來建立 HttpClient
        /// </summary>
        public static readonly HttpClient _client = new HttpClient();
        public static T PostWebApi<T>(string url, object objPostBody, bool useJsonPropertyNameMapping = false)
        {
            // 指定 authorization header(若有需要的話)
            //client.DefaultRequestHeaders.Add("authorization", "token {api token}");

            //.NET 5+ 推薦使用 PostAsJsonAsync 方法,效能會較好
            //todo 留意PostAsJsonAsync() 方法的 Encoding.UTF8, "application/json"的設定是否自動有效?並更新blog
            //HttpResponseMessage response = client.PostAsJsonAsync(url, objPostBody).Result;

            // 要 Post傳遞給 WebAPI 的內容
            StringContent content = new StringContent(JsonSerializer.Serialize(objPostBody), Encoding.UTF8, "application/json");
            // 發出 http post (json body) 並取得結果
            HttpResponseMessage response = _client.PostAsync(url, content).Result;
            // 回應結果字串
            string strResponse = response.Content.ReadAsStringAsync().Result;

            /* JsonSerializerOptions 若被用過一次 Serialize 或 Deserialize,就不可再更改 TypeInfoResolver 的參考
               例如:jso.TypeInfoResolver = new DefaultJsonTypeInfoResolver(); →會報錯
               所以才會用 myJso = new JsonSerializerOptions(jso); 
               重新複製一份 Json 設定的參考
            */
            var myJso = new JsonSerializerOptions(jso)
            {
               TypeInfoResolver = new MyJsonTypeInfoResolver() { UseJsonPropertyName = useJsonPropertyNameMapping };            
            }; 
            var result = JsonSerializer.Deserialize<T>(strResponse, myJso);
            if (result == null)
            {//為了消除VS IDE的null警告...
                result = Activator.CreateInstance<T>();
            }
            return result;
        }
         
        static void Main(string[] args)
        {
            #region 發出 WebAPI 傳遞會員guid

            string g_user_id = "{Test-test-Test-test-Test}";
            
            Console.WriteLine("↓呼叫API useJsonPropertyNameMapping: true");
            Console.WriteLine("※若 JsonPropertyName 和 WebAPI 回傳的屬性不同,該屬性值會是預設值(空字串);若屬性未套用 JsonPropertyNameAttrubite,則比對原始屬性名稱來反序列化");
            Console.WriteLine("");
            var listResult = PostWebApi<List<MyResult>>(WebApiUrl,
                new //要傳遞的JsonBody
                {
                    strGuid = g_user_id
                },
                //具名參數好閱讀↓
                useJsonPropertyNameMapping: true);
           
            //顯示結果,假裝從DB只取出兩筆
            foreach (var item in listResult.Take(2))
            {
                Console.WriteLine($"{nameof(item.Product1)}:{(item.Product1==""?"空字串":item.Product1)},{nameof(item.buy_date)}:{(item.buy_date==""?"空字串":item.buy_date)},{nameof(item.GiVeAwaY)}:{item.GiVeAwaY}");
            }
            Console.WriteLine("====================");
            
            Console.WriteLine("↓呼叫API useJsonPropertyNameMapping: false");
            Console.WriteLine("※ WebAPI 回傳的屬性名稱直接和物件原始屬性名稱進行比對來反序列化(不管類別屬性是否套用 JsonPropertyNameAttrubite )");
            Console.WriteLine("");
            listResult = PostWebApi<List<MyResult>>(WebApiUrl,
                new //要傳遞的JsonBody
                {
                    strGuid = g_user_id
                },
                //具名參數好閱讀↓
                useJsonPropertyNameMapping: false);

            //顯示結果,假裝從DB只取出兩筆
            foreach (var item in listResult.Take(2))
            {
                Console.WriteLine($"{nameof(item.Product1)}:{(item.Product1==""?"空字串":item.Product1)},{nameof(item.buy_date)}:{(item.buy_date==""?"空字串":item.buy_date)},{nameof(item.GiVeAwaY)}:{item.GiVeAwaY}");
            }
            #endregion
             
            Console.WriteLine("====================");
             
            #region 序列化....
            var obj = new MyResult()
            {
                Product1 = "我的Product1",
                buy_date = "2000-01-01",
                GiVeAwaY = "贈品(7-11商品卡)",
            };
            Console.WriteLine("↓序列化 UseJsonPropertyName = true,指定只要輸出的JsonPropertyName(modelName,purchasedDate)");
            //複製序列化的設定
            var myJso1 = new JsonSerializerOptions(jso)
            {
              TypeInfoResolver = new MyJsonTypeInfoResolver()
              {
                SerializeJsonPropertyNames = "modelName,purchasedDate".Split(new string[] { "," },StringSplitOptions.RemoveEmptyEntries).ToList(),
                UseJsonPropertyName = true
            }
            
            };
            
            string strJson = JsonSerializer.Serialize(obj, myJso1);
            
            Console.WriteLine(strJson);
            Console.WriteLine("====================");


            Console.WriteLine("↓序列化 UseJsonPropertyName = false(輸出原始屬性名稱),全部屬性都要序列化");
            //複製序列化的設定
            var myJso2 = new JsonSerializerOptions(jso)
            {
              TypeInfoResolver = new MyJsonTypeInfoResolver()
              {
                 UseJsonPropertyName = false
              }
            };
            
            strJson = JsonSerializer.Serialize(obj, myJso2);
            Console.WriteLine(strJson);//所有結果原始輸出
            Console.WriteLine("====================");

            Console.WriteLine("↓序列化 UseJsonPropertyName = false(輸出原始屬性名稱),指定只要輸出的JsonPropertyName(modelName,GiVeAwaY)");

            //複製序列化的設定
            var myJso3 = new JsonSerializerOptions(jso);
            myJso3.TypeInfoResolver = new MyJsonTypeInfoResolver()
            {
                //只想要序列化的JsonPropertyName
                SerializeJsonPropertyNames = "modelName,GiVeAwaY".Split(new string[] { "," }, StringSplitOptions.RemoveEmptyEntries).ToList(),
                //但序列化後卻想要輸出的是原始屬性名稱
                UseJsonPropertyName = false
            }; 
            strJson = JsonSerializer.Serialize(obj, myJso3);
            Console.WriteLine(strJson);
            #endregion

        }//end Main() 
    }//end class Program
}//end namespace

↓ 執行結果

參考文章

MSDN官方文章:
如何在 .NET 中序列化和還原序列化 (封送處理和 unmarshal) JSON
↑ System.Text.Json 基本用法
System.Text.Json JsonSerializerOptions 類別
如何使用 System.Text.Json 自訂屬性名稱與值
自訂 JSON 合約

網友部落格:
反序列化的設定參考文章:
System.Text.Json 可使用 JsonSerializerDefaults.Web 處理常見的 JSON 格式 - Will 保哥
ASP.NET Core – Configure JSON serializer options
C# – Deserialize a JSON array to a list

討論文:
使用 System.Text.Json 找出原始物件屬性名稱:
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?
這我也還在擱置研究中:
How to globally set default options for System.Text.Json.JsonSerializer?
List<string>的.Contains() 函數裡的參數,想要字串比較時忽略大小寫,請使用:「StringComparer.OrdinalIgnoreCase」,文章說明↓
Case-Insensitive List Search