很多 .NET 开发者第一次从“写完接口能跑”走向“认真设计接口”时都会遇到一个相似的尴尬Controller 里到底要不要用 DTOEF Core 对应的数据库上下文放哪一层SQL Server 连接字符串里的参数为什么总是配不对这些问题单独拎出来都不难组合在一起却容易让人无从下手最后变成“只要能跑就行”等项目进入维护期后才开始后悔。这篇文章不打算介绍 .NET 10 的某个炫酷新特性而是把一条从零到一、能在真实项目里长期维护的路径完整走一遍用 Controller 搭建 RESTful Web API用 EF Core 访问 SQL Server用 DTO 隔离数据模型和接口模型同时解释每个选择的理由和真正容易踩坑的地方。读完这篇文章你应该能自己搭出一个结构清晰、可测试、可扩展的 .NET 10 Web API 项目骨架。如果你正准备开始一个新的企业级项目或者正在整理团队的基础工程模板这篇文章应该对你有直接帮助。1. 从零搭建之前先想清楚这套组合适合谁先说结论.NET 10 EF Core SQL Server DTO 这套组合最适合的是企业内部管理系统、业务中台、 ERP/进销存、订单管理这类“业务规则复杂、字段多、关系多、需要长期维护”的项目。它并不适合所有场景比如高并发短链接服务、边缘计算网关、极简微服务 Demo这些场景有更轻量或更专用的技术栈。为什么这样说因为这套组合的核心优势是“工程化”而不是“性能极限”。.NET 10 是 LTS长期支持版本适合做底层依赖稳定、需要多年维护的产品线。EF Core 提供了事务、迁移、模型映射、关系管理可以极大减少手写数据访问代码的工作量。SQL Server 在企业环境中使用广泛成熟的运维体系、备份恢复、权限管理、BI 工具链都是它的红利。在 Controller 层引入 DTO则能让你在数据模型和接口契约之间建立一道明确的边界。当然如果你只是想做一个几十个接口的内部工具直接用 Minimal API Dapper 反而更快。但如果你预见到这个项目会不断加需求、会有多个人协作、需要前端开发对齐接口文档那么从一开始就使用 Controller DTO 的组织方式比“先爽一把再重构”要划算得多。这里真正容易踩坑的地方是很多团队会把“能用 EF Core 操作数据库”当作目标结果 Controller 里直接返回实体对象数据表结构一变接口返回结构跟着变前端瞬间炸掉。DTO 不是可有可无的封装它是 API 和数据库之间的稳定契约。所以本文会把 DTO 作为核心组成部分来写而不是最后顺带提一句。2. 核心概念四个角色各管什么在一套 Web API 里Controller、EF Core、SQL Server、DTO 是四个分工明确的角色。把它们的职责边界搞清楚后面写代码才不会混成一团。2.1 Controller 是 HTTP 层Controller 负责接收 HTTP 请求、调用业务逻辑、返回 HTTP 响应。它不应该关心 SQL 怎么写、表结构是什么样只应该关心“客户端传进来的数据是否合法”“该调用哪个领域方法”“该返回 200 还是 404”。在 ASP.NET Core 中Controller 通过路由和模型绑定自动完成参数映射配合[ApiController]特性还能自动做模型验证。2.2 EF Core 是数据访问层EF Core 是微软官方的 ORM 框架它的作用是把 C# 对象映射成数据库表记录并把 LINQ 查询转换为 SQL 语句。你不再需要手写SqlConnection、SqlCommand、DataReader那一套样板代码。EF Core 还提供了 Migrations迁移机制可以像管理代码版本一样管理数据库结构变更。2.3 SQL Server 是持久化存储SQL Server 负责最终保存数据、保障事务、提供索引和查询优化。EF Core 只是访问工具数据库的性能和安全性仍然由 SQL Server 本身以及 DBA 的配置决定。不要以为用了 ORM 就不需要关心数据库索引和连接池。2.4 DTO 是 API 契约DTOData Transfer Object数据传输对象是定义在 HTTP 接口边界上的数据模型。它的价值在于数据库表结构是内部实现可以随时调整而接口返回格式是对前端公开的承诺不能随意变更。DTO 可以把数据库字段的变更挡在 API 层之外。为了便于理解可以看成角色类比职责Controller前台接待处理请求、参数校验、返回状态码EF Core业务助理把 C# 操作翻译成数据库操作SQL Server档案库真正存储和管理数据DTO对外合同定义接口层的数据格式这四个角色缺一不可混乱的根源通常在于“控制器直接操作数据库并返回实体”。本文会用一套完整的示例展示如何隔离它们。3. 环境准备与项目创建3.1 环境清单在开始之前建议先确认你的开发环境操作系统Windows 10/11 或 Windows ServermacOS/Linux 可以开发但连接 SQL Server 需要额外的驱动配置。.NET SDK需要安装 .NET 10 SDK。安装完成后可以在命令行执行dotnet --version确认。IDE推荐 Visual Studio 202217.x 以上或 VS Code C# Dev Kit。VS Code 轻量Visual Studio 对调试和模板集成更方便。数据库推荐安装 SQL Server Developer Edition 或 SQL Server Express LocalDB。开发阶段用 LocalDB 最省事但要注意 LocalDB 只适合本机开发不适合生产。EF Core 工具命令行执行dotnet tool install --global dotnet-ef用于执行迁移命令。3.2 验证安装打开命令行终端依次执行dotnet --version能输出版本号说明 SDK 正常。再执行dotnet ef --version如果提示找不到命令说明 dotnet-ef 工具还没安装或 PATH 没生效。执行下面的命令安装dotnet tool install --global dotnet-ef这里需要注意dotnet ef工具的版本最好和项目引用的 Microsoft.EntityFrameworkCore 包版本保持一致否则可能在执行迁移时出现兼容性警告。3.3 创建 Web API 项目使用dotnet new命令创建项目dotnet new webapi -n BookStore.Api -o BookStore.Api cd BookStore.Api创建完成后项目里会包含默认的Program.cs、Controllers目录、appsettings.json和示例的天气接口。模板自带的WeatherForecast相关代码可以直接删除避免干扰后续步骤。项目结构如下BookStore.Api/ ├── Controllers/ ├── Models/ ├── Data/ ├── Dtos/ ├── Program.cs ├── appsettings.json └── BookStore.Api.csproj这里我提前规划了几个目录虽然目前还不存在但后面会依次创建Models放实体类Data放 DbContextDtos放 DTO 类Controllers放 API 控制器。创建完项目后先跑一次dotnet build确保基础模板能编译通过再往下走。这样后面出现问题至少可以排除“项目本身没建好”的干扰。4. 配置 EF Core 与 SQL Server4.1 安装 NuGet 包在项目目录下执行以下命令安装 EF Core 相关包dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Design第一个包是 SQL Server 驱动和 EF Core 的核心能力第二个包提供了dotnet ef迁移命令在设计时所需的功能。如果你的项目需要显式控制迁移配置文件还要用到Microsoft.EntityFrameworkCore.Tools不过在命令行场景下Design包已经足够。4.2 配置连接字符串打开appsettings.json加入连接字符串{ Logging: { LogLevel: { Default: Information, Microsoft.AspNetCore: Warning } }, AllowedHosts: *, ConnectionStrings: { DefaultConnection: Server(localdb)\\MSSQLLocalDB;DatabaseBookStoreDb;Trusted_ConnectionTrue;MultipleActiveResultSetstrue;TrustServerCertificateTrue } }如果你本机安装的是 SQL Server Express 实例连接字符串可以写成类似DefaultConnection: Serverlocalhost\\SQLEXPRESS;DatabaseBookStoreDb;Trusted_ConnectionTrue;MultipleActiveResultSetstrue;TrustServerCertificateTrue这里的Server指定 SQL Server 实例名Trusted_ConnectionTrue表示使用 Windows 身份验证。如果你使用 SQL Server 账号密码登录则要改成DefaultConnection: Serverlocalhost;DatabaseBookStoreDb;User Idsa;Password你的密码;TrustServerCertificateTrue重要提醒不要把生产环境的连接字符串直接写在appsettings.json并提交到代码仓库。开发环境的默认值可以放但要通过环境变量、用户机密或配置中心覆盖生产配置。sa账号是数据库超级管理员生产环境不要使用sa作为 API 的连接账号应该为应用单独创建最小权限账号。4.3 注册 DbContext在Program.cs中注册 DbContext 服务。这里我会把 EF Core 的配置统一放到IServiceCollection的扩展方法里保持Program.cs简洁。在项目根目录创建Extensions/ServiceCollectionExtensions.csusing BookStore.Api.Data; using Microsoft.EntityFrameworkCore; namespace BookStore.Api.Extensions; public static class ServiceCollectionExtensions { public static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration) { var connectionString configuration.GetConnectionString(DefaultConnection); services.AddDbContextBookStoreContext(options options.UseSqlServer(connectionString)); return services; } }然后在Program.cs中调用using BookStore.Api.Extensions; var builder WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddDatabase(builder.Configuration); var app builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();这个Program.cs文件是模板生成的精简版。使用扩展方法的好处是将来换数据库、加缓存、加认证中间件都不会把Program.cs无限膨胀每个关注点都能在不同的扩展类里找到。5. 定义实体、DbContext 与 DTO5.1 实体类实体类对应数据库表的结构。我以一个简单的图书管理场景为例创建一个Book实体。文件路径Models/Book.csnamespace BookStore.Api.Models; public class Book { public int Id { get; set; } public string Title { get; set; } string.Empty; public string? Author { get; set; } public string? ISBN { get; set; } public DateTime PublishDate { get; set; } public decimal Price { get; set; } public DateTime CreatedAt { get; set; } }这里有几个细节Title使用string.Empty初始化并且设计为不可为空因为图书标题是必填字段。Author、ISBN使用了string?允许为空。CreatedAt是创建时间可以在保存时统一赋值而不是让前端传入。5.2 DbContextDbContext 是 EF Core 与数据库之间的桥梁。它负责跟踪实体状态、生成并执行 SQL、管理事务。文件路径Data/BookStoreContext.csusing BookStore.Api.Models; using Microsoft.EntityFrameworkCore; namespace BookStore.Api.Data; public class BookStoreContext : DbContext { public BookStoreContext(DbContextOptionsBookStoreContext options) : base(options) { } public DbSetBook Books SetBook(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityBook(entity { entity.HasKey(b b.Id); entity.Property(b b.Title).IsRequired().HasMaxLength(200); entity.Property(b b.Author).HasMaxLength(100); entity.Property(b b.ISBN).HasMaxLength(20); entity.Property(b b.Price).HasPrecision(18, 2); entity.Property(b b.CreatedAt).HasDefaultValueSql(GETUTCDATE()); }); base.OnModelCreating(modelBuilder); } }在OnModelCreating中配置了主要约束主键、必填字段、字段长度、价格精度、默认值。这样可以确保数据库表结构在创建时就是规范的而不是依赖 EF Core 的默认推断。有一点需要理解OnModelCreating中的配置可以全部用 Data Annotation数据注解写在实体类上但 Fluent API如上面代码配置更集中适合统一管理。5.3 DTO 类DTO 的作用是定义接口层的数据契约。我这里按常见的 REST API 习惯拆分三类BookResponseDto返回给客户端的完整数据。BookCreateDto创建时客户端需要提交的数据。BookUpdateDto更新时客户端需要提交的数据。为什么要拆成三个因为创建和更新场景下客户端不需要提交Id、CreatedAt返回场景下才需要。如果所有字段都用一个类接口文档会变得含糊客户端也不知道哪些字段可以不传。文件路径Dtos/BookDtos.csnamespace BookStore.Api.Dtos; public record BookResponseDto( int Id, string Title, string? Author, string? ISBN, DateTime PublishDate, decimal Price, DateTime CreatedAt); public record BookCreateDto( string Title, string? Author, string? ISBN, DateTime PublishDate, decimal Price); public record BookUpdateDto( string Title, string? Author, string? ISBN, DateTime PublishDate, decimal Price);这里使用 C# 的record类型它天然适合只读数据传输对象支持值相等比较代码也更简洁。如果你需要传统 class 风格也可以换成普通类但record在 DTO 场景下是更现代、更推荐的选择。6. 实现 Controller 完成 CRUD6.1 构造注入 DbContext文件路径Controllers/BooksController.cs在 Controller 的构造函数中注入BookStoreContext。这里采用构造注入是 ASP.NET Core 依赖注入的标准做法。严格来说业务复杂后不应直接注入 DbContext 到 Controller而应该经过 Service/Repository 层。但为了让示例聚焦在“Controller EF Core SQL Server DTO”的链路本身我暂时直接把 DbContext 注入 Controller并在第 9 节讨论如何进一步拆分。using BookStore.Api.Data; using BookStore.Api.Dtos; using BookStore.Api.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace BookStore.Api.Controllers; [ApiController] [Route(api/[controller])] public class BooksController : ControllerBase { private readonly BookStoreContext _context; public BooksController(BookStoreContext context) { _context context; } }6.2 查询列表[HttpGet] public async TaskActionResultIEnumerableBookResponseDto GetBooks() { var books await _context.Books .AsNoTracking() .OrderByDescending(b b.CreatedAt) .Select(b new BookResponseDto( b.Id, b.Title, b.Author, b.ISBN, b.PublishDate, b.Price, b.CreatedAt)) .ToListAsync(); return Ok(books); }AsNoTracking()告诉 EF Core 不需要跟踪实体状态因为查询结果只是只读展示这样可以减少状态管理开销。使用Select直接把实体映射到 DTO而不是先查出实体再手动转 DTOSQL 语句也会只查询 DTO 中出现的字段效率更高。6.3 查询单个[HttpGet({id:int})] public async TaskActionResultBookResponseDto GetBook(int id) { var book await _context.Books .AsNoTracking() .Where(b b.Id id) .Select(b new BookResponseDto( b.Id, b.Title, b.Author, b.ISBN, b.PublishDate, b.Price, b.CreatedAt)) .FirstOrDefaultAsync(); if (book is null) { return NotFound(); } return Ok(book); }这里的关键是路由约束{id:int}。它保证api/books/abc这类请求直接在路由匹配阶段失败不会进入真正的方法体减少不必要的参数校验代码。6.4 创建[HttpPost] public async TaskActionResultBookResponseDto CreateBook(BookCreateDto dto) { var book new Book { Title dto.Title, Author dto.Author, ISBN dto.ISBN, PublishDate dto.PublishDate, Price dto.Price }; _context.Books.Add(book); await _context.SaveChangesAsync(); var response new BookResponseDto( book.Id, book.Title, book.Author, book.ISBN, book.PublishDate, book.Price, book.CreatedAt); return CreatedAtAction(nameof(GetBook), new { id book.Id }, response); }创建成功返回 201 Created同时通过CreatedAtAction在响应头中携带新资源的访问 URI。这是 RESTful API 的规范行为很多刚接触 Web API 的开发者会直接返回 200 OK虽然能用但不够标准。SaveChangesAsync执行后EF Core 会把数据库生成的自增主键回写到book.Id所以后续构造response时可以直接使用。6.5 更新[HttpPut({id:int})] public async TaskIActionResult UpdateBook(int id, BookUpdateDto dto) { var book await _context.Books.FindAsync(id); if (book is null) { return NotFound(); } book.Title dto.Title; book.Author dto.Author; book.ISBN dto.ISBN; book.PublishDate dto.PublishDate; book.Price dto.Price; await _context.SaveChangesAsync(); return NoContent(); }更新接口使用HttpPut语义是“全量替换”。这里FindAsync查出来的实体会被 DbContext 跟踪修改属性后调用SaveChangesAsyncEF Core 会自动生成 UPDATE 语句。更新成功返回 204 No Content表示没有返回体需要客户端解析。注意PUT的语义是全量更新所以客户端必须提交所有必填字段。如果只想更新个别字段应使用PATCH需要额外处理部分更新本文不再展开。6.6 删除[HttpDelete({id:int})] public async TaskIActionResult DeleteBook(int id) { var book await _context.Books.FindAsync(id); if (book is null) { return NotFound(); } _context.Books.Remove(book); await _context.SaveChangesAsync(); return NoContent(); }删除同样是先判断存在性删除成功返回 204。对于真实业务通常不建议物理删除业务数据而应采用软删除加入IsDeleted字段查询时自动过滤。这个建议会在第 9 节详细展开。到这里Controller 的 CRUD 已经完整实现。六个方法覆盖了列表、详情、创建、更新、删除五个操作响应状态码也符合 REST 习惯200、201、204、404。7. 迁移、运行与接口验证7.1 创建迁移在项目目录下执行dotnet ef migrations add InitialCreate正常执行后项目里会生成Migrations目录包含一个时间戳命名的迁移文件。迁移文件记录的是从“空数据库”到“当前模型”的增量变化它可以被提交到代码仓库团队成员通过dotnet ef database update同步到本地数据库。如果执行失败最常见的原因是dotnet ef没找到项目中的 DbContext。确认BookStoreContext已经通过AddDbContext注册到Program.cs并且项目可以正常编译。7.2 更新数据库dotnet ef database update该命令会根据迁移记录创建数据库和表。执行完成后可以打开 SSMSSQL Server Management Studio或使用命令行验证sqlcmd -S (localdb)\\MSSQLLocalDB -d BookStoreDb -Q SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES如果能看到Books表说明数据库已经生成成功。7.3 启动 APIdotnet run默认端口由launchSettings.json决定通常是http://localhost:5xxx。启动后在浏览器打开http://localhost:5xxx/swagger可以看到 Swagger UI里面列出了 Books 的五个接口。7.4 用 curl 验证接口用 curl 发送一个创建请求curl -X POST http://localhost:5xxx/api/books \ -H Content-Type: application/json \ -d { title: 深入理解ASP.NET Core, author: 张三, isbn: 978-7-xxx-xxxxx-x, publishDate: 2025-01-01T00:00:00, price: 99.00 }预期输出{ id: 1, title: 深入理解ASP.NET Core, author: 张三, isbn: 978-7-xxx-xxxxx-x, publishDate: 2025-01-01T00:00:00, price: 99.0, createdAt: 2025-01-01T00:00:00 }再验证列表接口curl http://localhost:5xxx/api/books返回 JSON 数组说明整个链路已经打通HTTP 请求进入 ControllerDTO 被绑定EF Core 操作 SQL Server 完成持久化结果再次映射为 DTO 返回客户端。如果请求返回 500 错误先看控制台日志重点排查数据库连接串是否写对、SQL Server 实例是否启动。这是最常见的两个原因。8. 常见问题与排查思路从评论区和网上搜索热度来看SQL Server 连接问题出现频率最高。下面整理几组典型问题供你对照排查。问题现象可能原因排查方式解决方案运行迁移时报“wait on the database engine recovery handle failed”SQL Server 服务启动失败或数据库文件损坏查看 Windows 事件查看器中的 SQL Server 日志检查服务账号权限尝试修复或重装 SQL Server 实例连接字符串报 Named Pipes Provider 错误(08001)客户端协议被禁用或实例名错误打开 SQL Server 配置管理器确认 TCP/IP 和 Named Pipes 已启用启用相应协议或用 sqlcmd 测试实例是否可连本机提示未找到 LocalDB 实例未安装 LocalDB 或 SQL Server Express执行sqllocaldb info查看实例列表安装 SQL Server Express/Developer Edition或改用完整实例名调用接口返回 500 Internal Server Error连接字符串错误、数据库未迁移、模型字段约束冲突查看 API 控制台异常堆栈先确认数据库可连通再执行dotnet ef database updatePOST 请求返回 400 且模型校验失败DTO 必填字段未传或类型不匹配查看响应体中的 ModelState 错误按错误提示补充字段确认 JSON 属性名与 DTO 一致执行dotnet ef提示找不到命令dotnet-ef 工具未安装或 PATH 未生效执行dotnet tool list -g执行dotnet tool install --global dotnet-ef后重开终端排查 SQL Server 连接问题时最有效的方法是先用sqlcmd或 SSMS 确认数据库实例能连上再回到 API 代码中找问题。很多“程序连不上数据库”的案例最后发现是服务没启动、端口没开放、实例名写错而不是代码问题。如果你使用的 SQL Server 版本较老例如安装在 Windows 2016 等旧系统上还需要额外关注 TLS 协议和驱动兼容性。微软官方建议尽量使用受支持的 SQL Server 版本新的应用项目优先选择 SQL Server 2022 或更新的 Developer Edition。9. 最佳实践与工程建议项目能跑通只是第一步。如果要把这套骨架放进真实项目里长期维护下面这些建议值得认真考虑。9.1 DTO 不要直接复用实体不要图省事直接在 Controller 里返回实体对象也不要让 DTO 继承实体。DTO 应该是独立的、只服务于 API 边界的类型。数据库加字段、改字段名时DTO 和映射逻辑能为你挡住大部分破坏性变化。9.2 所有数据库操作都使用异步方法EF Core 提供的ToListAsync、FirstOrDefaultAsync、SaveChangesAsync等异步方法能在数据库等待期间释放线程提升 API 在高并发下的吞吐能力。Controller Action 本身也应该是async Task或async TaskActionResultT。9.3 查询列表时尽量使用分页真实项目中数据量会逐步增长直接返回全表列表早晚要出问题。及时引入分页参数[HttpGet] public async TaskActionResultIEnumerableBookResponseDto GetBooks( [FromQuery] int page 1, [FromQuery] int pageSize 20) { var books await _context.Books .AsNoTracking() .OrderByDescending(b b.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(b new BookResponseDto( b.Id, b.Title, b.Author, b.ISBN, b.PublishDate, b.Price, b.CreatedAt)) .ToListAsync(); return Ok(books); }分页参数要设置上限避免客户端传一个巨大的pageSize把数据库拖垮。关注接口性能的团队还可以在Select前加入Where条件并在数据库层建立合适索引。9.4 生产环境必须处理安全边界不要使用sa账号连接数据库为应用创建独立账号并只授予所需库的最小权限。连接字符串不要硬编码在代码里使用环境变量、用户机密或配置中心。如果 API 需要认证授权在Program.cs中正确配置AddAuthentication和AddAuthorization并且使用 HTTPS。不要信任前端传入的任意字段。BookCreateDto中未声明的属性不会被绑定但 Controller 里仍要使用数据注解验证必填字段和字段长度。9.5 不要为了“分层”而分层很多文章会推荐 Controller - Service - Repository 三层架构。但若项目规模不大引入过多抽象层只会增加阅读成本。合理的做法是团队在 5 个接口以内时先保持简单等出现业务逻辑复用或事务跨多个实体时再提取 Service 层。避免在刚起步时就写出一堆没有任何业务逻辑的 Service 和 Repository。9.6 使用迁移管理数据库变更每次模型变更都通过dotnet ef migrations add生成迁移并通过dotnet ef database update应用到目标数据库。生产环境的数据库变更需要走审核流程确保迁移脚本先备份再执行有条件的话先恢复到预发布环境验证。小结与后续方向到这里一条从零到一的 .NET 10 Web API 构建路径已经完整走完创建项目、配置 EF Core 和 SQL Server、定义实体和 DbContext、设计 DTO、实现 Controller 的 CRUD、执行迁移、通过 Swagger/curl 验证接口以及常见的 SQL Server 连接问题排查思路。这套骨架的结构并不复杂难的是理解每个组件为什么放在这个位置DTO 是 API 的合同EF Core 是数据访问的抽象SQL Server 是持久化的担当Controller 是 HTTP 的门面。理解这一点后后续无论项目规模如何膨胀你都能清楚地知道某个改动应该落在哪一层。下一步你可以继续探索的方向包括使用 AutoMapper 优化实体到 DTO 的映射、引入 Service 层拆解复杂业务、为 Controller 编写集成测试、加入认证授权、使用 FluentValidation 替代默认数据注解校验。建议先把本文的代码跑通一遍再按自己的业务场景填字段、加逻辑。如果把本文收藏起来照着敲一遍你会发现自己已经具备从零搭建完整 Web API 骨架的能力。这一套组合值得成为你技术栈里最常用的选项之一。