在 ASP.NET Web API 2 中创建具有属性路由的 REST API
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”。 (如果下拉列表中未列出 Book
类,请确保已生成 project.) 然后单击“+”按钮。
在“新建数据上下文”对话框中单击“添加”。
在 “添加 控制器”对话框中 单击“添加 ”。 基架添加一个名为 BooksController
的类,用于定义 API 控制器。 它还在 Models 文件夹中添加名为 BooksAPIContext
的类,该类定义 Entity Framework 的数据上下文。
设定数据库种子
在“工具”菜单中,选择“ NuGet 包管理器”,然后选择“ 包管理器控制台”。
在“Package Manager Console”窗口中,输入以下命令:
Add-Migration
此命令创建 Migrations 文件夹并添加名为 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": "Midnight 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
实例。 我们将使用 Queryable.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);
}
}
}
注意
我删除了 PutBook
、 PostBook
和 DeleteBook
方法,因为本教程不需要它们。
现在,如果运行应用程序并请求 /api/books/1,则响应正文应如下所示:
{"Title":"Midnight 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/books” | http://localhost/api/books |
GetBook |
“api/books/{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": "Midnight 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 模板中包含 {genre} 参数。 请注意,Web API 能够区分这两个 URI 并将其路由到不同的方法:
/api/books/1
/api/books/fantasy
这是因为 方法 GetBook
包含一个约束,即“id”段必须是整数值:
[Route("{id:int}")]
public BookDto GetBook(int id)
{
// ...
}
如果请求 /api/book/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”的日期匹配。 请注意,我们不会使用正则表达式来验证我们是否获得了真实日期。 当 Web API 尝试将 URI 段转换为 DateTime 实例时,会处理此情况。 无效日期(如“2012-47-99”)将无法转换,并且客户端将收到 404 错误。
还可以通过添加具有不同正则表达式 /api/books/date/yyyy/mm/dd
的另一个 [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)
{
// ...
}
这里有一个微妙但重要的细节。 第二个路由模板在 {pubdate} 参数的开头具有通配符 (*) :
{*pubdate: ... }
这会告知路由引擎 {pubdate} 应与 URI 的其余部分匹配。 默认情况下,模板参数与单个 URI 段匹配。 在这种情况下,我们希望 {pubdate} 跨多个 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 时,属性路由提供了更大的控制权和更大的灵活性。