Web APi之捕获请求原始内容的实现方法以及接受POST请求多个参数多种解决方案(十四)
前言
我们知道在Web APi中捕获原始请求的内容是肯定是很容易的,但是这句话并不是完全正确,前面我们是不是讨论过,在Web APi中,如果对于字符串发出非Get请求我们则会出错,为何?因为Web APi对于简单的值不能很好的映射。之前我们谈论过请求内容注意事项问题,本节我们将更加深入的来讨论这个问题,我们会循序渐进进行探讨,并给出可行的解决方案,。细细品,定让你收货多多!
捕获复杂属性值
Web APi对于复杂属性值以JSON或者XML的形式成功发送到服务器,基于这点是非常容易而且简单的,如果我们想捕获一个对象,我们只需简单的创建一个控制并在其方法上有一个对象参数即可,因为Web APi会自动以解码JSON或者XML的处理形式到控制器上的方法参数对象中,如下:
[HttpPost] public HttpResponseMessage PostPerson(Person person) { }
对于上述我们不需要获得person并进行解析,Web APi内部会自动检测content type,并将其映射到MediaFormatter媒体格式并将其转换为JSON或者XML格式,或者说我们配置的其他类型,并将其转换为对应的格式。
如果我们是发出POST请求的表单数据,且表单数据以键值对的形式进行编码,此时Web APi会利用模型绑定将其表单的键映射到对象的属性中,所以由上知,对于复杂类型的映射那将是非常简单的,这点和MVC模型绑定类似,以上就是复杂类型映射的一部分。接着我们将继续进行讨论,请往下看。
捕获原始请求内容
对于这个请求却不如上述复杂类型的映射那么简单并且透明,例如,当我们想要通过简单的参数如string、 number、DateTime等等。都说复杂的并不复杂,简单的反而不简单,从这里看出,老外是不是也吸取了这句话的精华呢。因为Web APi是基于宿主约定,对于一些通过POST或者PUT请求的操作来捕获其值,这是很容易的,但是就如以上复杂类型它不会进行自动检测其类型进行映射,而且是不透明的。
我们可能会进行如下操作,并且认为结果会如我们所料,我们会认为获取其值并进行映射到方法上的参数中。
[HttpPost] public string PostRawContent(string content) { return content; }
如上,最终没能如我们所愿,并且还给我们任何提示,为何?因为此方法的参数签名是有问题的。我们就不演示了,我们这里可以总结出如下结论:
当我们发出POST值时,以下参数签名是无效的。
(1)原始缓存数据内容
(2)带有application/json content type的JSON字符串
(3)经过编码的表单变量
(4)QueryString变量
事实上,我们在POST发出请求中字符串内容时,此时字符串总是空,这样的结果对于Number、DateTime、byte[]皆是如此,在没有添加特性的情况下都是不会进行映射,除了复杂类型比如对象、数组等。由此我们不得不想到在Web APi中对于参数的绑定,参数绑定默认情况下是利用了某种算法进行映射,且都是基于媒体类型例如(content-type header) ,当我们POST一个字符串或者字节数组时,此时Web APi内部不知道如何去映射它,是将其映射到字节数组?是将其映射到字符串?还是将其映射到表单数据?不得而知,因此需要对此作出一些处理才行。请继续往下看。
为什么JSON字符串无效?
我们其实应该将其解释为原始字符串,而不是JSON字符串,令我们非常疑惑的是POST一个有application/json content type的JSON字符串将是无效的,像如下:
POST ...... Host: ...... Content-type: application/json; charset=utf-8 Content-Length: ...... "POST a JSON string"
此上是一个验证JSON的请求,但是结果是无法进行映射而失败。
添加【FromBody】特性到方法签名的参数中
我们可以通过参数绑定特性到方法签名上的参数中,这样就告诉Web APi这个内容的显式来源,【FromBody】抑或【FromUrl】特性强迫POST请求的中的内容会被进行映射。例如:
[HttpPost] public string PostRaw([FromBody] string text) { return text; }
这样之后就允许来自Body中的内容以JSON或者XML形式进行映射,以上是演示字符串,对于其他简单类型亦是如此,现在如果我们想POST,如下:
POST ...... Content-Type: application/json; charset=utf-8 Host: ...... Content-Length: ...... "POST a JSON string"
现在我们就行获得原始参数映射属性,因为输入的字符串是以JSON格式输入。从此知,用【FromBody】特性标记参数能够被映射,主要是对于要序列化的内容,例如:JSON或者XML。它要求数据以某种格式进行传输,【FromBody】当然也只能在单一POST表单变量中有效,但是它的限制是仅仅只能对于一个参数。
但是,假如我们想捕获整个原始内容利用【FromBody】将是无效的,也就是说,如果数据不会经过JSON或者XML编码的话,此时利用【FromBody】将毫无帮助。
捕获请求原始内容
如果我们不使用自定义扩展的参数绑定,我们还是有办法来捕获原始Http请求内容,但是此时无法将其原始捕获值赋到一个参数上,利用这个是非常的简单,代码如下:
[HttpPost] public async Task<string> PostRaw() { string result = await Request.Content.ReadAsStringAsync(); return result; }
ReadAsStringAsync 方法还有其他重载来捕获如byte[]或者Stream等原始内容,似乎非常简单。但是这样就解决问题了吗,如果是要捕获其他类型的呢?难道我们写重载方法吗?就我们所描述的问题,这根本不是解决方案,而是解决问题。千呼万唤始出来,最终解决方案出来了,请往下看。
创建自定义参数绑定
为了解决我们上述所描述捕获请求中的原始内容,我们不得的手动来实现的参数绑定,工作原理和【FromBody】实现方式类似,不过涉及Web APi中更多内容,感兴趣话可以参考我最后给出有关Web APi的整个生命周期去进行了解。为了解决这个问题,我们需要实现两点
(1)自定义参数绑定类
(2)自定义参数绑定特性来绑定参数
创建参数绑定类
首先,我们一个参数绑定特性类来获取请求中的内容并将其可以应用到任何控制器上的方法的参数上。 默认情况下是使用基于媒体类型的绑定来处理来自JSON或者XML的模型绑定或者原始数据绑定,我们通过使用【FromBody】、【FromUrl】或者【自定义参数绑定特性】来覆盖默认的参数绑定行为,当Web APi解析控制器上的方法签名时参数绑定会被调用。下面我们开始进行实现。
- 定义一个自定义参数绑定类,并继承于HttpParameterBinding
public class CustomParameterBinding : HttpParameterBinding { public CustomParameterBinding(HttpParameterDescriptor descriptor) : base(descriptor) { } public override Task ExecuteBindingAsync(ModelMetadataProvider metadataProvider, HttpActionContext actionContext, CancellationToken cancellationToken) { var binding = actionContext .ActionDescriptor .ActionBinding; if (binding.ParameterBindings.Length > 1 || actionContext.Request.Method == HttpMethod.Get) return EmptyTask.Start();
}
......
}
- 若参数绑定同样只适用一个参数并且是非GET请求,若不满足,此时将执行一个空任务【EmptyTask】
public class EmptyTask { public static Task Start() { var taskSource = new TaskCompletionSource<AsyncVoid>(); taskSource.SetResult(default(AsyncVoid)); return taskSource.Task as Task; } private struct AsyncVoid { } }
- 当满足条件后,则进行参数类型判断并获取原始内容
if (type == typeof(string)) { return actionContext.Request.Content .ReadAsStringAsync() .ContinueWith((task) => { var stringResult = task.Result; SetValue(actionContext, stringResult); }); } else if (type == typeof(byte[])) { return actionContext.Request.Content .ReadAsByteArrayAsync() .ContinueWith((task) => { byte[] result = task.Result; SetValue(actionContext, result); }); }
- 综上,整个代码如下:
public class CustomParameterBinding : HttpParameterBinding { public CustomParameterBinding(HttpParameterDescriptor descriptor) : base(descriptor) { } public override Task ExecuteBindingAsync(ModelMetadataProvider metadataProvider, HttpActionContext actionContext, CancellationToken cancellationToken) { var binding = actionContext .ActionDescriptor .ActionBinding; if (binding.ParameterBindings.Length > 1 || actionContext.Request.Method == HttpMethod.Get) return EmptyTask.Start(); var type = binding .ParameterBindings[0] .Descriptor.ParameterType; if (type == typeof(string)) { return actionContext.Request.Content .ReadAsStringAsync() .ContinueWith((task) => { var stringResult = task.Result; SetValue(actionContext, stringResult); }); } else if (type == typeof(byte[])) { return actionContext.Request.Content .ReadAsByteArrayAsync() .ContinueWith((task) => { byte[] result = task.Result; SetValue(actionContext, result); }); } throw new InvalidOperationException("Only string and byte[] are supported for [CustomParameterBinding] parameters"); } public override bool WillReadBody { get { return true; } } }
参数绑定方法 ExecuteBindingAsync() 方法用来处理参数的转换,通过上述Web APi提供给我们的ActionContext来根据参数类型决定参数是否是我们需要处理的参数,若检测到该请求为非GET请求并且参数只有一个那将进行接下来的处理,读取Body中的请求内容,最终调用SetValue()方法来设置其值到绑定参数上,否则将忽略绑定。稍微复杂一点的就是异步任务的操作逻辑,我们知道ExecuteBingdingAsync方法始终都要返回一个Task但是不能返回一个null或者不能获得一个服务器错误,所以当条件不满足时我们需要继续执行操作而不做任何其他事情,所以我们实现一个异步执行任务EmptyTask。
创建参数绑定特性
我们知道自定义实现了参数绑定,我们需要一个机制让Web APi知道一个参数需要这种绑定,所以我们需要将上述参数绑定类进行附加,此种自定义绑定作为默认绑定的话将作为最后一个绑定,但是这种情况下工作并不是很可靠,因为在执行到这里之前如果content type没有匹配到已经注册的媒体类型之一时,Web APi此时将会阻塞,因此一个明确的特性是可靠工作的唯一保证。
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] public sealed class CustomBodyAttribute : ParameterBindingAttribute { public override HttpParameterBinding GetBinding(HttpParameterDescriptor parameter) { if (parameter == null) throw new ArgumentException("Invalid parameter"); return new CustomParameterBinding(parameter); } }
上述CustomBodyAttribute特性继承自ParameterBindingAttribute,此唯一的目的是动态的确定此种绑定将被应用在使用了特性的参数上,这一切无非就是为了创建了上述参数绑定类的实例,并进行传递参数。
使用自定义参数绑定特性验证
上述操作已经全部完成,接下来就是实现,如下:
[HttpPost] public string PostRawContent([CustomBody]string rawContent) { return rawContent; }
单元测试
鉴于上述,我们利用单元测试来试试是否成功。我们利用Xunit来进行测试,代码如下:
public class UnitTest1 { [Fact] public async Task TestMethod1() { string url = "http://localhost:7114/api/product/PostRawContent"; string post = "Hello World"; var httpClient = new HttpClient(); var content = new StringContent(post); var response = await httpClient.PostAsync(url, content); string result = await response.Content.ReadAsStringAsync(); Xunit.Assert.Equal(result, "\"" + post + "\""); } }
测试通过如下:
总结
【FromBody】只适用于接受经过JSON序列化的值,并且仅仅只能是一个参数,若我们想不经过JSON序列化而获得其原始值,那么用【FromBody】标记方法签名的参数将无效。
接受POST请求多个参数解决方案
利用模型绑定不再叙述
利用JSON Formatter
我们给出一个Person类,并在控制器上的方法中的参数中用此类变量来接受传递过来的值,如下:
public class User { public string Name { get; set; } public int Age { get; set; } public string Gender { get; set; } } public class ProductController : ApiController { [HttpPost] public int PostUser(User user) { return user.Age; } }
前台进行传递参数:
var user = { Name: "xpy0928", Age: 12, Gender: "男" }; $("#btn").click(function () { $.ajax({ type: "post", url: "http://localhost:7114/api/product/PostUser/1", dataType: "json", data: JSON.stringify(user), contentType: "application/json", cache: false, error: function (x, c, e) { if (c == "error") { $(this).val(c); } }, success: function (r) { alert(r); } }); });
总结如下:
我们只需创建一个需要传递的参数对象,并利用JSON.stringfy将其序列化成JSON字符串即可
第三种解决方案
对于此种解决方案,我们需要首先来叙述下应用的场景,我们知道第一和第二种解决方案是类似的,这两种解决方案只不过在前台进行处理的方式不同而已,模型绑定总是有效主要是依靠一个单个的对象并将其映射到实体中,但是如果是如下的多个参数呢?
[HttpPost] public int PostUser(User user,string userToken) {}
这样的场景是很常见的,我们应该如何去求解呢?有如下几种解决办法
- 利用POST和QueryString联合解决,这就不再叙述
此种方式只能说暂时解决了问题,对于一个简单的参数用QueryString还可以,如果是多个复杂类型对象的话,这种方式将无效,因为QueryString不支持复杂类型映射,仅仅只对于简单类型才有效。
- 利用单个对象将两个参数进行包裹
我们简单的想象一下,如果如上述要接受这样的参数,我们可以将其作为一个对象来获取,就如同数学中的整体思想,将上述两个参数封装为一个对象来实现,一般来看的话,当我们发出POST请求最终肯定是要获得此请求的结果或者说是请求成功的状态,换言之,也就是我们输入应该包裹输入的多个参数,并且输出最终的结果值,也就是说利用Request和Response来获得其请求并作出响应。如下:
- 用户类依然不变
public class User { public string Name { get; set; } public int Age { get; set; } public string Gender { get; set; } }
- 包裹请求的两个参数
public class UserRequest { public User User { get; set; } public string UserToken { get; set; } }
- 最后响应结果
public class UserResponse { public string Result { get; set; } public int StatusCode { get; set; } public string ErrorMessage { get; set; } }
- 控制器方法接受传入参数
[HttpPost] public UserResponse PostUser(UserRequest userRequest) { var name = userRequest.User.Name; var age = userRequest.User.Age; var userToken = userRequest.UserToken; return new UserResponse() { StatusCode = 200, Result = string.Format("name:{0},age:{1},userToken:{2}", name, age, userToken) }; }
- 前台进行传递参数并将其序列化
var user = { Name: "xpy0928", Age: 12, Gender: "男" }; var userToken = "xpy09284356fd765fdf"; $("#btn").click(function () { $.ajax({ type: "post", url: "http://localhost:7114/api/product/PostUser/1", dataType: "json", data: JSON.stringify({ User: user, UserToken: userToken }), contentType: "application/json", cache: false, error: function (x, c, e) { if (c == "error") { $(this).val(c); } }, success: function (r) { alert(r); } }); });
接下来我们进行验证,是否接受成功
- 利用JObject解析多个属性(完美解决方案,你值得拥有)
上述似乎成功了解决了问题,但是我们不得不为方法签名创建用户接受和响应的对象,如果上述两个参数是频繁要用到,我们是不是就得每次都这样做,这样的话,我们就不能偷懒了,我们所说的懒,不是偷工减料而是有没有做成代码可复用的可能。我们想想,难道就不能将参数抽象成一个单个的对象并且为所有方法进行复用吗?好像很复杂的样子,确实,在JSON.NET未出世之前确实令人头疼,但是现在一切都将变得如此简单。
直接在Web APi上进行全自动包装是不可能的,但是有了JSON.NET代替JSON.Serializer我们就再也不用担心了,我们利用JObject来接受一个静态的JSON结果,并最终将JObject的子对象进行动态转换为强类型对象即可
- 控制器方法改造
[HttpPost] public string PostUser(JObject jb) { dynamic json = jb; //获得动态对象 JObject userJson = json.User; //获取动态对象中子对象 string userToken = json.UserToken; var user = userJson.ToObject<User>(); //将其转换为强类型对象 return string.Format("name:{0},age:{1},userToken:{2}", user.Name, user.Age, userToken); }
- 前台调用不变
- 瞧瞧验证结果
总结
以上对于POST请求获取多个参数的方式可能不是最好的解决方法,将一堆参数串联起来供Web APi来调用,在理想情况下,Web APi是只接受单一的个参数,但是这并不意味着在任何场景下我们不需要应用上述方法,当我们需要传递几个对象到服务器上时有以上几种方式在不同场景下供我们选择并且是有效的。
说明
最近找工作中,所以博客暂时停止更新,Web APi原理还剩下参数绑定、模型绑定原理解析未更新,后续有时间再进行更新,下面给出Web APi整个生命周期的示意图,有想学习而不知从何学Web APi的原理的园友,可以借助此示意图进行参考学习。