使用 dynamic 型別讓 ASP.NET Core 實現 HATEOAS 結構的 RESTful API

solenovex發表於2018-04-08

上一篇寫的是使用靜態基類方法的實現步驟:  http://www.cnblogs.com/cgzl/p/8726805.html

使用dynamic (ExpandoObject)的好處就是可以動態組建返回型別, 之前使用的是ViewModel, 如果想返回結果的話, 肯定需要把ViewModel所有的屬性都返回, 如果屬性比較多, 就有可能造成效能和靈活性等問題. 而使用ExpandoObject(dynamic)就可以解決這個問題.

返回一個物件

返回一個dynamic型別的物件, 需要把所需要的屬性從ViewModel抽取出來並轉化成dynamic物件, 這裡所需要的屬性通常是從引數傳進來的, 例如針對下面的CustomerViewModel類, 引數可能是這樣的: "Name, Company":

using System;
using SalesApi.Core.Abstractions.DomainModels;

namespace SalesApi.ViewModels
{
    public class CustomerViewModel: EntityBase
    {
        public string Company { get; set; }
        public string Name { get; set; }
        public DateTimeOffset EstablishmentTime { get; set; }
    }
}

還需要一個Extension Method可以把物件按照需要的屬性轉化成dynamic型別:

using System;
using System.Collections.Generic;
using System.Dynamic;
using System.Reflection;

namespace SalesApi.Shared.Helpers
{
    public static class ObjectExtensions
    {
        public static ExpandoObject ToDynamic<TSource>(this TSource source, string fields = null)
        {
            if (source == null)
            {
                throw new ArgumentNullException("source");
            }

            var dataShapedObject = new ExpandoObject();
            if (string.IsNullOrWhiteSpace(fields))
            {
                // 所有的 public properties 應該包含在ExpandoObject裡 
                var propertyInfos = typeof(TSource).GetProperties(BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance);
                foreach (var propertyInfo in propertyInfos)
                {
                    // 取得源物件上該property的值
                    var propertyValue = propertyInfo.GetValue(source);
                    // 為ExpandoObject新增field
                    ((IDictionary<string, object>)dataShapedObject).Add(propertyInfo.Name, propertyValue);
                }
                return dataShapedObject;
            }

            // field是使用 "," 分割的, 這裡是進行分割動作.
            var fieldsAfterSplit = fields.Split(',');
            foreach (var field in fieldsAfterSplit)
            {
                var propertyName = field.Trim();

                // 使用反射來獲取源物件上的property
                // 需要包括public和例項屬性, 並忽略大小寫.
                var propertyInfo = typeof(TSource).GetProperty(propertyName, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance);
                if (propertyInfo == null)
                {
                    throw new Exception($"沒有在‘{typeof(TSource)}’上找到‘{propertyName}’這個Property");
                }

                // 取得源物件property的值
                var propertyValue = propertyInfo.GetValue(source);
                // 為ExpandoObject新增field
                ((IDictionary<string, object>)dataShapedObject).Add(propertyInfo.Name, propertyValue);
            }

            return dataShapedObject;
        }
    }
}

注意: 這裡的邏輯是如果沒有選擇需要的屬性的話, 那麼就返回所有合適的屬性.

然後在CustomerController裡面:

首先建立為物件新增link的方法:

        private IEnumerable<LinkViewModel> CreateLinksForCustomer(int id, string fields = null)
        {
            var links = new List<LinkViewModel>();
            if (string.IsNullOrWhiteSpace(fields))
            {
                links.Add(
                    new LinkViewModel(_urlHelper.Link("GetCustomer", new { id = id }),
                    "self",
                    "GET"));
            }
            else
            {
                links.Add(
                    new LinkViewModel(_urlHelper.Link("GetCustomer", new { id = id, fields = fields }),
                    "self",
                    "GET"));
            }

            links.Add(
                new LinkViewModel(_urlHelper.Link("DeleteCustomer", new { id = id }),
                "delete_customer",
                "DELETE"));

            links.Add(
                new LinkViewModel(_urlHelper.Link("CreateCustomer", new { id = id }),
                "create_customer",
                "POST"));

            return links;
        }

針對返回一個物件, 新增了本身的連線, 新增的連線 以及 刪除的連線.

然後修改Get和Post的Action:

        [HttpGet]
        [Route("{id}", Name = "GetCustomer")]
        public async Task<IActionResult> Get(int id, string fields)
        {
            var item = await _customerRepository.GetSingleAsync(id);
            if (item == null)
            {
                return NotFound();
            }
            var customerVm = Mapper.Map<CustomerViewModel>(item);
            var links = CreateLinksForCustomer(id, fields);
            var dynamicObject = customerVm.ToDynamic(fields) as IDictionary<string, object>;
            dynamicObject.Add("links", links);
            return Ok(dynamicObject);
        }

        [HttpPost(Name = "CreateCustomer")]
        public async Task<IActionResult> Post([FromBody] CustomerViewModel customerVm)
        {
            if (customerVm == null)
            {
                return BadRequest();
            }

            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            var newItem = Mapper.Map<Customer>(customerVm);
            _customerRepository.Add(newItem);
            if (!await UnitOfWork.SaveAsync())
            {
                return StatusCode(500, "儲存時出錯");
            }

            var vm = Mapper.Map<CustomerViewModel>(newItem);

            var links = CreateLinksForCustomer(vm.Id);
            var dynamicObject = vm.ToDynamic() as IDictionary<string, object>;
            dynamicObject.Add("links", links);

            return CreatedAtRoute("GetCustomer", new { id = dynamicObject["Id"] }, dynamicObject);
        }

紅色部分是相關的程式碼. 建立links之後把vm物件按照需要的屬性轉化成dynamic物件. 然後往這個dynamic物件裡面新增links屬性. 最後返回該物件.

下面測試一下.

POST:

結果:

由於POST方法裡面沒有選擇任何fields, 所以返回所有的屬性.

下面試一下GET:

 

再試一下GET, 選擇幾個fields:

OK, 效果都如預期.

但是有一個問題, 因為返回的json的Pascal case的(只有dynamic物件返回的是Pascal case, 其他ViewModel現在返回的都是camel case的), 而camel case才是更好的選擇 .

所以在Startup裡面可以這樣設定:

            services.AddMvc(options =>
            {
                options.ReturnHttpNotAcceptable = true;
                // the default formatter is the first one in the list.
                options.OutputFormatters.Remove(new XmlDataContractSerializerOutputFormatter());

                // set authorization on all controllers or routes
                var policy = new AuthorizationPolicyBuilder()
                    .RequireAuthenticatedUser()
                    .Build();
                options.Filters.Add(new AuthorizeFilter(policy));
            })
            .AddJsonOptions(options =>
            {
                options.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
            })
            .AddFluetValidations();

然後再試試:

OK.

 

返回集合

 首先編寫建立links的方法:

        private IEnumerable<LinkViewModel> CreateLinksForCustomers(string fields = null)
        {
            var links = new List<LinkViewModel>();
            if (string.IsNullOrWhiteSpace(fields))
            {
                links.Add(
                   new LinkViewModel(_urlHelper.Link("GetAllCustomers", new { fields = fields }),
                   "self",
                   "GET"));
            }
            else
            {
                links.Add(
                   new LinkViewModel(_urlHelper.Link("GetAllCustomers", new { }),
                   "self",
                   "GET"));
            }
            return links;
        }

這個很簡單.

然後需要針對IEnumerable<T>型別建立把ViewModel轉化成dynamic物件的Extension方法:

using System;
using System.Collections.Generic;
using System.Dynamic;
using System.Reflection;

namespace SalesApi.Shared.Helpers
{
    public static class IEnumerableExtensions
    {
        public static IEnumerable<ExpandoObject> ToDynamicIEnumerable<TSource>(this IEnumerable<TSource> source, string fields)
        {
            if (source == null)
            {
                throw new ArgumentNullException("source");
            }

            var expandoObjectList = new List<ExpandoObject>();
            var propertyInfoList = new List<PropertyInfo>();
            if (string.IsNullOrWhiteSpace(fields))
            {
                var propertyInfos = typeof(TSource).GetProperties(BindingFlags.Public | BindingFlags.Instance);
                propertyInfoList.AddRange(propertyInfos);
            }
            else
            {
                var fieldsAfterSplit = fields.Split(',');
                foreach (var field in fieldsAfterSplit)
                {
                    var propertyName = field.Trim();
                    var propertyInfo = typeof(TSource).GetProperty(propertyName, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance);
                    if (propertyInfo == null)
                    {
                        throw new Exception($"Property {propertyName} wasn't found on {typeof(TSource)}");
                    }
                    propertyInfoList.Add(propertyInfo);
                }
            }

            foreach (TSource sourceObject in source)
            {
                var dataShapedObject = new ExpandoObject();
                foreach (var propertyInfo in propertyInfoList)
                {
                    var propertyValue = propertyInfo.GetValue(sourceObject);
                    ((IDictionary<string, object>)dataShapedObject).Add(propertyInfo.Name, propertyValue);
                }
                expandoObjectList.Add(dataShapedObject);
            }

            return expandoObjectList;
        }
    }
}

注意: 反射的開銷很大, 注意效能.

然後修改GetAll方法:

        [HttpGet(Name = "GetAllCustomers")]
        public async Task<IActionResult> GetAll(string fields)
        {
            var items = await _customerRepository.GetAllAsync();
            var results = Mapper.Map<IEnumerable<CustomerViewModel>>(items);
            var dynamicList = results.ToDynamicIEnumerable(fields);
            var links = CreateLinksForCustomers(fields);
            var dynamicListWithLinks = dynamicList.Select(customer =>
            {
                var customerDictionary = customer as IDictionary<string, object>;
                var customerLinks = CreateLinksForCustomer(
                    (int)customerDictionary["Id"], fields);
                customerDictionary.Add("links", customerLinks);
                return customerDictionary;
            });
            var resultWithLink = new {
                Value = dynamicListWithLinks,
                Links = links
            };
            return Ok(resultWithLink);
        }

 

紅色部分是相關程式碼.

測試一下:

不選擇屬性:

選擇部分屬性:

OK.

 

HATEOAS這部分就寫到這.

其實 翻頁的邏輯很適合使用HATEOAS結構. 有空我再寫一個翻頁的吧.

 

相關文章