ValueTuple的坑、Blazor WebAssembly中作用域的坑与DI实例注册的坑
一、ValueTuple序列化困境:元数据表达的先天缺陷
现象还原
在实现跨服务数据契约时,开发者可能采用如下设计:
1
2
3
4
5
|
public (Guid OrderId, decimal Amount) ProcessOrder(OrderRequest request)
{
// 业务逻辑
return (Guid.NewGuid(), request.Items.Sum(x => x.Price));
}
|
当通过ASP.NET Core返回该元组时,发现客户端接收到的JSON数据为空。
ValueTuple与Tuple/Record/自定义类型对比
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// ValueTuple - 不推荐用于序列化场景
public ValueTuple<string, int> BadChoice = ("value", 123);
// 推荐方案1: 引用类型Tuple
public Tuple<string, int> BetterChoice = Tuple.Create("value", 123);
// 推荐方案2: C# 9.0+ Record类型
public record DataRecord(string Name, int Value);
// 推荐方案3: 自定义类/结构体
public class CustomType
{
public string Name { get; set; }
public int Value { get; set; }
}
|
| 特征维度 |
ValueTuple |
Tuple |
Record |
自定义类型 |
| 成员类型 |
公共字段 |
只读属性 |
只读属性 |
属性(可读写) |
| 序列化支持 |
需特殊处理 |
开箱即用 |
开箱即用 |
开箱即用 |
| 语义化 |
强 (命名参数) |
弱 (Item1, Item2) |
强 (命名参数) |
强 (自定义属性名) |
| 不可变性 |
可变 |
不可变 |
不可变 |
可配置 |
| 使用场景 |
内部实现/临时数据 |
API返回值/持久化数据 |
领域模型/DTO |
完整业务模型 |
为什么避免在序列化场景使用ValueTuple
通过反射可以看到ValueTuple的内部结构问题:
1
2
3
4
5
6
7
8
|
var type = typeof((string, int));
var members = type.GetMembers(BindingFlags.Public | BindingFlags.Instance);
// 输出结果显示ValueTuple使用字段而非属性:
// [Field] Item1
// [Field] Item2
// [Method] GetHashCode
// [Method] ToString
|
大多数序列化库(如System.Text.Json、Newtonsoft.Json)默认只序列化属性而非字段,导致ValueTuple序列化结果为空对象。
最佳实践推荐
方案选择矩阵
| 场景 |
推荐类型 |
理由 |
| API契约/DTO |
Record |
不可变、简洁语法、良好序列化支持 |
| 领域模型 |
自定义类 |
完整封装、业务逻辑、可扩展性 |
| 内部临时数据 |
Tuple |
比ValueTuple更好的序列化支持 |
| 高性能内部实现 |
ValueTuple |
仅限非序列化场景,栈分配性能优势 |
Record类型示例(推荐)
1
2
3
4
5
6
7
|
// 定义简洁,序列化友好
public record UserInfo(string Name, int Age, string Email);
// 使用示例
var user = new UserInfo("张三", 30, "zhangsan@example.com");
var json = JsonSerializer.Serialize(user);
// 输出: {"Name":"张三","Age":30,"Email":"zhangsan@example.com"}
|
自定义类型(完整控制)
1
2
3
4
5
6
7
8
9
10
11
12
13
|
public class Product
{
public string Name { get; set; }
public decimal Price { get; set; }
public string Category { get; set; }
// 可添加自定义业务逻辑
public bool IsOnSale => Price < 100;
}
// 序列化正常工作
var product = new Product { Name = "笔记本", Price = 89.99m, Category = "办公用品" };
var json = JsonSerializer.Serialize(product);
|
通过选择合适的类型,可以避免序列化问题,同时获得更好的代码可读性和维护性。
二、Blazor WebAssembly作用域隔离:DelegatingHandler的生存周期陷阱
现象还原
在实现JWT令牌自动注入的场景中,我们通常会采用以下模式:
1
2
3
4
5
6
7
8
9
10
11
|
// 注册Scoped的用户存储服务
builder.Services.AddScoped<IUserStore, UserStore>();
// 注册授权消息处理程序
builder.Services.AddTransient<AuthorizationMessageHandler>();
// 配置命名的HttpClient
builder.Services.AddHttpClient("api", (sp, client) =>
{
client.BaseAddress = new Uri(apiUrl);
}).AddHttpMessageHandler<AuthorizationMessageHandler>();
|
我们的AuthorizationMessageHandler实现如下:
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
|
/// <summary>
/// 授权消息处理程序,用于在HTTP请求中自动添加JWT令牌
/// </summary>
public class AuthorizationMessageHandler : DelegatingHandler
{
private readonly IUserStore _userStore;
/// <summary>
/// 创建授权消息处理程序的新实例
/// </summary>
/// <param name="userStore">用户存储,用于获取和更新JWT令牌</param>
public AuthorizationMessageHandler(IUserStore userStore)
{
_userStore = userStore;
}
/// <summary>
/// 发送HTTP请求并处理授权相关逻辑
/// </summary>
/// <param name="request">请求消息</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>响应消息</returns>
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var token = _userStore.JwtToken;
if (!string.IsNullOrEmpty(token))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
var response = await base.SendAsync(request, cancellationToken);
// ...其他逻辑...
return response;
}
}
|
然而,当在页面组件中使用HttpClient发送请求时,发现每次请求都缺失预期的认证头,即使用户已登录并且IUserStore中存储了有效的JWT令牌:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// 页面组件中的消费
@inject IHttpClientFactory ClientFactory
@inject IUserStore UserStore
protected override async Task LoadDataAsync()
{
// 在组件中可以看到UserStore.JwtToken有值
Console.WriteLine($"组件中的Token: {UserStore.JwtToken}");
var client = ClientFactory.CreateClient("api");
// 但请求中却没有包含认证头
var response = await client.GetAsync("data");
}
|
机制解剖
核心矛盾:DI容器作用域分裂
问题的根源在于Blazor WebAssembly中的DI容器作用域机制与HttpClientFactory的工作方式之间的冲突:
- 页面组件中注入的
IUserStore实例来自组件的作用域
AuthorizationMessageHandler中注入的IUserStore实例来自根容器作用域
- 这两个
IUserStore实例不是同一个对象,导致令牌状态不同步
问题验证
可以通过以下代码验证作用域差异:
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
|
public class AuthorizationMessageHandler : DelegatingHandler
{
private readonly IUserStore _userStore;
private readonly ILogger<AuthorizationMessageHandler> _logger;
public AuthorizationMessageHandler(IUserStore userStore, ILogger<AuthorizationMessageHandler> logger)
{
_userStore = userStore;
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
// 输出Handler中IUserStore实例的HashCode
_logger.LogInformation("Handler中的UserStore实例: {HashCode}", _userStore.GetHashCode());
_logger.LogInformation("Handler中的Token: {Token}", _userStore.JwtToken);
// ... 其余代码
}
}
// 在组件中
protected override void OnInitialized()
{
// 输出组件中IUserStore实例的HashCode
Console.WriteLine($"组件中的UserStore实例: {UserStore.GetHashCode()}");
Console.WriteLine($"组件中的Token: {UserStore.JwtToken}");
}
|
执行后会发现两个HashCode不同,且只有组件中的IUserStore实例包含Token。
推荐方案:提升IUserStore为单例
最简单的解决方案是将IUserStore注册为单例:
1
2
|
// 将IUserStore注册为单例
builder.Services.AddSingleton<IUserStore, UserStore>();
|
这样,组件和Handler中使用的就是同一个IUserStore实例。
深度分析:Blazor WebAssembly的作用域特性
Blazor WebAssembly的DI容器与ASP.NET Core服务端应用有本质区别:WebAssembly运行在浏览器中,本质上是单用户应用,在Blazor WebAssembly中,Scoped服务和Singleton服务在组件生命周期内基本保持一致,但是HttpClientFactory创建的DelegatingHandler与组件不在同一作用域,导致Scoped服务实例不同步。在Blazor WebAssembly中,可以放心使用Singleton服务。
三、依赖注入的实例隔离:Singleton语义的重定义
现象还原
在基础设施层设计时,开发者可能进行如下注册:
1
2
|
services.AddSingleton<ICacheProvider, RedisCache>();
services.AddSingleton<IDistributedLock, RedisCache>();
|
预期RedisCache实例应同时服务于两种接口,但实际运行时发现:
1
2
3
4
|
var cache = services.GetRequiredService<ICacheProvider>();
var locker = services.GetRequiredService<IDistributedLock>();
Debug.Assert(ReferenceEquals(cache, locker)); // 断言失败
|
机制解剖
通过分析ServiceDescriptor的内部结构,可以证明每个接口注册都会创建独立的工厂委托。
解决方案
1
2
3
4
5
6
7
8
9
10
11
12
|
services.AddSingleton<RedisCache>();
services.AddSingleton<ICacheProvider>(sp => sp.GetRequiredService<RedisCache>());
services.AddSingleton<IDistributedLock>(sp => sp.GetRequiredService<RedisCache>());
// 验证实例一致性
var instance = services.GetRequiredService<RedisCache>();
var cache = services.GetRequiredService<ICacheProvider>();
var locker = services.GetRequiredService<IDistributedLock>();
Debug.Assert(ReferenceEquals(instance, cache)); // 通过
Debug.Assert(ReferenceEquals(cache, locker)); // 通过
|