尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

51core框架深度解析:ASP.NET Core分层架构与最佳实践

51core框架深度解析:ASP.NET Core分层架构与最佳实践 1. 为什么我会盯上51core这个项目第一次看到“51core”这个名字是在一个.NET技术群里有人甩了个链接说“又一个ASP.NET Core的脚手架但这次有点不一样”。说实话市面上各种Core的模板、脚手架、快速开发框架我见过太多了大部分都是把官方模板改个名字加几行Swagger配置就敢叫“企业级框架”。所以我当时的第一反应是又一个蹭热度的。但点进去翻了翻源码结构之后我改主意了。51core这个项目本质上是一个基于ASP.NET Core构建的Web开发基础框架或者说项目模板集合。它做的事情不是发明新轮子而是把.NET生态里那些成熟但分散的最佳实践——EF Core的数据访问、依赖注入的规范用法、中间件的合理编排、配置系统的分层管理——整合成一套可以直接拿来就用的项目结构。你可以把它理解成一个“有主见的脚手架”它替你做了很多架构层面的决策而这些决策背后是有逻辑的。这篇文章适合谁看如果你正在用ASP.NET Core做Web项目不管是刚入门还是已经写过几个项目但总觉得结构不够清晰那51core的设计思路都值得参考。我不会把它吹成什么“神器”但我会把它的核心设计、关键实现、以及我在实际使用中踩过的坑原原本本讲清楚。2. 51core的整体架构设计思路拆解2.1 它到底解决了什么问题ASP.NET Core本身已经足够灵活了。灵活是好事但灵活也意味着你要自己做很多决策项目怎么分层数据访问用Repository模式还是直接DbContext异常怎么统一处理日志怎么配置认证授权怎么组织这些问题每一个都有多种方案而每种方案都有各自的适用场景。51core的价值在于它把这些决策提前做了一遍并且把决策的理由隐含在了代码结构里。你拿到这个项目不需要从零开始想“我该怎么组织代码”而是可以直接看到一个经过思考的结构然后根据自己项目的实际情况去调整。具体来说它解决的核心痛点是从“能跑起来”到“结构清晰、可维护”之间的那段路。官方模板能让你五分钟跑起来一个Hello World但当你真正开始写业务代码的时候你会发现需要自己补的东西太多了。51core补上了这部分。2.2 分层结构的取舍逻辑51core采用了经典的分层结构但它的分层不是那种教条式的“Controller-Service-Repository”三层套娃。我翻了一遍它的目录结构大致是这样的Web层表现层Controller、中间件配置、过滤器、ViewModelApplication层应用层业务逻辑编排、DTO定义、服务接口与实现Domain层领域层实体定义、领域服务、值对象Infrastructure层基础设施层EF Core的DbContext、数据迁移、外部服务集成这个分层方式借鉴了DDD领域驱动设计的思路但做了简化。它没有引入聚合根、领域事件这些重量级概念而是保留了DDD最核心的“关注点分离”思想领域层不依赖任何外部框架应用层编排业务逻辑基础设施层负责技术实现。为什么这样分因为在实际项目中最容易出问题的地方就是业务逻辑和数据访问逻辑混在一起。你可能在Controller里直接写LINQ查询也可能在Service里直接操作HttpContext。这些做法在项目小的时候没问题但一旦业务复杂起来代码就会变成一团乱麻。51core通过分层强制你把不同职责的代码放在不同的地方虽然写起来多了一层调用但长期来看维护成本会低很多。注意分层不是目的可维护性才是。如果你做的是一个只有几个接口的小项目强行套这个结构反而会增加不必要的复杂度。51core的定位是中大型Web项目的基础框架小项目用它属于杀鸡用牛刀。2.3 依赖注入的规范用法ASP.NET Core内置的依赖注入容器已经很强大了但很多人在用的时候不太注意生命周期管理。51core在这一点上做得很规范它把服务的注册按照生命周期分成了三组// 单例全局唯一适合无状态的工具类 services.AddSingletonICacheService, MemoryCacheService(); // 作用域每个请求一个实例适合DbContext和业务服务 services.AddScopedIUserService, UserService(); services.AddScopedIOrderService, OrderService(); // 瞬态每次注入都创建新实例适合轻量级无状态服务 services.AddTransientIEmailSender, SmtpEmailSender();这个分组看起来简单但背后有明确的逻辑DbContext必须用Scoped因为它是非线程安全的缓存服务用Singleton因为它需要维护全局状态邮件发送器用Transient因为它每次调用都是独立的。51core通过扩展方法把这些注册逻辑封装起来让Program.cs保持干净。2.4 配置系统的分层管理配置管理是很多项目容易忽视的地方。51core把配置分成了三层appsettings.json存默认配置appsettings.Development.json存开发环境覆盖环境变量存敏感信息比如数据库连接字符串、API密钥。这个优先级顺序是环境变量 环境特定配置 默认配置。它还封装了一个强类型的配置绑定机制你不需要在代码里到处写Configuration[ConnectionStrings:Default]而是定义一个配置类通过IOptionsT注入使用。这样做的好处是编译时就能发现配置项名称写错的问题而不是等到运行时才报错。3. 核心细节解析与实操要点3.1 EF Core的集成方式与关键配置51core用的是EF Core作为ORM这一点没什么特别的但它在集成方式上有几个值得说的细节。首先是DbContext的注册。它没有直接把DbContext注册到容器里而是通过一个IDbContextFactory来管理。这样做的好处是可以在需要的时候手动创建DbContext实例比如在后台任务或者并发场景下。当然对于常规的请求-响应流程它还是通过Scoped生命周期注入的。services.AddDbContextAppDbContext(options { options.UseSqlServer( connectionString, sqlOptions sqlOptions.MigrationsAssembly(MyProject.Infrastructure) ); options.EnableSensitiveDataLogging(false); options.EnableDetailedErrors(true); });这里有几个关键点MigrationsAssembly指定了迁移文件所在的程序集因为DbContext定义在Infrastructure层而迁移文件也应该放在同一层EnableSensitiveDataLogging在生产环境必须关掉否则日志里会包含参数值有泄露敏感数据的风险EnableDetailedErrors在开发环境打开方便排查问题。其次是实体的配置方式。51core没有用Data Annotation就是在实体类上打[Required]、[MaxLength]这些特性而是用了Fluent API在单独的配置类里定义。这样做的好处是实体类保持干净不依赖EF Core的程序集领域层可以独立于ORM存在。public class UserConfiguration : IEntityTypeConfigurationUser { public void Configure(EntityTypeBuilderUser builder) { builder.ToTable(Users); builder.HasKey(u u.Id); builder.Property(u u.UserName).HasMaxLength(50).IsRequired(); builder.HasIndex(u u.UserName).IsUnique(); } }3.2 统一异常处理的实现细节异常处理是Web项目里最容易写得乱七八糟的地方。51core的做法是用中间件来统一捕获异常然后根据异常类型返回不同的HTTP状态码和响应体。它的异常处理中间件大致逻辑是这样的捕获到异常后先判断是不是自定义的业务异常比如BusinessException如果是返回400和具体的错误信息如果是NotFoundException返回404如果是未处理的系统异常返回500并且只在开发环境返回堆栈信息生产环境只返回一个通用的错误提示。public class ExceptionHandlingMiddleware { private readonly RequestDelegate _next; private readonly ILoggerExceptionHandlingMiddleware _logger; private readonly IWebHostEnvironment _env; public async Task InvokeAsync(HttpContext context) { try { await _next(context); } catch (BusinessException ex) { _logger.LogWarning(ex, 业务异常); context.Response.StatusCode 400; await context.Response.WriteAsJsonAsync(new { error ex.Message }); } catch (Exception ex) { _logger.LogError(ex, 系统异常); context.Response.StatusCode 500; var message _env.IsDevelopment() ? ex.ToString() : 服务器内部错误; await context.Response.WriteAsJsonAsync(new { error message }); } } }这个中间件看起来简单但有几个容易踩坑的地方。第一它必须注册在管道的早期否则前面的中间件抛出的异常它捕获不到。第二context.Response一旦开始写入就不能再修改状态码了所以要在写入之前设置好。第三如果异常发生在响应已经开始发送之后这个中间件也无能为力只能记录日志。3.3 认证授权的组织方式51core默认集成了JWT认证但它的实现方式比较灵活。它把认证配置放在了一个单独的扩展方法里你可以根据需要替换成Cookie认证或者第三方认证。services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidateAudience true, ValidateLifetime true, ValidateIssuerSigningKey true, ValidIssuer configuration[Jwt:Issuer], ValidAudience configuration[Jwt:Audience], IssuerSigningKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(configuration[Jwt:Key])) }; });这里的关键是ValidateLifetime必须设为true否则过期的Token还能继续用。另外Jwt:Key必须足够长至少16个字符否则HMAC-SHA256会报错。我见过有人用123456作为密钥这在开发环境可能能跑但生产环境绝对不行。授权方面51core用了基于策略的授权Policy-based Authorization而不是简单的[Authorize(Roles Admin)]。策略授权更灵活你可以把多个条件组合成一个策略比如“必须是管理员且账号未过期”。3.4 日志与监控的接入日志这块51core用的是Serilog而不是内置的ILogger。为什么因为Serilog的结构化日志能力更强而且可以方便地输出到多种目标控制台、文件、Seq、Elasticsearch等。Log.Logger new LoggerConfiguration() .MinimumLevel.Information() .MinimumLevel.Override(Microsoft, LogEventLevel.Warning) .Enrich.FromLogContext() .WriteTo.Console() .WriteTo.File(logs/log-.txt, rollingInterval: RollingInterval.Day) .CreateLogger();这里有个细节MinimumLevel.Override(Microsoft, LogEventLevel.Warning)这行很重要。因为ASP.NET Core框架本身会输出大量Information级别的日志如果不覆盖掉你的日志文件会被框架日志淹没。把它设为Warning之后只有框架的警告和错误才会被记录你自己的业务日志则保持Information级别。4. 实操过程与核心环节实现4.1 从零搭建一个基于51core的项目假设你要用51core的结构来搭建一个新项目完整的流程是这样的。第一步创建项目结构。你需要创建四个项目MyProject.Web、MyProject.Application、MyProject.Domain、MyProject.Infrastructure。Web项目引用Application和InfrastructureApplication引用DomainInfrastructure引用Domain。注意Application不引用Infrastructure这是依赖倒置原则的体现——Application定义接口Infrastructure实现接口。第二步配置依赖注入。在Web项目的Program.cs里通过扩展方法注册各层的服务builder.Services.AddApplicationServices(); builder.Services.AddInfrastructureServices(builder.Configuration); builder.Services.AddWebServices();每个扩展方法定义在对应的层里比如AddInfrastructureServices定义在Infrastructure层负责注册DbContext、Repository、外部服务客户端等。第三步配置中间件管道。51core的中间件顺序是这样的app.UseExceptionHandling(); app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints endpoints.MapControllers());这个顺序不能乱。异常处理必须在最前面认证必须在授权前面路由必须在认证前面。我见过有人把UseAuthentication放在UseRouting前面结果认证中间件拿不到路由信息导致某些基于路由的认证策略失效。4.2 数据库迁移的完整流程EF Core的迁移是开发过程中绕不开的环节。51core的迁移流程是这样的首先确保Microsoft.EntityFrameworkCore.Design包已经安装在Web项目或者启动项目里。然后在Infrastructure层定义好实体和配置之后运行迁移命令dotnet ef migrations add InitialCreate --project MyProject.Infrastructure --startup-project MyProject.Web这里--project指定迁移文件放在哪个项目--startup-project指定启动项目因为需要读取配置。迁移文件生成后不要急着database update先打开迁移文件检查一下生成的SQL是否符合预期。我踩过的坑是有时候EF Core生成的迁移会包含一些意外的改动比如把某个字段的类型从nvarchar(50)改成nvarchar(max)如果你不检查直接更新可能会造成数据丢失。确认无误后执行更新dotnet ef database update --project MyProject.Infrastructure --startup-project MyProject.Web提示生产环境的数据库更新不要用dotnet ef database update而是用dotnet ef migrations script生成SQL脚本然后由DBA审核后手动执行。这样做的好处是可控而且可以回滚。4.3 一个完整的业务模块实现示例我拿一个用户注册的模块来演示51core的实际用法。Domain层定义实体public class User { public Guid Id { get; private set; } public string UserName { get; private set; } public string Email { get; private set; } public string PasswordHash { get; private set; } public DateTime CreatedAt { get; private set; } private User() { } public static User Create(string userName, string email, string passwordHash) { return new User { Id Guid.NewGuid(), UserName userName, Email email, PasswordHash passwordHash, CreatedAt DateTime.UtcNow }; } }注意这里用了私有构造函数和静态工厂方法这是DDD的常见做法目的是保证实体只能通过工厂方法创建从而保证创建时的参数校验。Application层定义服务接口和DTOpublic interface IUserService { TaskGuid RegisterAsync(RegisterRequest request); } public record RegisterRequest(string UserName, string Email, string Password);Infrastructure层实现服务public class UserService : IUserService { private readonly AppDbContext _dbContext; private readonly IPasswordHasher _passwordHasher; public async TaskGuid RegisterAsync(RegisterRequest request) { var existingUser await _dbContext.Users .FirstOrDefaultAsync(u u.UserName request.UserName); if (existingUser ! null) throw new BusinessException(用户名已存在); var passwordHash _passwordHasher.Hash(request.Password); var user User.Create(request.UserName, request.Email, passwordHash); _dbContext.Users.Add(user); await _dbContext.SaveChangesAsync(); return user.Id; } }Web层定义Controller[ApiController] [Route(api/[controller])] public class UserController : ControllerBase { private readonly IUserService _userService; [HttpPost(register)] public async TaskIActionResult Register([FromBody] RegisterRequest request) { var userId await _userService.RegisterAsync(request); return Ok(new { userId }); } }这个流程看起来多了一层调用但每一层都有明确的职责Domain负责业务规则Application负责编排Infrastructure负责技术实现Web负责HTTP交互。当业务逻辑变复杂的时候这种结构的优势就会体现出来。4.4 性能优化的几个关键点51core在性能方面也做了一些预设的优化。首先是响应压缩它默认启用了Gzip压缩对于JSON响应来说压缩率通常能达到70%以上。其次是缓存它封装了一个基于IMemoryCache的缓存服务你可以很方便地给查询结果加缓存。public async TaskListProductDto GetProductsAsync() { return await _cache.GetOrCreateAsync(products_all, async entry { entry.AbsoluteExpirationRelativeToNow TimeSpan.FromMinutes(5); return await _dbContext.Products .Select(p new ProductDto(p.Id, p.Name, p.Price)) .ToListAsync(); }); }这里有个细节缓存的是DTO而不是实体因为实体可能包含导航属性序列化的时候会有循环引用的问题。另外缓存时间设了5分钟这是根据业务场景定的——如果商品数据变化不频繁可以设更长如果需要实时性可以设更短或者用分布式缓存。5. 常见问题与排查技巧实录5.1 EF Core迁移相关的典型问题问题一迁移文件生成在错误的项目里。这个问题的表现是运行dotnet ef migrations add之后迁移文件出现在了Web项目而不是Infrastructure项目里。原因是--project参数没有指定或者指定的项目没有安装Microsoft.EntityFrameworkCore.Design包。解决方法是在Infrastructure项目里安装Design包并且每次执行命令时都带上--project参数。问题二数据库更新时提示“无法连接到数据库”。这个问题的原因通常是连接字符串配置错误或者数据库服务没有启动。排查步骤是先检查appsettings.json里的连接字符串是否正确然后确认数据库服务是否在运行最后检查防火墙是否允许连接。如果是本地开发可以用(localdb)\MSSQLLocalDB作为连接字符串这是Visual Studio自带的轻量级数据库。问题三迁移文件冲突。当多人协作时可能会出现两个迁移文件修改了同一个表的情况。解决方法是先拉取最新的代码然后删除自己本地的迁移文件重新生成一个包含所有改动的迁移。如果已经更新到数据库了需要用dotnet ef migrations remove回滚。5.2 依赖注入的常见错误错误一在Singleton服务里注入Scoped服务。这个错误会在启动时报“Cannot consume scoped service from singleton”的异常。原因是Singleton的生命周期比Scoped长如果Singleton持有了Scoped的引用Scoped对象就无法被正确释放。解决方法是把Singleton改成Scoped或者用IServiceScopeFactory手动创建作用域。错误二循环依赖。当A服务依赖B服务B服务又依赖A服务时容器会抛出循环依赖异常。解决方法是引入第三个服务来打破循环或者用LazyT延迟加载。我个人的经验是循环依赖通常是设计问题的信号应该重新审视服务之间的职责划分。错误三忘记注册服务。这个错误的表现是运行时抛出“Unable to resolve service for type”的异常。解决方法是检查Program.cs里是否注册了对应的服务。51core的做法是用扩展方法集中注册这样可以减少遗漏。5.3 认证授权相关的排查问题一JWT Token验证失败。常见原因有密钥长度不够、Issuer或Audience不匹配、Token已过期、时钟偏移。排查时可以先在jwt.io上解析Token看看Payload里的iss、aud、exp是否正确。如果时钟偏移是问题可以在TokenValidationParameters里设置ClockSkew TimeSpan.Zero。问题二授权策略不生效。这个问题的表现是明明配置了策略但请求还是能通过。原因通常是[Authorize]特性没有加或者策略名称写错了。另外要注意UseAuthentication和UseAuthorization中间件必须按顺序注册否则授权不会生效。问题三跨域请求被拦截。前后端分离的项目里跨域是常见问题。51core默认没有开启CORS需要手动配置。配置时要注意AllowAnyOrigin和AllowCredentials不能同时使用否则浏览器会拒绝请求。5.4 常见问题速查表问题现象可能原因排查方法解决方案启动时报“Cannot consume scoped service”Singleton注入了Scoped检查服务注册的生命周期改为Scoped或用IServiceScopeFactory迁移文件生成在错误项目未指定--project参数检查命令参数安装Design包并指定--projectJWT验证失败密钥太短或Issuer不匹配在jwt.io解析Token检查配置项确保密钥≥16字符授权策略不生效中间件顺序错误检查Program.cs管道配置确保UseAuthentication在UseAuthorization前跨域请求被拦截未配置CORS查看浏览器控制台错误添加CORS策略注意AllowAnyOrigin限制数据库连接失败连接字符串错误检查appsettings.json确认数据库服务运行且连接字符串正确5.5 我踩过的几个坑第一个坑是关于async/await的。在EF Core里如果你用了async方法但没有await查询会同步执行失去异步的优势。更严重的是如果在using块里返回了未await的TaskDbContext可能已经被释放了导致“ObjectDisposedException”。我的做法是所有数据库操作都用await并且不要在using块里返回Task。第二个坑是关于日志的。Serilog默认会记录所有Information级别的日志包括框架的。如果不加MinimumLevel.Override日志文件会迅速膨胀。我建议把Microsoft的日志级别设为Warning自己的业务日志保持Information。第三个坑是关于配置的。IOptionsT默认是Singleton的这意味着配置一旦加载就不会变。如果你的配置需要热更新要用IOptionsSnapshotTScoped或IOptionsMonitorTSingleton但支持变更通知。我一开始不知道这个区别改配置文件后重启才生效后来换成IOptionsMonitor就解决了。6. 这个项目后续可以怎么扩展51core作为一个基础框架它的扩展性是我比较看重的。它预留了很多扩展点你可以根据自己的需求往里加东西。比如你可以把认证从JWT换成IdentityServer或者OpenIddict只需要替换认证配置和Token生成逻辑。你也可以把EF Core换成Dapper只需要在Infrastructure层重新实现Repository接口。你还可以加入MediatR来实现CQRS模式把命令和查询分开处理。我个人在实际使用中的体会是51core最大的价值不是它提供了多少功能而是它提供了一套清晰的代码组织方式。你可以在它的基础上做减法把不需要的部分删掉也可以做加法把新的技术栈集成进来。这种灵活性比那些“大而全”的框架要实用得多。最后分享一个小技巧如果你觉得51core的分层太细可以先把Application层和Domain层合并等业务复杂了再拆开。架构不是一成不变的适合当前团队的才是最好的。
返回列表