Contents

Net 5 TimeSpan 做 Json 序列化引發的慘劇

最近因為用兩層緩存,第一層會抓 Memory Cache,沒有資料會抓第二層 SQL Server Cache,在沒有資料會抓取 API 相關資料,簡單多層緩存功能。因為 IDistributedCache 無法抓取到資料庫該 key 設定的資料,這邊有把 DistributedCacheEntryOptions做 Json 序列化衍生這次慘劇。

心智圖

mindmap .Net 5 TimeSpan 做 Json 序列化引發的慘劇 問題 .Net 5 和 .Net 6 TimeSpan 序列化不一樣結果 TimeSpan 屬性唯讀 解決方法 實作 System.Text.Json Converter 使用 Json.Net

前景提要

因為 IDistributedCache 無法抓取到資料庫該 key 設定的資料,這邊有把 DistributedCacheEntryOptions做 Json 序列化衍生這次慘劇。

主要我把程式使用CacheItem<T>封裝起來,最後做 Json 序列化存進去,解決從 Cache 資料表抓出來,可以知道相關設定寫入到資料庫。其實下面我也是用 ChatGPT 產生出來程式調整。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using System.Threading.Tasks;
using System;
using System.Text.Json;

namespace XxxxxxxBackend.WebAPI.Infrastructure
{
    public class CacheManager
    {
        private readonly IMemoryCache _memoryCache;
        private readonly IDistributedCache _distributedCache;

        public CacheManager(IMemoryCache memoryCache, IDistributedCache distributedCache)
        {
            _memoryCache = memoryCache;
            _distributedCache = distributedCache;
        }

        public async Task<T> GetDataAsync<T>(string id, string dataGroup = "MyData", MemoryCacheEntryOptions memoryOptions = null)
        {
            string cacheKey = $"{dataGroup}_{id}";

            if (!_memoryCache.TryGetValue(cacheKey, out T data))
            {
                // 緩存未命中,從 IDistributedCache 獲取資料
                byte[] cacheValue = await _distributedCache.GetAsync(cacheKey);

                if (cacheValue != null)
                {
                    // 將字節數組轉換為泛型類型 T
                    CachedItem<T> wrapData = JsonSerializer.Deserialize<CachedItem<T>>(cacheValue);
                    data = wrapData.Value;

                    // 將資料寫入 IMemoryCache
                    memoryOptions ??= new MemoryCacheEntryOptions
                    {
                        SlidingExpiration = wrapData.Options.SlidingExpiration,
                        AbsoluteExpirationRelativeToNow = wrapData.Options.AbsoluteExpirationRelativeToNow,
                        AbsoluteExpiration = wrapData.Options.AbsoluteExpiration,
                    };
                    _memoryCache.Set(cacheKey, data, memoryOptions);
                }
                else
                {
                    // 資料不存在,執行其他邏輯
                }
            }

            return data;
        }

        public async Task UpdateDataAsync<T>(string id, T data, string dataGroup = "MyData", MemoryCacheEntryOptions memoryOptions = null ,DistributedCacheEntryOptions distributedCacheOptions = null)
        {
            string cacheKey = $"{dataGroup}_{id}";

            // 將泛型類型 T 轉換為字節數組
            byte[] cacheValue = JsonSerializer.SerializeToUtf8Bytes(new CachedItem<T> {Options = distributedCacheOptions, Value = data} );

            // 將資料寫入 IDistributedCache
            distributedCacheOptions ??= new DistributedCacheEntryOptions
            {
                SlidingExpiration = TimeSpan.FromMinutes(10),
                AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1),
            };
            await _distributedCache.SetAsync(cacheKey, cacheValue, distributedCacheOptions);

            // 將資料寫入 IMemoryCache
            memoryOptions ??= new MemoryCacheEntryOptions
            {
                SlidingExpiration = TimeSpan.FromMinutes(5),
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
            };
            _memoryCache.Set(cacheKey, data, memoryOptions);
        }

        public async Task DeleteDataAsync(string id, string dataGroup = "MyData")
        {
            string cacheKey = $"{dataGroup}_{id}";

            _memoryCache.Remove(cacheKey);
            await _distributedCache.RemoveAsync(cacheKey);
        }

    }

    public class CachedItem<T>
    {
        public T Value { get; set; }
        public DistributedCacheEntryOptions Options { get; set; }
    }
}

慘劇發生

https://user-images.githubusercontent.com/75846914/253622230-2b1526ee-772d-46d4-8ad1-260298a9786d.png

半夜系統上線,早上收到有很多使用者遇到不能用,原因這次上板我加上SlidingExpiration 而造成下面錯誤。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
[18:39:39 ERR] {"ExceptionMessage":"The sliding expiration value must be positive. (Parameter 'SlidingExpiration')\r\nActual value was 00:00:00.","Request":{"Method":"GET","Path":"************","Body":"","ResponseStatusCode":200}}
System.ArgumentOutOfRangeException: The sliding expiration value must be positive. (Parameter 'SlidingExpiration')
Actual value was 00:00:00.
   at Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions.set_SlidingExpiration(Nullable`1 value)
   at System.Text.Json.JsonPropertyInfo`1.ReadJsonAndSetMember(Object obj, ReadStack& state, Utf8JsonReader& reader)
   at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
   at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
   at System.Text.Json.JsonPropertyInfo`1.ReadJsonAndSetMember(Object obj, ReadStack& state, Utf8JsonReader& reader)
   at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
   at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)
   at System.Text.Json.JsonSerializer.ReadCore[TValue](JsonConverter jsonConverter, Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)
   at System.Text.Json.JsonSerializer.ReadCore[TValue](Utf8JsonReader& reader, Type returnType, JsonSerializerOptions options)
   at System.Text.Json.JsonSerializer.Deserialize[TValue](String json, Type returnType, JsonSerializerOptions options)
   at System.Text.Json.JsonSerializer.Deserialize[TValue](String json, JsonSerializerOptions options)
   at ...................CacheManager.GetDataAsync[T](String id, String dataGroup, MemoryCacheEntryOptions memoryOptions) in .........\CacheManager.cs:line 37
   at .....................

好了,花了很多時間找到問題,這邊整理一下。文章都已經破題了,沒錯!就是 TimeSpan Json 序列化造成的。其實當初我看到重點在 SMicrosoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions.set_SlidingExpiration(Nullable1 value)所造成錯誤,這邊也翻了這個地方源碼。找到錯誤的地方,奇怪,我明明有設定 SlidingExpiration 五分鐘,怎麼這邊顯示 00:00:00?

https://user-images.githubusercontent.com/75846914/253625509-9f257fc7-205f-4f14-a5c6-06abccb82bd9.png

分析問題

其實這個實例反序列化就錯了,其實從 log 中可以看出System.Text.Json.JsonPropertyInfo1.ReadJsonAndSetMember反序列化遇到這個錯誤問題。

Info
這邊以為是微軟內部 Cache抓不到資訊所造成的,結果查錯方向浪費一堆時間。

那到底為什麼會錯呢?

.Net 5 和 .Net 6 TimeSpan 序列化不一樣結果

我程式是用我從資料表抓出 Cache 資料是

1
{"Value":.......,"Options":{"AbsoluteExpiration":"2023-07-14T19:37:08.48+08:00","AbsoluteExpirationRelativeToNow":null,"SlidingExpiration":{"Ticks":3000000000,"Days":0,"Hours":0,"Milliseconds":0,"Minutes":5,"Seconds":0,"TotalDays":0.003472222222222222,"TotalHours":0.08333333333333333,"TotalMilliseconds":300000,"TotalMinutes":5,"TotalSeconds":300}}}

不過我從 LinqPad 7使用是.Net6,這邊產生結果不一樣。

https://user-images.githubusercontent.com/75846914/253630743-1b40436e-cabc-4261-b799-42d17919a63b.png

這也是這次不能寫入原因,原因是 .Net 6 會針對 TimeSpan 做格式轉換,.Net 5之前都不會做。可以看相關文章:

TimeSpan 屬性唯讀

其實上面 .Net 5 和 .Net 6 TimeSpan 序列化不一樣結果 也不是主要原因,其實 TimeSpan 是 struct,主要反序列化也會寫回屬性才對。然後我去看 TimeSpan 文件看到主要原因,原來屬性沒有 set 方法

https://user-images.githubusercontent.com/75846914/253632047-b92755f5-dee5-4fee-9de1-9dc03879e8d3.png

測試圖片

.Net 6 做序列化和反序列化正常。
https://user-images.githubusercontent.com/6058558/253583386-4cb602af-d74a-4668-bed3-241c5cab7b4e.png

.Net 5 做序列化和反序列化沒成功還原,原因屬性沒有set方法。
https://user-images.githubusercontent.com/6058558/253583530-11456f8d-07a3-454c-80c1-76e1c305238b.png

.Net 5 就算空的,他也會回來空的 TimeSpan。這也是為什麼屬性沒塞到也不會錯。
https://user-images.githubusercontent.com/6058558/253585167-d79d2d39-043d-4bb3-9301-7dfc22e4941c.png

解決方法

實作 System.Text.Json Converter

.Net 5 自己要寫個 TimeSpan Json Converter 方法。可參考:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using System.Globalization;

namespace System.Text.Json.Serialization
{
    /// <summary>
    /// <see cref="JsonConverter"/> to convert TimeSpan to and from strings.
    /// </summary>
    public class JsonTimeSpanConverter : JsonConverter<TimeSpan>
    {
        public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if (reader.TokenType != JsonTokenType.String)
                throw new NotSupportedException();
            if (typeToConvert != typeof(TimeSpan))
                throw new NotSupportedException();

            // 使用常量 "c" 來指定用 [-][d.]hh:mm:ss[.fffffff] 作為 TimeSpans 轉換的格式
            return TimeSpan.ParseExact(reader.GetString(), "c", CultureInfo.InvariantCulture);
        }

        public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
        {
            writer.WriteStringValue(value.ToString("c", CultureInfo.InvariantCulture));
        }
    }
}

最後在 TimeSpan 加上[JsonConverter(typeof(JsonTimeSpanConverter))] 屬性就完事。
我把程式執行放在 LinqPad: http://share.linqpad.net/mx45h3.linq

使用 Json.Net

JSON.Net 內建有 TimeSpan Converter,所以我當初用這個的話,我說不定就不會翻車🤔

不過 JsonConvert 序列化/反序列化要轉成 bytes 給 Cache 存。這邊順便留著。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
    public class CacheManager
    {
        private readonly IMemoryCache _memoryCache;
        private readonly IDistributedCache _distributedCache;

        public CacheManager(IMemoryCache memoryCache, IDistributedCache distributedCache)
        {
            _memoryCache = memoryCache;
            _distributedCache = distributedCache;
        }

        public async Task<T> GetDataAsync<T>(string id, string dataGroup = "MyData", MemoryCacheEntryOptions memoryOptions = null)
        {
            string cacheKey = $"{dataGroup}_{id}";

            if (!_memoryCache.TryGetValue(cacheKey, out T data))
            {
                // 緩存未命中,從 IDistributedCache 獲取資料
                byte[] cacheValue = await _distributedCache.GetAsync(cacheKey);

                if (cacheValue != null)
                {
                    using var memoryStream = new MemoryStream(cacheValue);
                    using var streamReader = new StreamReader(memoryStream);
                    var stream = await streamReader.ReadToEndAsync();
                    // 將字節數組轉換為泛型類型 T
                    CachedItem<T> wrapData = JsonConvert.DeserializeObject<CachedItem<T>>(stream);
                    data = wrapData.Value;

                    // 將資料寫入 IMemoryCache
                    memoryOptions ??= new MemoryCacheEntryOptions
                    {
                        SlidingExpiration = wrapData.Options.SlidingExpiration,
                        AbsoluteExpirationRelativeToNow = wrapData.Options.AbsoluteExpirationRelativeToNow,
                        AbsoluteExpiration = wrapData.Options.AbsoluteExpiration,
                    };
                    _memoryCache.Set(cacheKey, data, memoryOptions);
                }
                else
                {
                    // 資料不存在,執行其他邏輯
                }
            }

            return data;
        }

        public async Task UpdateDataAsync<T>(string id, T data, string dataGroup = "MyData", MemoryCacheEntryOptions memoryOptions = null ,DistributedCacheEntryOptions distributedCacheOptions = null)
        {
            string cacheKey = $"{dataGroup}_{id}";

            // 將泛型類型 T 轉換為字節數組
            byte[] cacheValue = System.Text.Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new CachedItem<T> {Options = distributedCacheOptions, Value = data} ));

            // 將資料寫入 IDistributedCache
            distributedCacheOptions ??= new DistributedCacheEntryOptions
            {
                SlidingExpiration = TimeSpan.FromMinutes(10),
                AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1),
            };
            await _distributedCache.SetAsync(cacheKey, cacheValue, distributedCacheOptions);

            // 將資料寫入 IMemoryCache
            memoryOptions ??= new MemoryCacheEntryOptions
            {
                SlidingExpiration = TimeSpan.FromMinutes(5),
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
            };
            _memoryCache.Set(cacheKey, data, memoryOptions);
        }

        public async Task DeleteDataAsync(string id, string dataGroup = "MyData")
        {
            string cacheKey = $"{dataGroup}_{id}";

            _memoryCache.Remove(cacheKey);
            await _distributedCache.RemoveAsync(cacheKey);
        }

    }

    public class CachedItem<T>
    {
        public T Value { get; set; }
        public DistributedCacheEntryOptions Options { get; set; }
    }

彩蛋

因為 TimeSpan 是 Struct,這邊順便查查跟 Class 哪裡不一樣。