zoukankan      html  css  js  c++  java
  • 学习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

    声明

  • 相关阅读:
    [日常训练]大灾难
    [cf235D]Graph Game
    [日常训练]选课
    [日常训练]挂科
    [学习笔记]概率&期望
    [日常训练]yayamao的神题
    [学习笔记]原根
    LOJ#2132. 「NOI2015」荷马史诗
    LOJ#2131. 「NOI2015」寿司晚宴
    LOJ#2129. 「NOI2015」程序自动分析
  • 原文地址:https://www.cnblogs.com/Jscroop/p/12969405.html
Copyright © 2011-2022 走看看