ASP.NET开发心得
开发模式ASP.NET有两套开发模式ASP.NET Core│├── MVC 模式传统模型-视图-控制器│ └── 适合大型应用、团队开发、复杂页面│└── Minimal APIs极简模型└── 适合微服务、云函数、小型 API、原型开发url路由控制器级别[Route(/ui/device)]注解作用为整个控制器添加路由前缀指定该控制器下所有接口的基础路径。语法[Route(路径模板)]其中路径模板可以包含固定字符串或参数。理论上该注解会为控制器下的所有方法添加/ui/device前缀。但如果方法级的路由注解以/开头实际会覆盖此前缀这和spring 里的RequestMapping注解的行为是不一样的千万注意方法级别[HttpGet(list)]等注解作用指定 HTTP 方法[HttpGet]表示该接口接受 GET 请求类似的还有[HttpPost]、[HttpPut]、[HttpDelete]等。定义路由路径括号内的字符串定义了接口的具体路径。特殊规则当路径以/开头时如/list会完全覆盖控制器级别的路由前缀直接从根路径开始匹配。当路径不以/开头时如list会继承控制器级别的路由前缀形成完整路径如/ui/device/list。EF CoreEntity Framework.NET生态的ORM框架等价于spring的Hibernate。.NET里也有跟mybatis对应的半ORM框架Dapper。通常ASP.NET会用DbContext类来完成C#实体与数据库表的映射比如public class MyContext : DbContext { public MyContext(DbContextOptionsMyContext options) : base(options) {} // 这里的DbSet就对应一张数据库表尖括号里的Device就是entity类 public DbSetDevice Devices { get; set; } ...DBContextDBContextsql执行器对象缓存changeTrackingFind查找的时候它优先查缓存除非显式指定AsNoTracking。DBContext的更新操作一般做法是先Find、接着内存修改、最后saveChanges这样会执行SELECTUPDATE两次SQL只更新少量字段时效率不高var device await context.Devices.FindAsync(id); if (device null) return false; device.Status Consts.DeviceOnline; await context.SaveChangesAsync(); return true;可改为ExecuteUpdateExecuteUpdate会绕过changeTracking缓存直接操作数据库。但这里要注意ExecuteUpdate更新后若仍使用原DBContext的Find来查找而不指定AsNoTracking查到的还是老数据。因为ExecuteUpdate是不走ChangeTracking的。类似的删除操作可使用ExecuteDelete也可节省一次查询。对于 REST API我们建议GET 请求AsNoTracking只读PUT/PATCH 简单更新ExecuteUpdate高性能DELETE 简单删除 ExecuteDelete高性能复杂业务更新ChangeTracker功能完整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因为很可能不会如我们想象般生效。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和GetGetValue’拿到一个叶子节点的值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. GetValueT 对于不存在的键 - 返回该类型的默认值 int intValue config.GetValueint(NonExistent:Int); Console.WriteLine(intValue); // 输出: 0 bool boolValue config.GetValuebool(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可以很方便的处理json的片段见下例var itfTypesConfig configuration.GetSection(ItfTypes); var result new ListItfTypeDto(); foreach (var itfTypeSection in itfTypesConfig.GetChildren()) { var type itfTypeSection.Key; //这里直接把一个IConfigurationSection可认为是一个json子片段使用Get方法转成一个复杂对象的列表 var paramsDefList itfTypeSection.GetListParamDefDto() ?? []; ... }System.Text.Json里类似的做法如下using var jsonDoc JsonDocument.Parse(fs); var protocolParamsElement jsonDoc.RootElement.GetProperty(ItfTypes); // 这里调用Element的GetRawText方法传入JsonSerializer.Deserialize反序列化 var protocolParams JsonSerializer.DeserializeListParamDefDto(protocolParamsElement.GetRawText()) ?? [];mysql json字段处理对于mysql的json字段如果要在model类里使用强类型表示它可以在DBContext.OnModelCreating里这么做// 支持MyEntity.ParseOut字段的自动Json转换 modelBuilder.EntityMyEntity() .Property(e e.ParseOut) .HasConversion( v JsonUtil.ToJson(v), // JsonUtil是我对System.Text.Json的封装 v JsonUtil.FromJsonSafeDictionarylong, Liststring(v) ) .HasColumnType(json);这样EF框架会自动在写入和读取时做json和强类型的互转。事实上对于json字段强烈推荐用强类型来替换原本的string。但实际项目中如果json字段在不同的情况下代表不同的结构则无法用同一个强类型来表示这时就只能用json string了。不过如果该json字段始终是一个字典只是不同情况下的键不一样且所有的值都是string则还可以用Dictionarystring, string来表示比起孤零零的json字符串表达还是略好点。如果值除了string还有其它类型我们不建议用Dictionarystring, object来表示了因为JsonSerializer.Deserialize只会把键值对里的值转成System.Text.Json.JsonElement类型使用起来并不方便。依赖注入不像spring通过Component注解声明beanASP.NET里的service是要手工注册的// 添加依赖注入 builder.Services.AddScopedIDeviceService, DeviceService(); builder.Services.AddScopedICollectDataService, 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/?linkid2097913.遇到这种问题必须用IServiceScopeFactory来解决IServiceScopeFactory创建一个新的scope每个scope有新的DBContext从而做到db访问隔离。IServiceProvider和IServiceScopeFactoryIServiceProvider是根容器。通过它直接解析服务会得到 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.GetRequiredServiceIDeviceService(); ... // 获取所有在线设备 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服务对CtrlC停服务也有影响必须对其中的每个异步操作都处理CancellationToken才能确保及时响应CtrlC信号。rest入参校验使用DataAnnotation校验等价于spring validation常用的注解如下[Range] 校验数值范围[Required] 必给校验[MaxLength] 字符串或集合的最大长度校验[StringLength] 字符串最小、最大长度校验这些注解可以加在controller类的接口参数上也可以加在FromBody的结构体里面。其实跟spring validation的做法也是一样的。定时器的并发行为Timer 类型并发行为风险System.Threading.Timer默认可能并发取决于dueTime和period参数回调可能重叠执行System.Timers.Timer默认可能并发AutoReset true时多个 Elapsed 事件可能同时执行PeriodicTimer永不并发无风险PeriodicTimer的另一个重要特性是tick 合并“ThePeriodicTimerbehaves like an auto-reset event, in that multiple ticks are coalesced into a single tick if they occur between calls toWaitForNextTickAsync.”含义如果你的任务执行时间超过了计时器周期在此期间发生的所有丢失的 tick会被合并成一次当你的任务完成后下一次WaitForNextTickAsync调用会立即返回只产生一个 tick而不是累积多次DateTime与时区C#代码的最佳实践是使用UTC时区这样可以避免夏令时陷阱效率也比使用本地时区要高同时内部统一。但是我们常用的mysql datetime字段是没有时区概念的你存什么它记录什么。而mysql的CURRENT_TIMESTAMP是有时区概念的它取的是当前的mysql会话时区mysql里用下面命令查看SELECT session.time_zone;查出来的一般是system即操作系统时区。操作系统时区在linux下用timedatectl查看timedatectl没有timedatectl工具的用下面命令也可查看date %Z像国内的机器一般是东八区操作系统时区就是UTC8。如前所述ASP.NET里不要依赖DEFAULT CURRENT_TIMESTAMP 和ON UPDATE CURRENT_TIMESTAMP的写法而是完全交给代码来处理。国际化一般用IStringLocalizer或ResourceManager前者常用于ASP.NET中因为可以方便的DI注入后者要手工new出来常用于dll的国际化中。IStringLocalizer的例子public class MyService(MyDbContext context, IStringLocalizerResources 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。单元测试测试类构造函数UT测试类的构造函数会在每个测试用例执行前跑一次。InMemory与sqlite下面代码中使用的InMemory数据库var services new ServiceCollection(); services.AddDbContextMyContext(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方法还是改用sqliteInMemory也不支持CloseConnection方法与spring boot的内存占用对比我这里一个功能相同的web应用spring boot占用内存为370M对应的ASP.NET仅为150M节约了一半cpu占用方面ASP.NET也略低一些。显然对于资源受限系统而言ASP.NET更合适。