
简介C# WebAPI示例项目面向刚开始接触.NET RESTful服务开发的程序员演示如何搭建一个能与SQL Server数据库交互的基础HTTP接口。项目基于ASP.NET WebAPI实现覆盖RESTful API设计、ApiController控制器、模型类定义、数据访问层可通过Entity Framework或ADO.NET实现等核心环节并展现HttpGet/HttpPost/HttpPut/HttpDelete等动词如何映射到资源操作以及默认/{controller}/{id}路由、Ok()/BadRequest()响应封装、JSON/XML自动协商等关键知识点。代码中暂未加入用户认证与缓存逻辑这恰好能让读者聚焦于接口基本链路并理解实际项目中需另行补充OAuth/JWT、Redis或HTTP缓存策略的优化方向。压缩包大小约172.56MB已有291人学习下载。整体代码结构清晰适合作为入门WebAPI和SQL Server联调的参考模板按模型、控制器、数据访问与路由配置逐层拆解便于快速迁移到自己的业务场景。1. 为什么一个合格的WebAPI Demo远不止“能跑”网上搜“C# WebAPI demo code”能找到一大堆教程项目但说实话大部分Demo都停留在“新建一个Controller返回一句Hello World”的层面。这种Demo只能帮你确认模板能编译真要拿到实际项目里你会立刻撞上一堆问题前端跨域调用不了、文件下载中文文件名乱码、发布到服务器后接口404、扫码枪数据进来不知道怎么处理。我个人的看法是一个真正有价值的C# WebAPI Demo应该是一个“能落地的骨架”。它不需要特别复杂但必须把日常开发里最高频的那几个场景都覆盖到标准CRUD接口、参数校验、文件上传与下载、跨域配置、依赖注入以及一个能查数据的最小持久层。这样你在做新项目、写上位机接口、给前端Vue提供后端服务时都能从这套Demo里直接抄作业而不是从零开始踩坑。这套Demo适合谁适合三类人刚入门C#想理解WebAPI工作原理的新手用Vue或其它前端框架、需要自己搭一个后端来做联调的全栈开发者以及做C#上位机、需要为设备数据提供HTTP接口的工控开发者。不管你是哪一类下面这套思路和代码都是可以直接拿过去改的。整篇文章我会按“思路拆解 - 环境搭建 - 核心实现 - 问题排查”这个顺序来写里面包含大量我在实际项目中踩过的坑和验证过的写法不是网上那种复制粘贴的套话。2. 核心设计与思路拆解这个Demo到底要解决什么问题写代码的第一步不是敲键盘而是先想清楚这个Demo的边界。我在设计这套WebAPI Demo时给自己定了三个原则。原则一分层清晰但不过度设计。很多教程喜欢把项目拆成Controller、Service、Repository、DTO、Domain五六个项目看起来“架构很正规”但对于一个Demo来说过度分层只会让你难以理解调用关系。我推荐的项目结构是Controller只负责接收HTTP请求和返回响应Service负责业务逻辑比如数据校验、文件名处理、业务规则判断数据访问可以直接用EF Core内置的DbContext不需要再单独套一层Repository。理由很简单以Demo的体量多一层Repository不会带来任何好处反而会让新人不知道应该在哪一层改代码。原则二覆盖高频真实场景。日常WebAPI开发里最常遇到的需求是几个根据我的经验排在前五的是CRUD接口、文件上传下载、跨域访问、环境配置开发/生产/测试切换、异常统一处理。这套Demo就围绕这五件事来做每一块都给出可运行的代码。你以后写任何业务系统无论OA、设备管理还是扫码数据收集都是在这五个能力上叠业务而已。原则三前端友好。现在几乎没有哪个WebAPI是给纯后端自己玩的前端用Vue也好、React也好都会碰到两个问题跨域调用被浏览器拦截下载文件时文件名中文乱码。这两个问题我在实际联调中出现频率极高所以这套Demo直接内置了解决方案而不是等踩坑了再去找答案。一句话总结设计思路这套Demo的目标是让你拿到手之后改改数据库连接字符串、改改表名就能直接进入业务代码编写而不是花一整天时间研究为什么POST请求有405错误。2.1 Demo的目录结构与核心模块设计我建议用VS2022创建一个空的ASP.NET Core Web API项目目标框架选择.NET 8LTS版本支持时间长生态稳定。项目内部按如下方式组织DemoAPI/ ├── Controllers/ │ ├── WeatherController.cs # 最简单的接口示例 │ ├── ProductController.cs # 标准CRUD │ └── FileController.cs # 文件上传与下载 ├── Services/ │ └── ProductService.cs # 业务逻辑数据校验 ├── Data/ │ └── AppDbContext.cs # EF Core 数据库上下文 ├── Dtos/ │ ├── ProductDto.cs # 请求/响应对象 │ └── ApiResult.cs # 统一返回格式 ├── Middleware/ │ └── ExceptionHandlingMiddleware.cs # 全局异常处理 ├── Program.cs # 服务注册与管道配置 └── appsettings.json # 配置信息各模块的职责非常明确Controller里面不会出现任何业务计算逻辑查询数据库的代码也只存在于Data层。为什么要把DTO单独拎出来因为直接暴露EF Core的实体类给前端会遇到两个麻烦一是会返回你不想暴露的字段比如内部状态、冗余外键二是前端字段命名和C#命名规范PascalCase不一致搞得JSON属性名很乱。DTO就是专门的“传输信封”控制什么能被外界看到。统一返回格式ApiResult也很值得一提。很多初学者直接让接口返回一个List或一个实体类感觉是“能用就行”。但一旦前端要做统一的错误提示、统一的加载状态处理没有统一返回格式就变得非常痛苦。我的ApiResult设计非常简单public class ApiResultT { public int Code { get; set; } // 0表示成功非0表示失败 public string Message { get; set; } // 错误信息或提示 public T Data { get; set; } // 业务数据 public static ApiResultT Success(T data) new() { Code 0, Data data }; public static ApiResultT Fail(string msg) new() { Code 1, Message msg }; }这样前端看到Code为0就知道请求成功非0就直接弹Message非常简单粗暴但极其好用。3. 环境准备与项目搭建VS2022创建WebAPI项目的完整过程3.1 创建项目的两种方式用VS2022创建WebAPI项目有界面操作和命令行两种方式我两个都讲因为不同人习惯不同。方式一VS2022图形界面打开VS2022选择“创建新项目”搜索“ASP.NET Core Web API”选择C#模板。这里有一个关键选项如果你只是做纯API不要勾选“配置HTTPS”旁边的“使用控制器”也可以但建议勾上因为Demo里我们需要Controller。框架选择.NET 8.0长周期支持。创建完成后项目会自动带一个WeatherForecastController这就是最基本的Demo。方式二命令行创建如果你用的是VS Code或者更喜欢命令行可以直接这样dotnet new webapi -n DemoAPI -f net8.0 cd DemoAPI code .两条命令搞定。我个人在实际工作中经常用命令行方式因为它快而且不依赖IDE版本。无论用哪种方式最终生成的目录结构基本一致。3.2 Program.cs到底在干什么很多新手看.Net 6之后的Program.cs会一头雾水——没有Startup.cs了就一个“魔法文件”。其实没那么神秘。Program.cs就是应用的入口做两件事注册服务和配置管道。var builder WebApplication.CreateBuilder(args); // 1. 注册服务告诉系统有哪些东西可以用 builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); // 2. 配置管道告诉系统请求来了怎么走 var app builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();AddControllers()是核心它把项目中所有的Controller扫描注册进去。你后面每增加一个Controller不需要手动在这里登记系统会自动发现。AddSwaggerGen()则是注册Swagger这个在当前开发联调阶段简直是神器你可以直接在浏览器里看到所有接口的请求参数和响应格式还能直接“Try it out”调试接口。Demo里建议保留Swagger开发效率提升不是一点半点。注意如果你是在VS2022中创建的项目默认会勾选OpenAPI支持这样Swagger已经预置了。如果是老的.NET 5或.NET Core 3.1项目迁移过来的需要手动确认这几个配置是否正确。3.3 最容易被忽略的launchSettings.json这个文件控制的是“开发环境下启动时发生什么”。它包含两段配置IIS Express和项目本身。有一个高频问题为什么我启动后访问的端口和别人不一样答案就在这里。profiles: { DemoAPI: { commandName: Project, dotnetRunMessages: true, launchBrowser: true, launchUrl: swagger, applicationUrl: http://localhost:5060;https://localhost:7060, environmentVariables: { ASPNETCORE_ENVIRONMENT: Development } } }applicationUrl里配置了HTTP和HTTPS两个端口。launchUrl设为swagger启动后浏览器会自动打开Swagger页面。ASPNETCORE_ENVIRONMENT设为Development这决定了运行时会加载appsettings.Development.json而不是appsettings.json。这个环境变量在生产环境非常重要——你部署到Linux服务器上的时候一定不能让它跑在Development环境下否则可能会暴露详细错误信息有安全风险。4. 核心实操一个能直接用的CRUD 文件模块实现4.1 标准CRUD接口以Product为例我习惯用一个“商品”实体来做CRUD演示因为大家都能理解。先定义实体public class Product { public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public DateTime CreatedAt { get; set; } }然后创建对应的DTO注意这里我特意把CreatedAt从DTO中移除了因为创建时间应该由服务器生成前端不应该传进来public class ProductDto { public string Name { get; set; } public decimal Price { get; set; } }Controller的长相如下[ApiController] [Route(api/[controller])] public class ProductController : ControllerBase { private readonly AppDbContext _context; public ProductController(AppDbContext context) { _context context; } [HttpGet] public async TaskActionResultApiResultListProduct GetAll() { var list await _context.Products.ToListAsync(); return Ok(ApiResultListProduct.Success(list)); } [HttpGet({id})] public async TaskActionResultApiResultProduct GetById(int id) { var product await _context.Products.FindAsync(id); if (product null) return NotFound(ApiResultProduct.Fail(数据不存在)); return Ok(ApiResultProduct.Success(product)); } [HttpPost] public async TaskActionResultApiResultProduct Create(ProductDto dto) { var product new Product { Name dto.Name, Price dto.Price, CreatedAt DateTime.Now }; _context.Products.Add(product); await _context.SaveChangesAsync(); return CreatedAtAction(nameof(GetById), new { id product.Id }, ApiResultProduct.Success(product)); } [HttpPut({id})] public async TaskIActionResult Update(int id, ProductDto dto) { var product await _context.Products.FindAsync(id); if (product null) return NotFound(ApiResultProduct.Fail(数据不存在)); product.Name dto.Name; product.Price dto.Price; await _context.SaveChangesAsync(); return Ok(ApiResultstring.Success(更新成功)); } [HttpDelete({id})] public async TaskIActionResult Delete(int id) { var product await _context.Products.FindAsync(id); if (product null) return NotFound(ApiResultProduct.Fail(数据不存在)); _context.Products.Remove(product); await _context.SaveChangesAsync(); return Ok(ApiResultstring.Success(删除成功)); } }这里面有几个细节值得单独拎出来讲。第一[ApiController]特性自带模型验证如果前端传来的DTO缺少必填字段框架会直接返回400不需要你写任何校验代码。第二CreatedAtAction在POST成功时返回201状态码并在响应头中自动带上新资源的URL这是RESTful规范要求但很多人没做到的标准做法。第三所有的业务数据都不是裸返回的而是包在ApiResultT里前端可以根据Code字段统一判断业务是否成功。4.2 文件下载如何保持文件名不变这个需求是热搜词里我特别关注的一个点——“net webapi 下载文件”和“如何保持文件名不变 blob”连在一起说明很多人在做Vue前端下载文件时都被中文文件名折磨过。这个坑的根源在于HTTP响应头里的Content-Disposition字段。如果直接写return File(bytes, application/octet-stream, 测试文件.pdf);在Postman里看文件名是正常的“测试文件.pdf”但到了浏览器或前端代码里可能变成乱码甚至下载后是“__”之类的占位符。原因是不同浏览器对Content-Disposition中非ASCII文件名的解析规则不一致。正确的做法是使用RFC 5987规范把文件名编码为UTF-8的百分号格式。ASP.NET Core有个内置的FileContentResult你可以显式构造[HttpGet(download/{id})] public async TaskIActionResult Download(int id) { var product await _context.Products.FindAsync(id); if (product null) return NotFound(); // 模拟生成文件内容实际项目中可能是从数据库读取或临时生成 byte[] fileBytes Encoding.UTF8.GetBytes($商品名称{product.Name}\n价格{product.Price}); var fileName ${product.Name}.txt; // 关键显式设置 Content-Disposition var cd new ContentDispositionHeaderValue(attachment) { FileNameStar fileName, // RFC 5987支持中文现代浏览器优先使用 FileName download.txt // 回退方案 }; Response.Headers.Add(Content-Disposition, cd.ToString()); return File(fileBytes, application/octet-stream); }在Vue前端用axios下载文件时也要配套处理Content-Disposition才能正确提取文件名axios.get(/api/product/download/1, { responseType: blob }) .then(res { const disposition res.headers[content-disposition]; // 优先取 filename*它按 RFC 5987 编码 let fileName download.txt; if (disposition) { const starMatch disposition.match(/filename\*UTF-8([^;])/i); if (starMatch) { fileName decodeURIComponent(starMatch[1]); } else { const nameMatch disposition.match(/filename?([^;])?/i); if (nameMatch) fileName nameMatch[1]; } } const blobUrl window.URL.createObjectURL(res.data); const a document.createElement(a); a.href blobUrl; a.download fileName; a.click(); window.URL.revokeObjectURL(blobUrl); });这套组合拳敲下去“下载文件名变成乱码”的问题就彻底解决了。我在这块吃了不少亏早期总是只在后端写FileName fileName结果Chrome里看着正常Safari和Firefox里就乱码后来统一改成FileNameStar FileName双保险才消停。4.3 上传接口接收多文件与表单参数扫码枪、上位机、前端表单上传本质上都是POST文件到服务器。下面这段代码覆盖了“接收多文件附加表单参数”的常见场景[HttpPost(upload)] public async TaskIActionResult Upload([FromForm] ListIFormFile files, [FromForm] string note) { if (files null || files.Count 0) return BadRequest(ApiResultstring.Fail(未接收到文件)); var saveDir Path.Combine(Directory.GetCurrentDirectory(), Uploads); if (!Directory.Exists(saveDir)) Directory.CreateDirectory(saveDir); var savePaths new Liststring(); foreach (var file in files) { if (file.Length 0) continue; var ext Path.GetExtension(file.FileName); var safeName ${DateTime.Now:yyyyMMddHHmmss}_{Guid.NewGuid():N}{ext}; var fullPath Path.Combine(saveDir, safeName); using (var stream new FileStream(fullPath, FileMode.Create)) { await file.CopyToAsync(stream); } savePaths.Add(safeName); } return Ok(ApiResultListstring.Success(savePaths)); }这里我做了一个安全处理没有直接用前端传来的原始文件名存储而是用时间戳 GUID 扩展名重新生成存储名。为什么因为前端上传的文件名可能包含路径分隔符或特殊字符直接拼接路径会有安全隐患。扩展名校验这层最好也加上后面在业务代码里对扩展名做白名单如只允许.jpg/.pdf/.txt不然被传上来一个.exe就不好玩了。5. 数据库、日志、跨域与生产发布让Demo真正可用的最后几步5.1 用EF Core SQLite做持久化Demo里如果只是用静态列表存数据那真的就只能“玩一玩”了。我推荐直接引入EF Core加上SQLite因为SQLite零配置、生成一个本地文件就能用最适合Demo场景。需要安装的NuGet包Microsoft.EntityFrameworkCore.Sqlite Microsoft.EntityFrameworkCore.Tools Microsoft.EntityFrameworkCore.Design然后在Program.cs中注册DbContextbuilder.Services.AddDbContextAppDbContext(options options.UseSqlite(builder.Configuration.GetConnectionString(Default)));在appsettings.json里加上连接字符串{ ConnectionStrings: { Default: Data Sourcedemo.db } }DbContext长这样public class AppDbContext : DbContext { public AppDbContext(DbContextOptionsAppDbContext options) : base(options) { } public DbSetProduct Products { get; set; } }首次运行前在VS的“程序包管理器控制台”里执行两条命令建库建表Add-Migration Init Update-Database如果你用命令行则对应为dotnet ef migrations add Init和dotnet ef database update。这里有一个非常容易踩的坑如果没有安装Microsoft.EntityFrameworkCore.Design包执行dotnet ef命令时会报“无法执行此操作因为没有设计时提供程序”。看到这个报错不用慌装上Design包就行。5.2 全局异常处理别再让前端收到500和一串堆栈开发阶段看到Yellow Screen开发者异常页很正常但线上环境绝对不能把堆栈信息暴露给客户端。我的做法是加一个全局异常处理中间件把所有未捕获异常统一包装成ApiResultobject格式返回public class ExceptionHandlingMiddleware { private readonly RequestDelegate _next; private readonly ILoggerExceptionHandlingMiddleware _logger; public ExceptionHandlingMiddleware(RequestDelegate next, ILoggerExceptionHandlingMiddleware logger) { _next next; _logger logger; } public async Task InvokeAsync(HttpContext context) { try { await _next(context); } catch (Exception ex) { _logger.LogError(ex, 未处理异常); context.Response.StatusCode 500; context.Response.ContentType application/json; var result ApiResultobject.Fail(服务器内部错误); await context.Response.WriteAsJsonAsync(result); } } }在Program.cs里注册app.UseMiddlewareExceptionHandlingMiddleware();注意中间件的顺序很重要UseMiddleware要在MapControllers之前注册否则异常不会被这个中间件捕获。我自己就犯过这种低级错误把中间件注册放在最后面结果异常直接在Controller阶段就抛出去了前端收到的还是原始500页面。5.3 CORS跨域配置Vue前端联调的必需品Vue开发服务器默认跑在http://localhost:5173你的WebAPI跑在http://localhost:5060不同端口就是跨域。浏览器会拦截JavaScript发起的跨域请求所以必须配置CORS。在Program.cs中builder.Services.AddCors(options { options.AddPolicy(AllowAll, policy { policy.AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader(); }); }); // 注意UseCors必须放在UseAuthorization之前 app.UseCors(AllowAll);开发环境用AllowAnyOrigin图省事没问题但生产环境一定要收敛成具体域名比如.WithOrigins(https://admin.example.com)否则你的API谁都能从网页端跨域调用了。另外如果你用了AllowAnyOrigin又需要携带Cookie或Authorization头记得改成AllowCredentials()但AllowCredentials不能和AllowAnyOrigin同时用这是CORS规范的限制CSDN上这个问题出现的频率也非常高。5.4 发布WebAPI项目的关键配置开发完成后要发布可以用命令行dotnet publish -c Release -o ./publish发布后会生成一个publish文件夹里面有dll、exe、wwwroot等文件。在Windows上部署可以直接用dotnet DemoAPI.dll运行也可以用IIS托管。在Linux上部署建议使用dotnet DemoAPI.dll配合Nginx反向代理。发布后最常见的两个问题第一数据库文件没有生成或路径找不到。因为你发布后的工作目录变成publish文件夹SQLite的相对路径Data Sourcedemo.db会指向publish目录所以要确认该目录有写入权限。第二端口被占用。生产环境下端口需要在appsettings.json里显式配置Urls: http://0.0.0.0:5080这样无论你在哪个环境运行监听的都是5080端口而不是默认的5000。设置0.0.0.0而不是127.0.0.1表示允许外部访问这在部署到服务器时非常关键。6. 常见问题与排查技巧实录以下是这套C# WebAPI Demo在实际使用中我遇到过的、以及被问得最多的问题整理成速查表现象可能原因解决方法Postman能调通浏览器Vue调不通CORS未配置或配置顺序错误确认UseCors在UseAuthorization之前注册下载文件中文名乱码缺少FileNameStar设置使用ContentDispositionHeaderValue同时设置FileName和FileNameStarPUT/DELETE请求返回405服务器或中间件拦截了HTTP方法检查IIS/Nginx配置是否允许对应动词发布后接口404Swagger打不开未在Program.cs中注册Swagger中间件生产环境也保留Swagger开发时方便调试SQLite报“no such table”未执行迁移或迁移未更新执行dotnet ef database update启动端口和期望不一致launchSettings.json中applicationUrl配置修改applicationUrl或生产环境appsettings.json的Urls前端传的JSON属性名变成小写ASP.NET Core默认使用camelCase序列化JSON如需要保持PascalCase在Program.cs中配置JsonOptions大量文件上传失败或超时Kestrel默认请求体大小限制调整MaxRequestBodySize30MB以内合理扫码枪输入数据触发多个接口前端将扫码枪的按键输入当成多次触发前端加防抖或按回车键确认结束再提交6.1 关于扫码枪和上位机场景的友情提醒热搜词里出现了大量“C#上位机”“扫码枪触发事件”“C#工业级网口通讯”之类的词这条路我也走过。这里给你一个非常真诚的建议如果你在做上位机WebAPI通常不是用来和设备直接通信的设备通信走的是Socket、串口、Modbus这类协议。WebAPI在上位机架构里的角色是“上层应用与前端展示之间的桥梁”。比如扫码枪的数据通过串口或USB-HID进到上位机程序上位机解析后通过WebAPI写入数据库或推送给前端页面。所以这套Demo对你来说是那个“上层接口层”不要试图让它去处理PLC协议那会非常痛苦且本末倒置。6.2 排查WebAPI问题的一个高效步骤遇到接口行为不对我建议按这个顺序排查先看Swagger直接调确认后端本身没毛病再看浏览器的开发者工具Network面板确认请求是否发出、响应是什么状态码最后看后端日志确认请求有没有进到Controller没进就是中间件或路由问题进了就是业务代码问题。按照这个顺序80%的问题能在5分钟内定位。不要一头扎进代码里逐行读先确认问题出在链路哪一段效率高得多。6.3 值得拥有的小技巧开发时自动重载VS2022里有“热重载”功能但很多人没注意它仅限于C#代码。改完Program.cs或Controller后按CtrlF5重启是比较稳妥的方式。不过.NET 8推出了dotnet watch命令你可以直接用dotnet watch run它会在你修改代码后自动重启应用配合Swagger使用基本可以达到“改完保存就能测”的体验前端写Vue的朋友应该比较熟悉这种模式。这个用法在Demo阶段非常提升效率强烈建议试用。7. 写在最后的个人体会这套Demo我从.NET Core 3.1一直迭代到现在的.NET 8结构基本没大变但每一版都在修正那些“看似能用、实际坑人”的细节。最大的体会是写Demo的时候一定要站在“看代码的人”的角度去设计而不是站在“编译器不报错就行”的角度。你放出去的每一个Controller、每一次返回格式的统一都会成为对方照着学的样板。如果样板本身就是随意的后面整个项目都会跟着随意起来。另外一个很实际的建议把这里的ApiResultT、ExceptionHandlingMiddleware、文件下载的Content-Disposition处理单独抽成一个模板或代码片段下次新建项目时第一时间放进去。这些是每个WebAPI项目都会用到的地基代码不必每次都重新写一遍。等你经历过两三个项目的沉淀会有自己的一套“开局标准件”那时候搭一个能用的WebAPI项目可能只需要十分钟。这套Demo就是那个起点。本文还有配套的精品资源点击获取