学习ASP.NET Core(09)-数据塑形与HATEOAS及内容协商

上一篇我们介绍了过滤与搜索、分页与排序,并在一个控制器方法中完成了对应功能的添加;本章我们将介绍数据塑形与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

声明

原文地址:https://www.cnblogs.com/Jscroop/p/12969405.html