发布于 2026-01-06 0 阅读
0

第 012 集 - 迁移到 Web API - ASP.NET Core:从零到精通

第 012 集 - 迁移到 Web API - ASP.NET Core:从零到精通

在本集中,我们将开始把当前的应用程序转换为 Web API,以便它可以在我们即将开发的单页应用程序中使用。

如需了解详细步骤,您可以观看下一个视频;如果您更喜欢快速阅读,请跳至文字总结部分。

整个系列的播放列表在这里

引言

目前我们一直在构建一个服务器端渲染的 MVC 应用程序。然而,这个项目的最终目标是创建一个单页应用程序 (SPA),以处理大部分 UI 需求。为了实现这一目标,我们将把这个 MVC 应用程序迁移到 Web API,以便它将来以及我们构建的其他组件可以被 SPA(或其他可能需要它的组件)使用。

另外,关于我称之为 Web API 或 HTTP API 而不是 REST API 这一点,我想说明一下。我不想因为没有完全按照 REST API 的要求(包括但不限于超媒体)来实现,就把它称为 REST API 而惹恼任何人。姑且称之为类 REST API(😛),因为它实现了大多数人期望的功能,但并不完全符合 REST 的标准。

删除不需要的部分

由于我们要迁移到 Web API,有些东西我们不再需要了,可以进行清理/调整,具体如下:

  • 移除视图
  • 替换AddMvcAddMvcCore,仅添加所需的 MVC 功能
  • 继承自ControllerBase而不是Controller

关于第一个问题,没什么好说的,直接删除存放视图的文件夹就行了,因为我们不再需要它们了。至于其他问题,我们来详细说说。

将 AddMvc 替换为 AddMvcCore

AddMvc它会在依赖注入容器中注册 MVC 的服务,包括它确定需要的以及一些可能需要的服务。为此,它会调用AddMvcCore并添加一些其他服务。我们可以通过AddMvcCore直接调用来避免注册不需要的服务,以及任何其他我们已知需要的 MVC 服务。

这会对性能产生影响吗?可能不会有太大影响(虽然我没有实际测试过),但既然如此,我们就移除一些未使用的部分,并仔细研究一下可用的 MVC 功能。不过大多数情况下,保留它们就好AddMvc,不用担心 🙂

让我们看一下MvcServiceCollectionExtensions.csGitHub 上的文件,其中AddMvc定义了它。

// ...

public static class MvcServiceCollectionExtensions
{
    /// <summary>
    /// Adds MVC services to the specified <see cref="IServiceCollection" />.
    /// </summary>
    /// <param name="services">The <see cref="IServiceCollection" /> to add services to.</param>
    /// <returns>An <see cref="IMvcBuilder"/> that can be used to further configure the MVC services.</returns>
    public static IMvcBuilder AddMvc(this IServiceCollection services)
    {
        // ...

        var builder = services.AddMvcCore();

        builder.AddApiExplorer();
        builder.AddAuthorization();

        AddDefaultFrameworkParts(builder.PartManager);

        // Order added affects options setup order

        // Default framework order
        builder.AddFormatterMappings();
        builder.AddViews();
        builder.AddRazorViewEngine();
        builder.AddRazorPages();
        builder.AddCacheTagHelper();

        // +1 order
        builder.AddDataAnnotations(); // +1 order

        // +10 order
        builder.AddJsonFormatters();

        builder.AddCors();

        return new MvcBuilder(builder.Services, builder.PartManager);
    }

    private static void AddDefaultFrameworkParts(ApplicationPartManager partManager)
    {
        var mvcTagHelpersAssembly = typeof(InputTagHelper).GetTypeInfo().Assembly;
        if (!partManager.ApplicationParts.OfType<AssemblyPart>().Any(p => p.Assembly == mvcTagHelpersAssembly))
        {
            partManager.ApplicationParts.Add(new FrameworkAssemblyPart(mvcTagHelpersAssembly));
        }

        var mvcRazorAssembly = typeof(UrlResolutionTagHelper).GetTypeInfo().Assembly;
        if (!partManager.ApplicationParts.OfType<AssemblyPart>().Any(p => p.Assembly == mvcRazorAssembly))
        {
            partManager.ApplicationParts.Add(new FrameworkAssemblyPart(mvcRazorAssembly));
        }
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

我清理了文件中一些我们目前不需要的部分。

查看代码AddMvc,我们发现它首先调用了 `get_configuration()` 方法AddMvcCore,并将返回值存储IMvcBuilder在一个变量中以供后续配置,这也是我们需要做的。现在让我们来看看使用构建器配置的其他内容。

  • AddApiExplorer 用于公开 MVC 应用程序的相关信息。例如,它可用于创建 Swagger 文档端点。更多信息请查看Andrew Lock 的文章。我们最终会使用 Swagger,但既然目前还没用上……那就先用起来吧!
  • AddAuthorization - 添加我们将来肯定会需要的必要授权服务,但目前不需要,所以……退出!
  • AddDefaultFrameworkParts - 查看AddDefaultFrameworkParts下面的实现AddMvc,我们可以看到它正在注册来自 Razor 和 TagHelpers 程序集的服务,因此我们也可以绕过它。
  • AddFormatterMappings——我看了看这个函数的实现才明白它的作用,它基本上注册了一个FormatFilter检查请求的路由数据和查询字符串中是否存在格式参数的映射,其作用类似于Accept请求头。我们不需要这个映射,所以可以跳过它。
  • 添加视图 - 视图...下一个!
  • AddRazorViewEngine - 更多 Razor 相关内容,敬请期待!
  • 添加 RazorPages - 以及更多 Razor 代码……我们继续跳过吧。
  • AddCacheTagHelper - 更多 TagHelper,跳过。
  • AddDataAnnotations - 我们目前还没有使用数据注释,而且我也不打算将来在这个 API 中使用它们(我们将使用Fluent Validation),所以我们也可以安全地忽略它。
  • AddJsonFormatters - 现在我们需要这个,因为我们的 API 需要处理 JSON。
  • AddCors - 我预计我们不会从不同的域访问 API,所以我们也可以安全地忽略这一点。

那么,最终结果是什么呢?在Startup类中,`the`services.AddMvc();被替换为services.AddRequiredMvcComponents();一个新的扩展方法,我们将其添加到ServiceCollectionExtensions已创建的类中。在这个文件中,我们添加以下内容:

ServiceCollectionExtensions.cs

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddRequiredMvcComponents(this IServiceCollection services)
    {
        var mvcBuilder = services.AddMvcCore();
        mvcBuilder.AddJsonFormatters();
        return services;
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

考虑到我们现有的条件,我们只替换了 ` AddMvcwith` AddMvcCore,并添加了对 `.` 的调用AddJsonFormatters。稍后我们会再回到这个方法,因为我们需要一些额外的配置来进行更改。

继承自 ControllerBase 而不是 Controller

我们的GroupsController类目前继承自Controller。它仍然可以继承自Controller,但由于唯一Controller没有ControllerBase(顺便说一句,它继承自 )的是对视图的支持,我们可以跳过不必要的额外部分。

由于这一改动,我们仍然在调用该View方法,因此会出现编译错误。我们马上就来解决这个问题。

调整端点

现在让我们把我们的代码重构GroupsController为一个 API 控制器。总而言之,它最终会包含一些签名与原代码基本相同的方法IGroupsService,这些方法会调用服务的各种方法,并添加一些额外的“控制器特性”,例如路由、HTTP 方法和 HTTP 响应。

GroupsController.cs

[Route("groups")]
public class GroupsController : ControllerBase
{
    private readonly IGroupsService _groupsService;

    public GroupsController(IGroupsService groupsService)
    {
        _groupsService = groupsService;
    }

    [HttpGet]
    [Route("")]
    public async Task<IActionResult> GetAllAsync(CancellationToken ct)
    {
        var result = await _groupsService.GetAllAsync(ct);
        return Ok(result.ToModel());
    }


    [HttpGet]
    [Route("{id}")]
    public async Task<IActionResult> GetByIdAsync(long id, CancellationToken ct)
    {
        var group = await _groupsService.GetByIdAsync(id, ct);

        if (group == null)
        {
            return NotFound();
        }

        return Ok(group.ToModel());
    }

    [HttpPut]
    [Route("{id}")]
    public async Task<IActionResult> UpdateAsync(long id, GroupModel model, CancellationToken ct)
    {
        model.Id = id; //not needed when we move to MediatR
        var group = await _groupsService.UpdateAsync(model.ToServiceModel(), ct);

        return Ok(group.ToModel());
    }

    [HttpPut]
    [HttpPost]
    [Route("")]
    public async Task<IActionResult> AddAsync(GroupModel model, CancellationToken ct)
    {
        model.Id = 0; //not needed when we move to MediatR
        var group = await _groupsService.AddAsync(model.ToServiceModel(), ct);

        return CreatedAtAction(nameof(GetByIdAsync), new { id = group.Id }, group.ToModel());
    }

    [HttpDelete]
    [Route("{id}")]
    public async Task<IActionResult> RemoveAsync(long id, CancellationToken ct)
    {
        await _groupsService.RemoveAsync(id, ct);

        return NoContent();
    }
}
Enter fullscreen mode Exit fullscreen mode

我觉得这里发生的事情很容易理解,但我还是会尽量指出一些细节。

  • 读取操作及其路由基本保持不变,只是对一些方法名称进行了调整,并直接返回模型而不是视图(JSON 或任何其他格式的序列化由框架处理)。

  • 更新操作绑定到 HTTP PUT,这是 HTTP API 中常见的做法,因为我们期望完全替换存储的实体。

  • 添加操作同时绑定到 HTTP POST 和 PUT 方法,与更新操作不同的是,添加操作不需要 ID。当然,这取决于当前的实现,因为该实现会自动生成 ID。其他实现可能允许客户端提供 ID,在这种情况下,我们可能会将这两个操作合并为一个操作:如果实体不存在则添加,否则更新。

  • 添加操作返回 HTTP 201 Created 状态代码,其中包含Location指向要获取添加实体的 URL 的标头,同时也将其包含在响应正文中。

本节最后一点需要注意的是,在 add 和 update 方法中,我覆盖了客户端返回的 id。我本不应该这样做,但这样做是为了确保服务不会收到不一致或意外的 id——例如,在 add 方法中定义了一个本不应该有 id 的 id(至少在我们目前的做法中是这样,允许定义 id 也可能是有效的),而在 update 方法中定义了一个与路由中设置的 id 不同的 id。

问题主要在于我们对所有操作都使用了相同的模型。最佳方案是为每个操作使用特定的模型,我们将在把MediatR添加到项目时讨论这个问题。

使用 API 控制器属性

API 基本已经准备就绪,但如果我们现在尝试运行它,读取操作将按预期工作,但写入操作则不会。

我们尝试通过向http://localhost:5000/groupsinfo发送 POST 请求来添加一个新组{ "name": "Test Group" }。我们收到的响应如下:

{
    "id": 1,
    "name": null,
    "rowVersion": "611"
}
Enter fullscreen mode Exit fullscreen mode

idrowVersion正确的,但不是name。前者没问题,因为它们是在服务器端生成的,但名称没有传递给操作方法。如果我们在方法中设置断点,AddAsync会发现model参数为空。同样的情况也发生在UpdateAsync

问题在于,操作本身并不知道应该model从响应体中反序列化数据。我们可以通过[FromBody]model参数添加属性来轻松解决这个问题。

这尚可接受,但我们可以做得更好。ASP.NET Core 2.1ApiController引入了 `@Controller` 属性,允许我们装饰控制器,使其遵循一些约定,从而节省我们的工作,例如推断复杂对象来自请求体。当然,这些约定是可以重写的,但默认设置可以避免一些重复输入。您可以点击此处阅读更多关于此属性的信息。

要使用此属性,我们必须转到依赖注入 MVC 配置并添加以下内容:

ServiceCollectionExtensions.cs

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddRequiredMvcComponents(this IServiceCollection services)
    {
        var mvcBuilder = services.AddMvcCore();
        mvcBuilder.SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
        // ...
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

由于使用此属性需要使用 ASP.NET Core MVC 2.1 及更高版本中的新组件,这可能会导致与 2.0 版本相比出现重大变更,因此我们必须明确告知系统我们要使用这些新特性。点击此处了解更多关于兼容性版本的信息。

我使用的是兼容版本 2.2,因为在这个版本中,我们可以将该ApiController属性用作程序集属性,自动应用于所有控制器,而无需对每个控制器进行修饰。

我在Startup类文件中添加了属性[assembly: ApiController],现在控制器可以正常工作了🙂

创建例外过滤器

基于前几集所学的知识,我们可以创建一个过滤器ExceptionFilter。在本例中,我们将创建一个过滤器,当由于组已过期而导致更新失败时(类似于我们在上一集中看到的乐观并发异常),该过滤器将返回 HTTP 409 冲突状态码。我们稍后可以改进该过滤器以处理更多类型的错误。

ApiExceptionFilter.cs

public class ApiExceptionFilter : IExceptionFilter
{
    public void OnException(ExceptionContext context)
    {
        if (context.Exception is DbUpdateConcurrencyException)
        {
            context.Result = 
                new ConflictObjectResult(
                    new
                    {
                        Message = "The updated entity has changed, please refresh your current copy."
                    });
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

未来我们可以改进这一点,让业务层将到达 API 的异常抽象成更通用的并发异常,而不是绑定到 Entity Framework 特有的异常。但就目前而言,这已经足够好了。

现在我们回到ServiceCollectionExtensions课堂,通过向 MVC 配置中添加此过滤器,完成(今天)对 MVC 配置的更改。

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddRequiredMvcComponents(this IServiceCollection services)
    {
        services.AddTransient<ApiExceptionFilter>();

        var mvcBuilder = services.AddMvcCore(options =>
        {
            options.Filters.AddService<ApiExceptionFilter>();
        });
        mvcBuilder.SetCompatibilityVersion(CompatibilityVersion.Version_2_2);   
        mvcBuilder.AddJsonFormatters();
        return services;
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

结尾

这篇文章就到这里。我们把 MVC 应用程序迁移到了 Web API——虽然 Web API 本身也是基于 MVC 构建的,但你应该明白我的意思了!

在以后的剧集中,我们将在此 API 的基础上进行构建,虽然它目前还很简单,但已经很好地满足了我们的需求,我们也得以探索许多 ASP.NET Core 构建模块和功能。

不过在下一集中,我们将暂时放下 ASP.NET Core,开始使用 Vue.js 创建一个单页应用程序,这将是我们的 PlayBall 项目前端,并将使用此 API 来实现其功能。

帖子中的链接:

本文的源代码在这里

如果您想查看从先前代码到本集更改的变化,不要忘记您可以查看 GitHub 中的提交和拉取请求,例如,您可以在这里看到本集的 PR 。

欢迎随时提问,也请不吝提供反馈意见。

谢谢光临,cyaz!

文章来源:https://dev.to/joaofbantunes/episode-012---move-to-a-web-api---aspnet-core-from-0-to-overkill-50fg