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了. 所以針對集合類結果需要再做一個父類.
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的方案, 另外一種等我稍微研究下再寫.