上一篇我们介绍了过滤与搜索、分页与排序,并在一个控制器方法中完成了对应功能的添加;本章我们将介绍数据塑形与HATEOAS的概念,并添加对应的功能
注:本章内容大多是基于solenovex的使用 ASP.NET Core 3.x 构建 RESTful Web API视频内容,若想进一步了解相关知识,请查看原视频
一、数据塑形
1、定义介绍
数据塑形就是指API用户自由地选择自己需要的字段。举个例子,若一个Dto/ViewModel中存在很多字段,但API用户只需要其中的几个,那我们返回API用户需要的字段就可以了,不需要全部返回。通常情况下我们会添加一个数据塑形字段如fields,并采用QueryString的形式让API用户选择所需字段,如/api/article?fields=title,content
2、集合资源实现
1、这里还是以ArticleController控制器中的GetArticles方法做示例,其对应ArticleService中的逻辑方法返回的是ArticleListViewModel,这里我们需要将其改变为动态类型ExpandoObject,这里我们需要针对IEnumerable进行方法的扩展。我们在Commen层的Helpers文件夹中添加一个名为IEnumerableExtensions的类,实现逻辑如下:
using System;
using System.Collections.Generic;
using System.Dynamic;
using System.Linq;
using System.Reflection;
namespace BlogSystem.Common.Helpers
{
//数据塑形——针对集合的扩展方法
public static class IEnumerableExtensions
{
public static IEnumerable<ExpandoObject> ShapeDataList<TSource>(this IEnumerable<TSource> source, string fields)
{
if (source == null)
{
throw new ArgumentNullException(nameof(source));
}
var expandoObjectList = new List<ExpandoObject>(source.Count());
var propertyInfoList = new List<PropertyInfo>();
//field无字段则反射全部
if (string.IsNullOrWhiteSpace(fields))
{
var propertyInfos = typeof(TSource).GetProperties(BindingFlags.Public | BindingFlags.Instance);
propertyInfoList.AddRange(propertyInfos);
}
else //field有字段则去除空格并判断后添加至list
{
var fieldAfterSplit = fields.Split(",");
foreach (var field in fieldAfterSplit)
{
var propertyName = field.Trim();
var propertyInfo =
typeof(TSource).GetProperty(propertyName, BindingFlags.IgnoreCase
| BindingFlags.Public | BindingFlags.Instance);
if (propertyInfo == null)
{
throw new Exception($"Property:{propertyName}没有找到:{typeof(TSource)}");
}
propertyInfoList.Add(propertyInfo);
}
}
foreach (TSource obj in source)
{
var shapedObj = new ExpandoObject();
//根据获取的属性额值添加到shapedObj中
foreach (var propertyInfo in propertyInfoList)
{
var propertyValue = propertyInfo.GetValue(obj);
((IDictionary<string, object>)shapedObj).Add(propertyInfo.Name, propertyValue);
}
expandoObjectList.Add(shapedObj);
}
return expandoObjectList;
}
}
}
2、另外,我们需要在Model层的ArticleParameters类中添加属性字段public string Fields { get; set; }
3、在最终的实现层ArticleController的GetArticles方法中,将最终返回的list修改如下:
return Ok(list.ShapeDataList(parameters.Fields));
4、同样需要考虑到将生成的三个分页url中加入对应的field字段 fields=parameters.Fields
5、在field中录入希望得到的字段信息,实现效果如下:
3、单个资源实现
1、这里以ArticleController控制器中的GetArticleByArticleId方法做示例,我们需要针对ExpandoObject进行方法的扩展。我们在Commen层的Helpers文件夹中添加一个名为ObjectExtensions的类,实现逻辑与集合资源类似,但是出于性能的考虑,集合资源是将属性信息单独提取出来进行处理,而单个资源则是依次进行判断处理,具体实现如下:
using System;
using System.Collections.Generic;
using System.Dynamic;
using System.Reflection;
namespace BlogSystem.Common.Helpers
{
//数据塑形——单个资源
public static class ObjectExtensions
{
public static ExpandoObject ShapeData<TSource>(this TSource source, string fields)
{
if (source == null)
{
throw new ArgumentNullException(nameof(source));
}
var expandoObj = new ExpandoObject();
if (string.IsNullOrWhiteSpace(fields))
{
var propertyInfos = typeof(TSource).GetProperties(BindingFlags.Public | BindingFlags.IgnoreCase |
BindingFlags.Instance);
foreach (var propertyInfo in propertyInfos)
{
var propertyValue = propertyInfo.GetValue(source);
((IDictionary<string, object>)expandoObj).Add(propertyInfo.Name, propertyValue);
}
}
else
{
var fieldAfterSplit = fields.Split(",");
foreach (var field in fieldAfterSplit)
{
var propertyName = field.Trim();
var propertyInfo =
typeof(TSource).GetProperty(propertyName, BindingFlags.IgnoreCase
| BindingFlags.Public | BindingFlags.Instance);
if (propertyInfo == null)
{
throw new Exception($"在{typeof(TSource)}上没有找到{propertyName}这个属性");
}
var propertyValue = propertyInfo.GetValue(source);
((IDictionary<string, object>)expandoObj).Add(propertyInfo.Name, propertyValue);
}
}
return expandoObj;
}
}
}
2、ArticleController控制器中的GetArticleByArticleId修改如下:
3、实现效果如下
4、异常处理
1、这里我们发现,在输入不存在的字段时,虽然会返回错误提示,但是错误代码为500,这显然是不合理的,这个是客户端引起的错误,应当返回4xx错误。我们在Commen层的Helpers文件夹中添加一个名为PropertyCheckService的类,并定义名为IPropertyCheckService的接口,以达到复用的效果,实现逻辑如下:
using System.Reflection;
namespace BlogSystem.Common.Helpers
{
//判断字段是否存在的服务
public class PropertyCheckService : IPropertyCheckService
{
public bool TypeHasProperties<T>(string fields)
{
if (string.IsNullOrWhiteSpace(fields))
{
return true;
}
var fieldAfterSplit = fields.Split(",");
foreach (var field in fieldAfterSplit)
{
var propertyName = field.Trim();
var propertyInfo =
typeof(T).GetProperty(propertyName, BindingFlags.IgnoreCase
| BindingFlags.Public | BindingFlags.Instance);
if (propertyInfo == null)
{
return false;
}
}
return true;
}
}
}
namespace BlogSystem.Common.Helpers
{
public interface IPropertyCheckService
{
bool TypeHasProperties<T>(string fields);
}
}
2、在BlogSystem.Core项目的StartUp类的ConfigureServices方法中进行上面接口的注入,如下:services.AddTransient<IPropertyCheckService, PropertyCheckService>();
3、在对应的ArticleController方法中进行接口的注入,在获取集合资源的方法中添加判断逻辑,如下:
在获取单个资源的方法中添加判断逻辑,如下:
5、其他说明
数据塑形功能还可以实现父子资源的联合查询,高级过滤等,实际应用中还是需要根据需求进行变化。上述我们只是从功能出发自定义实现,实际上我们可以使用已经实现并封装好了的插件,如微软的OData,有兴趣的朋友可以自行研究。
二、HATEOAS
1、定义介绍
HATEOAS的全程是Hypermedia As The Engine Of Application State,即超媒体作为应用程序状态引擎。它是作为REST统一界面约束中的一个子约束,是REST架构中最重要,最复杂的约束,也是构建成熟REST服务的核心。
它是REST的Richardson成熟度模型中最成熟的一个层次,达到一成熟的的API不仅在响应中包含资源,也包含与之相关的链接,这些链接不仅易于被发现,而且可以通过这些链接发现当前资源所支持的动作,这些动作又能驱动应用程序状态的改变。
2、实际应用
1、上面我们提到HATEOAS会在响应中包含链接,实际上我们正是通过这些链接告知客户端,服务端能提供哪些服务,客户端只需要检查这些链接即可。所以我们要做的就是展示这些link,而每个链接包含三个属性—href、rel和method
- href:用户可以检查资源或者改变应用状态的URL
- rel:描述href指向资源和现有资源的关系
- method:请求该URL要使用的HTTP方法
举个例子,当获取一本图书资源时,服务器能够判断该图书是否能够被借阅,如果可以,则链接中应当包含请求借阅的API的URL和HTTP方法
2、实现HATEOAS我们需要针对集合资源和单个资源进行不同的考虑,而实现方案有两种,静态类型方法和动态类型方案:
静态类型方案:返回的资源中全部包含link,通过继承同一个基类进行实现;
动态类型方案:使用匿名类或之前使用过的动态类型对象ExpandoObject实现,单个资源使用ExpandoObject,而集合资源使用匿名类
3、单个资源实现
这里我们采用动态类型方案进行实现,处理的对象是ArticleController类中的GetArticleByArticleId方法
1、首先我们在Modle层建立一个HATEOAS文件夹,里面添加一个LinkDto类,添加如下信息:
namespace BlogSystem.Model.HATEOAS
{
public class LinkDto
{
public string Href { get; }
public string Rel { get; }
public string Method { get; }
public LinkDto(string href, string rel, string method)
{
Href = href;
Rel = rel;
Method = method;
}
}
}
2、在ArticelController中添加创建link的方法CreateLinksForArticle,我们在内部添加了自身link和删除文章、编辑文章的link,前提是需要为方法命名,如 [Httpxxx(Name = nameof(xxx))],实现逻辑如下:
//实现HATEOAS单个资源的简单方法
private IEnumerable<LinkDto> CreateLinksForArticle(Guid articleId, string fields)
{
var links = new List<LinkDto>();
if (string.IsNullOrWhiteSpace(fields))
{
links.Add(new LinkDto(Url.Link(nameof(GetArticleByArticleId), new { articleId }), "self", "Get"));
}
else
{
links.Add(new LinkDto(Url.Link(nameof(GetArticleByArticleId), new { articleId, fields }), "self", "Get"));
}
//删除文章的link
links.Add(new LinkDto(Url.Link(nameof(RemoveArticle), new { articleId, fields }), "delete_article need_auth", "DELETE"));
//编辑文章的link
links.Add(new LinkDto(Url.Link(nameof(EditArticle), new { articleId }), "edit_article need _auth", "PATCH"));
return links;
}
3、修改ArticleController类中的GetArticleByArticleId方法,如下:
4、实现效果,如下:
4、集合资源实现
1、同样我们在ArticelController中添加创建link的方法CreateLinksForArticles,该方法返回信息是包括分页信息及前后页信息的,所以我们要借助CreateArticleUrl方法,但是在返回当前页面信息时因为页面枚举类UrlType没有添加当前页,所以无法获取,修改枚举类,实现CreateLinksForArticles方法,如下:
namespace BlogSystem.Model.Helpers
{
public enum UrlType
{
PreviousPage,
NextPage,
CurrentPage
}
}
//实现HATEOAS集合资源的简单方法,将自身的前一页信息和后一页信息也放到headoas中
private IEnumerable<LinkDto> CreateLinksForArticles(ArticleParameters parameters, bool hasPrevious, bool hasNext)
{
var links = new List<LinkDto>();
links.Add(new LinkDto(CreateArticleUrl(parameters, UrlType.CurrentPage), "self", "GET"));
if (hasPrevious)
{
links.Add(new LinkDto(CreateArticleUrl(parameters, UrlType.PreviousPage), "Previous", "GET"));
}
if (hasNext)
{
links.Add(new LinkDto(CreateArticleUrl(parameters, UrlType.NextPage), "Next", "GET"));
}
return links;
}
2、主要注意的是集合类型的结果是每条记录都有其自身的HATEOAS,并且每条记录HATEOAS都应该有前后页的信息,所以我们要先删除之前添加的创建前后页面url的逻辑,如下:
3、修改ArticleController类中的GetArticles方法中返回结果的逻辑,实现如下:
4、实现效果如下图所示,集合自身添加前后分页信息,集合内部元素有自身支持方法的links
5、异常处理
可以发现集合资源与其内部元素是依靠articleId来建立联系的,如果使用数据塑形功能但是没有添加articleId字段,系统会产生异常,所以这里我们在数据塑形前加个判断逻辑,如下:
6、其他说明
1、在实际生产中,HATEOAS经常会与单页应用一起被提到,而单页应用往往会存在一个"根"页面。我们这里就不实现了,感兴趣的朋友可以自己研究下,本章一开始提到的视频内容中也是有实现过程的。
2、为方便大家更好的理解,我们从https://www.jianshu.com/p/ecd6a4a7a2e4摘抄了部分内容,如下:
前后端分离的开发模式进一步细化了分工,但同时也引入了不少重复的工作,例如一些业务规则在后端必须实现的情况下,前端也需要再实现一遍以获得更好的用户体验。HATEOAS虽然不是唯一消除这些重复的方法,但作为一种架构原则,它更容易让团队找到消除重复的“套路”。
在非HATOEAS的项目中,由于URI是在客户端硬编码的,即使你把它们设计的非常漂亮(准确的HTTP动词,以复数命名的资源,禁止使用动词等等),也不能帮助你更容易地修改它们,因为你的重构需要前端开发者的配合,而他/她不得不停下手头的其他工作。但在采用了HATEOAS的项目中,这很容易,因为客户端是通过Link来查找API的URI,所以你可以在不破坏API Scheme的情况下修改它的URI。当然,你不可能保证所有API的URI都是通过Link来获取的,你需要安排一些Root Resource,例如 /api/currentLoggedInUser
,否则客户端没有办法发起第一次请求。
三、内容协商
1、定义介绍
在实现HATEOAS时,我们得到的返回结果是{values:[xx,xx,xx...],links:[xx,xx...]}格式的,它是相同资源的不同表述方式,所以服务器应当根据客户端请求的媒体类型(Media Type)返回与之对应的表述资源,否则将破坏自我描述性约束。
2、实际应用
这里我们应当创建一个新的媒体类型,来应对这类情况。通常我们会使用供应商特定媒体类型(Vendor-special media type),缩写为application/vnd.companyName.hateoas+json
- vnd为Vendor的缩写,表示媒体类型是供应商特定的
- companyName为自定义的Vendor标识,通常为公司的名称,当然也可以包括额外的信息
- hateoas是媒体类型的名称,它表示返回的响应里面包含链接信息
- +json表示数据为Json格式,它会告知客户端应当如何处理响应信息
3、功能实现
1、这里我们处理的对象是ArticleController类中的GetArticleByArticleId方法,修改如下:
2、这里使用PostMan测试返回406错误,控制台显示没有对应的输出格式,所以这里我们在startup中添加全局的支持,如下:
3、最终实现如下:
4、其他说明
媒体类型的可以应用在不同的情况下,下面再介绍两种,这里就不实现了,感兴趣的朋友可以自己研究下,本章一开始提到的视频内容中也是有实现过程的。
4.1、Vendor-Specific Media Type输入
在上面的方法中,我们完成了根据特定的媒体类型输出不同表述数据的功能;实际上与之对应的还有输入功能的实现,我们通过设置Content-Type Header来接受不同的媒体类型的输入。比如说编辑文章功能,一般来说只是编辑文章内容,但是在一些情况下我们还希望可以更新创建时间CreateTime,也就是通过输入不同的媒体类型来实现不同的功能。
4.2、带有语义的媒体类型Semantic Media Types
我们还可以通过使用带有语义的媒体类型来告知API使用者数据的语义,比如说希望看到简洁数据和完整数据两类信息,就可以设置两个媒体类型,而不同的媒体类型则可以应对不同的数据结果。
本章完~
该项目源码已更新上传至GitHub,有需要的朋友可以下载使用:https://github.com/Jscroop/BlogSystem
本人知识点有限,若文中有错误的地方请及时指正,方便大家更好的学习和交流。
本文部分内容参考了网络上的视频内容和文章,仅为学习和交流,视频地址如下:
solenovex,ASP.NET Core 3.x 入门视频
solenovex,使用 ASP.NET Core 3.x 构建 RESTful Web API