☰
C# .NET 8 WebAPI 实战:SqlSugar + 仓储 + DTO + 服务层架构设计与避坑指南
2026/10/9 3:23:11 网站建设 项目流程

简介:基于C#.NET 8构建Web API的综合应用源码包,集成了SqlSugar ORM、仓储模式、DTO(数据传输对象)映射、服务层业务封装与控制器层接口暴露,并充分利用.NET 8新特性,特别适合希望掌握分层架构的中高级.NET开发者,能有效解决数据库操作重复、业务逻辑与数据访问耦合等问题。压缩包共137个文件,以.cs源码和.csproj工程文件为核心,另有dll程序集、json配置、缓存文件等支撑内容,包体积22.24MB,便于快速打开研读。目前已有1676人学习下载。项目完整演示了从仓储接口定义、DTO转换、服务层业务处理到控制器响应HTTP请求的完整开发链路,包含增删改查实例、依赖注入及异常处理实践,并提供了UserController等示例控制器,覆盖GET、POST、PUT、DELETE等常见HTTP动词,可作为构建可扩展、易维护API的参考样板,边读边改即可复现。

1. C#.net8创建WebAPI,SqlSugar+仓储+DTO+服务层这套组合到底在解决什么

如果你用 C#.net8 写过 WebAPI,一定有这种经历:控制器里直接 new SqlSugarClient,查询写在控制器方法里,返回的又是数据库实体,结果接口一多,代码乱成一锅粥。后来领导说要搞什么仓储模式、DTO、服务层,你心想这怕不是又要造轮子。但等你真的把 SqlSugar 和分层结构揉进一个 net8 WebAPI 项目,你会发现大多数业务接口根本不需要反复写 SQL,改表结构时也不用手忙脚乱。这篇文章就是把我做这套组合的方案、参数和踩坑写出来,新手能照着搭,熟手能对比着调。

这套组合的核心价值在于:用仓储模式把数据访问收口,用 DTO 把实体和接口参数隔离,用服务层把业务规则从控制器里抽出来。控制器只负责 HTTP 请求和响应,服务层负责拼凑业务逻辑,仓储负责任何一条 SQL 的读写,最后 SqlSugar 负责执行。适合中小型项目、快速迭代的管理后台、API 服务,也适合那些想从三层架构往领域驱动方向过渡但不想引入太重框架的团队。下面我按实际搭建顺序来写。

2. 创建 net8 WebAPI 项目并接入 SqlSugar:两步走,先跑通查询链路

2.1 用 dotnet CLI 创建 WebAPI 的最小命令

我现在一般不用 Visual Studio 向导建项目,直接敲命令更快。打开终端,执行:

dotnet new webapi -n Demo.Api --framework net8.0 --no-https

这条命令创建一个名为 Demo.Api 的 WebAPI 项目,目标框架是 net8.0。--no-https表示暂时不生成 HTTPS 配置,本地调试少绕点弯。如果你的机器上没有安装 net8 SDK,先跑dotnet --version确认。创建完成后进入目录:

cd Demo.Api dotnet restore

此时解决方案里只有一个 Controllers 目录和一个 Program.cs。接着安装 SqlSugarCore 包,注意不是 SqlSugar(那是老版本 .NET Framework 用的)。我一般用命令行:

dotnet add package SqlSugarCore

这个包会自动拉取依赖,包含 SqlSugar 核心和 ADO.NET 驱动。如果你用的是 SqlServer,需要额外确认项目里引用了Microsoft.Data.SqlClient,不过 SqlSugarCore 通常会把对应的包带进来。

2.2 配置 SqlSugar 连接串和启动注册

打开 appsettings.json,添加连接串。我习惯单独放一个ConnectionStrings节点,同时把 SqlSugar 特有的枚举转字符串也放进去,方便按环境改:

{ "ConnectionStrings": { "Default": "Server=localhost;Database=DemoDb;User Id=sa;Password=your_password;TrustServerCertificate=true;" }, "SqlSugar": { "DbType": "SqlServer", "IsAutoCloseConnection": true, "InitKeyType": "SystemTable" } }

TrustServerCertificate=true是 SqlServer 本地开发必加的,不然会因为证书链报错。IsAutoCloseConnection设为 true,让每个请求用完即关底层连接,配合 WebAPI 的并发场景。然后去 Program.cs 里注册 SqlSugarClient 的服务。这一步必须想清楚生命周期,我后面避坑章节会单独说,这里先给一个比较稳的写法:

var builder = WebApplication.CreateBuilder(args); builder.Services.AddScoped<ISqlSugarClient>(sp => { var config = builder.Configuration; var connectionString = config.GetConnectionString("Default"); var dbType = (DbType)Enum.Parse(typeof(DbType), config["SqlSugar:DbType"]); return new SqlSugarClient(new ConnectionConfig { ConnectionString = connectionString, DbType = dbType, IsAutoCloseConnection = bool.Parse(config["SqlSugar:IsAutoCloseConnection"]), InitKeyType = InitKeyType.SystemTable, MoreSettings = new ConnMoreSettings { IsWithNoLock = true } }); });

这里用AddScoped注册,每个请求拿到独立的 SqlSugarClient 实例。MoreSettings.IsWithNoLock会让查询默认加WITH(NOLOCK),适合读多写少的报表查询,但如果你的业务对脏读敏感,这里可以去掉。另外,InitKeyType.SystemTable是让 SqlSugar 从数据库系统表读表结构,而不是靠实体特性推断主键,更可靠。

2.3 写一个能查库的接口,验证链路通没通

项目建完、配置完,先别急着分层,写一个最小接口验证 SqlSugar 能正常连库。我创建一个 User 实体对应一张用户表:

[SugarTable("UserInfo")] public class User { [SugarColumn(IsPrimaryKey = true, IsIdentity = true, ColumnName = "Id")] public int Id { get; set; } [SugarColumn(ColumnName = "UserName")] public string UserName { get; set; } [SugarColumn(ColumnName = "CreateTime")] public DateTime CreateTime { get; set; } }

然后在 Controllers 里加一个测试控制器:

[ApiController] [Route("api/[controller]")] public class UserController : ControllerBase { private readonly ISqlSugarClient _db; public UserController(ISqlSugarClient db) { _db = db; } [HttpGet("first")] public async Task<IActionResult> GetFirst() { var user = await _db.Queryable<User>() .Where(u => u.Id > 0) .OrderBy(u => u.Id) .FirstAsync(); return Ok(user); } }

直接注入ISqlSugarClient,用它的 Queryable 接口查第一条用户。这个接口能跑通,说明连接串、实体映射、数据库权限都正常。注意这里[SugarTable]和[SugarColumn]是 SqlSugar 特性,ColumnName指定数据库列名,避免和你 C# 属性名的大小写映射出现偏差。跑起来后访问/api/User/first,看到 JSON 返回就说明基础链路通了。

3. 仓储模式设计与落地:把 SqlSugar 的查询细节关在栅栏里

3.1 为什么需要仓储:隔离数据访问,而不是为了炫技

直接在控制器里用ISqlSugarClient写查询很爽,但项目一复杂,你会发现同一个查询散落在好几个接口,改表字段时要全局搜索,老眼昏花。仓储模式的核心目的是给数据访问一个稳定的抽象接口,业务层不再关心 SqlSugar 的 Queryable、SugarQueryable、AsQueryable 这些概念,只管调用仓储方法,返回实体或实体集合。当你想换 ORM、改连库方式、加缓存时,只改仓储实现,服务层和控制器不用动。

常见做法是定义一个泛型仓储接口IRepository<T>,把增删改查、分页、条件查询都封装进去。具体到 SqlSugar,我的做法是直接在仓储实现里持有ISqlSugarClient,但不把 client 暴露给上层。因为一旦把 Queryable 暴露出去,调用方很容易写出各种复杂查询,仓储形同虚设。

3.2 泛型仓储接口:读、写、分页、软删除

我设计的接口大概长这样:

public interface IRepository<T> where T : class, new() { Task<T?> GetByIdAsync(object id); Task<List<T>> GetListAsync(Expression<Func<T, bool>> predicate); Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate); Task<int> CountAsync(Expression<Func<T, bool>> predicate); Task<T> InsertAsync(T entity); Task<bool> UpdateAsync(T entity); Task<bool> UpdateAsync(Expression<Func<T, bool>> predicate, Expression<Func<T, T>> updateExpression); Task<bool> DeleteAsync(Expression<Func<T, bool>> predicate); Task<List<T>> PageListAsync(int pageIndex, int pageSize, Expression<Func<T, bool>> predicate, Expression<Func<T, object>> orderBy, bool isAsc = true); }

这里的Expression<Func<T, bool>>是表达式树,SqlSugar 能把它直接翻译成 SQL 的 WHERE 条件。注意UpdateAsync我重载了一个新版本,第二个参数Expression<Func<T, T>>用来做局部更新,比如只更新某个字段,避免整行覆盖。接口里没有暴露ISqlSugarClient,也没有 IQueryable,这样上层永远拿不到 Queryable。

3.3 基于 SqlSugar 的仓储实现

实现类继承IRepository<T>,构造注入ISqlSugarClient。下面是核心方法:

public class Repository<T> : IRepository<T> where T : class, new() { protected readonly ISqlSugarClient _db; public Repository(ISqlSugarClient db) { _db = db; } public async Task<T?> GetByIdAsync(object id) { return await _db.Queryable<T>() .In(id) .FirstAsync(); } public async Task<List<T>> GetListAsync(Expression<Func<T, bool>> predicate) { return await _db.Queryable<T>() .Where(predicate) .ToListAsync(); } public async Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate) { return await _db.Queryable<T>() .Where(predicate) .FirstAsync(); } public async Task<int> CountAsync(Expression<Func<T, bool>> predicate) { return await _db.Queryable<T>() .Where(predicate) .CountAsync(); } public async Task<T> InsertAsync(T entity) { return await _db.Insertable(entity).ExecuteReturnEntityAsync(); } public async Task<bool> UpdateAsync(T entity) { return await _db.Updateable(entity).ExecuteCommandHasChangeAsync(); } public async Task<bool> UpdateAsync( Expression<Func<T, bool>> predicate, Expression<Func<T, T>> updateExpression) { return await _db.Updateable<T>() .SetColumns(updateExpression) .Where(predicate) .ExecuteCommandHasChangeAsync(); } public async Task<bool> DeleteAsync(Expression<Func<T, bool>> predicate) { return await _db.Deleteable<T>() .Where(predicate) .ExecuteCommandHasChangeAsync(); } public async Task<List<T>> PageListAsync( int pageIndex, int pageSize, Expression<Func<T, bool>> predicate, Expression<Func<T, object>> orderBy, bool isAsc = true) { var query = _db.Queryable<T>().Where(predicate); if (isAsc) query = query.OrderBy(orderBy, OrderByType.Asc); else query = query.OrderBy(orderBy, OrderByType.Desc); return await query .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .ToListAsync(); } }

In(id)是 SqlSugar 根据主键查询的快捷键,它会自动读取实体的IsPrimaryKey特性。ExecuteReturnEntityAsync适合自增主键插入后拿到完整实体的场景。ExecuteCommandHasChangeAsync返回影响行数是否大于 0,用来判断更新删除是否成功。

3.4 事务和工作单元:让多个仓储操作要么全成要么全败

业务里经常要同时写多张表:先插订单,再更新库存。如果每个仓储各自提交,到第二条失败时第一条已经写进库,数据就残了。所以仓储模式要配套工作单元。我用 SqlSugar 的Ado.UseTranAsync来包一个事务:

public class UnitOfWork { private readonly ISqlSugarClient _db; public UnitOfWork(ISqlSugarClient db) { _db = db; } public async Task ExecuteAsync(Func<Task> action) { await _db.Ado.UseTranAsync(async () => { await action(); }); } }

使用的时候,把多个仓储调用放进这个 lambda 里:

await _unitOfWork.ExecuteAsync(async () => { var order = await _orderRepository.InsertAsync(newOrder); await _stockRepository.UpdateAsync(s => s.ProductId == productId, s => new Stock { Quantity = s.Quantity - newOrder.Quantity }); });

这里有个关键点:_orderRepository和_stockRepository必须使用同一个ISqlSugarClient实例,因为 SqlSugar 的事务建立在同一个连接上。如果你在服务层分别 new 两个SqlSugarClient,事务完全无效。依赖注入里把ISqlSugarClient注册为 Scoped,然后仓储和工作单元都注入这个 Scoped 实例,就能保证一个请求内是同一个 client。

4. DTO 与服务层实战:把业务边界画清楚,接口才稳

4.1 DTO 不等于实体:三个类分别管什么

很多新手直接拿数据库实体当请求参数和返回结果,短平快,但一旦表结构里加了字段,接口文档直接泄露内部设计,而且有些字段比如 CreateTime 不该让前端改,Password 不该返回。DTO 就是带边界的数据传输对象,它和实体属性往往不一样。我一般分三类:

  • 实体User:对应数据库表,字段完全映射数据库列。
  • 输入 DTOCreateUserRequest:只包含创建用户时前端能传的字段,比如 UserName、Email。
  • 输出 DTOUserResponse:包含前端展示需要的字段,比如 Id、UserName、CreateTime,但不包含 Password、InternalRemark。

这样当数据库表加一列LastLoginAt,你只需要决定这个字段要不要进输出 DTO,不需要动所有接口签名。这是用 DTO 最直接的价值。

4.2 服务层如何编排仓储和映射

服务层是业务逻辑的容器。我创建 UserService,它依赖IRepository<User>,返回输出 DTO。下面是典型实现:

public interface IUserService { Task<UserResponse> GetUserByIdAsync(int id); Task<int> CreateUserAsync(CreateUserRequest request); } public class UserService : IUserService { private readonly IRepository<User> _userRepository; public UserService(IRepository<User> userRepository) { _userRepository = userRepository; } public async Task<UserResponse> GetUserByIdAsync(int id) { var user = await _userRepository.GetByIdAsync(id); if (user == null) return null; // 手写映射,简单可靠 return new UserResponse { Id = user.Id, UserName = user.UserName, CreateTime = user.CreateTime }; } public async Task<int> CreateUserAsync(CreateUserRequest request) { if (string.IsNullOrWhiteSpace(request.UserName)) throw new ArgumentException("用户名不能为空"); var entity = new User { UserName = request.UserName, Email = request.Email, CreateTime = DateTime.Now }; var inserted = await _userRepository.InsertAsync(entity); return inserted.Id; } }

这里我故意手写映射而不是引入 AutoMapper。在项目只有几十个实体的规模下,AutoMapper 的配置成本可能比手写高,特别是嵌套映射时出错排查麻烦。手写映射的好处有两个:第一是编译期就能发现属性名写错;第二是运行时的映射逻辑完全可控,方便打日志。

4.3 统一返回模型:让控制层只做透传

如果服务层把 UserResponse 直接抛给控制器,遇到业务异常时,控制器只能返回 500 或者裸的异常信息,前端没法统一解析。我会定义一个ApiResult<T>包装所有响应:

public class ApiResult<T> { public bool Success { get; set; } public int Code { get; set; } public string Message { get; set; } public T Data { get; set; } public static ApiResult<T> Ok(T data) { return new ApiResult<T> { Success = true, Code = 0, Message = "ok", Data = data }; } public static ApiResult<T> Fail(int code, string message) { return new ApiResult<T> { Success = false, Code = code, Message = message }; } }

服务层返回这个模型,或者让服务层抛出业务异常,由全局异常过滤器统一转成 Fail。我更推荐后者,因为在深层业务里抛异常比一层层返回 Error 状态更直观。控制器就变成了:

[HttpGet("{id}")] public async Task<ApiResult<UserResponse>> GetUser(int id) { var user = await _userService.GetUserByIdAsync(id); if (user == null) return ApiResult<UserResponse>.Fail(1001, "用户不存在"); return ApiResult<UserResponse>.Ok(user); }

这样前端不管成功失败,都能拿到{ success, code, message, data }。Code 0 表示成功,非 0 表示业务错误,HTTP 状态码可以统一 200,也可以把 4xx、5xx 映射到业务 Code,看团队约定。

5. 避坑指南:发布 WebAPI 项目前,我会检查这 5 个 SqlSugar + 仓储的常见问题

5.1 SqlSugarClient 的生命周期错了,接口时好时坏

  • 现象:一个接口连查询几次后突然报The connection is not open或者ObjectDisposedException,过一会又自己好了。
  • 原因:把SqlSugarClient注册成了AddSingleton。SqlSugarClient 虽然是线程安全的,但它内部有连接管理、缓存、上下文状态,多个请求共用一个单例,连接码头一个月就崩。我见过有人把SqlSugarClient当数据库连接池用,这是最大的误解。
  • 解决:改成AddScoped。每个请求创建一个客户,请求结束自动 Dispose。如果项目里某些后台定时任务需要单例注入,那就在任务内用IServiceScopeFactory创建新 scope 再拿 client,或者让 SqlSugarClient 用SqlSugarScope类,这个是官方推荐的单例模式线程安全型。但我个人建议你不折腾,WebAPI 场景一律 Scoped。

5.2 DTO 映射出 null,堆栈却指向实体

  • 现象:服务层返回 DTO,前端看到 UserName 是 null,但数据库里有值。调试时发现实体 UserName 有值,映射表达式看起来也没写错。
  • 原因:最常见的是属性名拼写不一致,比如实体叫user_name而 DTO 叫UserName;也可能是 SqlSugar 列名映射不对,实体属性UserName加上了[SugarColumn(ColumnName="user_name")],然后你手写映射UserName = user.UserName,编译器不报错,但user.UserName本身实际读出来是 null,因为 SqlSugar 映射到的是数据库列user_name,而实体属性如果没配对,就会得到默认值。
  • 解决:在映射方法里加一行防御判断:if (user == null) throw new Exception("user is null"),然后检查实体属性上的SugarColumn特性。或者干脆不用手写映射,先用一个临时实体验证 SqlSugar 是否把列读出来了。另一个办法是写单元测试,直接测实体属性的值,别先验证 DTO。

5.3 分页查询的总数和列表数据对不上

  • 现象:PageListAsync返回的列表只有 10 条,但 Count 查出来是 500,前端分页一直有空页。
  • 原因:分页查询里如果带着联接条件或者去重条件,CountAsync和PageListAsync可能不是同一套条件。常见的错误是仓储方法里先query = _db.Queryable<T>().Where(predicate),然后 Count 时直接query.Count(),但 PageList 时又额外加了OrderBy和Skip/Take,如果predicate里有GroupBy,Count 的是分组后的总数,而列表是明细,就必然对不上。
  • 解决:把 Count 和分页列表拆成两次独立查询,且共用同一个筛选条件,不要复用同一个 query 对象。SqlSugar 的 Queryable 是可重用的,但它内部会累加状态,所以我一般写两个方法:CountAsync(predicate)和PageListAsync(predicate, orderBy, pageIndex, pageSize),各自调用_db.Queryable<T>().Where(predicate)。同时在仓储接口里明确约定:分页查询只做单表或简单联查,复杂统计走专门的方法,不硬套泛型分页。

5.4 服务层事务异常被吞,数据只写了一半

  • 现象:订单创建接口,调用UnitOfWork.ExecuteAsync插入订单成功,更新库存失败,结果订单还在,库存没变。
  • 原因:UseTranAsync的回调里如果没有抛出异常,SqlSugar 会认为事务成功并提交。有些人喜欢在 lambda 里写try-catch只记日志不重抛,事务就永远回滚不了。
  • 解决:在ExecuteAsync内部,action 的异常必须向外抛。同时UseTranAsync返回一个DbResult<bool>,你需要检查isTranSuccess。我的 UnitOfWork 改成这样:
public async Task<bool> ExecuteAsync(Func<Task> action) { var result = await _db.Ado.UseTranAsync(async () => { await action(); }); if (!result.IsSuccess) { // 记录日志 result.ErrorMessage return false; } return true; }

如果ExecuteAsync返回 false,服务层就要把当前请求标记为失败,不要让接口继续往下走。我就是在这上面吃过亏:当时觉得日志里有异常就可以,结果回滚没生效。

5.5 发布 WebAPI 项目后连不上数据库,本地却好好的

  • 现象:本地dotnet run接口正常,发布到 Windows 服务器或者 Linux 容器里,一访问就报连接超时或登录失败。
  • 原因:发布后的appsettings.json被覆盖或没有同步;连接串里的Server地址还是localhost,在服务器上指向了自己;另外 SqlSugar 的DbType配置如果数据库是 PostgreSQL 而配成了 SqlServer,那连错误信息都看不懂。
  • 解决:发布 WebAPI 项目时,把appsettings.Production.json(或环境变量)一起发布,连接串使用服务器能访问的内网地址。连接串里加上Connect Timeout=5,让失败快一点,日志里能快速看到。还有一点容易被忽略:SqlSugar 的 NuGet 包在发布时会带出原生的数据库驱动,比如连接 MySQL 的MySqlConnector,如果你发布的是单文件(Self-contained),要确保这些原生库被包含,否则报找不到驱动的错。我一般会确认publish目录下有没有对应驱动的 dll。

6. 最后一章的进阶技巧:用仓储扩展 SqlSugar 原生查询,再验证分层是否健康

当你的仓储模式稳定运行之后,会碰上一个新问题:有些报表查询需要 join 五张表,或者要返回一个自定义 DTO,而泛型仓储里都是单实体操作。这时候不要破坏仓储封装,我习惯在仓储实现里加一个通用方法,允许传入泛型查询委托。

方法定义在接口里,类似这样:

Task<TResult?> QueryAsync<TResult>(Func<ISqlSugarClient, Task<TResult>> query);

实现很简单:

public async Task<TResult?> QueryAsync<TResult>(Func<ISqlSugarClient, Task<TResult>> query) { return await query(_db); }

这样服务层如果要做复杂联表,就写一个表达式树,把ISqlSugarClient当参数传进去:

var report = await _orderRepository.QueryAsync(async db => { return await db.Queryable<Order>() .LeftJoin<User>((o, u) => o.UserId == u.Id) .Where((o, u) => o.CreateTime >= start && o.CreateTime <= end) .Select<OrderReportDto>((o, u) => new OrderReportDto { OrderId = o.Id, UserName = u.UserName, Amount = o.Amount }) .ToListAsync(); });

这个方法的妙处在于:仓储仍然只暴露一个受控的口子,但服务层不至于被憋死。注意这里的QueryAsync<TResult>泛型返回值类型,可以是实体、DTO、List,完全由调用方决定。它有点越权,但这是现实中的折中:完全禁止复杂查询会让服务层用ISqlSugarClient直接操作,反而更混乱。

另一个进阶技巧是给 SqlSugar 增加 AOP 日志,用来观测每个接口到底走了几条 SQL。在 Program.cs 注册时设置:

db.Aop.OnLogExecuting = (sql, pars) => { var logger = sp.GetService<ILogger<Program>>(); logger.LogInformation("SQL: {sql}, Params: {pars}", sql, pars); };

然后你会惊悚地发现,一个简单列表接口竟然发了 5 条 SQL——原来是查询用户后又遍历查了每条订单。SQL 日志是验证分层的照妖镜,它直接暴露 N+1 问题和没走索引的慢查询。我会定期翻着这个日志,看到重复的 SQL 就去服务层加一个 Join 或者缓存。

最后说一个我自己的习惯:每次发布 WebAPI 项目之前,我会写一个最小的“冒烟脚本”,用命令行调用登录接口、列表接口、事务接口,循环 50 次,看有没有内存泄漏和连接断开。这个操作花不了十分钟,但它救过我好几次。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询