最近因為用兩層緩存,第一層會抓 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; }
}
}
|
慘劇發生
半夜系統上線,早上收到有很多使用者遇到不能用,原因這次上板我加上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
?
分析問題
其實這個實例反序列化就錯了,其實從 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
,這邊產生結果不一樣。
這也是這次不能寫入原因,原因是 .Net 6 會針對 TimeSpan 做格式轉換,.Net 5之前都不會做。可以看相關文章:
TimeSpan 屬性唯讀
其實上面 .Net 5 和 .Net 6 TimeSpan 序列化不一樣結果
也不是主要原因,其實 TimeSpan 是 struct
,主要反序列化也會寫回屬性才對。然後我去看 TimeSpan
文件看到主要原因,原來屬性沒有 set 方法。
測試圖片
.Net 6 做序列化和反序列化正常。
.Net 5 做序列化和反序列化沒成功還原,原因屬性沒有set
方法。
.Net 5 就算空的,他也會回來空的 TimeSpan。這也是為什麼屬性沒塞到也不會錯。
解決方法
實作 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 哪裡不一樣。