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 的方案, 另外一种等我稍微研究下再写.
来源: https://www.cnblogs.com/cgzl/p/8726805.html