C# .NET 8 WebAPI分层架构:SqlSugar+仓储+DTO+服务层实战
2026/9/15 5:18:51 网站建设 项目流程

简介:针对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.Queryable<User>().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 SqlSugarCore

dotnet add package SqlSugarCore会自动拉取当前兼容 .NET Standard 2.1 / .NET 8 的稳定包,不需要手动指定版本。注意 WebAPI 模板自带的WeatherForecast样例可以删除,避免干扰后续的分层演示。

接下来把数据库连接和 SqlSugar 配置放到appsettings.json,连接字符串里不要暴露明文密码到代码仓库,开发环境可以用 User Secrets。这里先给出一个本地开发用的 MySQL 连接:

{ "ConnectionStrings": { "Default": "Server=127.0.0.1;Port=3306;Database=MallDb;Uid=root;Pwd=123456;" }, "SqlSugar": { "DbType": "PostgreSQL", "IsAutoCloseConnection": true, "IsEnableLog": true } }

DbType字段填写的是枚举名,比如MySqlSqlServerPostgreSQLSqlite,千万不要写成连接字符串里的mysql字符串,否则 SqlSugar 在初始化连接时直接抛NotSupportedExceptionIsAutoCloseConnection=true表示每次操作完自动关闭 ADO.NET 连接,对 API 场景很重要,避免长时间占用数据库连接。

2.2 把 SqlSugarScope 注册为单例并开启 AOP 日志

SqlSugar 官方文档里推荐的 API 场景是SqlSugarScope,它在内部做了线程安全处理,支持同一个实例并发执行查询,所以不要注册成Scoped再每次new。注册到Program.cs时,我把ISqlSugarClient作为服务类型暴露,这样仓储层只依赖抽象,替换测试时也更方便。

using SqlSugar; builder.Services.AddSingleton<ISqlSugarClient>(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.AopOnLogExecuting会在每次 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.csapp.Run()之前加一段临时代码(只用于环境验证):

var db = app.Services.GetRequiredService<ISqlSugarClient>(); db.CodeFirst.InitTables<User>();

运行一次项目后,打开数据库看到sys_user表就说明连接成功。SugarColumn(ColumnName = "nick_name")可以把实体属性映射到带下划线的数据库列,这样 C# 侧保持 PascalCase,数据库侧保持业务命名规范。IsIdentity=true又配合IsPrimaryKey=true时,SqlSugar 会在插入时忽略这个字段,让数据库自增。

配置项作用常见误用
DbType指定数据库类型枚举写成字符串"mysql"
IsAutoCloseConnection每次操作后自动关闭连接设成 false 不释放连接
InitKeyType.Attribute从实体特性识别主键/自增不设置内部会反射失败
Aop.OnLogExecuting打印/记录 SQL 与参数忽略它导致 SQL 排错靠猜

这段验证代码在确认后要删掉或用#if DEBUG包住,不然每次启动都会执行建表,可能覆盖表结构或额外生成索引。CodeFirst只适合初始化场景,线上变更要用迁移脚本或 SqlSugar 的差异更新功能,不要在 API 启动流程里做 DDL。

3. 仓储模式 + DTO:让数据访问和接口契约各自守住边界

3.1 仓储接口为什么不能只有一个泛型基类

很多文章只给你一个IRepository<T>,我实际落地时发现不够。泛型仓储解决的是通用增删改查,但业务查询一旦涉及wherejoin、分页,接口要么膨出十几个方法,要么直接暴露IQueryable<T>。暴露IQueryable的坏处是:你在服务层写完Where(...).OrderBy(...),真实执行时如果 SqlSugar 转换不出来,调用层无法单测。

常见做法是分开两层:一个小的泛型仓储IRepository<T>提供最基础的GetByIdInsertUpdateDelete;另一个面向具体聚合的业务仓储IUserRepository继承泛型仓储,并在里面写GetUserPageAsyncIsNameExistsAsync这类有业务含义的方法。仓储实现里仍然用注入进来的ISqlSugarClient,但接口签名里不出现任何 SqlSugar 类型。

public interface IRepository<T> where T : class, new() { Task<T?> GetByIdAsync(int id); Task<List<T>> SelectAllAsync(); Task<bool> InsertAsync(T entity); Task<bool> UpdateAsync(T entity); Task<bool> DeleteAsync(int id); } public interface IUserRepository : IRepository<User> { Task<bool> IsNameExistsAsync(string name); Task<PageResult<User>> GetUserPageAsync(int pageIndex, int pageSize); }

这里PageResult<User>是我自定义的分页返回类型,包含ItemsTotal。为了避免服务层依赖 ORM 的分页模型,我会在自己的领域层定义一个PagedList<T>IRepository<T>不引入 SqlSugar 命名空间,仓储接口可以放在独立的领域层项目里被单元测试引用。

3.2 用 SqlSugar 实现仓储基类和用户仓储

实现类的重点有两个:一是注入ISqlSugarClient,二是用Queryable<T>()而不是SqlQuery去写动态查询,这样 SqlSugar 能帮我们处理参数化和分页。下面是基类实现:

public class RepositoryBase<T> : IRepository<T> where T : class, new() { protected readonly ISqlSugarClient _db; public RepositoryBase(ISqlSugarClient db) { _db = db; } public async Task<T?> GetByIdAsync(int id) => await _db.Queryable<T>().InSingleAsync(id); public async Task<List<T>> SelectAllAsync() => await _db.Queryable<T>().ToListAsync(); public async Task<bool> InsertAsync(T entity) => await _db.Insertable(entity).ExecuteCommandAsync() > 0; public async Task<bool> UpdateAsync(T entity) => await _db.Updateable(entity).ExecuteCommandAsync() > 0; public async Task<bool> DeleteAsync(int id) => await _db.Deleteable<T>().In(id).ExecuteCommandAsync() > 0; }

InSingleAsync(id)是 SqlSugar 针对主键查询的短路方法,会生成WHERE id = @id,并自动按实体主键特性识别。ExecuteCommandAsync返回受影响行数,因此用> 0判断是否成功。Deleteable<T>().In(id)传入的是主键值,不要直接写WhereAsync,那样容易漏掉主键名。

UserRepository里我放两个业务查询,一个做名字重复校验,一个做分页。分页时用 SqlSugar 的ToPageListAsync,它要求传入ref int total参数,调用后total会被赋值为满足条件的总条数。

public class UserRepository : RepositoryBase<User>, IUserRepository { private readonly ISqlSugarClient _sqlSugar; public UserRepository(ISqlSugarClient sqlSugar) : base(sqlSugar) { _sqlSugar = sqlSugar; } public async Task<bool> IsNameExistsAsync(string name) => await _sqlSugar.Queryable<User>().AnyAsync(u => u.Name == name); public async Task<PageResult<User>> GetUserPageAsync(int pageIndex, int pageSize) { var total = 0; var list = await _sqlSugar.Queryable<User>() .OrderBy(u => u.Id, OrderByType.Desc) .ToPageListAsync(pageIndex, pageSize, ref total); return new PageResult<User> { Total = total, Items = list }; } }

AnyAsyncCountAsync() > 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() { CreateMap<User, UserDto>(); CreateMap<CreateUserDto, 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 { Task<UserDto?> GetByIdAsync(int id); Task CreateUserAsync(CreateUserDto dto); Task<PageResult<UserDto>> 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.Map<User>(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 Task<UserDto?> GetByIdAsync(int id) { var user = await _userRepository.GetByIdAsync(id); return user is null ? null : _mapper.Map<UserDto>(user); } }

构造函数里注入IMapper之后,服务层返回 DTO 就很顺手:先查实体,再_mapper.Map<UserDto>(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.Map<User>(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)返回一个DbResult<bool>IsSuccess能拿到事务是否成功,失败时ErrorMessage里是底层异常。事务内插入的user对应自增主键Id,在UseTranAsync执行完之后会被 SqlSugar 回填到实体上,所以事务内部new Account { UserId = user.Id }拿到的就是新主键值。如果你的数据库主键不是自增而是由应用生成,要等插入后手动读回。

事务 API适用场景注意点
BeginTran/CommitTran/RollbackTran手动控制生命周期记得在 finally 里 Rollback
UseTranAsync方法内一次完整事务委托内异常自动回滚
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 Task<ActionResult<UserDto>> GetById(int id) { var dto = await _userService.GetByIdAsync(id); if (dto is null) return NotFound(); return Ok(dto); } [HttpPost] public async Task<IActionResult> Create([FromBody] CreateUserDto dto) { await _userService.CreateUserAsync(dto); return StatusCode(201, new ResponseModel(201, "created", null)); } [HttpGet] public async Task<ActionResult<PageResult<UserDto>>> 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,也可以返回code=404而不抛异常。

4.4 用统一响应中间件和异常过滤器盖住底层细节

很多接口前端需要拿到固定的{ code, message, data }结构,而不是直接收裸数据。你可以在 Action 里每个都写new ResponseModel(...),但那会重复。更推荐的方式是自定义一个ResponseModel<T>,再用全局IExceptionHandler把未捕获异常统一转成这个结构。.NET 8 的 WebAPI 内置了问题详情格式,但我不太喜欢让前端直接拿 RFC 7807,所以自己套一层更可控。

下面是一个极简的BusinessException处理中间件示例(也可以注册为IExceptionHandler):

public class UnifiedResponseMiddleware { private readonly RequestDelegate _next; private readonly ILogger<UnifiedResponseMiddleware> _logger; public UnifiedResponseMiddleware(RequestDelegate next, ILogger<UnifiedResponseMiddleware> 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.UseMiddleware<UnifiedResponseMiddleware>()一定要放在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.AddSingleton<ISqlSugarClient>(...); builder.Services.AddScoped(typeof(IRepository<>), typeof(RepositoryBase<>)); builder.Services.AddScoped<IUserRepository, UserRepository>(); builder.Services.AddScoped<IUserService, UserService>();

启动项目后,用 curl 先验证路由是否通:

curl -s "http://localhost:5000/api/users?pageIndex=1&pageSize=2"

返回的 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里的hostingModel

<aspNetCore processPath="dotnet" arguments=".\Mall.Api.dll" stdoutLogEnabled="true" stdoutLogFile=".\logs\stdout" hostingModel="inprocess" />

stdoutLogEnabled=true会在发布目录下生成logs/stdout_xxx.log,如果进程起不来,优先看这个日志,比事件查看器直接。注意logs目录要提前创建并给到IIS_IUSRS写权限。若使用独立部署则processPath要改成应用可执行文件,比如.\Mall.Api.exe,且不需要目标机器装运行时。

5.3 用 C# HttpClient 做一个最小回测

浏览器只适合测 GET,POST 和带鉴权的请求我常用 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,少一行手动序列化。

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

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

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

立即咨询