简介:这份 C# WebAPI 示例代码面向具备一定 .NET 基础的开发者,用于快速理解基于 ASP.NET 构建 RESTful 风格 HTTP 服务的完整流程,并演示如何与 SQL Server 数据库进行数据交互。示例涵盖 ApiController 控制器编写、HttpGet/HttpPost 等 HTTP 动词映射、Entity Framework 或 ADO.NET 数据访问层、模型类定义、Global.asax.cs 路由配置以及 HttpResponseMessage 返回值处理等核心知识点,同时保留了用户认证与缓存机制尚未实现的原始状态,便于读者在此基础上自行扩展 OAuth、JWT 或 Redis 缓存等进阶功能。资源以 zip 压缩包形式提供,整体约 172.56MB,目前已有 294 人学习下载。通过研读该示例,开发者可掌握 RESTful API 设计原则、控制器与模型分层组织方式以及数据库读写的基本实现路径,适合作为入门练手或项目脚手架参考。
1. 从一份 C# WebAPI Demo 说起:SQL Server 后端到底该怎么搭
很多同学第一次接触 RESTful 接口开发,都是从一个简单的 C# WebAPI Demo 开始的。但真正拿到一份能跑的 Demo 代码之后,问题往往不是“怎么跑起来”,而是“这套东西放到真实项目里到底能不能用”。我见过太多人把 Demo 里的内存集合一换,直接接上 SQL Server,结果第二天就翻车——连接池爆了、并发写冲突、返回的 JSON 循环引用报错,一个都跑不掉。
这份 C# WebAPI Demo 的核心价值,就是给你一个从 Controller 到 SQL Server 的完整闭环:用 Entity Framework 或 Dapper 做数据访问,用 RESTful 风格暴露资源,用依赖注入管理上下文生命周期。它适合两类人:一是刚学完 C# 语法、想搞明白“接口到底怎么连数据库”的新手;二是从其他语言转过来、想快速对齐 .NET 生态里 RESTful 落地方式的熟手。下面我就按自己拆项目的顺序,把这份 Demo 从结构到参数、从跑通到避坑,一层层拆开讲。
2. 拆开 Demo 的目录结构:Controller、DbContext 与依赖注入怎么串
2.1 先看项目骨架:三个必须理解的文件夹
拿到一份 C# WebAPI Demo,别急着 F5。我一般先扫一眼目录,重点看三个地方:Controllers、Models、Data(或者叫Infrastructure)。这三个文件夹基本决定了这份 Demo 的架构水平。
Controllers里放的是对外暴露的 RESTful 端点。一个合格的 Demo 不会把所有逻辑塞进 Controller,而是让 Controller 只负责接收请求、调用服务、返回结果。如果你看到 Controller 里直接new SqlConnection(...),那这份 Demo 大概率是教学级别的,离生产还有距离。
Models里放的是实体类和 DTO。注意区分这两者:实体类对应数据库表,DTO 对应接口的输入输出。Demo 里经常混用,但你在实际项目里一定要分开,否则一旦表结构变了,接口契约就跟着崩。
Data里放的是DbContext或者 Dapper 的仓储实现。这是整个项目跟 SQL Server 打交道的地方,也是后面讲连接池、事务、并发时最需要关注的部分。
2.2 依赖注入的生命周期:AddDbContext 的坑最多
在Program.cs(.NET 6+)或Startup.cs里,你会看到类似这样的注册代码:
// Program.cs - .NET 6+ 最小宿主模型 var builder = WebApplication.CreateBuilder(args); // 注册 DbContext,指定 SQL Server 提供程序 builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer( builder.Configuration.GetConnectionString("DefaultConnection"), sqlOptions => sqlOptions.EnableRetryOnFailure( maxRetryCount: 3, // 失败重试次数 maxRetryDelay: TimeSpan.FromSeconds(5), // 最大重试间隔 errorNumbersToAdd: null // 额外需要重试的错误号 ) ) ); builder.Services.AddControllers(); var app = builder.Build(); app.MapControllers(); app.Run();这段代码看起来简单,但有几个参数直接决定你后面会不会翻车。AddDbContext默认注册的生命周期是Scoped,也就是每个 HTTP 请求一个实例。这本身没问题,但如果你在某个单例服务里注入了AppDbContext,就会直接抛InvalidOperationException——因为单例不能依赖 Scoped。我见过不止一个项目在这里栽跟头,报错信息还特别绕,新手根本反应不过来是生命周期的问题。
EnableRetryOnFailure是 SQL Server 提供程序特有的弹性策略。maxRetryCount: 3意味着遇到瞬时故障(比如网络抖动、数据库短暂不可用)时会自动重试三次。但注意,这个重试不是万能的——如果是 SQL 语法错误或者主键冲突,重试多少次都没用,反而会拖慢响应。我一般只在生产环境开这个,本地调试时关掉,免得掩盖真实错误。
连接字符串的配置也有讲究。Demo 里经常直接写Server=localhost;Database=TestDb;Trusted_Connection=True;,但放到真实环境,你至少要考虑MultipleActiveResultSets=True(MARS)和Connection Timeout这两个参数。MARS 允许多个命令在同一个连接上并发执行,EF Core 在某些查询场景下会用到;Connection Timeout默认 15 秒,如果数据库响应慢,请求会一直挂在那里,把线程池拖垮。
2.3 Controller 里的 RESTful 路由:别把动词写进 URL
RESTful 的核心是“用 HTTP 动词表达操作,用 URL 表达资源”。但我在很多 Demo 里看到的是[HttpGet("GetUserById")]这种写法,URL 里带动词,这就不太 RESTful 了。正确的做法是用路由特性配合 HTTP 动词:
[ApiController] [Route("api/[controller]")] public class UsersController : ControllerBase { private readonly AppDbContext _context; public UsersController(AppDbContext context) { _context = context; } // GET api/users [HttpGet] public async Task<ActionResult<IEnumerable<UserDto>>> GetUsers() { return await _context.Users .Select(u => new UserDto { Id = u.Id, Name = u.Name }) .ToListAsync(); } // GET api/users/5 [HttpGet("{id}")] public async Task<ActionResult<UserDto>> GetUser(int id) { var user = await _context.Users.FindAsync(id); if (user == null) return NotFound(); return new UserDto { Id = user.Id, Name = user.Name }; } // POST api/users [HttpPost] public async Task<ActionResult<UserDto>> CreateUser(CreateUserDto input) { var user = new User { Name = input.Name, Email = input.Email }; _context.Users.Add(user); await _context.SaveChangesAsync(); return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user); } }这里有几个细节值得说。[ApiController]特性会自动处理模型验证,如果CreateUserDto里有[Required]标记的字段没传,框架直接返回 400,不需要你手动写if (!ModelState.IsValid)。CreatedAtAction会生成一个带Location头的 201 响应,指向新建资源的 URL,这是 RESTful 的标准做法,但很多 Demo 直接返回Ok(user),严格来说不够规范。
另外注意FindAsync和FirstOrDefaultAsync的区别。FindAsync会先查本地缓存,再查数据库;FirstOrDefaultAsync每次都查数据库。对于按主键查询的场景,FindAsync更高效,但如果你在同一个上下文里已经跟踪了该实体,FindAsync会直接返回缓存实例,可能拿到的是旧数据。这个坑在并发场景下特别隐蔽。
3. 接上 SQL Server:连接字符串、迁移与并发处理的实操细节
3.1 连接字符串的五个关键参数
Demo 里的连接字符串通常很简陋,但真实项目里至少要关注这几个参数:
| 参数 | 作用 | 推荐值 | 不设置的后果 |
|---|---|---|---|
Connection Timeout | 建立连接的超时秒数 | 30 | 默认 15 秒,网络慢时频繁超时 |
Command Timeout | 命令执行的超时秒数 | 60 | 复杂查询直接抛超时异常 |
Max Pool Size | 连接池最大连接数 | 100 | 默认 100,高并发下排队 |
Min Pool Size | 连接池最小连接数 | 5 | 默认 0,首次请求有冷启动开销 |
MultipleActiveResultSets | 是否允许多结果集 | True | EF Core 某些查询会报错 |
这些参数不是拍脑袋定的。Max Pool Size设成 100 意味着最多 100 个并发连接,超出的请求会排队等待。如果你的接口平均响应时间是 100ms,那理论上每秒能处理 1000 个请求;但如果某个查询跑了 5 秒,连接就被占住 5 秒,吞吐量直接掉到 20。所以连接池大小要结合接口的 P99 响应时间来算,不能随便抄。
3.2 EF Core 迁移:从代码到数据库表的自动化
Demo 里如果用的是 EF Core,通常会带一个Migrations文件夹。如果没有,你需要自己生成:
# 安装 EF Core 工具(如果还没装) dotnet tool install --global dotnet-ef # 在项目目录下创建初始迁移 dotnet ef migrations add InitialCreate # 把迁移应用到数据库 dotnet ef database updatemigrations add会根据你的实体类生成建表脚本,database update则把脚本执行到 SQL Server 上。这里有个血泪经验:如果你改了实体类但忘了加迁移,程序跑起来不会报错,但数据库表结构还是旧的,查询时会抛Invalid column name。我一般会在 CI 流程里加一步dotnet ef migrations script,把生成的 SQL 跟数据库实际结构做对比,不一致就直接阻断构建。
另外,迁移文件里的Up和Down方法要成对写。Up是应用迁移,Down是回滚。很多人只写Up,结果出问题时没法回滚,只能手动改数据库。我见过一个项目因为迁移没写Down,上线后发现字段类型错了,硬是花了两个小时手动修数据。
3.3 并发冲突:乐观锁与 DbUpdateConcurrencyException
SQL Server 后端最容易被忽视的就是并发。两个请求同时修改同一条记录,后提交的会覆盖先提交的,这就是“丢失更新”。EF Core 提供了乐观锁机制,通过在实体上加[Timestamp]字段实现:
public class User { public int Id { get; set; } public string Name { get; set; } public string Email { get; set; } [Timestamp] public byte[] RowVersion { get; set; } // SQL Server 的 rowversion 类型 }RowVersion对应 SQL Server 的rowversion列,每次更新自动递增。EF Core 在SaveChanges时会检查这个值是否变了,如果变了就抛DbUpdateConcurrencyException。你需要捕获这个异常并决定怎么处理:
try { await _context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException ex) { var entry = ex.Entries.Single(); var dbValues = await entry.GetDatabaseValuesAsync(); if (dbValues == null) { // 记录已被删除 return NotFound(); } // 可以用 dbValues 覆盖当前值,或者提示用户刷新 entry.OriginalValues.SetValues(dbValues); return Conflict("数据已被其他用户修改,请刷新后重试"); }这段代码的逻辑是:先拿到数据库里的最新值,如果记录已经不存在了,返回 404;如果存在,把原始值更新为最新值,然后返回 409 冲突。实际项目里,你可以选择让用户刷新后重试,也可以自动合并——但自动合并要小心,搞不好会把别人的修改覆盖掉。
4. 避坑与排查:Demo 跑通之后最容易翻车的五个地方
4.1 现象:接口返回 JSON 时报“循环引用”
原因:实体类之间有导航属性,比如User有Orders,Order又有User,序列化时互相引用,无限递归。
解决:不要在 Controller 里直接返回实体类,用 DTO 投影。如果非要用实体类,在Program.cs里配置JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles,但这只是掩盖问题,最好的办法还是 DTO。
4.2 现象:高并发下报“超时时间已到,但尚未从池中获取连接”
原因:连接池耗尽。要么是Max Pool Size太小,要么是有代码没释放连接(比如手动new SqlConnection后忘了Dispose)。
解决:先查有没有手动管理连接的地方,确保用using包裹。如果都是 EF Core 管理的,适当调大Max Pool Size,同时检查有没有长事务——一个事务持有连接不放,其他请求只能干等。
4.3 现象:迁移执行成功,但查询时报“无效的列名”
原因:实体类改了,迁移也加了,但database update没执行,或者执行到了错误的数据库。
解决:先dotnet ef migrations list看迁移状态,再确认连接字符串指向的数据库对不对。我一般会在database update后跑一条SELECT * FROM __EFMigrationsHistory,确认最新迁移已经记录在案。
4.4 现象:POST 请求返回 400,但模型验证明明通过了
原因:[ApiController]自动验证时,如果 DTO 里有不可为空的引用类型属性(比如string Name),而请求体里没传这个字段,框架会直接返回 400,错误信息在ProblemDetails里。
解决:要么把属性改成可空(string? Name),要么在 DTO 上加[Required]并确保客户端传值。注意 .NET 6+ 默认开启了可空引用类型,这个坑在新项目里特别常见。
4.5 现象:本地跑得好好的,部署到服务器后连不上 SQL Server
原因:连接字符串里的Server=localhost在服务器上指向的是服务器本机,如果数据库在另一台机器上,自然连不上。另外,SQL Server 默认没开 TCP/IP 协议,或者防火墙没放行 1433 端口。
解决:把Server改成实际的 IP 或主机名,确认 SQL Server 配置管理器里 TCP/IP 已启用,防火墙入站规则放行 1433。如果用的是命名实例,还要确认 SQL Browser 服务在跑。
5. 进阶技巧:用 Dapper 替换 EF Core 做轻量查询,以及接口压测验证
5.1 什么时候该换 Dapper
EF Core 适合写操作和复杂对象图,但如果你有一个报表接口,要 join 五六张表、返回几十个字段,EF Core 生成的 SQL 可能又长又慢。这时候 Dapper 更合适——它就是个 SQL 映射器,你写什么 SQL 就执行什么,没有翻译层。
// 用 Dapper 做多表 join 查询 public async Task<IEnumerable<OrderReportDto>> GetOrderReportAsync(DateTime start, DateTime end) { const string sql = @" SELECT o.Id, o.OrderNo, u.Name AS CustomerName, SUM(oi.Amount) AS TotalAmount FROM Orders o INNER JOIN Users u ON o.UserId = u.Id INNER JOIN OrderItems oi ON oi.OrderId = o.Id WHERE o.CreatedAt BETWEEN @Start AND @End GROUP BY o.Id, o.OrderNo, u.Name ORDER BY o.CreatedAt DESC"; using var connection = new SqlConnection(_connectionString); return await connection.QueryAsync<OrderReportDto>(sql, new { Start = start, End = end }); }注意using var connection这行——Dapper 不会自动管理连接,你必须自己确保释放。QueryAsync<T>会把结果集映射到OrderReportDto,字段名要跟 SQL 的列名对上,对不上就映射不上,不会报错但值是 null。这个坑我踩过,排查了半天才发现是列别名的问题。
5.2 压测验证:用 bombardier 看 P99 和错误率
接口写完不是终点,你得知道它能扛多少并发。我一般用bombardier做快速压测:
# 安装 bombardier(以 Linux 为例) # 下载对应平台的二进制包后解压即可 # 对 GET 接口做 100 并发、持续 30 秒的压测 bombardier -c 100 -d 30s -m GET http://localhost:5000/api/users # 对 POST 接口压测,带 JSON 请求体 bombardier -c 50 -d 30s -m POST \ -H "Content-Type: application/json" \ -b '{"name":"test","email":"test@example.com"}' \ http://localhost:5000/api/users重点看三个指标:Reqs/sec(吞吐量)、Latency P99(99% 请求的响应时间)、Errors(错误数)。如果 P99 超过 500ms,或者错误率超过 1%,就得回头查 SQL 索引和连接池配置。我一般会先跑一轮基线,改一个参数再跑一轮,对比看效果——比如把Max Pool Size从 100 调到 200,看吞吐量有没有提升,如果没有,说明瓶颈不在连接池。
5.3 一个我坚持了多年的习惯
每次改完数据访问层,不管是换 ORM 还是调连接字符串,我都会强制走一遍“迁移 → 压测 → 看日志”这三步。迁移确保表结构对,压测确保性能没退化,日志确保没有隐藏的异常被吞掉。有一次我改了一个索引,压测通过,但日志里全是DbUpdateConcurrencyException,只是被全局异常处理器吞了没暴露出来。从那以后我每次压测都会同时开dotnet run的控制台输出,盯着有没有异常刷屏。
希望这份拆解能帮你把这份 C# WebAPI Demo 真正用起来,而不是停留在“能跑就行”的阶段。
本文还有配套的精品资源,点击获取