使用 ASP.NET Web API 2 中的属性路由创建 REST API

作者: Mike Wasson

Web API 2 支持一种新的路由类型,称为 属性路由。 有关属性路由的一般概述,请参阅 WEB API 2 中的属性路由。 在本教程中,您将使用属性路由为书籍集合创建 REST API。 该 API 将支持以下操作:

操作 示例 URI
获取所有书籍的列表。 /api/books
按 ID 获取书籍。 /api/books/1
获取书籍的详细信息。 /api/books/1/details
按流派获取书籍列表。 /api/books/fantasy
按发布日期获取书籍列表。 /api/books/date/2013-02-16/api/books/date/2013/02/16 (备用形式)
获取特定作者的书籍列表。 /api/authors/1/books

所有方法都是只读的 (HTTP GET 请求) 。

对于数据层,我们将使用实体框架。 图书记录将具有以下字段:

  • ID
  • 标题
  • 流派
  • 发布日期
  • 价格
  • 说明
  • AuthorID (作者表的外键)

但对于大多数请求,API 将返回此数据的子集, (标题、作者和流派) 。 若要获取完整记录,客户端将请求 /api/books/{id}/details

先决条件

Visual Studio 2017 社区版、专业版或企业版。

创建 Visual Studio 项目

首先运行 Visual Studio。 从“文件”菜单中,选择“新建”,然后选择“项目”************。

展开 "已安装的 > Visual c # " 类别。 在 Visual c # 下选择 " Web"。 在项目模板列表中,选择 " **ASP.NET Web 应用程序 ( .NET Framework) **"。 将项目命名为 " BooksAPI " 。

在 " 新建 ASP.NET Web 应用程序 " 对话框中,选择 " " 模板。 在 "添加文件夹和核心引用" 下,选择 " WEB API " 复选框。 单击“确定”。

这会创建一个为 Web API 功能配置的主干项目。

域模型

接下来,添加域模型的类。 在解决方案资源管理器中,右键单击“模型”文件夹。 选择 " 添加",然后选择 " "。 命名类 Author

将 Author.cs 中的代码替换为以下代码:

using System.ComponentModel.DataAnnotations;

namespace BooksAPI.Models
{
    public class Author
    {
        public int AuthorId { get; set; }
        [Required]
        public string Name { get; set; }
    }
}

现在,添加另一个名为 Book 的类。

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace BooksAPI.Models
{
    public class Book
    {
        public int BookId { get; set; }
        [Required]
        public string Title { get; set; }
        public decimal Price { get; set; }
        public string Genre { get; set; }
        public DateTime PublishDate { get; set; }
        public string Description { get; set; }
        public int AuthorId { get; set; }
        [ForeignKey("AuthorId")]
        public Author Author { get; set; }
    }
}

添加 Web API 控制器

在此步骤中,我们将添加一个使用实体框架作为数据层的 Web API 控制器。

按 Ctrl+Shift+B 生成项目。 实体框架使用反射来发现模型的属性,因此它需要已编译的程序集来创建数据库架构。

在“解决方案资源管理器”中,右键单击“控制器”文件夹。 选择 " 添加",然后选择 " 控制器"。

在 " 添加基架 " 对话框中,选择 "包含操作的 Web API 2 控制器",并使用实体框架

在 " 添加控制器 " 对话框中的 " 控制器名称" 下输入 " BooksController " 。 选中 " " 使用异步控制器操作" " 复选框。 对于 " 模型类",选择 " " 书籍" " 。 (如果未在 Book 下拉列表中看到该类,请确保生成项目。 ) 然后单击 "+" 按钮。

单击 "新建数据上下文" 对话框中的 "添加"。

在 "添加控制器" 对话框中单击 "添加"。 基架添加一个名为 BooksController 的类,该类定义 API 控制器。 它还会在模型文件夹中添加一个名为的类 BooksAPIContext ,该类定义实体框架的数据上下文。

设定数据库种子

从 "工具" 菜单中,选择 " NuGet 包管理器",然后选择 " 程序包管理器控制台"。

在“Package Manager Console”窗口中,输入以下命令:

Add-Migration

此命令创建一个迁移文件夹并添加一个名为 Configuration.cs 的新代码文件。 打开此文件,并将以下代码添加到 Configuration.Seed 方法。

protected override void Seed(BooksAPI.Models.BooksAPIContext context)
{
    context.Authors.AddOrUpdate(new Author[] {
        new Author() { AuthorId = 1, Name = "Ralls, Kim" },
        new Author() { AuthorId = 2, Name = "Corets, Eva" },
        new Author() { AuthorId = 3, Name = "Randall, Cynthia" },
        new Author() { AuthorId = 4, Name = "Thurman, Paula" }
        });

    context.Books.AddOrUpdate(new Book[] {
        new Book() { BookId = 1,  Title= "Midnight Rain", Genre = "Fantasy", 
        PublishDate = new DateTime(2000, 12, 16), AuthorId = 1, Description =
        "A former architect battles an evil sorceress.", Price = 14.95M }, 

        new Book() { BookId = 2, Title = "Maeve Ascendant", Genre = "Fantasy", 
            PublishDate = new DateTime(2000, 11, 17), AuthorId = 2, Description =
            "After the collapse of a nanotechnology society, the young" +
            "survivors lay the foundation for a new society.", Price = 12.95M },

        new Book() { BookId = 3, Title = "The Sundered Grail", Genre = "Fantasy", 
            PublishDate = new DateTime(2001, 09, 10), AuthorId = 2, Description =
            "The two daughters of Maeve battle for control of England.", Price = 12.95M },

        new Book() { BookId = 4, Title = "Lover Birds", Genre = "Romance", 
            PublishDate = new DateTime(2000, 09, 02), AuthorId = 3, Description =
            "When Carla meets Paul at an ornithology conference, tempers fly.", Price = 7.99M },

        new Book() { BookId = 5, Title = "Splish Splash", Genre = "Romance", 
            PublishDate = new DateTime(2000, 11, 02), AuthorId = 4, Description =
            "A deep sea diver finds true love 20,000 leagues beneath the sea.", Price = 6.99M},
    });
}

在 "包管理器控制台" 窗口中,键入以下命令。

add-migration Initial

update-database

这些命令将创建一个本地数据库,并调用 Seed 方法来填充该数据库。

添加 DTO 类

如果现在运行应用程序并将 GET 请求发送到/api/books/1,则响应类似于以下内容。 (添加了缩进以提高可读性。 )

{
    "BookId": 1,
    "Title": "午夜 Rain",
    "Genre": "Fantasy",
    "PublishDate": "2000-12-16T00:00:00",
    "Description": "A former architect battles an evil sorceress.",
    "Price": 14.95,
    "AuthorId": 1,
    "Author": null
}

而是希望此请求返回字段的子集。 此外,我希望它返回作者姓名,而不是作者 ID。 为实现此目的,我们将修改控制器方法,以将 数据传输对象 (DTO) (而不是 EF 模型)返回。 DTO 是仅用于携带数据的对象。

在解决方案资源管理器中,右键单击项目并选择 "添加 | 新文件夹"。 将文件夹命名为 " dto " 。 BookDto使用以下定义,将名为的类添加到 dto 文件夹中:

namespace BooksAPI.DTOs
{
    public class BookDto
    {
        public string Title { get; set; }
        public string Author { get; set; }
        public string Genre { get; set; }
    }
}

添加名为 BookDetailDto 的另一个类。

using System;

namespace BooksAPI.DTOs
{
    public class BookDetailDto
    {
        public string Title { get; set; }
        public string Genre { get; set; }
        public DateTime PublishDate { get; set; }
        public string Description { get; set; }
        public decimal Price { get; set; }         
        public string Author { get; set; }
    }
}

接下来,更新 BooksController 类以返回 BookDto 实例。 我们将使用可 查询的 Select 方法将 Book 实例投影到 BookDto 实例。 下面是控制器类的更新代码。

using BooksAPI.DTOs;
using BooksAPI.Models;
using System;
using System.Data.Entity;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using System.Web.Http;
using System.Web.Http.Description;

namespace BooksAPI.Controllers
{
    public class BooksController : ApiController
    {
        private BooksAPIContext db = new BooksAPIContext();

        // Typed lambda expression for Select() method. 
        private static readonly Expression<Func<Book, BookDto>> AsBookDto =
            x => new BookDto
            {
                Title = x.Title,
                Author = x.Author.Name,
                Genre = x.Genre
            };

        // GET api/Books
        public IQueryable<BookDto> GetBooks()
        {
            return db.Books.Include(b => b.Author).Select(AsBookDto);
        }

        // GET api/Books/5
        [ResponseType(typeof(BookDto))]
        public async Task<IHttpActionResult> GetBook(int id)
        {
            BookDto book = await db.Books.Include(b => b.Author)
                .Where(b => b.BookId == id)
                .Select(AsBookDto)
                .FirstOrDefaultAsync();
            if (book == null)
            {
                return NotFound();
            }

            return Ok(book);
        }
        
        protected override void Dispose(bool disposing)
        {
            db.Dispose();
            base.Dispose(disposing);
        }
    }
}

Note

我删除了 PutBookPostBookDeleteBook 方法,因为本教程不需要它们。

现在,如果您运行应用程序并请求/api/books/1,则响应正文应如下所示:

{
    "Title": "午夜 Rain",
    "Author": "Ralls, Kim",
    "Genre": "Fantasy"
}

添加路由属性

接下来,将控制器转换为使用属性路由。 首先,将 RoutePrefix 属性添加到控制器。 此属性定义此控制器上所有方法的初始 URI 段。

[RoutePrefix("api/books")]
public class BooksController : ApiController
{
    // ...

然后将 [Route] 特性添加到控制器操作,如下所示:

[Route("")]
public IQueryable<BookDto> GetBooks()
{
    // ...
}

[Route("{id:int}")]
[ResponseType(typeof(BookDto))]
public async Task<IHttpActionResult> GetBook(int id)
{
    // ...
}

每个控制器方法的路由模板都是前缀加上 route 特性中指定的字符串。 对于 GetBook 方法,路由模板包含参数化字符串 " {id: int} " ,如果 URI 段包含整数值,则匹配。

方法 路由模板 示例 URI
GetBooks "api/书籍" http://localhost/api/books
GetBook "api/书籍/{id: int}" http://localhost/api/books/5

获取书籍详细信息

若要获取书籍详细信息,客户端将向发送 GET 请求 /api/books/{id}/details ,其中 {id} 是书籍的 id。

将以下方法添加到 BooksController 类。

[Route("{id:int}/details")]
[ResponseType(typeof(BookDetailDto))]
public async Task<IHttpActionResult> GetBookDetail(int id)
{
    var book = await (from b in db.Books.Include(b => b.Author)
                where b.BookId == id
                select new BookDetailDto
                {
                    Title = b.Title,
                    Genre = b.Genre,
                    PublishDate = b.PublishDate,
                    Price = b.Price,
                    Description = b.Description,
                    Author = b.Author.Name
                }).FirstOrDefaultAsync();

    if (book == null)
    {
        return NotFound();
    }
    return Ok(book);
}

如果请求 /api/books/1/details ,响应如下所示:

{
    "Title": "午夜 Rain",
    "Genre": "Fantasy",
    "PublishDate": "2000-12-16T00:00:00",
    "Description": "A former architect battles an evil sorceress.",
    "Price": 14.95,
    "Author": "Ralls, Kim"
}

按流派获取书籍

若要获取特定流派的书籍列表,客户端将向发送 GET 请求 /api/books/genre ,其中, 流派 为流派的名称。 (例如:/api/books/fantasy。)

将以下方法添加到 BooksController

[Route("{genre}")]
public IQueryable<BookDto> GetBooksByGenre(string genre)
{
    return db.Books.Include(b => b.Author)
        .Where(b => b.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase))
        .Select(AsBookDto);
}

此处定义的路由包含 URI 模板中的 {流派} 参数。 请注意,Web API 能够区分这两个 Uri 并将它们路由到不同的方法:

/api/books/1

/api/books/fantasy

这是因为该 GetBook 方法包含一个约束,该约束的 "id" 段必须是整数值:

[Route("{id:int}")] 
public BookDto GetBook(int id)
{
    // ... 
}

如果请求/api/books/fantasy,响应如下所示:

[ { "Title": "Midnight Rain", "Author": "Ralls, Kim", "Genre": "Fantasy" }, { "Title": "Maeve Ascendant", "Author": "Corets, Eva", "Genre": "Fantasy" }, { "Title": "The Sundered Grail", "Author": "Corets, Eva", "Genre": "Fantasy" } ]

按作者获取书籍

若要获取特定作者的书籍列表,客户端将向发送 GET 请求 /api/authors/id/books ,其中 id 是作者的 id。

将以下方法添加到 BooksController

[Route("~/api/authors/{authorId:int}/books")]
public IQueryable<BookDto> GetBooksByAuthor(int authorId)
{
    return db.Books.Include(b => b.Author)
        .Where(b => b.AuthorId == authorId)
        .Select(AsBookDto);
}

此示例很有趣,因为 " 书籍 " 被视为作者的子 " 资源 " 。 此模式在 RESTful Api 中非常常见。

路由模板中的波形符 (~) 会替代 RoutePrefix 属性中的路由前缀。

按发布日期获取书籍

若要按发布日期获取书籍列表,客户端将向发送 GET 请求 /api/books/date/yyyy-mm-dd ,其中 yyyy-mm-dd 为日期。

下面是执行此操作的一种方法:

[Route("date/{pubdate:datetime}")]
public IQueryable<BookDto> GetBooks(DateTime pubdate)
{
    return db.Books.Include(b => b.Author)
        .Where(b => DbFunctions.TruncateTime(b.PublishDate)
            == DbFunctions.TruncateTime(pubdate))
        .Select(AsBookDto);
}

{pubdate:datetime}参数被约束为与DateTime值匹配。 这种方法很有效,但实际上比我们更喜欢。 例如,这些 Uri 还会与路由匹配:

/api/books/date/Thu, 01 May 2008

/api/books/date/2000-12-16T00:00:00

允许这些 Uri 没有任何错误。 但是,您可以通过将正则表达式约束添加到路由模板,来限制特定格式的路由:

[Route("date/{pubdate:datetime:regex(\\d{4}-\\d{2}-\\d{2})}")]
public IQueryable<BookDto> GetBooks(DateTime pubdate)
{
    // ...
}

现在,只有 yyyy-mm-dd 格式的日期才 " " 匹配。 请注意,我们不会使用 regex 来验证我们是否获得了真实的数据。 当 Web API 尝试将 URI 分段转换为 日期时间 实例时进行处理。 无效日期(如 "2012-47-99")将失败,客户端将收到404错误。

还可以 /api/books/date/yyyy/mm/dd 通过使用其他 regex 添加另一个 [Route] 属性, () 来支持斜线分隔符。

[Route("date/{pubdate:datetime:regex(\\d{4}-\\d{2}-\\d{2})}")]
[Route("date/{*pubdate:datetime:regex(\\d{4}/\\d{2}/\\d{2})}")]  // new
public IQueryable<BookDto> GetBooks(DateTime pubdate)
{
    // ...
}

这里有一个微妙但重要的细节。 第二个路由模板 * 在 {e} 参数的开头有一个通配符 () :

{*pubdate: ... }

这将告知 {e} 应与 URI 的其余部分匹配的路由引擎。 默认情况下,模板参数匹配单个 URI 段。 在这种情况下,我们希望 {e} 跨越几个 URI 段:

/api/books/date/2013/06/17

控制器代码

下面是 BooksController 类的完整代码。

using BooksAPI.DTOs;
using BooksAPI.Models;
using System;
using System.Data.Entity;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using System.Web.Http;
using System.Web.Http.Description;

namespace BooksAPI.Controllers
{
    [RoutePrefix("api/books")]
    public class BooksController : ApiController
    {
        private BooksAPIContext db = new BooksAPIContext();

        // Typed lambda expression for Select() method. 
        private static readonly Expression<Func<Book, BookDto>> AsBookDto =
            x => new BookDto
            {
                Title = x.Title,
                Author = x.Author.Name,
                Genre = x.Genre
            };

        // GET api/Books
        [Route("")]
        public IQueryable<BookDto> GetBooks()
        {
            return db.Books.Include(b => b.Author).Select(AsBookDto);
        }

        // GET api/Books/5
        [Route("{id:int}")]
        [ResponseType(typeof(BookDto))]
        public async Task<IHttpActionResult> GetBook(int id)
        {
            BookDto book = await db.Books.Include(b => b.Author)
                .Where(b => b.BookId == id)
                .Select(AsBookDto)
                .FirstOrDefaultAsync();
            if (book == null)
            {
                return NotFound();
            }

            return Ok(book);
        }

        [Route("{id:int}/details")]
        [ResponseType(typeof(BookDetailDto))]
        public async Task<IHttpActionResult> GetBookDetail(int id)
        {
            var book = await (from b in db.Books.Include(b => b.Author)
                              where b.AuthorId == id
                              select new BookDetailDto
                              {
                                  Title = b.Title,
                                  Genre = b.Genre,
                                  PublishDate = b.PublishDate,
                                  Price = b.Price,
                                  Description = b.Description,
                                  Author = b.Author.Name
                              }).FirstOrDefaultAsync();

            if (book == null)
            {
                return NotFound();
            }
            return Ok(book);
        }

        [Route("{genre}")]
        public IQueryable<BookDto> GetBooksByGenre(string genre)
        {
            return db.Books.Include(b => b.Author)
                .Where(b => b.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase))
                .Select(AsBookDto);
        }

        [Route("~/api/authors/{authorId}/books")]
        public IQueryable<BookDto> GetBooksByAuthor(int authorId)
        {
            return db.Books.Include(b => b.Author)
                .Where(b => b.AuthorId == authorId)
                .Select(AsBookDto);
        }

        [Route("date/{pubdate:datetime:regex(\\d{4}-\\d{2}-\\d{2})}")]
        [Route("date/{*pubdate:datetime:regex(\\d{4}/\\d{2}/\\d{2})}")]
        public IQueryable<BookDto> GetBooks(DateTime pubdate)
        {
            return db.Books.Include(b => b.Author)
                .Where(b => DbFunctions.TruncateTime(b.PublishDate)
                    == DbFunctions.TruncateTime(pubdate))
                .Select(AsBookDto);
        }

        protected override void Dispose(bool disposing)
        {
            db.Dispose();
            base.Dispose(disposing);
        }
    }
}

摘要

在设计 API 的 Uri 时,属性路由可提供更多控制和更大的灵活性。