使用靜態基類方案讓 ASP.NET Core 實現遵循 HATEOAS Restful Web API. 還有一個動態的方案, 以後再寫. ...
Hypermedia As The Engine Of Application State (HATEOAS)
HATEOAS(Hypermedia as the engine of application state)是 REST 架構風格中最複雜的約束,也是構建成熟 REST 服務的核心。它的重要性在於打破了客戶端和伺服器之間嚴格的契約,使得客戶端可以更加智能和自適應,而 REST 服務本身的演化和更新也變得更加容易。
HATEOAS的優點有:
具有可進化性並且能自我描述
超媒體(Hypermedia, 例如超鏈接)驅動如何消費和使用API, 它告訴客戶端如何使用API, 如何與API交互, 例如: 如何刪除資源, 更新資源, 創建資源, 如何訪問下一頁資源等等.
例如下麵就是一個不使用HATEOAS的響應例子:
{ "id" : 1, "body" : "My first blog post", "postdate" : "2015-05-30T21:41:12.650Z" }
如果不使用HATEOAS的話, 可能會有這些問題:
- 客戶端更多的需要瞭解API內在邏輯
- 如果API發生了一點變化(添加了額外的規則, 改變規則)都會破壞API的消費者.
- API無法獨立於消費它的應用進行進化.
如果使用HATEOAS:
{ "id" : 1, "body" : "My first blog post", "postdate" : "2015-05-30T21:41:12.650Z", "links" : [ { "rel" : "self", "href" : http://blog.example.com/posts/{id}, "method" : "GET" },
{
"rel": "update-blog",
"href": http://blog.example.com/posts/{id},
"method" "PUT"
}
.... ] }
這個response裡面包含了若幹link, 第一個link包含著獲取當前響應的鏈接, 第二個link則告訴客戶端如何去更新該post.
Roy Fielding的一句名言: "如果在部署的時候客戶端把它們的控制項都嵌入到了設計中, 那麼它們就無法獲得可進化性, 控制項必須可以實時的被髮現. 這就是超媒體能做到的." ????
比如說針對上面的例子, 我可以在不改變響應主體結果的情況下添加另外一個刪除的功能(link), 客戶端通過響應里的links就會發現這個刪除功能, 但是對其他部分都沒有影響.
所以說HTTP協議還是很支持HATEOAS的:
如果你仔細想一下, 這就是我們平時瀏覽網頁的方式. 瀏覽網站的時候, 我們並不關心網頁裡面的超鏈接地址是否變化了, 只要知道超鏈接是乾什麼就可以.
我們可以點擊超鏈接進行跳轉, 也可以提交表單, 這就是超媒體驅動應用程式(瀏覽器)狀態的例子.
如果伺服器決定改變超鏈接的地址, 客戶端程式(瀏覽器)並不會因為這個改變而發生故障, 這就瀏覽器使用超媒體響應來告訴我們下一步該怎麼做.
那麼怎麼展示這些link呢?
JSON和XML並沒有如何展示link的概念. 但是HTML卻知道, anchor元素:
<a href="uri" rel="type" type="media type">
href包含了URI
rel則描述了link如何和資源的關係
type是可選的, 它表示了媒體的類型
為了支持HATEOAS, 這些形式就很有用了:
{ ... "links" : [ { "rel" : "self", "href" : http://blog.example.com/posts/{id}, "method" : "GET" } .... ] }
method: 定義了需要使用的方法
rel: 表明瞭動作的類型
href: 包含了執行這個動作所包含的URI.
為了讓ASP.NET Core Web API 支持HATEOAS, 得需要自己手動編寫代碼實現. 有兩種辦法:
靜態類型方案: 需要基類(包含link)和包裝類, 也就是返回的資源的ViewModel裡面都含有link, 通過繼承於同一個基類來實現.
動態類型方案: 需要使用例如匿名類或ExpandoObject等, 對於單個資源可以使用ExpandoObject, 而對於集合類資源則使用匿名類.
這一篇文章介紹如何實施第一種方案 -- 靜態類型方案
首先需要準備一個asp.net core 2.0 web api的項目. 項目搭建的過程就不介紹了, 我的很多文章里都有介紹.
下麵開始建立Domain Model -- Vehicle.cs:
using SalesApi.Core.Abstractions.DomainModels; namespace SalesApi.Core.DomainModels { public class Vehicle: EntityBase { public string Model { get; set; } public string Owner { get; set; } } }
這裡的父類EntityBase是我的項目特有的, 您可能不需要.
然後為這個類添加約束(資料庫映射的欄位長度, 必填等等) VehicleConfiguration.cs:
using Microsoft.EntityFrameworkCore.Metadata.Builders; using SalesApi.Core.Abstractions.DomainModels; namespace SalesApi.Core.DomainModels { public class VehicleConfiguration : EntityBaseConfiguration<Vehicle> { public override void ConfigureDerived(EntityTypeBuilder<Vehicle> b) { b.Property(x => x.Model).IsRequired().HasMaxLength(50); b.Property(x => x.Owner).IsRequired().HasMaxLength(50); } } }
然後把Vehicle添加到SalesContext.cs:
using Microsoft.EntityFrameworkCore; using SalesApi.Core.Abstractions.Data; using SalesApi.Core.DomainModels; namespace SalesApi.Core.Contexts { public class SalesContext : DbContextBase { public SalesContext(DbContextOptions<SalesContext> options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfiguration(new ProductConfiguration()); modelBuilder.ApplyConfiguration(new VehicleConfiguration()); modelBuilder.ApplyConfiguration(new CustomerConfiguration()); } public DbSet<Product> Products { get; set; } public DbSet<Vehicle> Vehicles { get; set; } public DbSet<Customer> Customers { get; set; } } }
建立IVehicleRepository.cs:
using SalesApi.Core.Abstractions.Data; using SalesApi.Core.DomainModels; namespace SalesApi.Core.IRepositories { public interface IVehicleRepository: IEntityBaseRepository<Vehicle> { } }
這裡面的IEntityBaseRepository也是我項目裡面的類, 您可以沒有.
然後實現這個VehicleRepository.cs:
using SalesApi.Core.Abstractions.Data; using SalesApi.Core.DomainModels; using SalesApi.Core.IRepositories; namespace SalesApi.Repositories { public class VehicleRepository : EntityBaseRepository<Vehicle>, IVehicleRepository { public VehicleRepository(IUnitOfWork unitOfWork) : base(unitOfWork) { } } }
具體的實現是在我的泛型父類裡面了, 所以這裡沒有代碼, 您可能需要實現一下.
然後是重要的部分:
建立一個LinkViewMode.cs 用其表示超鏈接:
namespace SalesApi.Core.Abstractions.Hateoas { public class LinkViewModel { public LinkViewModel(string href, string rel, string method) { Href = href; Rel = rel; Method = method; } public string Href { get; set; } public string Rel { get; set; } public string Method { get; set; } } }
裡面的三個屬性正好就是超鏈接的三個屬性.
然後建立LinkedResourceBaseViewModel.cs, 它將作為ViewModel的父類:
using System.Collections.Generic; using SalesApi.Core.Abstractions.DomainModels; namespace SalesApi.Core.Abstractions.Hateoas { public abstract class LinkedResourceBaseViewModel: EntityBase { public List<LinkViewModel> Links { get; set; } = new List<LinkViewModel>(); } }
這樣一個ViewModel就可以包含多個link了.
然後就可以建立VehicleViewModel了:
using SalesApi.Core.Abstractions.DomainModels; using SalesApi.Core.Abstractions.Hateoas; namespace SalesApi.ViewModels { public class VehicleViewModel: LinkedResourceBaseViewModel { public string Model { get; set; } public string Owner { get; set; } } }
註冊Repository:
services.AddScoped<IVehicleRepository, VehicleRepository>();
註冊Model/ViewModel到AutoMapper:
CreateMap<Vehicle, VehicleViewModel>();
CreateMap<VehicleViewModel, Vehicle>();
建立VehicleController.cs:
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using SalesApi.Core.Abstractions.Hateoas; using SalesApi.Core.DomainModels; using SalesApi.Core.IRepositories; using SalesApi.Core.Services; using SalesApi.Shared.Enums; using SalesApi.ViewModels; using SalesApi.Web.Controllers.Bases; namespace SalesApi.Web.Controllers { [AllowAnonymous] [Route("api/sales/[controller]")] public class VehicleController : SalesBaseController<VehicleController> { private readonly IVehicleRepository _vehicleRepository; private readonly IUrlHelper _urlHelper; public VehicleController( ICoreService<VehicleController> coreService, IVehicleRepository vehicleRepository, IUrlHelper urlHelper) : base(coreService) { _vehicleRepository = vehicleRepository; this._urlHelper = urlHelper; } [HttpGet] [Route("{id}", Name = "GetVehicle")] public async Task<IActionResult> Get(int id) { var item = await _vehicleRepository.GetSingleAsync(id); if (item == null) { return NotFound(); } var vehicleVm = Mapper.Map<VehicleViewModel>(item); return Ok(CreateLinksForVehicle(vehicleVm)); } [HttpPost] public async Task<IActionResult> Post([FromBody] VehicleViewModel vehicleVm) { if (vehicleVm == null) { return BadRequest(); } if (!ModelState.IsValid) { return BadRequest(ModelState); } var newItem = Mapper.Map<Vehicle>(vehicleVm); _vehicleRepository.Add(newItem); if (!await UnitOfWork.SaveAsync()) { return StatusCode(500, "保存時出錯"); } var vm = Mapper.Map<VehicleViewModel>(newItem); return CreatedAtRoute("GetVehicle", new { id = vm.Id }, CreateLinksForVehicle(vm)); } [HttpPut("{id}", Name = "UpdateVehicle")] public async Task<IActionResult> Put(int id, [FromBody] VehicleViewModel vehicleVm) { if (vehicleVm == null) { return BadRequest(); } if (!ModelState.IsValid) { return BadRequest(ModelState); } var dbItem = await _vehicleRepository.GetSingleAsync(id); if (dbItem == null) { return NotFound(); } Mapper.Map(vehicleVm, dbItem); _vehicleRepository.Update(dbItem); if (!await UnitOfWork.SaveAsync()) { return StatusCode(500, "保存時出錯"); } return NoContent(); } [HttpPatch("{id}", Name = "PartiallyUpdateVehicle")] public async Task<IActionResult> Patch(int id, [FromBody] JsonPatchDocument<VehicleViewModel> patchDoc) { if (patchDoc == null) { return BadRequest(); } var dbItem = await _vehicleRepository.GetSingleAsync(id); if (dbItem == null) { return NotFound(); } var toPatchVm = Mapper.Map<VehicleViewModel>(dbItem); patchDoc.ApplyTo(toPatchVm, ModelState); TryValidateModel(toPatchVm); if (!ModelState.IsValid) { return BadRequest(ModelState); } Mapper.Map(toPatchVm, dbItem); if (!await UnitOfWork.SaveAsync()) { return StatusCode(500, "更新時出錯"); } return NoContent(); } [HttpDelete("{id}", Name = "DeleteVehicle")] public async Task<IActionResult> Delete(int id) { var model = await _vehicleRepository.GetSingleAsync(id); if (model == null) { return NotFound(); } _vehicleRepository.Delete(model); if (!await UnitOfWork.SaveAsync()) { return StatusCode(500, "刪除時出錯"); } return NoContent(); } private VehicleViewModel CreateLinksForVehicle(VehicleViewModel vehicle) { vehicle.Links.Add( new LinkViewModel( href: _urlHelper.Link("GetVehicle", new { id = vehicle.Id }), rel: "self", method: "GET")); vehicle.Links.Add( new LinkViewModel( href: _urlHelper.Link("UpdateVehicle", new { id = vehicle.Id }), rel: "update_vehicle", method: "PUT")); vehicle.Links.Add( new LinkViewModel( href: _urlHelper.Link("PartiallyUpdateVehicle", new { id = vehicle.Id }), rel: "partially_update_vehicle", method: "PATCH")); vehicle.Links.Add( new LinkViewModel( href: _urlHelper.Link("DeleteVehicle", new { id = vehicle.Id }), rel: "delete_vehicle", method: "DELETE")); return vehicle; } } }
在Controller里, 查詢方法返回的都是ViewModel, 我們需要為ViewModel生成Links, 所以我建立了CreateLinksForVehicle方法來做這件事.
假設客戶通過API得到一個Vehicle的時候, 它可能會需要得到修改(整體修改和部分修改)這個Vehicle的鏈接以及刪除這個Vehicle的鏈接. 所以我把這兩個鏈接放進去了, 當然別忘了還有本身的鏈接也一定要放進去, 放在最前邊.
這裡我使用了IURLHelper, 它會通過Action的名字來定位Action, 所以我把相應Action都賦上了Name屬性.
在ASP.NET Core 2.0裡面使用IUrlHelper需要在Startup裡面註冊:
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>(); services.AddSingleton<IActionContextAccessor, ActionContextAccessor>(); services.AddScoped<IUrlHelper>(factory => { var actionContext = factory.GetService<IActionContextAccessor>() .ActionContext; return new UrlHelper(actionContext); });
最後, 在調用Get和Post方法返回的時候使用CreateLinksForVehicle方法對要返回的VehicleViewModel進行包裝, 生成links.
下麵我們可以使用POSTMAN來測試一下效果:
首先添加一筆數據:
返回結果:
沒問題, 這就是我想要的效果.
然後看一下GET:
也沒問題.
針對集合類返回結果
上面的例子都是返回單筆數據, 如果返回集合類的數據, 我當然可以遍歷集合里的每一個數據, 然後做CreateLinksForVehicle. 但是這樣就無法添加這個GET集合Action本身的link了. 所以針對集合類結果需要再做一個父類.
LinkedCollectionResourceWrapperViewModel.cs:using System.Collections.Generic; namespace SalesApi.Core.Abstractions.Hateoas { public class LinkedCollectionResourceWrapperViewModel<T> : LinkedResourceBaseViewModel where T : LinkedResourceBaseViewModel { public LinkedCollectionResourceWrapperViewModel(IEnumerable<T> value) { Value = value; } public IEnumerable<T> Value { get; set; } } }
這裡, 我把集合數據包裝到了這個類的value屬性里.
然後在Controller裡面添加另外一個方法:
private LinkedCollectionResourceWrapperViewModel<VehicleViewModel> CreateLinksForVehicle(LinkedCollectionResourceWrapperViewModel<VehicleViewModel> vehiclesWrapper) { vehiclesWrapper.Links.Add( new LinkViewModel(_urlHelper.Link("GetAllVehicles", new { }), "self", "GET" )); return vehiclesWrapper; }
然後針對集合查詢的ACTION我這樣修改:
[HttpGet(Name = "GetAllVehicles")] public async Task<IActionResult> GetAll() { var items = await _vehicleRepository.All.ToListAsync(); var results = Mapper.Map<IEnumerable<VehicleViewModel>>(items); results = results.Select(CreateLinksForVehicle); var wrapper = new LinkedCollectionResourceWrapperViewModel<VehicleViewModel>(results); return Ok(CreateLinksForVehicle(wrapper)); }
這裡主要有三項工作:
- 通過results.Select(x => CreateLinksForVehicle(x)) 對集合的每個元素添加links.
- 然後把集合用上面剛剛建立的父類進行包裝
- 使用剛剛建立的CrateLinksForVehicle重載方法對這個包裝的集合添加本身的link.
最後看看效果:
嗯, 沒問題.
這是第一種實現HATEOAS的方案, 另外一種等我稍微研究下再寫.