开发模式
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]等。 - 定义路由路径:括号内的字符串定义了接口的具体路径。
- 指定 HTTP 方法:
特殊规则:
- 当路径以
/开头时(如/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(功能完整)
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和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可以很方便的处理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()) ?? [];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信号。
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 合并:
“The
PeriodicTimerbehaves 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像国内的机器一般是东八区,操作系统时区就是UTC+8。
如前所述,ASP.NET里不要依赖DEFAULT CURRENT_TIMESTAMP 和ON UPDATE CURRENT_TIMESTAMP的写法,而是完全交给代码来处理。
国际化
一般用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。
单元测试
测试类构造函数
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方法
与spring boot的内存占用对比
我这里一个功能相同的web应用,spring boot占用内存为370M,对应的ASP.NET仅为150M,节约了一半!cpu占用方面,ASP.NET也略低一些。
显然,对于资源受限系统而言,ASP.NET更合适。