一、前言
从这篇文章开始,我们进入进阶开发部分。Web API 是现代应用开发的核心,无论是网站、手机 App 还是小程序,都需要通过 API 与后端服务器通信。
ASP.NET Core 是微软推出的高性能 Web 框架,用它来开发 Web API 既简单又高效。
二、什么是 Web API?
这一节先建立直观理解,再用 RESTful 的角度把概念落到设计规范上,方便你后续写接口时有统一的思路。
2.1 用大白话解释
这里用生活场景来理解 Web API 的角色。想象你去餐厅吃饭,你(客户端)看菜单点菜,服务员(API)把你的需求传达给厨房,厨房(服务器)做好菜后再由服务员端给你,整个过程里服务员负责接收请求、协调处理并返回结果,这就是 Web API 的本质。
2.2 RESTful API
REST 是一种 API 设计风格,核心是用 HTTP 方法表达资源操作,常见对应关系如下表所示。
| HTTP 方法 | 用途 | 示例 |
|---|---|---|
| GET | 获取数据 | 获取用户列表 |
| POST | 创建数据 | 创建新用户 |
| PUT | 更新数据(全量) | 更新用户信息 |
| PATCH | 更新数据(部分) | 更新用户邮箱 |
| DELETE | 删除数据 | 删除用户 |
从语义上看,GET 只读取资源,POST 创建资源,PUT 用完整实体替换现有资源,PATCH 仅修改部分字段,DELETE 则删除资源,这样的约定能让 API 更可读、也更容易被工具和团队理解。
除了方法语义,RESTful 还强调"资源"的概念,比如用 /users 表示用户集合、/users/1 表示具体用户,这种命名方式天然可预测。配合清晰的状态码与一致的数据结构,前端与第三方调用方能更快理解接口行为,也更容易用自动化工具生成客户端代码与文档。
三、创建第一个 Web API 项目
3.1 使用命令行创建
本节用最短路径把项目跑起来,先感受开发流程,再深入结构与代码。
bash
# 创建 Web API 项目
dotnet new webapi -n MyFirstApi
# 进入项目目录
cd MyFirstApi
# 运行项目
dotnet run
这三行命令依次完成了项目模板创建、进入目录和启动服务,其中 dotnet new webapi 会生成一个自带控制器与 Swagger 的基础 Web API 项目,dotnet run 会编译并启动 Kestrel 服务器。运行后打开浏览器访问 https://localhost:5001/swagger,你会看到 Swagger UI 界面,它会自动根据控制器生成接口文档并提供在线测试入口。
如果你在开发过程中修改了代码,dotnet run 会重新编译并重启应用,你也可以在实际项目中启用热重载以提升迭代速度。对于初学者来说,先把项目跑通、能通过 Swagger 发出请求并看到响应,是理解 Web API 开发流程的最佳起点。
3.2 项目结构
先认识目录与关键文件,后续讲解会频繁用到它们。
MyFirstApi/
├── Controllers/ # 控制器目录
│ └── WeatherForecastController.cs
├── Properties/
│ └── launchSettings.json
├── appsettings.json # 配置文件
├── appsettings.Development.json
├── Program.cs # 程序入口
└── MyFirstApi.csproj # 项目文件
在这个结构中,Controllers 用来放控制器类,Program.cs 是应用启动入口,appsettings.json 及其 Development 版本用于配置环境差异,launchSettings.json 提供本地启动时的地址与环境变量,而 csproj 则定义了项目编译信息与依赖。
后续你会经常在 Controllers 下添加业务接口,在 Models 或者更细的目录里放请求模型与实体模型,Program.cs 则承担"依赖注册与中间件编排"的职责。理解这几个核心位置,会让你更快定位问题和组织代码结构。
3.3 理解 Program.cs
这里把启动文件逐行拆开看,理解服务注册与中间件管道的关系。
csharp
var builder = WebApplication.CreateBuilder(args);
// 添加服务到容器
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// 配置 HTTP 请求管道
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
这段代码可以理解为"先注册能力,再配置流程"。WebApplication.CreateBuilder(args) 创建应用构建器并读取配置,builder.Services.AddControllers() 把 MVC 控制器相关服务注册进依赖注入容器,AddEndpointsApiExplorer() 与 AddSwaggerGen() 分别提供 API 描述与 Swagger 文档生成。builder.Build() 会把已注册的服务和配置编译成可运行的应用实例,之后通过 app.UseSwagger() 与 app.UseSwaggerUI() 在开发环境中加入 Swagger 中间件,UseHttpsRedirection() 处理 HTTP 到 HTTPS 的跳转,UseAuthorization() 启用授权中间件,MapControllers() 将控制器路由挂接到请求管道上,最后 app.Run() 启动应用并开始监听请求。
需要注意的是,中间件的顺序会直接影响请求处理流程,比如授权中间件必须放在路由映射之前才能生效,异常处理与日志中间件通常也要放在更靠前的位置。理解"从上到下依次执行"的管道模型,有助于你在出现 401、404 或跨域问题时快速定位原因。
四、创建控制器
4.1 基本控制器结构
先用一个最小可用的控制器示例,展示路由与动作方法是如何对应到 HTTP 请求的。
csharp
using Microsoft.AspNetCore.Mvc;
namespace MyFirstApi.Controllers;
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
// GET: api/users
[HttpGet]
public IActionResult GetAll()
{
return Ok(new[] { "张三", "李四", "王五" });
}
// GET: api/users/1
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
return Ok(new { Id = id, Name = "张三" });
}
// POST: api/users
[HttpPost]
public IActionResult Create([FromBody] string name)
{
return CreatedAtAction(nameof(GetById), new { id = 1 }, new { Id = 1, Name = name });
}
// PUT: api/users/1
[HttpPut("{id}")]
public IActionResult Update(int id, [FromBody] string name)
{
return NoContent();
}
// DELETE: api/users/1
[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
return NoContent();
}
}
这里的 [ApiController] 标记告诉框架启用 Web API 习惯用法,比如自动模型验证与更友好的参数绑定错误返回;[Route("api/[controller]")] 表示路由以 api/ 开头,[controller] 会被替换为类名去掉 Controller 后的部分。每个动作方法通过 [HttpGet]、[HttpPost] 等特性绑定到 HTTP 方法与路由模板,方法返回 IActionResult 以便灵活返回不同的 HTTP 状态码与结果体,例如 Ok() 返回 200,CreatedAtAction() 返回 201 并带 Location 头,NoContent() 返回 204 表示成功但不包含响应体。
控制器的职责是协调输入与输出,它不应该承载复杂的业务逻辑。实际项目中,你会把业务计算放到服务层,再由控制器调用服务并返回结果,这样结构更清晰,也便于测试与维护。
五、使用模型(DTO)
这一节把输入输出的结构抽象成模型类,这样控制器更清晰,数据也更容易校验与扩展。
csharp
// Models/User.cs
public class User
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public int Age { get; set; }
public DateTime CreatedAt { get; set; }
}
// Models/CreateUserRequest.cs
public class CreateUserRequest
{
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public int Age { get; set; }
}
// Models/UpdateUserRequest.cs
public class UpdateUserRequest
{
public string? Name { get; set; }
public string? Email { get; set; }
public int? Age { get; set; }
}
这里把"领域模型"和"请求模型"区分开:User 表示系统中的用户实体,包含 Id 与 CreatedAt 等系统生成字段;CreateUserRequest 与 UpdateUserRequest 则只包含来自客户端的输入字段,且更新模型使用可空类型表示"是否提供该字段"。这种分离能避免前端随意覆盖服务器生成的字段,同时让更新操作更安全。
当模型逐渐复杂时,你还可以进一步拆分为读取模型与返回模型,例如 UserResponse 专门面向前端展示,避免直接暴露内部字段。DTO 的核心价值是"让外部输入输出与内部结构隔离",这是大型系统里非常重要的边界。
5.1 完整的用户控制器
本节把模型应用到完整的 CRUD 流程中,让你看到请求、处理与响应的完整链路。
csharp
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
// 模拟数据库
private static List<User> _users = new()
{
new User { Id = 1, Name = "张三", Email = "zhangsan@example.com", Age = 25, CreatedAt = DateTime.Now },
new User { Id = 2, Name = "李四", Email = "lisi@example.com", Age = 30, CreatedAt = DateTime.Now }
};
// GET: api/users
[HttpGet]
public ActionResult<IEnumerable<User>> GetAll()
{
return Ok(_users);
}
// GET: api/users/1
[HttpGet("{id}")]
public ActionResult<User> GetById(int id)
{
var user = _users.FirstOrDefault(u => u.Id == id);
if (user == null)
{
return NotFound(new { message = "用户不存在" });
}
return Ok(user);
}
// POST: api/users
[HttpPost]
public ActionResult<User> Create([FromBody] CreateUserRequest request)
{
var user = new User
{
Id = _users.Max(u => u.Id) + 1,
Name = request.Name,
Email = request.Email,
Age = request.Age,
CreatedAt = DateTime.Now
};
_users.Add(user);
return CreatedAtAction(nameof(GetById), new { id = user.Id }, user);
}
// PUT: api/users/1
[HttpPut("{id}")]
public IActionResult Update(int id, [FromBody] UpdateUserRequest request)
{
var user = _users.FirstOrDefault(u => u.Id == id);
if (user == null)
{
return NotFound(new { message = "用户不存在" });
}
if (request.Name != null) user.Name = request.Name;
if (request.Email != null) user.Email = request.Email;
if (request.Age.HasValue) user.Age = request.Age.Value;
return NoContent();
}
// DELETE: api/users/1
[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
var user = _users.FirstOrDefault(u => u.Id == id);
if (user == null)
{
return NotFound(new { message = "用户不存在" });
}
_users.Remove(user);
return NoContent();
}
}
这段代码通过一个静态集合模拟数据库,GetAll() 直接返回列表,GetById() 通过 FirstOrDefault 查找并在找不到时返回 404。Create() 会根据当前最大 Id 生成新用户并加入集合,随后使用 CreatedAtAction() 指向 GetById,让客户端能直接定位新资源。Update() 采用可空字段做"部分更新",只有传入的字段才会覆盖已有数据;Delete() 删除前同样先校验存在性,避免无意义操作。这一套结构就是典型的 RESTful CRUD 模式。
需要强调的是,这里用内存集合只是为了演示流程,真实项目会用数据库或缓存进行持久化,并通过服务层处理并发、校验与日志记录。即便如此,控制器层的接口设计方式依然一致,换掉数据源不会影响外部 API 行为。
六、路由配置
6.1 路由属性
本段展示路由模板与约束写法,帮助你表达更清晰的地址规则。
csharp
[ApiController]
[Route("api/v1/[controller]")] // api/v1/products
public class ProductsController : ControllerBase
{
// GET: api/v1/products
[HttpGet]
public IActionResult GetAll() => Ok();
// GET: api/v1/products/123
[HttpGet("{id:int}")] // 约束 id 必须是整数
public IActionResult GetById(int id) => Ok();
// GET: api/v1/products/search?keyword=手机
[HttpGet("search")]
public IActionResult Search([FromQuery] string keyword) => Ok();
// GET: api/v1/products/category/electronics
[HttpGet("category/{categoryName}")]
public IActionResult GetByCategory(string categoryName) => Ok();
}
[Route("api/v1/[controller]")] 体现了版本号在路径中的写法,[controller] 仍然是控制器名占位符。[HttpGet("{id:int}")] 中的 :int 是路由约束,确保只有整数才会匹配这个动作方法,从而减少错误请求的进入。search 与 category/{categoryName} 则演示了"静态段 + 参数段"的组合,用于表达查询与分类等业务意图。
在版本控制上,路径版本是最直观的做法,后续如果要升级接口,可以通过 /api/v2 并行维护新旧版本。除此之外也可以使用请求头或查询参数做版本区分,但对初学者来说路径版本更易理解和调试。
6.2 参数绑定
这里说明常见参数来源的绑定方式,方便你按需选择数据入口。
csharp
public class ProductsController : ControllerBase
{
// 从路由获取参数
[HttpGet("{id}")]
public IActionResult GetById([FromRoute] int id) => Ok();
// 从查询字符串获取参数
[HttpGet]
public IActionResult Search(
[FromQuery] string? keyword,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 10) => Ok();
// 从请求体获取参数
[HttpPost]
public IActionResult Create([FromBody] CreateProductRequest request) => Ok();
// 从请求头获取参数
[HttpGet("auth")]
public IActionResult GetWithAuth([FromHeader(Name = "Authorization")] string token) => Ok();
}
[FromRoute] 明确参数来自路由模板,[FromQuery] 表示从查询字符串取值,同时也支持默认值以简化分页参数,[FromBody] 用于读取 JSON 请求体并反序列化为对象,[FromHeader] 则读取指定请求头。虽然在 [ApiController] 下很多绑定可以自动推断,但显式标注能让接口意图更清晰,也便于阅读与维护。
参数绑定还有一个常见的细节是"命名一致性",比如查询参数 pageSize 会自动绑定到方法参数 pageSize,而大小写不敏感。只要命名清晰,接口调用方就能一眼看懂参数用途,减少文档成本。
七、数据验证
7.1 使用数据注解
这一部分用最常见的数据注解说明如何把校验规则直接写在模型上。
csharp
using System.ComponentModel.DataAnnotations;
public class CreateUserRequest
{
[Required(ErrorMessage = "姓名不能为空")]
[StringLength(50, MinimumLength = 2, ErrorMessage = "姓名长度必须在2-50之间")]
public string Name { get; set; } = string.Empty;
[Required(ErrorMessage = "邮箱不能为空")]
[EmailAddress(ErrorMessage = "邮箱格式不正确")]
public string Email { get; set; } = string.Empty;
[Range(1, 150, ErrorMessage = "年龄必须在1-150之间")]
public int Age { get; set; }
[Phone(ErrorMessage = "手机号格式不正确")]
public string? Phone { get; set; }
[Url(ErrorMessage = "网址格式不正确")]
public string? Website { get; set; }
}
[Required] 强制字段必填,[StringLength] 约束长度范围,[EmailAddress]、[Phone]、[Url] 分别验证格式,而 [Range] 则用于数值区间校验。把这些规则写在请求模型上,可以在控制器逻辑执行前就完成校验,从而保证业务代码接收到的是合法数据。
当校验规则变得复杂时,也可以使用自定义验证特性或 FluentValidation 这类库,表达跨字段或业务级校验逻辑。无论使用哪种方式,目标都是让错误尽早暴露,并且让客户端得到明确、可执行的错误提示。
7.2 控制器中的验证
这里展示如何在动作方法里处理验证结果,以便向客户端返回明确的错误信息。
csharp
[HttpPost]
public IActionResult Create([FromBody] CreateUserRequest request)
{
// ModelState 会自动验证
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
// 处理业务逻辑...
return Ok();
}
在启用 [ApiController] 后,框架会在模型绑定完成时自动验证,ModelState 里会包含失败原因。这里显式判断 ModelState.IsValid 并返回 BadRequest(ModelState),可以将所有验证错误按字段返回给客户端,方便前端逐项提示用户修正。
如果你希望在模型验证失败时统一返回格式,也可以通过自定义过滤器或配置 ApiBehaviorOptions 来替换默认行为,这样可以保持错误返回结构的一致性,降低前端处理复杂度。
八、返回结果
8.1 常用返回方法
本节把常见 HTTP 状态码的返回方式集中演示,便于你根据场景选择。
csharp
public class ExampleController : ControllerBase
{
[HttpGet("ok")]
public IActionResult OkExample()
{
return Ok(new { message = "成功" }); // 200
}
[HttpGet("created")]
public IActionResult CreatedExample()
{
return Created("/api/users/1", new { id = 1 }); // 201
}
[HttpGet("nocontent")]
public IActionResult NoContentExample()
{
return NoContent(); // 204
}
[HttpGet("badrequest")]
public IActionResult BadRequestExample()
{
return BadRequest(new { error = "参数错误" }); // 400
}
[HttpGet("unauthorized")]
public IActionResult UnauthorizedExample()
{
return Unauthorized(); // 401
}
[HttpGet("forbidden")]
public IActionResult ForbiddenExample()
{
return Forbid(); // 403
}
[HttpGet("notfound")]
public IActionResult NotFoundExample()
{
return NotFound(new { error = "资源不存在" }); // 404
}
[HttpGet("error")]
public IActionResult ErrorExample()
{
return StatusCode(500, new { error = "服务器内部错误" }); // 500
}
}
Ok() 返回 200 并带数据体,Created() 返回 201 并指向新资源地址,NoContent() 表示操作成功但无需返回内容,BadRequest() 用于客户端参数错误,Unauthorized() 与 Forbid() 分别对应未认证与无权限场景,NotFound() 处理资源不存在,StatusCode(500, ...) 则用于明确的服务器端异常响应。合理的状态码能显著提升 API 的可用性与可调试性。
实践中建议先确认"业务成功或失败",再选择最贴切的 HTTP 状态码,不要把所有错误都用 200 包起来。状态码配合统一响应格式,能让调用方更快定位问题来源。
8.2 统一响应格式
这一部分通过统一响应模型来规范返回结构,便于前后端约定与日志追踪。
csharp
// Models/ApiResponse.cs
public class ApiResponse<T>
{
public int Code { get; set; }
public string Message { get; set; } = string.Empty;
public T? Data { get; set; }
public DateTime Timestamp { get; set; } = DateTime.Now;
public static ApiResponse<T> Success(T data, string message = "操作成功")
{
return new ApiResponse<T> { Code = 200, Message = message, Data = data };
}
public static ApiResponse<T> Fail(string message, int code = 400)
{
return new ApiResponse<T> { Code = code, Message = message };
}
}
// 使用
[HttpGet("{id}")]
public ActionResult<ApiResponse<User>> GetById(int id)
{
var user = _users.FirstOrDefault(u => u.Id == id);
if (user == null)
{
return NotFound(ApiResponse<User>.Fail("用户不存在", 404));
}
return Ok(ApiResponse<User>.Success(user));
}
ApiResponse<T> 约定了统一的响应格式,其中 Code 与 Message 表示业务级状态,Data 携带实际数据,Timestamp 记录服务器时间。Success() 与 Fail() 作为工厂方法减少重复代码,同时把"成功与失败"的结构固定下来。控制器使用 ApiResponse<User>.Success(user) 返回一致的数据包,客户端解析逻辑也会更稳定。
统一响应不意味着忽视 HTTP 状态码,通常建议同时使用合适的状态码,再用 Code 表达更细的业务状态,比如"库存不足"或"账号被锁定"。这样既符合 HTTP 语义,也方便前端按业务场景做更精细的提示。
九、实战案例:商品管理 API
这一节综合前面的知识点,用一个商品管理 API 把查询、创建、分页、统一返回与验证串联起来。
csharp
// Models/Product.cs
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Stock { get; set; }
public string Category { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
// Controllers/ProductsController.cs
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private static List<Product> _products = new()
{
new Product { Id = 1, Name = "iPhone 15", Description = "苹果手机", Price = 6999, Stock = 100, Category = "手机", CreatedAt = DateTime.Now },
new Product { Id = 2, Name = "MacBook Pro", Description = "苹果笔记本", Price = 14999, Stock = 50, Category = "电脑", CreatedAt = DateTime.Now }
};
// 获取商品列表(支持分页和筛选)
[HttpGet]
public ActionResult<ApiResponse<object>> GetAll(
[FromQuery] string? category,
[FromQuery] decimal? minPrice,
[FromQuery] decimal? maxPrice,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 10)
{
var query = _products.AsQueryable();
if (!string.IsNullOrEmpty(category))
query = query.Where(p => p.Category == category);
if (minPrice.HasValue)
query = query.Where(p => p.Price >= minPrice.Value);
if (maxPrice.HasValue)
query = query.Where(p => p.Price <= maxPrice.Value);
var total = query.Count();
var items = query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
return Ok(ApiResponse<object>.Success(new
{
items,
pagination = new
{
page,
pageSize,
total,
totalPages = (int)Math.Ceiling(total / (double)pageSize)
}
}));
}
// 获取单个商品
[HttpGet("{id}")]
public ActionResult<ApiResponse<Product>> GetById(int id)
{
var product = _products.FirstOrDefault(p => p.Id == id);
if (product == null)
return NotFound(ApiResponse<Product>.Fail("商品不存在", 404));
return Ok(ApiResponse<Product>.Success(product));
}
// 创建商品
[HttpPost]
public ActionResult<ApiResponse<Product>> Create([FromBody] CreateProductRequest request)
{
var product = new Product
{
Id = _products.Max(p => p.Id) + 1,
Name = request.Name,
Description = request.Description,
Price = request.Price,
Stock = request.Stock,
Category = request.Category,
CreatedAt = DateTime.Now
};
_products.Add(product);
return CreatedAtAction(
nameof(GetById),
new { id = product.Id },
ApiResponse<Product>.Success(product, "商品创建成功"));
}
// 更新库存
[HttpPatch("{id}/stock")]
public IActionResult UpdateStock(int id, [FromBody] UpdateStockRequest request)
{
var product = _products.FirstOrDefault(p => p.Id == id);
if (product == null)
return NotFound(ApiResponse<object>.Fail("商品不存在", 404));
product.Stock += request.Quantity; // 可以是正数(入库)或负数(出库)
product.UpdatedAt = DateTime.Now;
if (product.Stock < 0)
return BadRequest(ApiResponse<object>.Fail("库存不足"));
return Ok(ApiResponse<object>.Success(new { newStock = product.Stock }));
}
}
public class CreateProductRequest
{
[Required(ErrorMessage = "商品名称不能为空")]
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
[Range(0.01, double.MaxValue, ErrorMessage = "价格必须大于0")]
public decimal Price { get; set; }
[Range(0, int.MaxValue, ErrorMessage = "库存不能为负数")]
public int Stock { get; set; }
[Required(ErrorMessage = "分类不能为空")]
public string Category { get; set; } = string.Empty;
}
public class UpdateStockRequest
{
public int Quantity { get; set; }
}
这一套示例包含了完整的数据模型与控制器逻辑。列表接口通过 AsQueryable() 构建查询,并按分类与价格区间筛选,再用 Skip 与 Take 实现分页,同时返回分页元数据;创建接口将请求模型映射到实体并设置 CreatedAt,再用 CreatedAtAction 返回资源地址;库存更新接口使用 PATCH 表达"部分更新",将增减数量应用到库存并校验是否出现负数,最后统一用 ApiResponse 返回结果。这样组织后,接口的语义、错误处理与响应结构都保持一致。
如果你把这个示例继续扩展到真实项目,可以加入排序、关键词搜索、软删除、并发控制等能力,并把列表分页与筛选封装成可复用的查询对象。通过这样的演进路径,示例代码就能逐步长成可落地的业务模块。
十、小结
这篇文章围绕 ASP.NET Core Web API 的核心实践展开,从 RESTful 设计、项目创建、控制器与路由、参数绑定、数据验证到统一响应格式逐步串联,并通过商品管理案例把各环节贯通起来。
十一、下一篇预告
下一篇我们将学习 Entity Framework Core,这是 .NET 中最流行的 ORM 框架,能让你用 C# 代码操作数据库,而不用写 SQL。
练习题:尝试创建一个图书管理 API,覆盖增删改查流程,同时实现分页查询功能,并添加数据验证以确保图书名称不为空、价格大于 0。