Featured image of post 技术随笔#1

技术随笔#1


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)); // 通过