ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C# .NET 8 WebAPI分层架构:SqlSugar+仓储+DTO+服务层实战

2026/9/15 5:19:11 拓冰建站 浏览量
C# .NET 8 WebAPI分层架构:SqlSugar+仓储+DTO+服务层实战 简介针对C#开发者设计的一份Web API分层实战项目聚焦.NET 8环境下SqlSugar ORM、仓储模式、DTO、服务层与控制层的协同应用帮助解决从数据访问到接口响应的架构落地问题。压缩包约22.24MB共137个文件以dll、cs、json、sln等类型为主既包含编译产物与程序集也提供源码、配置文件与解决方案便于对照学习和调试。已有1670人学习浏览适合对分层架构感兴趣、希望上手完整示例的中高级C#/.NET开发者。项目中可看到IUserRepository等仓储接口、UserService服务层、UserDto转换逻辑以及UserController接口示例通过依赖注入串联各层依赖并附有NuGet引用与编译相关配置能帮助理解从数据库操作到HTTP响应返回的完整链条也便于在此基础上继续扩展授权、缓存、异常处理等生产级能力。1. 一套组合C#.net8 WebAPI 里的 SqlSugar、仓储、DTO、服务层到底在解什么题在真实项目里我看到太多UserService里直接new SqlSugarClient控制器里db.QueryableUser().Where(...)到处飞表结构一改接口响应也跟着变形。这个标题把 C#.net8 创建 WebAPI 时最常用的一套工程组合串了起来SqlSugar 负责数据访问仓储模式挡住 ORM 细节DTO 定义接口契约服务层沉淀业务规则控制层只做编排。适合准备把项目从单文件控制器重构出来的 .NET 开发者也适合团队新建 API 项目时想把架构定下来的技术负责人。先说明一下标题里的“可联系作者购买”我不在本文展开这套方案你按下面的步骤完全可以在本地免费跑起来。读完你会拿到一个能从空目录编译运行的 WebAPI 骨架并且知道每个层为什么存在、参数在哪改、踩坑时看哪。2. 先搭地基.NET 8 WebAPI 项目与 SqlSugar 的初始化配置2.1 用命令行把 WebAPI 项目和 NuGet 包准备好我习惯先建一个空目录然后用 dotnet CLI 创建控制器版 WebAPI。.NET 8 的模板默认是最小 API所以要加--use-controllers参数让路由和管道按 MVC 方式注册这样后面控制器的ApiController特性才能正常工作。如果你的 SDK 版本更老或新到参数改名可以先跑dotnet new webapi -h查看可用选项。dotnet new webapi --use-controllers -n Mall.Api dotnet new sln -n Mall dotnet sln add Mall.Api cd Mall.Api dotnet add package SqlSugarCoredotnet add package SqlSugarCore会自动拉取当前兼容 .NET Standard 2.1 / .NET 8 的稳定包不需要手动指定版本。注意 WebAPI 模板自带的WeatherForecast样例可以删除避免干扰后续的分层演示。接下来把数据库连接和 SqlSugar 配置放到appsettings.json连接字符串里不要暴露明文密码到代码仓库开发环境可以用 User Secrets。这里先给出一个本地开发用的 MySQL 连接{ ConnectionStrings: { Default: Server127.0.0.1;Port3306;DatabaseMallDb;Uidroot;Pwd123456; }, SqlSugar: { DbType: PostgreSQL, IsAutoCloseConnection: true, IsEnableLog: true } }DbType字段填写的是枚举名比如MySql、SqlServer、PostgreSQL、Sqlite千万不要写成连接字符串里的mysql字符串否则 SqlSugar 在初始化连接时直接抛NotSupportedException。IsAutoCloseConnectiontrue表示每次操作完自动关闭 ADO.NET 连接对 API 场景很重要避免长时间占用数据库连接。2.2 把 SqlSugarScope 注册为单例并开启 AOP 日志SqlSugar 官方文档里推荐的 API 场景是SqlSugarScope它在内部做了线程安全处理支持同一个实例并发执行查询所以不要注册成Scoped再每次new。注册到Program.cs时我把ISqlSugarClient作为服务类型暴露这样仓储层只依赖抽象替换测试时也更方便。using SqlSugar; builder.Services.AddSingletonISqlSugarClient(sp { var conn builder.Configuration.GetConnectionString(Default); var sqlSugar new SqlSugarScope(new ConnectionConfig { ConnectionString conn, DbType DbType.PostgreSQL, IsAutoCloseConnection true, InitKeyType InitKeyType.Attribute }, db { db.Aop.OnLogExecuting (sql, pars) { Console.WriteLine($SQL: {sql}); Console.WriteLine($Params: {string.Join(, , pars.Select(p ${p.ParameterName}{p.Value}))}); }; }); return sqlSugar; });代码里的InitKeyType.Attribute表示实体类用SugarColumn特性标记主键和自增列而不用去数据库反向生成这要求你建实体的同时把特性写对。db.Aop的OnLogExecuting会在每次 SQL 执行前回调这是排错时必须开的一扇窗——你可以看到 ORM 真正执行的 SQL 长什么样避免“代码查出来和数据库直接查不一样”的问题。这里有一个常见困惑服务里注入的是ISqlSugarClient那SqlSugarScope有什么额外能力SqlSugarScope是线程安全壳在同一个作用域里它管理的Ado.BeginTran能覆盖后续所有 SqlSugar 调用。注册成单例时如果你在服务里使用SqlSugarScope的实例上下文不是最外层ISqlSugarClient要注意按请求上下文隔离。我们后面章节的服务层会尽量在方法内部用注入的ISqlSugarClient操作避免状态串扰。2.3 用 CodeFirst 快速验证数据库连接是否通先建一个不涉及外键的User实体然后用 SqlSugar 的CodeFirst.InitTables自动建表。这一步不是为了最终生产结构而是为了验证连接字符串、DbType、主键特性都生效。[SugarTable(sys_user)] public class User { [SugarColumn(IsPrimaryKey true, IsIdentity true)] public int Id { get; set; } [SugarColumn(ColumnName nick_name, Length 50)] public string? Name { get; set; } public DateTime CreatedAt { get; set; } }在Program.cs的app.Run()之前加一段临时代码只用于环境验证var db app.Services.GetRequiredServiceISqlSugarClient(); db.CodeFirst.InitTablesUser();运行一次项目后打开数据库看到sys_user表就说明连接成功。SugarColumn(ColumnName nick_name)可以把实体属性映射到带下划线的数据库列这样 C# 侧保持 PascalCase数据库侧保持业务命名规范。IsIdentitytrue又配合IsPrimaryKeytrue时SqlSugar 会在插入时忽略这个字段让数据库自增。配置项作用常见误用DbType指定数据库类型枚举写成字符串mysqlIsAutoCloseConnection每次操作后自动关闭连接设成 false 不释放连接InitKeyType.Attribute从实体特性识别主键/自增不设置内部会反射失败Aop.OnLogExecuting打印/记录 SQL 与参数忽略它导致 SQL 排错靠猜这段验证代码在确认后要删掉或用#if DEBUG包住不然每次启动都会执行建表可能覆盖表结构或额外生成索引。CodeFirst只适合初始化场景线上变更要用迁移脚本或 SqlSugar 的差异更新功能不要在 API 启动流程里做 DDL。3. 仓储模式 DTO让数据访问和接口契约各自守住边界3.1 仓储接口为什么不能只有一个泛型基类很多文章只给你一个IRepositoryT我实际落地时发现不够。泛型仓储解决的是通用增删改查但业务查询一旦涉及where、join、分页接口要么膨出十几个方法要么直接暴露IQueryableT。暴露IQueryable的坏处是你在服务层写完Where(...).OrderBy(...)真实执行时如果 SqlSugar 转换不出来调用层无法单测。常见做法是分开两层一个小的泛型仓储IRepositoryT提供最基础的GetById、Insert、Update、Delete另一个面向具体聚合的业务仓储IUserRepository继承泛型仓储并在里面写GetUserPageAsync、IsNameExistsAsync这类有业务含义的方法。仓储实现里仍然用注入进来的ISqlSugarClient但接口签名里不出现任何 SqlSugar 类型。public interface IRepositoryT where T : class, new() { TaskT? GetByIdAsync(int id); TaskListT SelectAllAsync(); Taskbool InsertAsync(T entity); Taskbool UpdateAsync(T entity); Taskbool DeleteAsync(int id); } public interface IUserRepository : IRepositoryUser { Taskbool IsNameExistsAsync(string name); TaskPageResultUser GetUserPageAsync(int pageIndex, int pageSize); }这里PageResultUser是我自定义的分页返回类型包含Items和Total。为了避免服务层依赖 ORM 的分页模型我会在自己的领域层定义一个PagedListT。IRepositoryT不引入 SqlSugar 命名空间仓储接口可以放在独立的领域层项目里被单元测试引用。3.2 用 SqlSugar 实现仓储基类和用户仓储实现类的重点有两个一是注入ISqlSugarClient二是用QueryableT()而不是SqlQuery去写动态查询这样 SqlSugar 能帮我们处理参数化和分页。下面是基类实现public class RepositoryBaseT : IRepositoryT where T : class, new() { protected readonly ISqlSugarClient _db; public RepositoryBase(ISqlSugarClient db) { _db db; } public async TaskT? GetByIdAsync(int id) await _db.QueryableT().InSingleAsync(id); public async TaskListT SelectAllAsync() await _db.QueryableT().ToListAsync(); public async Taskbool InsertAsync(T entity) await _db.Insertable(entity).ExecuteCommandAsync() 0; public async Taskbool UpdateAsync(T entity) await _db.Updateable(entity).ExecuteCommandAsync() 0; public async Taskbool DeleteAsync(int id) await _db.DeleteableT().In(id).ExecuteCommandAsync() 0; }InSingleAsync(id)是 SqlSugar 针对主键查询的短路方法会生成WHERE id id并自动按实体主键特性识别。ExecuteCommandAsync返回受影响行数因此用 0判断是否成功。DeleteableT().In(id)传入的是主键值不要直接写WhereAsync那样容易漏掉主键名。UserRepository里我放两个业务查询一个做名字重复校验一个做分页。分页时用 SqlSugar 的ToPageListAsync它要求传入ref int total参数调用后total会被赋值为满足条件的总条数。public class UserRepository : RepositoryBaseUser, IUserRepository { private readonly ISqlSugarClient _sqlSugar; public UserRepository(ISqlSugarClient sqlSugar) : base(sqlSugar) { _sqlSugar sqlSugar; } public async Taskbool IsNameExistsAsync(string name) await _sqlSugar.QueryableUser().AnyAsync(u u.Name name); public async TaskPageResultUser GetUserPageAsync(int pageIndex, int pageSize) { var total 0; var list await _sqlSugar.QueryableUser() .OrderBy(u u.Id, OrderByType.Desc) .ToPageListAsync(pageIndex, pageSize, ref total); return new PageResultUser { Total total, Items list }; } }AnyAsync比CountAsync() 0效率更好因为只要命中一条就会停止。分页的pageIndex起始值注意统一SqlSugar 的ToPageListAsync从 1 开始如果前端传的是 0 基分页需要先做pageIndex再往下传。OrderBy(u u.Id, OrderByType.Desc)里的OrderByType.Desc是 SqlSugar 枚举别拼成字符串DESC。3.3 DTO 不是实体副本它是对外契约直接用实体作为 API 响应的最大问题是它把数据库结构暴露给了所有调用方字段多了就会多传字段名变了接口也跟着变实体里如果有导航属性还可能引发 JSON 循环序列化异常。DTO 的核心价值是把“存储模型”和“接口模型”分开。public record CreateUserDto { [Required] [MaxLength(50)] public string Name { get; set; } string.Empty; } public record UserDto { public int Id { get; set; } public string? Name { get; set; } public DateTime CreatedAt { get; set; } public string Status { get; set; } active; }我经常让 DTO 使用record因为适合做简单数据载体支持值比较。CreateUserDto上用[Required]和[MaxLength]后ASP.NET Core 的模型验证会自动返回 400不用在控制器里手写 if。UserDto里加了一个Status这是数据库实体里没有的字段是接口层自己算出来的展示状态——这就是 DTO 存在的意义可以在不碰数据库的情况下调整响应内容。3.4 实体到 DTO 的映射手动映射和 AutoMapper 怎么选如果你只在零星两三个地方做映射手写最简单new UserDto { Id u.Id, Name u.Name, CreatedAt u.CreatedAt }。但项目里超过几十个 Action每个 Action 都要这种赋值手写就变成大量样板代码。这时候用 AutoMapper 很常见。.NET 8 里注册 AutoMapper 只需要两行using AutoMapper; builder.Services.AddAutoMapper(typeof(Program));然后在同程序集写一个Profile子类public class UserProfile : Profile { public UserProfile() { CreateMapUser, UserDto(); CreateMapCreateUserDto, User(); } }AddAutoMapper(typeof(Program))会扫描当前程序集中所有继承Profile的类型不用逐个AddScoped。注意它扫描的是typeof(Program)所在程序集如果你的映射类放在独立类库就需要把类库的程序集传给AddAutoMapper比如AddAutoMapper(cfg {}, typeof(UserProfile).Assembly)这也是网上搜“.net8 automapper 如何注册”时最常见的坑。场景手写AutoMapper字段少于 5 个快直接要建映射类字段名不一致要逐一赋值要配ForMember嵌套对象/集合麻烦递归映射方便映射过程加业务逻辑灵活只能用AfterMap或扩展我个人的准则是CRUD 的简单实体映射手写聚合根、多层级 DTO 用 AutoMapper尽量避免为了省事把 DTO 和实体直接串成一串。4. 服务层装业务规则控制器只做参数绑定和响应编排4.1 服务接口的粒度应该按业务场景切而不是按表切服务层Service 层的核心职责是组合仓储、校验业务规则、发起事务、转换 DTO。很多新手会把服务层写成“每个实体建一个 Service”结果里面除了调用仓储没有别的逻辑。正确的划分是一个服务接口面向某个完整业务用例比如IUserService里的CreateUserAsync可能同时操作user表和user_account表而不是提供一个InsertUserAsync完事。public interface IUserService { TaskUserDto? GetByIdAsync(int id); Task CreateUserAsync(CreateUserDto dto); TaskPageResultUserDto GetPageAsync(int pageIndex, int pageSize); Task ToggleStatusAsync(int id); } public class UserService : IUserService { private readonly IUserRepository _userRepository; private readonly ISqlSugarClient _db; private readonly IMapper _mapper; public UserService(IUserRepository userRepository, ISqlSugarClient db, IMapper mapper) { _userRepository userRepository; _db db; _mapper mapper; } public async Task CreateUserAsync(CreateUserDto dto) { if (await _userRepository.IsNameExistsAsync(dto.Name)) throw new BusinessException(用户名已存在); var user _mapper.MapUser(dto); user.CreatedAt DateTime.Now; var result await _db.Ado.UseTranAsync(async () { await _userRepository.InsertAsync(user); var account new Account { UserId user.Id, Amount 0 }; await _db.Insertable(account).ExecuteCommandAsync(); }); if (!result.IsSuccess) { throw new BusinessException(创建用户失败 result.ErrorMessage); } } public async TaskUserDto? GetByIdAsync(int id) { var user await _userRepository.GetByIdAsync(id); return user is null ? null : _mapper.MapUserDto(user); } }构造函数里注入IMapper之后服务层返回 DTO 就很顺手先查实体再_mapper.MapUserDto(user)。如果服务里发现业务规则不满足就抛自定义的BusinessException由全局处理转成 HTTP 400不要直接 return false那样控制器和调用方会看到一堆魔法数字。4.2 多表操作的事务放服务层SqlSugar 提供两套事务写法SqlSugar 的Ado对象提供了BeginTran/CommitTran/RollbackTran也有更简洁的UseTranAsync。在服务层组合多个仓储方法时建议使用UseTranAsync它可以自动提交或回滚不需要你手动try-catch。public async Task CreateUserAsync(CreateUserDto dto) { if (await _userRepository.IsNameExistsAsync(dto.Name)) throw new BusinessException(用户名已存在); var user _mapper.MapUser(dto); user.CreatedAt DateTime.Now; var result await _db.Ado.UseTranAsync(async () { await _userRepository.InsertAsync(user); var account new Account { UserId user.Id, Amount 0 }; await _db.Insertable(account).ExecuteCommandAsync(); }); if (!result.IsSuccess) { throw new BusinessException(创建用户失败 result.ErrorMessage); } }UseTranAsync(Action)返回一个DbResultboolIsSuccess能拿到事务是否成功失败时ErrorMessage里是底层异常。事务内插入的user对应自增主键Id在UseTranAsync执行完之后会被 SqlSugar 回填到实体上所以事务内部new Account { UserId user.Id }拿到的就是新主键值。如果你的数据库主键不是自增而是由应用生成要等插入后手动读回。事务 API适用场景注意点BeginTran/CommitTran/RollbackTran手动控制生命周期记得在 finally 里 RollbackUseTranAsync方法内一次完整事务委托内异常自动回滚UseTran同步代码配合异步代码慎用容易死锁事务范围要尽量短不要在UseTranAsync里做耗时 IO 或调外部 HTTP 服务。事务本身是一种资源长时间持有会造成连接池排队。4.3 控制器瘦身只做参数绑定、调用服务、包装响应控制器的 Action 写完后看起来应该很朴素从路由/Query/Body 拿参数调用服务然后返回统一结果。不要在这里写 SqlSugar 查询语句也不要在这里 new 仓储。下面的代码展示了UsersController的最小形态[ApiController] [Route(api/[controller])] public class UsersController : ControllerBase { private readonly IUserService _userService; public UsersController(IUserService userService) { _userService userService; } [HttpGet({id:int})] public async TaskActionResultUserDto GetById(int id) { var dto await _userService.GetByIdAsync(id); if (dto is null) return NotFound(); return Ok(dto); } [HttpPost] public async TaskIActionResult Create([FromBody] CreateUserDto dto) { await _userService.CreateUserAsync(dto); return StatusCode(201, new ResponseModel(201, created, null)); } [HttpGet] public async TaskActionResultPageResultUserDto Page( [FromQuery] int pageIndex 1, [FromQuery] int pageSize 20) { var result await _userService.GetPageAsync(pageIndex, pageSize); return Ok(result); } }[FromQuery]显式标出参数来自查询字符串[FromBody]标出 JSON 请求体。{id:int}是路由约束保证只有整数才能进到这个 Action否则返回 404。注意GetByIdAsync返回null时我们用NotFound()这是 REST 风格如果项目统一用ResponseModel也可以返回code404而不抛异常。4.4 用统一响应中间件和异常过滤器盖住底层细节很多接口前端需要拿到固定的{ code, message, data }结构而不是直接收裸数据。你可以在 Action 里每个都写new ResponseModel(...)但那会重复。更推荐的方式是自定义一个ResponseModelT再用全局IExceptionHandler把未捕获异常统一转成这个结构。.NET 8 的 WebAPI 内置了问题详情格式但我不太喜欢让前端直接拿 RFC 7807所以自己套一层更可控。下面是一个极简的BusinessException处理中间件示例也可以注册为IExceptionHandlerpublic class UnifiedResponseMiddleware { private readonly RequestDelegate _next; private readonly ILoggerUnifiedResponseMiddleware _logger; public UnifiedResponseMiddleware(RequestDelegate next, ILoggerUnifiedResponseMiddleware logger) { _next next; _logger logger; } public async Task InvokeAsync(HttpContext context) { try { await _next(context); } catch (BusinessException ex) { context.Response.StatusCode 400; await context.Response.WriteAsJsonAsync(new ResponseModel(400, ex.Message, null)); } catch (Exception ex) { _logger.LogError(ex, Unhandled exception); context.Response.StatusCode 500; await context.Response.WriteAsJsonAsync(new ResponseModel(500, 服务器内部错误, null)); } } }使用中间件时注意注册顺序app.UseMiddlewareUnifiedResponseMiddleware()一定要放在app.MapControllers()之前否则请求还没进管道就被短路异常拿不到。中间件里的WriteAsJsonAsync是异步方法不要用WriteAsync(string)直接拼 JSON结构转义容易出错。5. 综合应用验证分页、IIS 发布与 C# HttpClient 回测5.1 一条链路跑通从控制器到 SqlSugar 的真实分页把上面的仓储实现注册到容器后启动时依赖链就是UsersController - UserService - UserRepository - SqlSugarScope。如果忘了注册IUserRepository会直接抛InvalidOperationException: Unable to resolve service。所以在Program.cs里把注册代码集中放一起builder.Services.AddSingletonISqlSugarClient(...); builder.Services.AddScoped(typeof(IRepository), typeof(RepositoryBase)); builder.Services.AddScopedIUserRepository, UserRepository(); builder.Services.AddScopedIUserService, UserService();启动项目后用 curl 先验证路由是否通curl -s http://localhost:5000/api/users?pageIndex1pageSize2返回的 JSON 里total应该对应数据库实际记录数。如果 404先看日志里的路由匹配再用dotnet run启动不要用 IIS Express 的随机端口。5.2 发布到 IIS 时最常见的 .NET 8 宿主坑发布 WebAPI 时很多人遇到 502.5 进程退出浏览器看到 IIS 里根本没有 .NET 8 的应用程序池选项。原因是 IIS 默认只预装 .NET Framework.NET 8 的 ASP.NET Core 托管需要单独安装 Hosting Bundle它包含 .NET 8 运行时和 ASP.NET Core 模块。发布时选择框架依赖发布然后检查web.config里的hostingModelaspNetCore processPathdotnet arguments.\Mall.Api.dll stdoutLogEnabledtrue stdoutLogFile.\logs\stdout hostingModelinprocess /stdoutLogEnabledtrue会在发布目录下生成logs/stdout_xxx.log如果进程起不来优先看这个日志比事件查看器直接。注意logs目录要提前创建并给到IIS_IUSRS写权限。若使用独立部署则processPath要改成应用可执行文件比如.\Mall.Api.exe且不需要目标机器装运行时。5.3 用 C# HttpClient 做一个最小回测浏览器只适合测 GETPOST 和带鉴权的请求我常用 C# 写个小工具回测接口。在同一个解决方案里建控制台项目下面是核心调用using var client new HttpClient(); client.DefaultRequestHeaders.Accept.Add( new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue(application/json)); var body new { name test user }; var json System.Text.Json.JsonSerializer.Serialize(body); var response await client.PostAsync(http://localhost:5000/api/users, new StringContent(json, System.Text.Encoding.UTF8, application/json)); Console.WriteLine(await response.Content.ReadAsStringAsync());StringContent第三个参数必须写application/json不然 ASP.NET Core 的[FromBody]解析器不识别内容类型直接 415。如果你是 .NET 8 客户端可以直接await client.PostAsJsonAsync(http://localhost:5000/api/users, body)PostAsJsonAsync内部会自动设置Content-Type少一行手动序列化。本文还有配套的精品资源点击获取