news 2026/5/12 10:34:58

ASP.NET开发心得

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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(功能完整)

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默认可能并发(取决于dueTimeperiod参数)回调可能重叠执行
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

像国内的机器一般是东八区,操作系统时区就是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更合适。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/12 10:30:38

PCL圆柱拟合进阶:从模型参数到完整轴线的精准计算

1. PCL圆柱拟合的核心挑战与工业需求 在工业测量和逆向工程领域&#xff0c;圆柱体是最常见的几何特征之一。想象一下汽车发动机的活塞杆、液压缸的活塞筒&#xff0c;或者机械臂的旋转轴&#xff0c;这些关键部件都需要精确的圆柱几何参数。PCL&#xff08;Point Cloud Librar…

作者头像 李华
网站建设 2026/5/12 10:27:25

iFakeLocation:如何在5分钟内免费实现iOS虚拟定位的完整指南

iFakeLocation&#xff1a;如何在5分钟内免费实现iOS虚拟定位的完整指南 【免费下载链接】iFakeLocation Simulate locations on iOS devices on Windows, Mac and Ubuntu. 项目地址: https://gitcode.com/gh_mirrors/if/iFakeLocation 你是否曾想过在不越狱的情况下&am…

作者头像 李华
网站建设 2026/5/12 10:21:20

告别电老虎!用TJA1044的待机模式,让你的汽车ECU节点功耗直降90%

汽车电子节能革命&#xff1a;TJA1044待机模式实战解析与90%功耗优化方案 在汽车电子系统设计中&#xff0c;ECU节点的静态功耗一直是工程师们面临的棘手难题。传统CAN收发器即使在不工作时也会消耗数毫安电流&#xff0c;这对于需要长期保持通电状态的车身控制模块&#xff08…

作者头像 李华
网站建设 2026/5/12 10:21:18

跨平台桌面应用开发实战:Electron+React构建本地游戏资产管理工具

1. 项目概述&#xff1a;一个面向游戏《使命召唤&#xff1a;黑色行动6》的解锁工具最近在折腾《使命召唤&#xff1a;黑色行动6》&#xff08;简称bo6&#xff09;的时候&#xff0c;发现很多玩家&#xff0c;包括我自己&#xff0c;都对游戏里那些需要投入大量时间才能解锁的…

作者头像 李华
网站建设 2026/5/12 10:19:27

长期使用Taotoken聚合API的稳定性与可靠性观察

&#x1f680; 告别海外账号与网络限制&#xff01;稳定直连全球优质大模型&#xff0c;限时半价接入中。 &#x1f449; 点击领取海量免费额度 长期使用Taotoken聚合API的稳定性与可靠性观察 在持续数周将多个项目接入Taotoken平台进行日常开发与测试后&#xff0c;我们对这个…

作者头像 李华