ASP.NET开发心得

开发模式

ASP.NET有两套开发模式:

ASP.NET Core

├── MVC 模式(传统模型-视图-控制器)
│ └── 适合:大型应用、团队开发、复杂页面

└── Minimal APIs(极简模型)
└── 适合:微服务、云函数、小型 API、原型开发

url路由

  1. 控制器级别 [Route("/ui/device")] 注解
  • 作用:为整个控制器添加路由前缀,指定该控制器下所有接口的基础路径。

  • 语法:[Route("路径模板")],其中路径模板可以包含固定字符串或参数。

    理论上,该注解会为控制器下的所有方法添加 /ui/device 前缀。但如果方法级的路由注解以 / 开头,实际会覆盖此前缀,这和spring 里的@RequestMapping注解的行为是不一样的,千万注意

  1. 方法级别 [HttpGet("list")] 等注解
  • 作用:

    • 指定 HTTP 方法:[HttpGet] 表示该接口接受 GET 请求,类似的还有 [HttpPost][HttpPut][HttpDelete] 等。
    • 定义路由路径:括号内的字符串定义了接口的具体路径。
  • 特殊规则:

    • 当路径以 / 开头时(如 /list),会完全覆盖控制器级别的路由前缀,直接从根路径开始匹配。
  • 当路径不以 / 开头时(如 list),会继承控制器级别的路由前缀,形成完整路径(如 /ui/device/list)。

EF Core(Entity Framework)

.NET生态的ORM框架,等价于spring的Hibernate。

.NET里也有跟mybatis对应的半ORM框架Dapper。

通常,ASP.NET会用DbContext类来完成C#实体与数据库表的映射,比如:

public class MyContext : DbContext
{
    public MyContext(DbContextOptions<MyContext> options) : base(options)
    {}
    
    // 这里的DbSet就对应一张数据库表,尖括号里的Device就是entity类
    public DbSet<Device> Devices { get; set; }
    ...

DBContext

DBContext=sql执行器+对象缓存(changeTracking),Find查找的时候,它优先查缓存,除非显式指定AsNoTracking。DBContext的更新操作一般做法是先Find、接着内存修改、最后saveChanges,这样会执行SELECT+UPDATE两次SQL,只更新少量字段时效率不高:

var device = await context.Devices.FindAsync(id);
if (device == null) return false;

device.Status = Consts.DeviceOnline;
await context.SaveChangesAsync();
return true;

可改为ExecuteUpdate,ExecuteUpdate会绕过changeTracking缓存,直接操作数据库。但这里要注意,ExecuteUpdate更新后,若仍使用原DBContext的Find来查找,而不指定AsNoTracking,查到的还是老数据。因为,ExecuteUpdate是不走ChangeTracking的。

类似的,删除操作可使用ExecuteDelete,也可节省一次查询。

对于 REST API,我们建议:

  • GET 请求AsNoTracking(只读)
  • PUT/PATCH 简单更新ExecuteUpdate(高性能)
  • DELETE 简单删除: ExecuteDelete(高性能)
  • 复杂业务更新ChangeTracker(功能完整)

AsNoTracking细节讨论

LINQ里加上AsNoTracking并不影响生成的sql,它影响的是sql执行后的行为,没有AsNoTracking,EFCore会额外创建LINQ执行结果的对象快照,方便可能的后续使用。看下面例子:

// no AsNoTracking
query = from cp in _context.CollectPoints
    join device in _context.Devices on cp.DeviceId equals device.Id
    select new { CollectPoint = cp, DeviceName = device.Name };
result = await query.ToListAsync();
// LINQ查询结果和ChangeTracker里的条目数是一样的,说明结果被复制到快照中
Assert.Equal(2, result.Count);
Assert.Equal(2, _context.ChangeTracker.Entries().Count());

但对于纯查询操作而言,我直接返回上例中的result即可,显然,快照是多余的。

这时候,就必须在查询里加上AsNoTracking:

// has AsNoTracking
var query = from cp in _context.CollectPoints.AsNoTracking()
    join device in _context.Devices on cp.DeviceId equals device.Id
    select new { CollectPoint = cp, DeviceName = device.Name };
var result = await query.ToListAsync();
Assert.Equal(2, result.Count);
// 结果数量还是2,但加了AsNoTracking后,快照没了
Assert.Empty(_context.ChangeTracker.Entries());

特别说一下,AsNoTracking仅仅是一个提示不要快照的标记,在LINQ里只需出现一次,且只要在ToListAsync之前,无论出现在哪里都可以。下面是验证的例子:

// AsNoTracking can occur anywhere, just before ToListAsync()
query = from cp in _context.CollectPoints
    join device in _context.Devices on cp.DeviceId equals device.Id
    select new { CollectPoint = cp, DeviceName = device.Name };
result = await query.AsNoTracking().ToListAsync();
Assert.Equal(2, result.Count);
Assert.Empty(_context.ChangeTracker.Entries());

所以,没必要在LINQ的每张表对象后都加一个AsNoTracking()。
另外,前面说了,没有AsNoTracking,EFCore会额外创建LINQ执行结果的对象快照,如果LINQ使用了Count、CountAsync,它的结果是一个数字,这时候Change Tracker是不会追踪对象的。所以,AsNoTracking对于Count、Sum、Average等聚合方法,是没有意义的,加与不加一个样。

Entity自定义表名和列名

默认的表名是Entity类名的复数形式。

可使用[Table]和[Column]注解在Entity类里自定义表名和列名:

[Table("device")]
public class Device
{
    public long Id { get; set; }
    public string Name { get; set; } = string.Empty;
    [Column("create_time")] public DateTime CreateTime { get; set; }
    [Column("update_time")] public DateTime UpdateTime { get; set; }

SaveChanges覆盖时间戳字段的自动生成

在未提供值的情况下,EF Core的dbContext.SaveChanges会为时间戳字段自动生成一个“最小时间戳”的值“0001-01-01”,覆盖了mysql设置的default CURRENT_TIMESTAMP或on update CURRENT_TIMESTAMP。这点容易产生问题,需要特别注意!

用ExecuteUpdate则不会覆盖mysql的默认时间戳设置,因为它是直接用update语句操作数据库的,绕开了change tracker缓存。

所以,我们的mysql字段设置应避免使用default CURRENT_TIMESTAMP或on update CURRENT_TIMESTAMP,因为很可能不会如我们想象般生效。

自增ID与SaveChanges

不调用SaveChanges,自增ID不会自动生成,切记!

不要因为追求所有修改在一个事务中,而只使用一次SaveChanges,尤其要关注entity Add后,代码继续使用其Id的情况,此时必须先SaveChanges。

枚举与整型互转

EF Core会自动为我们处理枚举(C#侧)与整型(数据库侧,包含int、smallint、tinyint等)的互转。

TINYINT(1)陷阱

我们为了节省mysql存储空间,会用TINYINT代替int,这里有个常见陷阱:除非我们表达的是bool类型,否则,绝不应该加后面的(1),因为EF Core底层驱动只会将TINYINT(1)处理为0和1(false和true),其它的非0值也会转成1!

Id为Primary Key的不用显示声明

无需写这样的代码:

modelBuilder.Entity<Cabinet>().HasKey(c => c.Id);

EF框架能自动识别。

modelBuilder的OnDelete(DeleteBehavior.Cascade)会联动删除吗?

其实并不会,它只会在sql脚本迁移时生成一条外键删除的约束。但如果生产环境的sql是我们手工整理的,且并未加上该外键删除约束,EF Core也不会联动删除。说白了,OnDelete是通过迁移sql间接起作用的。

serilog日志

压缩转储

使用ArchiveHooks,像这样:

Log.Logger = new LoggerConfiguration()
            .MinimumLevel.Information()
            .Enrich.WithThreadId() // 要使用{ThreadId}必须添加此 enricher
            .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
            .MinimumLevel.Override("System", LogEventLevel.Warning)
            .Enrich.FromLogContext()
            .WriteTo.Console(LogEventLevel.Fatal)
            .WriteTo.File("logs/mylog-.log",
                rollOnFileSizeLimit: true,
                fileSizeLimitBytes: 100 * 1024 * 1024, // 100 MB
                retainedFileCountLimit: 20,
                rollingInterval: RollingInterval.Day,
                outputTemplate:
                "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [thread:{ThreadId}] {Message:lj}{NewLine}{Exception}",
                hooks: new ArchiveHooks(
                    CompressionLevel.Optimal // 压缩级别
                )
            )
            .CreateLogger();

要特别注意的是,ArchiveHooks 的设计逻辑是“在日志文件即将被删除之前,先对其进行归档”,而非在日志文件大小达到fileSizeLimitBytes时就归档,也就是说,它的触发时机受retainedFileCountLimit参数控制,当该限制达到时,原有的log文件删除,取而代之的是一个log.gz文件。

这里还有一个问题:压缩的log.gz文件算不算在retainedFileCountLimit的范围内?

实测下来,发现不算,serilog机制在计算retainedFileCountLimit时,只会算.log文件的个数。于是乎,带来另外一个问题,压缩文件需要每隔一段时间清理一次。

异步写入

异步写入使用生产者-消费者模型,消费端有单独的线程写入磁盘,队列大小默认10000。

异步写入有延迟问题,而且进程崩溃时,队列里的日志可能会丢失。一般情况下不用异步模式。

配置文件

launchSettings.json,指定开发阶段的协议(http或https)和监听端口,仅用于开发,生产不涉及。

appsettings.json,等价于spring的application.yml配置,可以有多个profile版本,比如appsettings.Development.json。

配置文件一般通过IConfiguration类访问,它有三个重要方法:

GetValue、GetSection和Get,GetValue’拿到一个叶子节点的值,GetSection拿到一个子节点,Get可将一个子节点转成某个强类型。

json解析

缺失字段的处理

自带的System.Text.Json库和IConfiguration,前者是专门的json处理库,后者则用于应用配置。

在处理不存在的字段时,两者的行为迥异,前者默认会抛出异常,后者则返回null或类型默认值:

using System.Text.Json;

string json = "{}"; // 空 JSON 对象

// 1. 使用 JsonDocument -> 抛出 KeyNotFoundException
using (JsonDocument doc = JsonDocument.Parse(json))
{
    // 下面这行代码会抛出异常: System.Collections.Generic.KeyNotFoundException
    JsonElement value = doc.RootElement.GetProperty("NonExistent");
}

// 假设 JSON 为 {}
var config = new ConfigurationBuilder()
    .AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes("{}")))
    .Build();

// 1. 直接索引不存在的键 -> 返回 null,不抛异常
string? value = config["NonExistent:Key"];
Console.WriteLine(value == null); // 输出: True

// 2. GetValue<T> 对于不存在的键 -> 返回该类型的默认值
int intValue = config.GetValue<int>("NonExistent:Int");
Console.WriteLine(intValue); // 输出: 0

bool boolValue = config.GetValue<bool>("NonExistent:Bool");
Console.WriteLine(boolValue); // 输出: False

// 3. GetSection 对于不存在的节 -> 返回一个空的 IConfigurationSection 对象
var section = config.GetSection("NonExistent");
Console.WriteLine(section.Value); // 输出: (空字符串/null)
Console.WriteLine(section.Exists()); // 输出: False

如上所示,使用 JsonElement 访问可能不存在的下层元素时,直接使用 GetProperty() 会抛出 KeyNotFoundException。若不想抛异常,可使用安全访问模式TryGetProperty

IConfiguration解析枚举类型

IConfiguration.GetValue能很好的处理枚举字段,特别的,IConfiguration.GetValue能同时处理枚举的字符串或数字形式,甚至在处理字符串时,还能忽视大小写差异。看下面的例子:

public enum FeedTrigger
{
    Falling = 0,
    Rising = 1
}

给出如下json字符串:

{
    "Trigger1": 0,
    "Trigger2": "Rising",
    "Trigger3": 99,
    "Trigger4": "RISING",
    "Trigger5": "Rise"
}

用UT来逐个测试:

Assert.Equal(FeedTrigger.Falling, config.GetValue<FeedTrigger>("Trigger1"));
Assert.Equal(FeedTrigger.Rising, config.GetValue<FeedTrigger>("Trigger2"));
//这里即使数字不在枚举范围内,依然能得到一个值为99的“伪枚举”,而不抛出异常
Assert.Equal((FeedTrigger)99, config.GetValue<FeedTrigger>("Trigger3")); 
// IConfiguration.GetValue能忽略枚举字符串的大小写
Assert.Equal(FeedTrigger.Rising, config.GetValue<FeedTrigger>("Trigger4"));
// 只有在字符串确实非法的情况下,才抛出InvalidOperationException
Assert.Throws<InvalidOperationException>(() => config.GetValue<FeedTrigger>("Trigger5"));

处理部分内容

IConfiguration可以很方便的处理json的片段,见下例:

        var itfTypesConfig = configuration.GetSection("ItfTypes");
        var result = new List<ItfTypeDto>();

        foreach (var itfTypeSection in itfTypesConfig.GetChildren())
        {
            var type = itfTypeSection.Key;
            //这里直接把一个IConfigurationSection(可认为是一个json子片段)使用Get方法转成一个复杂对象的列表
            var paramsDefList = itfTypeSection.Get<List<ParamDefDto>>() ?? [];
            ...
        }

System.Text.Json里类似的做法如下:

using var jsonDoc = JsonDocument.Parse(fs);

var protocolParamsElement = jsonDoc.RootElement.GetProperty("ItfTypes");
// 这里调用Element的GetRawText方法,传入JsonSerializer.Deserialize反序列化
var protocolParams =
    JsonSerializer.Deserialize<List<ParamDefDto>>(protocolParamsElement.GetRawText()) ?? [];

object的处理

当要把一个数字或字符串转成object、或者把json数组转成List<object>时,IConfiguration.Get<>方法实际上只能转换为string或List<string>,猜测应该是底层实现遇到object,不知道该如何处理,故选择了最保守的策略:所有值当做string提取。因为IConfiguration是用于配置的,作者可能认为当string处理问题也不大。

类似的情况下,System.Text.Json会把object处理成System.Text.Json.JsonElement类型,而非原始json里的真实类型。

mysql json字段处理

对于mysql的json字段,如果要在model类里使用强类型表示它,可以在DBContext.OnModelCreating里这么做:

// 支持MyEntity.ParseOut字段的自动Json转换
        modelBuilder.Entity<MyEntity>()
            .Property(e => e.ParseOut)
            .HasConversion(
                v => JsonUtil.ToJson(v), // JsonUtil是我对System.Text.Json的封装
                v => JsonUtil.FromJsonSafe<Dictionary<long, List<string>>>(v)
            )
            .HasColumnType("json"); 

这样,EF框架会自动在写入和读取时做json和强类型的互转。

事实上,对于json字段,强烈推荐用强类型来替换原本的string
但,实际项目中,如果json字段在不同的情况下代表不同的结构,则无法用同一个强类型来表示,这时就只能用json string了。不过,如果该json字段始终是一个字典,只是不同情况下的键不一样,且所有的值都是string,则还可以用Dictionary<string, string>来表示,比起孤零零的json字符串表达还是略好点。如果值除了string还有其它类型,我们不建议用Dictionary<string, object>来表示了,因为JsonSerializer.Deserialize只会把键值对里的值转成System.Text.Json.JsonElement类型,使用起来并不方便。

依赖注入

不像spring通过@Component注解声明bean,ASP.NET里的service是要手工注册的:

// 添加依赖注入
builder.Services.AddScoped<IDeviceService, DeviceService>();
builder.Services.AddScoped<ICollectDataService, CollectDataService>();

注意:上述注册顺序并不重要,ASP.NET框架会在实际使用的时候自动解析服务间的依赖关系的。

然后controller里的service则是由框架通过构造函数参数自动注入的(框架会去找IDeviceService类型的依赖):

[Route("/ui/device")]
[ApiController]
public class DeviceController : ControllerBase
{
    private readonly IDeviceService _deviceService;

    // 这里的IDeviceService是ASP.NET框架自动注入的,框架会自动处理递归依赖的情况,跟spring机制类似。
    public DeviceController(IDeviceService deviceService)
    {
        _deviceService = deviceService;
    }

生命周期

transient:使用一次就新建一个实例

scope:一个http请求一个实例

singleton:全局单例

注意:与spring不一样的是,HTTP请求的响应service在ASP.NET不是singlton,而是scoped!另外,用于数据库访问的DBContext也是scoped。

如果一个HTTP请求涉及好几个scoped service,且这些service都依赖DBContext,请注意,它们所注入的DBContext实例其实是同一个scoped实例,而并非每个scoped service都创建独立的DBContext实例。有时候,我们会要求几个scoped service的接口并行执行,这时候就会报错:

System.InvalidOperationException: A second operation was started on this context instance before a previous operation completed. This is usually caused by different threads concurrently using the same instance of DbContext. For more information on how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913.

遇到这种问题,必须用IServiceScopeFactory来解决,IServiceScopeFactory创建一个新的scope,每个scope有新的DBContext,从而做到db访问隔离。

IServiceProvider和IServiceScopeFactory

  • IServiceProvider:是根容器。通过它直接解析服务,会得到 Singleton(单例)或 Transient(瞬时)服务,但无法正确解析 Scoped 服务
  • IServiceScopeFactory:是创建新作用域的工厂。通过它创建一个新的作用域(Scope),然后在这个作用域内解析服务,这样才能正确处理 Scoped 服务的生命周期。

这里有个容易弄混的地方:IServiceProvider也是可以创建一个新作用域的,该能力等价于IServiceScopeFactory,但从职责明确的角度来看,用IServiceScopeFactory创建新作用域更合适。

Hosted服务

hosted服务也是单例的,但跟singleton service不同的是,它不被DI容器所管理,GetRequiredService方法是拿不到hosted服务的。事实上,hosted服务是单独管理的。

Hosted服务里访问scope Service

前者是框架启动即运行,作用域是全局,后者是rest请求时实例化,作用域是scoped。前者适合做长期任务,如定时任务。

HostedService启动时调用startAsync,停止时调用stopAsync。

因为作用域不同,在HostedService里要访问scope Service,必须通过IServiceScopeFactory,如下所示:

public class CollectJob : BackgroundService
{
    ...
    private readonly IServiceScopeFactory _scopeFactory;
    
    private async Task doSthAsync()
    {
        using var scope = _scopeFactory.CreateScope();
        var deviceService = scope.ServiceProvider.GetRequiredService<IDeviceService>();
        ...

        // 获取所有在线设备
        var onlineDevices = GetOnlineDevices(deviceService);
        ...

BackgroundService

可用ASP.NET自带的BackgroundService做定时任务,但这里有个陷阱:BackgroundService就是一个HostedService,随框架一起启动,所以ExecuteAsync的第一个await动作之前的代码都会跑在启动线程里(参考我的另一篇博文《C#异步开发探微》),如果这些代码做的是耗时操作或循环,就会阻塞启动线程!可以在ExecuteAsync的开头调用await Task.Yield();迅速把启动线程让出去,避免阻塞框架启动。比如:

 protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await Task.Yield();
        
        // do business
        ......

另外,Background服务对Ctrl+C停服务也有影响,必须对其中的每个异步操作都处理CancellationToken才能确保及时响应Ctrl+C信号。

注入配置

先注册到serviceCollection里:

services.Configure<SnmpConfig>(builder.Configuration.GetSection("SNMP"));

接着注入到服务里:

public class MyService(
    IServiceScopeFactory scopeFactory,
    IOptions<SnmpConfig> options,
    IStringLocalizer<Resources> localizer,
    ILogger<MyService> logger)

rest入参校验

使用DataAnnotation校验,等价于spring validation,常用的注解如下:

[Range] 校验数值范围

[Required] 必给校验

[MaxLength] 字符串或集合的最大长度校验

[StringLength] 字符串最小、最大长度校验

这些注解可以加在controller类的接口参数上,也可以加在FromBody的结构体里面。其实跟spring validation的做法也是一样的。

IActionResult

IActionResult是ASP.NET里后台返回给前端的标准结构。我们在spring里习惯自己包装一个通用的response结构体,在ASP.NET则大可不必,用IActionResult就好。

IActionResult有一些常用实现类:

ObjectResult: 根据accept头自动选择响应格式,如: json/xml/plain
OkObjectResult:状态码为200的ObjectResult
JsonResult: 响应格式固定为json,常用的就是这个

但在Controller里,框架为我们封装了更易用的方法,都定义在ControllerBase基类里:

方法状态码说明
Ok()200成功响应,可带数据
Created() / CreatedAtAction() / CreatedAtRoute()201创建成功,返回 Location Header
Accepted() / AcceptedAtAction()202请求已接受处理,异步任务
NoContent()204成功但无返回内容
BadRequest()400错误请求,可带错误详情
Unauthorized()401未认证
Forbid()403已认证但无权限
NotFound()404资源不存在
Conflict()409资源冲突(如重复创建)
UnprocessableEntity()422语义错误,常用于验证失败
StatusCode()任意自定义状态码

前端axios访问

若axios未设置响应拦截器,调用restful成功时会把IActionResult转成标准response结构:

data: 核心业务数据
status: http状态码
statusText: http状态文本
headers: server返回的http响应头
config: 本次restful请求的所有配置项,调试的时候可以用它重发一次一模一样的请求

比如,ASP.NET返回ControllerBase.Ok(),则axios的response的status为200,data就是Ok里的数据体。

如果状态码不在200~299之间,axios会认定请求失败,抛出异常error,并把IActionResult设置到error.response里。

如果服务器未返回(网络断链、超时等),error.response为undefined。

全局异常截获

ASP.NET里要做到类似spring的@RestControllerAdvice,做法略有不同,首先,做一个中间件,截获异常:

public class GlobalExceptionMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await next(context);
        }
        catch (Exception ex)
        {
            await HandleExceptionAsync(context, ex);
        }
    }

    private Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        context.Response.ContentType = "application/json";
        context.Response.StatusCode = StatusCodes.Status500InternalServerError;

        // 这里用我自己的response结构
        var result = ErrorResult.From(exception);
        return context.Response.WriteAsJsonAsync(result);
    }
}

Program.cs里注册:

    // 截获全局异常,包装为 ErrorResult,最好在UseRouting之前调用
    webApplication.UseMiddleware<GlobalExceptionMiddleware>();
    
    webApplication.UseRouting();

但对于rest入参校验,上述全局异常无法截获,原因是:ASP.NET框架帮我们提前处理了,不再抛出异常了。那么,对于rest入参校验,必须用ApiBehaviorOptions:

    // 添加参数校验失败处理,必须在builder.Services.AddControllers()之后配置,否则不生效
    builder.Services.Configure<ApiBehaviorOptions>(options =>
    {
        options.InvalidModelStateResponseFactory = context =>
        {
            var errors = context.ModelState
                .Where(x => x.Value?.Errors.Count > 0)
                // SelectMany等价于java stream的flatMap
                .SelectMany(x => x.Value?.Errors ?? [])
                .Select(e => e.ErrorMessage)
                .ToList();

            var errorMessage = errors.Any()
                ? string.Join("; ", errors)
                : "restful params validation failed";

            return new JsonResult(ErrorResult.Create(null, errorMessage, null))
            {
                StatusCode = StatusCodes.Status400BadRequest
            };
        };
    });

这样修改后,返回给前端的就不再是:

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Level":["告警级别最大长度为20个字符"]},"traceId":"..."}

而是我们自定义的结构了。

定时器的并发行为

Timer 类型并发行为风险
System.Threading.Timer默认可能并发(取决于 dueTimeperiod 参数)回调可能重叠执行
System.Timers.Timer默认可能并发(AutoReset = true 时)多个 Elapsed 事件可能同时执行
PeriodicTimer永不并发无风险

PeriodicTimer 的另一个重要特性是 tick 合并

“The PeriodicTimer behaves like an auto-reset event, in that multiple ticks are coalesced into a single tick if they occur between calls to WaitForNextTickAsync.”

含义

  • 如果你的任务执行时间超过了计时器周期
  • 在此期间发生的所有"丢失的 tick"会被合并成一次
  • 当你的任务完成后,下一次 WaitForNextTickAsync 调用会立即返回(只产生一个 tick),而不是累积多次

DateTime与时区

C#代码的最佳实践是使用UTC时区,这样可以避免夏令时陷阱,效率也比使用本地时区要高,同时内部统一。

但是,我们常用的mysql datetime字段是没有时区概念的,你存什么,它记录什么。

而mysql的CURRENT_TIMESTAMP是有时区概念的,它取的是当前的mysql会话时区,mysql里用下面命令查看:

SELECT @@session.time_zone;

查出来的一般是system,即操作系统时区。

操作系统时区在linux下用timedatectl查看:

timedatectl

没有timedatectl工具的,用下面命令也可查看:

date +%Z

像国内的机器一般是东八区,操作系统时区就是UTC+8。

如前所述,ASP.NET里不要依赖DEFAULT CURRENT_TIMESTAMP 和ON UPDATE CURRENT_TIMESTAMP的写法,而是完全交给代码来处理。

ASP框架如何解析前端传的DateTime

若前端传的是ISO8601标准格式,如:

2026-08-18T08:28:53.000Z

ASP框架能准确解析出DateTime,其Kind=UTC。这是最佳方式。

若前端传的是这样的无时区标记的字符串:

2026-08-18T08:33:40

ASP框架能解析出DateTime,但其Kind=Unspecified。

注意:Kind仅代表DateTime的时区信息,但并不参与两个DateTime的比较!因DateTime的==实现本质上是比较内部的long值,这个long值是没有时区概念的

例如,一个Kind=Unspecified与Kind=UTC的DateTime比较,Kind并未考量在内,见下例:

// 模拟从MySQL读取的 datetime: 2024-01-01 00:00:00 
var mysqlTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Unspecified);

// 创建一个明确的 UTC 时间:2024-01-01 00:00:00 UTC
var targetUtc = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);

// 比较两个时间点
Assert.True(mysqlTime == targetUtc); // 结果为 True

var laterTime = mysqlTime.AddMinutes(1);
Assert.Equal(DateTimeKind.Unspecified, laterTime.Kind);
Assert.True(laterTime > targetUtc);

类似的,两个Kind=Unspecified的比较、甚至Kind=Local和Kind=Utc的比较,Kind也没考虑:

var mysqlTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Local);

// 创建一个明确的 UTC 时间:2024-01-01 00:00:00 UTC
var targetTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);

// 比较两个时间点
Assert.True(mysqlTime == targetTime); // 结果为 True

要考虑带时区的比较,得用DateTimeOffset。

国际化

一般用IStringLocalizer或ResourceManager,前者常用于ASP.NET中,因为可以方便的DI注入,后者要手工new出来,常用于dll的国际化中。

IStringLocalizer的例子:

public class MyService(MyDbContext context, IStringLocalizer<Resources> localizer)
{
...
	localizer["your.resource.string"]
}

如果要支持参数,可这么写:

localizer["your.resource.string", para0, para1]

原始资源串里用{0},{1}来占位,指代para0和para1:

我爱你,{0} 和 {1}

[]利用了C#的运算符重载能力。

ResourceMananger的用法示例:

public class MyLocalizer : IProtocolLocalizer
{
    // ResourceManager的第二个参数指定了所在的exe或dll
    private static readonly ResourceManager ResourceManager =
        new("YourNamespace.Resources", typeof(MyLocalizer).Assembly);

    public string GetDisplayName(string paramName)
    {
        try
        {
            // ResourceManager.GetString获得资源字符串
            var value = ResourceManager.GetString(paramName);
            return value ?? paramName;
        }
        catch
        {
            return paramName;
        }
    }
}

上述例子其实是用ResourceManager来实现我们自己的Localizer。

CultureInfo.DefaultThreadCurrentUICulture和CultureInfo.CurrentUICulture

这俩都是全局变量,可以直接拿来用的,区别:

  • CultureInfo.DefaultThreadCurrentUICulture:是全局默认值,会影响之后所有新创建线程CurrentUICulture
  • CultureInfo.CurrentUICulture:是当前线程的 UI 文化,只影响当前线程

前述ResourceManager.GetString就是根据CurrentUICulture来获得对应的国际化资源。在ASP.NET里,CurrentUICulture一般是通过RequestLocalizationOptions来设置的,RequestLocalizationOptions会将rest请求里的国际化信息填充到CurrentUICulture中。如果不是在rest请求中,则使用默认的UICulture。RequestLocalizationOptions的使用样例:

var localizationOptions = new RequestLocalizationOptions
    {
        // 这里有个默认Culture,对于非rest请求的线程,就用默认Culture
        DefaultRequestCulture = new RequestCulture(Consts.DefaultCulture),
        SupportedCultures = Consts.SupportedCultures.Select(c => new CultureInfo(c)).ToList(),
        SupportedUICultures = Consts.SupportedCultures.Select(c => new CultureInfo(c)).ToList(),
        RequestCultureProviders = new List<IRequestCultureProvider>
        {
            // 从cookie里获取Culture
            new CookieRequestCultureProvider()
        }
    };
    webApplication.UseRequestLocalization(localizationOptions);

其中,CookieRequestCultureProvider 是通过读取 HTTP 请求携带的 Cookie 值(默认是名为.AspNetCore.Culture 的 Cookie),来确定当前请求应使用哪种语言。

我们可以在前端切换语言菜单时,填充上述cookie:

// 切换语言
const handleLanguageChange = (lang) => {
  locale.value = lang
  currentLanguage.value = lang
  ......
  
  // 设置cookie,遵循RequestLocalization中间件规范
  const cookieValue = `c=${lang}|uic=${lang}`
  document.cookie = `.AspNetCore.Culture=${encodeURIComponent(cookieValue)}; path=/; max-age=31536000`
}

其中:

  • c 表示当前文化(Culture,影响日期、数字、货币格式等)
  • uic 表示 UI 文化(UICulture,影响界面文本翻译、读哪个.resx文件)

一般情况下,两者是一样的。

异步调用下culture的传递

前面说CultureInfo.CurrentUICulture是当前线程的culture,其实不够准确,在async调用下,CultureInfo.CurrentUICulture其实是整个异步上下文的culture。我们知道,async调用本身就可能是跨线程的,如果CultureInfo.CurrentUICulture做不到跨线程自动传递,则会出问题。幸好,ASP.NET框架已经把CultureInfo.CurrentUICulture做成了AsyncLocal(对应于ThreadLocal线程本地存储,表示是异步本地存储),我们在async调用时,不必显式处理CultureInfo.CurrentUICulture。

但有一种特殊情况需要注意,当我们使用channel队列异步处理请求时,CultureInfo.CurrentUICulture是不会自动传递的,这时需要我们手动处理,例如把culture也一并放到channel队列里。

应用启动时的culture

UseRequestLocalization无法影响应用启动时的culture,启动时的culture默认使用系统默认locale,可通过下面命令查看:

echo $LANG

如果你是中文机器,这个值可能是zh-CN.UTF-8。当然,也可能是中性的C.UTF-8。

所以,如果要在程序启动时确保IStringLocalizer正常工作,一般要手工设置默认culture,像这样:

// 需要设置当前文化为默认文化,否则会导致名称退回到key
CultureInfo.CurrentCulture = new CultureInfo(Consts.DefaultCulture);
CultureInfo.CurrentUICulture = new CultureInfo(Consts.DefaultCulture);
await DoSth();

或者,干脆就用前面提到的CultureInfo.DefaultThreadCurrentUICulture,我们在ASP服务启动前,就设好默认Culture,这样更方便,不用到处设置CultureInfo.CurrentCulture。

单元测试

测试类构造函数

UT测试类的构造函数,会在每个测试用例执行前跑一次。

InMemory与sqlite

下面代码中使用的InMemory数据库:

var services = new ServiceCollection();
services.AddDbContext<MyContext>(options =>
    options.UseInMemoryDatabase("DashboardTestDb"));

UseInMemoryDatabase 使用的是 EF Core 自带的 InMemory 提供程序,其核心是Microsoft.EntityFrameworkCore.Infrastructure.DatabaseFacade类,而不是 SQLite。它不产生任何真实SQL,也并非关系数据库,就是一个C#对象缓存,由于C#里集合也是支持LINQ的,所以InMemory数据库可以很方便的支持LINQ。

使用InMemory,有几点需要注意:

  • InMemory所使用的db名是全局的,若在多个UT实例间共享使用,彼此可能互相干扰。此时,可在db名中加入GUID隔离开。

  • InMemory不支持DBContext的ExecuteUpdate和ExecuteDelete方法,若使用了ExecuteXXX方法,还是改用sqlite

  • InMemory也不支持CloseConnection方法

mysql分区的自动管理

mysql分区的好处:

  • 删除快
  • 有分区键的话,有效降低背景数据量,加快查询(若无分区键导致全表查询,可能比不分区还慢)

一般说来,采集数据或日志表可以考虑分区。

分区的劣势:

  • 分区管理比较复杂,要确保服务启动时,当前分区存在;服务长期运行时,下一个分区能自动且可靠的添加;分布式的情况下,分区添加不能冲突

因此,在ASP.NET里,有几个措施要做:

  • 服务启动前(也就是webApplication.Run执行前),把当前分区和下一个分区建出来;之所以要多建一个分区,是怕服务启动当天的定时任务若失效,我们还有个缓冲,不至于第二天写不进业务数据。
  • 每晚定时任务,要建2~3个未来的分区,作为容错。建议使用Quartz的定时任务,并开启[DisallowConcurrentExecution]注解,确保定时任务不会同时执行导致冲突。
  • 如果是按天来分区,最好有一个p_future的托底分区,因为每天的间隔毕竟太短,不能完全排除定时任务失败的可能。但按周、按月来分区的话,我觉得可以不要托底分区,毕竟前两条措施都留了余量,总不可能连续一周、一月都定时任务不生效吧。

RBAC支持

ASP.NET的core identity内置了对RBAC(基于角色的访问控制)的支持,它有如下内置表:

概念ASP.NET Core Identity 内置表说明
用户asp_net_usersIdentity 的 IdentityUser 替代
角色asp_net_rolesIdentity 的 IdentityRole 替代
用户-角色关联asp_net_user_rolesIdentity 自动管理
角色-权限关联asp_net_role_claims权限作为 Role 的 Claim 存储

生产环境这些内置表需要我们自行创建。框架并不会自动为我们创建。

创建好角色权限后,在controller里添加[Authorize]注解,指定权限策略,一旦当前用户没有这个权限,会报403Forbidden错误,在前端axios的拦截器里统一处理403即可。后台样例如下:

[Authorize(Policy = Permissions.Management.UserManage)]
    [HttpGet("user/list")]
    public async Task<IActionResult> GetUserList(

core identity还支持账户锁定和双因子认证。

用户session管理

用户会话有两种管理路径,java系的spring session是后台集中管理,把所有的用户session放到redis里,这样集群环境下不会因为机器A登录、机器B处理而被强制下线。ASP.NET里则采用无状态令牌会话(stateless token session),将用户session以JWT字符串形式放到前端cookie里,后台不存放用户session,在机器A上生成的JWT,只要加密密钥共用,在机器B上也可以验证通过。后者本质上是用CPU时间(需要加解密)换存储空间。

两种会话管理路径,一个是中心化管理,一个则恰恰相反,是去中心化管理。

接着我们来看用户session过期的问题,spring session很简单,redis是可以设置key的TTL的,TTL一到,redis key自动销毁,spring session查不到这个会话,就踢人;无状态令牌会话下,JWT里有一个exp字段,用来存放过期时间,校验JWT时,检查这个过期时间即可。

我们再看滑动续期(Sliding Expiration),这个主要是为了易用性考虑,会话过期时间一到,用户得重新登录,如果恰在当时,用户在提交表单,填写的数据就会丢失,好不烦人,自动续期就是为了解决这个问题。spring session下的自动续期比较简单,只要用户还在界面操作,redis key的TTL就会一直后延,只有很长一段时间用户不操作,redis key才会因为没续期而自然销毁。无状态令牌会话下,滑动续期就比较麻烦,一般是要准备两个token:一个是AT(Access Token,用于rest访问,寿命短),一个是RT(Refresh Token,用于续期获得新的AT,寿命长)。

再谈谈用户登出(或踢出)。spring session的用户登出很简单、很安全,干掉redis key即可,这个登出效果是立刻生效的(下一次rest访问即触发)。但无状态令牌会话下,因为AT过期设定的原因,登出效果是有滞后的。即使删除了前端的AT,但该AT其实并未过期,发给后台依然会认证通过,若被黑客获得,就存在一个时间差造成的威胁(也是因为这个原因,AT的寿命不能太长)。而如果用了RT,可能还得将后台的RT也清理掉。

最后是前端存储的安全性,spring session给前端的是一个session id,代表redis里的某个key,这个session id一般存放在http-only的cookie中,不会遭到xss脚本攻击,安全方面没问题(当然,也有放在X-Auth-Token头里的)。无状态令牌会话下,AT可以放在前端内存里或localStorage里,RT则应存放在http-only的cookie中,毕竟AT寿命短,即使localStorage被xss攻击获取,时效不长,仍可接受。

最后是两者对比:

特性维度Spring Session + RedisJWT 令牌认证
认证状态有状态。会话数据(Session)集中存储在服务端(如Redis),服务端需要“记住”每个用户无状态。用户信息被编码在令牌本身,服务端无需存储会话,只需验证令牌签名即可
扩展性 (Scalability)极佳。通过共享存储,应用集群中的任何实例都能访问同一会话数据,天然支持水平扩展极佳。由于服务端不存储状态,新加入的实例无需同步任何会话信息,扩展非常容易
性能开销中高。每次请求都需要从外部存储(如Redis)进行一次网络I/O来读取/写入会话,带来额外延迟。每次请求需要验证签名(如HMAC或RSA),这是计算开销;令牌体积通常比Session ID大,会增加网络传输开销
令牌/会话失效 (Revocation)简单直接。服务端可以随时从存储中删除会话,实现即时生效的登出、踢人下线或权限变更复杂。JWT在有效期内无法主动失效。通常需要引入Redis黑名单(又变回有状态)或设置极短的过期时间
安全性考量较高。会话ID本身不携带敏感信息,数据存在服务端,风险可控。但需防范Session Hijacking,并保护好Redis需重点关注。由于令牌可能被窃取,必须使用HTTPS;为应对XSS攻击,不建议将令牌存储在localStorage 无法主动注销是其主要安全短板
跨域/多端支持一般。传统的Cookie机制在移动端或第三方API调用中不太友好,需要额外配置优秀。通过在HTTP Header中传递令牌,天然支持移动App、单页应用、第三方服务等多种客户端
编程模型复杂度。对应用代码侵入性极低,开发者使用熟悉的HttpSession API即可,几乎无感知中高。需要自行实现JWT的生成、解析、验证过滤器,以及处理Token刷新等逻辑,有一定工作量
适用场景传统Web应用分布式集群,需要即时会话控制(如强制下线),并且希望代码改动最小前后端分离微服务架构移动端API单点登录(SSO),需要无状态、跨域认证的场景

JWT讨论

读写

在ASP.NET里使用core identity,我们一般会在登录成功时将用户的相关信息都编码进JWT里返回给前端,包括:用户id、用户名、用户角色编码等。这个过程是可以使用JwtSecurityToken类自行定制的。JwtSecurityToken里包含了一个Claim的数组,在这个数组里就可以存我们希望编码进JWT的内容。下面是一个例子:

var claims = new List<Claim>
{
    new(ClaimTypes.NameIdentifier, user.Id.ToString()),
    new(ClaimTypes.Name, user.UserName ?? string.Empty),
    new("display_name", user.DisplayName)
};

// 这个token就是我们返回前端的JWT
var token = new JwtSecurityToken(
            issuer,
            audience,
            claims,
            expires: DateTime.UtcNow.AddSeconds(expiresIn),
            signingCredentials: credentials
        );

后续,用户每次访问rest接口都会带上这个JWT,ASP.NET提供了IHttpContextAccessor类来获得这个JWT:

httpContextAccessor.HttpContext?.User

这是一个ClaimsPrincipal类,可以简单认为就是前面我们写入的JWT的Claim集合部分。使用ClaimsPrincipal的FindFirstValue等方法即可获得用户的相关信息。

从JWT到ClaimsPrincipal的转换过程:

客户端在请求头中携带 JWT Token(如 Authorization: Bearer <token>)。
ASP.NET Core 的 JWT Bearer 认证中间件自动拦截请求,验证 Token 的有效性(签名、发行者、受众、过期时间等)。
验证通过后,中间件会将 Token 中的 Claims 提取出来,构建成一个 ClaimsPrincipal 对象,并赋值给 HttpContext.User

超时

JWT有个exp字段,指明超时时间,这里必须注意:务必要使用UTC时区指定超时时间!!同样的,JWT的校验,ASP.NET底层也是用UTC时区做的检查。

服务端cookie设置的陷阱

服务端设置cookie时,cookie的唯一性由“domain+path+cookie名”联合保证。domain+path则由CookieOptions指定。CookieOptions对path的设置非常必要!

假设一开始我们这么写:

Response.Cookies.Append(RtCookieName, refreshToken!, new CookieOptions
        {
            HttpOnly = true,
            Secure = false, // 暂时不使用 HTTPS
            SameSite = SameSiteMode.Strict,
            Expires = DateTime.UtcNow.AddHours(refreshTokenExpiresInHours)
        });

默认CookieOptions的path是根路径/,domian是null,也就是说,我们的cookie是根路径/+RtCookieName来唯一表示。

随后,我们觉得不妥,应该用一个更具体的路径,于是这么写:

Response.Cookies.Append(RtCookieName, refreshToken!, new CookieOptions
        {
            HttpOnly = true,
            Secure = false, // 暂时不使用 HTTPS
            SameSite = SameSiteMode.Strict,
            Path = AuthPath, // 只在认证相关路径下发送此 cookie
            Expires = DateTime.UtcNow.AddHours(refreshTokenExpiresInHours)
        });

我们满心以为这次的cookie会覆盖上次的cookie,实则不然,因为path变了,cookie里有两个同名的项,只是这两项的path不同。

后面的结果就很诡异了,我们会发现前端cookie始终没刷新!

其实就是前后两次的path不同导致!

开发期的解决方法是调用一次Delete:

Response.Cookies.Delete(RtCookieName)

这会把path=/下的垃圾cookie删掉,这样新的cookie就能生效了。

与spring boot的内存占用对比

我这里一个功能相同的web应用,spring boot占用内存为370M,对应的ASP.NET仅为150M,节约了一半!cpu占用方面,ASP.NET也略低一些。

显然,对于资源受限系统而言,ASP.NET更合适。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值