浅析Content Negotation在Nancy的实现和使用

背景介绍

什么是Content Negotation呢?翻译成中文的话就是"内容协商"。当然,如果不清楚HTTP规范(RFC 2616)的话,可以对这个翻译也是一头雾水。

先来看看RFC 2616对其的定义是

The process of selecting the best representation for a given response when there are multiple representations available.

image

这句话是什么意思呢?可以简单的理解为:当存在多个不同的表现形式时,对给定的响应选择一个最好的表现形式的过程

其涉及到相关的请求报文头部有下面几个:

  • Accept:响应可接收的Media Type,如"application/json"等
  • Accept-Charset:可接收的字符集,如"UTF-8"等
  • Accept-Encoding:可接收的内容编码,如"gzip"等
  • Accept-Language:优先选用的自然语言,如"en-us等

本文主要用到的是Accept这个请求报文头!

注:RFC 2616在2014年被拆分成了6个单独的协议!具体可以参见下面的两个链接:

http://www.w3.org/Protocols/rfc2616/rfc2616.html

https://tools.ietf.org/html/rfc2616

前言

可能看了前面一节的背景介绍,大家可能会一脸懵逼,似乎跟本文的主题并不沾边,但是多了解一点相关的知识也是必不可少的

这样我们也可以知道Content Negotation存在的意义。才能对它的使用有一个更精确的定位。

我们都知道Nancy是基于Http协议开发的一个轻量级的Web框架,所以它的内部必然会涉及到Content Negotation的实现。

其实在Nancy的实现和在Web Api的实现可以说是大同小异,如果大家看过这两者这一块的实现,应该也会有同样的感觉。

下面主要介绍Nancy 2.0.0-clinteastwood(基于dotNet Core)。

如何实现?

对于一个web应用程序而言,它的起点一定是路由,Nancy自然也是不会例外。先来看起点!起点是位于Nancy.Route这个命名空间下面的DefaultRouteInvoker

里面有一个Invoke的方法是每个路由都会执行的!

public async Task<Response> Invoke(Route route, CancellationToken cancellationToken, DynamicDictionary parameters, NancyContext context)
{
    object result;

    try
    {
        result = await route.Invoke(parameters, cancellationToken).ConfigureAwait(false);
    }
    catch(RouteExecutionEarlyExitException earlyExitException)
    {
        context.WriteTraceLog(
            sb => sb.AppendFormat(
                    "[DefaultRouteInvoker] Caught RouteExecutionEarlyExitException - reason {0}",
                    earlyExitException.Reason));
        return earlyExitException.Response;
    }

    if (!(result is ValueType) && result == null)
    {
        context.WriteTraceLog(
            sb => sb.AppendLine("[DefaultRouteInvoker] Invocation of route returned null"));

        result = new Response();
    }

    return this.negotiator.NegotiateResponse(result, context);
}

除去在执行路由的Invoke方法抛出异常的情况,其他的都是会走NegotiateResponse这个方法!!

从而也就到了本文要讲的重点了。既然每个正常的请求都能要经过它的洗礼,有什么理由不简单的了解一下呢?

一切的开始都是源于IResponseNegotiator这个接口,这个接口也十分的简单,就一个方法的定义。

public interface IResponseNegotiator
{
    Response NegotiateResponse(dynamic routeResult, NancyContext context);
}

正如我们所知,几乎每一个模块,Nancy内部都会有一个默认的实现,正常情况下,都是以Default开头的方法,关于内容协商这一块的自然也会有其对应的默认实现。

这个默认实现位于Nancy.Responses.Negotiation这个命名空间下面!

从上面接口的方法签名可以看出,处理请求时,都需要传递当前路由处理的结果和当前的上下文。

这个上下文,其实在整个Nancy框架中占据着举足轻重的地位,与之类似的有HttpContext等。

当前路由处理的结果可谓是多种多样,只要是正常执行了一个请求里面的return,这个return的内容就是路由的处理结果。

下面通过几个简单的例子介绍一下这些处理结果。

Get("/", x =>
{
    var person = new Person
    {
        Name = "catcher",
        Gender = "man"
    };
    //return Negotiate.ReturnJsonAndXml(person);
    //return Negotiate.WithModel(person);
    //return person;
    //return View["person"];                
    //return Response.AsJson(person);
    //return Response.AsRedirect("/person");
    //return HttpStatusCode.RequestTimeout;
    return "";
});

这几个例子中,我们比较常用到的应该是Response.AsJson、View和Respnse.AsRedirect这3个。

NegotiateResponse方法的第一个参数routeResult不单单包含上面提到的正常的响应信息,

还有一些错误类的信息,如404、500等,这个时候routeResult就会是一个Nancy.ErrorHandling.DefaultStatusCodeHandler.DefaultStatusCodeHandlerResult对象了。

这个对象承载了我们的各种错误类的响应。

下面来看看具体做了什么内容!

在这个方法中执行的第一步就是先判断我们在Module中返回的结果是不是一个Response对象,

如果是一个Response对象就直接将这个对象返回了。具体的片段代码如下:

Response response;
if (TryCastResultToResponse(routeResult, out response))
{
    context.WriteTraceLog(sb =>
        sb.AppendLine("[DefaultResponseNegotiator] Processing as real response"));

    return response;
}

这个时候可能就会有这样的一个疑问,什么样的返回结果是一个Response对象,什么样的返回结果不是呢?

  • Response.AsXXX 这一类的返回结果就属于一个Response对象,这些以As开头的都是一些返回Response对象的扩展方法。
  • Negotiator对象 这一类的返回结果就不是Response对象,所以这一类返回结果是还要继续下面的层层审判!

到这里已经过滤掉了一部分"不属于"Content Negotation处理的请求了!需要注意的是View是属于Negotiator对象这一类的!

第二步是拿到NegotiationContext这个上下文

第三步就是处理Accept这个请求头的内容了

开始这一步的内容之前要先来简单了解一下Accept:

Accept首部字段可以通知服务器,用户代理能够处理的媒体类型及媒体类型的相对优先级。具体的使用形式为:type/subtype,当然也可以一次指定多种媒体类型。

如果想给显示的媒体类型添加优先级,那么就要使用q因子来额外表示该媒体类型的优先级(权重值),具体使用形式为:type/subtype;q=0.8

这个权重的取值范围是0~1(可精确到小数点后三位),最大值为1,并且当没有指定权重的时候,默认的权重就是为1。

所以,当服务器提供了多种不同的内容时,就会先返回权重最高的那个媒体类型。

下面拿一个具体的例子来看一下:

当我们访问博客园时,浏览器的Accept头为text/html, application/xhtml+xml, image/jxr, */*

这就表明浏览器想告诉服务器,“我支持这些媒体类型,你最好返回这些Media Type的数据给我。”

image

当服务器处理好了之后,就会在响应头中的Content-Type表现出要展示什么的内容。

OK,了解完毕,下面来看看Nancy是怎么处理的。

要处理Accept,肯定会有一个定义,从我们上面的了解中,也知道这肯定会是一个集合,每个集合的项包含两个内容:Media Type和权重值。下面来验证一下

public IEnumerable<Tuple<string, decimal>> Accept
{
    get { return this.GetWeightedValues("Accept"); }
    set { this.SetHeaderValues("Accept", value, GetWeightedValuesAsStrings); }
}

Nancy把集合的项定义成了元组(省去定义一个类那么麻烦),元组的第一个元素就是Media Type,第二个就是这个Media Type对应的权重值。

需要注意的是在get的时候,根据权重值对Media Type做了一个降序,后面的处理就直接是按照权重高的优先处理

private IEnumerable<Tuple<string, decimal>> GetWeightedValues(string headerName)
{
    return this.cache.GetOrAdd(headerName, r =>
    {
        var values = this.GetValue(r);
        var result = new List<Tuple<string, decimal>>();

        foreach (var header in values)
        {
            //....
        }
        
        return result.OrderByDescending(x => x.Item2);
    });
}

请求的相关信息都是会记录在Nancy上下文的Request属性中,所以想要处理Accept,NancyContext肯定是必不可少的。

这一步主要的处理是把Nancy上下文中的Accept信息强制转化成一个方便后续处理的集合对象。

前面的这三步可以说是铺垫,后面的处理才是重头戏。

第四步,获取合适的Media Type

var compatibleHeaders = this.GetCompatibleHeaders(coercedAcceptHeaders, negotiationContext, context).ToArray();

Nancy是如何来处理这一块的呢

首先是取到合法的Media Type:

当前negotiationContext的PermissableMediaRanges属性如果包含 */*这个Media Type,就直接把权重大于0的Media Type返回

这里也可以间接说返回的是Accept的所有内容,应该不会有人那么无聊弄个负数或者其他吧?

大部分情况下,权重大于0的就是合法的媒体类型。

拿到合法的媒体类型之后,还要根据媒体类型去拿到对应的内容。如:application/json ,返回一个序列化的Person对象,这个Person对象就是对应的内容。

还要对媒体类型处理,最后返回一个CompatibleHeader集合。

第五步,判断是否有合适的媒体类型,如果没有就直接返回406。

从这一步也得知,当客户端向服务器请求一种服务器无法处理的媒体类型时,就会返回406(Not Acceptable)!

第六步,创建当前请求的Response对象

在Nancy中,请求的最后都是以Response对象的形式呈现在我们面前,所以在创建好一个Response之前 ,Negotiate是属于不完善的!

下面看看是如何创建Response对象的:

首先是用NegotiateResponse方法创建了一个Response对象

var response = NegotiateResponse(compatibleHeaders, negotiationContext, context);    

在NegotiateResponse方法中,通过遍历前面得到的媒体类型集合。

根据每一个媒体类型去拿到对应的一个优先级列表

最后在优先级列表中根据 MediaRange , mediaRangeModel , NancyContext 这三个来判断能否生成一个Respone对象

如果能生成就返回上面创建好的这个Response对象,不能就只好返回null了。

由于这里的Response对象还是有可能为空,所以当其为空的时候,还是应该要向上面那样处理成406

后面就是处理一些响应头部的信息并最终返回这个Response。

下面是完整的NegotiateResponse方法:

public Response NegotiateResponse(dynamic routeResult, NancyContext context)
{
    Response response;
    if (TryCastResultToResponse(routeResult, out response))
    {
        context.WriteTraceLog(sb =>
            sb.AppendLine("[DefaultResponseNegotiator] Processing as real response"));

        return response;
    }

    context.WriteTraceLog(sb =>
        sb.AppendLine("[DefaultResponseNegotiator] Processing as negotiation"));

    NegotiationContext negotiationContext = GetNegotiationContext(routeResult, context);

    var coercedAcceptHeaders = this.GetCoercedAcceptHeaders(context).ToArray();

    context.WriteTraceLog(sb => GetAccepHeaderTraceLog(context, negotiationContext, coercedAcceptHeaders, sb));

    var compatibleHeaders = this.GetCompatibleHeaders(coercedAcceptHeaders, negotiationContext, context).ToArray();

    if (!compatibleHeaders.Any())
    {
        context.WriteTraceLog(sb =>
            sb.AppendLine("[DefaultResponseNegotiator] Unable to negotiate response - no headers compatible"));

        return new NotAcceptableResponse();
    }

    return CreateResponse(compatibleHeaders, negotiationContext, context);
}

上面大致履了一下相应的实现

对于它的大致实现,有了一定的了解,下面来看看具体是要怎么用

如何使用?

平时我们如果用Negotiate的话,基本都是用的Negotiator的扩展方法,输入Negotiator后,可以看到一堆扩展方法,这堆扩展方法就是我们经常用到的。

image

我们先尝试用Negotiate处理一个MIME Type为application/json的请求!

下面是具体的示例代码:

Get("/", x =>
{
    var person = new
    {
        Name = "catcher",
        Gender = "man"
    };

    return Negotiate.WithMediaRangeModel(new MediaRange("application/json"),person);
});

定义了一个匿名对象,并通过WithMdeiaRangeModel这个扩展方法来处理MIME Type和这个匿名对象。

此时,我们希望能够得到结果是对匿名对象进行json序列化后结果,和Response.AsJson得到的应该是基本一致的。

当然,这个时候我们在浏览器打开这个URL时,结果并不是我们所期望的那样!

image

不管三七二十一,来看看这个扩展方法做了一些什么操作!

//直接调用 
public static Negotiator WithMediaRangeModel(this Negotiator negotiator, MediaRange range, object model)
{
    return negotiator.WithMediaRangeModel(range, () => model);
}
//间接调用
public static Negotiator WithMediaRangeModel(this Negotiator negotiator, MediaRange range, Func<object> modelFactory)
{
    negotiator.NegotiationContext.PermissableMediaRanges.Add(range);
    negotiator.NegotiationContext.MediaRangeModelMappings.Add(range, modelFactory);
    return negotiator;
}

幡然醒悟,它是往我们的当前NegotiationContext的PermissableMediaRanges添加了application/json这个媒体类型!

并且此时PermissableMediaRanges集合就包含了两个对象:一个是*/*,一个是appliaction/json

image

我们用了一种错误的方式来请求这个URL!!因为我们是直接用浏览器打开的,而这个时候默认的Accept头是

Accept: text/html, application/xhtml+xml, image/jxr, */*

它并不包含我们所接收请求的application/json,所以它在生成Response对象的时候会抛出异常,然后就看到那个500的错误页面了。

这个时候我们应该借助工具来探讨,可以使用Fiddler、Postman和Charles等工具。

这里我用的是Fiddler,当我们在Composer中添加Accept请求头后,就能正常返回我们想要的结果了。

image

不知道大家是否有留意到这样子返回和用Response.AsJson这样返回有什么区别?

当然,这种写法是只能处理application/json的请求,并不能处理其他MIME Type的请求!

下面我们继续改进一下,让这个请求可以同时接收处理application/xmltext/html这两种MIME Type

Get("/", x =>
{
    var person = new
    {
        Name = "catcher",
        Gender = "man"
    };

    return Negotiate
    .WithMediaRangeModel(new MediaRange("application/json"), person)
    .WithMediaRangeModel(new MediaRange("application/xml"), person)
    .WithView("person")
    .WithModel(person);
});

下面来看看Accept为application/xml的试试:

image

可以看到,它并没有返回我们想要的结果,但是请求却是成功的!这里的问题出在我们定义的那个匿名对象!

这里默认处理的序列化XML的方法是不支持匿名对象的,具体可以参考Nancy对XML处理的方法。

修改匿名对象为实体对象后,它就能把数据正常返回给我们了。

var person = new Person
{
    Name = "catcher",
    Gender = "man"
};

image

可以看到,我们刚才的改造已经能够同时支持json和xml了!对于前面提到的匿名类的问题,如果有需要可以实现一个支持匿名类的序列化方法以达到对匿名类的适配。

前面我们直接在浏览器打开这个URL时,提示我们500错误,现在改进后再来看看能否返回一个正常页面给浏览器!

这个时候我们并没有编写对应的视图,所以得到的必然还是500错误(ViewNotFound)。下面就要处理这个错误。

我们在根目录添加一个person.html文件,并设置它的Copy to Output Directory属性为Copy always

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title></title>
</head>
<body>

    <p>Name:@Model.Name</p>
    <br />
    <p>Gender:@Model.Gender</p>
    
</body>
</html>

我们的页面比较简单,就把刚才实体对象的内容展示一下。此时再运行就可以发现页面已经能正常显示了!

image

其实还有一种比较直接的方法也是用Content Negotation实现的!

不知道大家是否记得在创建一个WEB API项目后,生成的valuecontroller,里面的方法都是直接返回一个数组或字符串。

在Nancy中也可以直接返回这样的一个对象!!来看看下面的这个例子:

Get("/", x =>
{
    var person = new Person
    {
        Name = "catcher",
        Gender = "man"
    };
    return person;
    //对等的写法
    //return Negotiate.WithModel(person);
}

上面的示例代码中也给出了一种等价的写法,最终它是给NegotiationContext的DefaultModel属性赋值为这个对象。

这样直接返回一个对象的写法,似乎就没有那么灵活,我想到的一个用来形容的词就是"任人宰割"

而用Negotiate就可以适当的加上一些控制,毕竟有那么多的扩展方法可以用。如果觉得不够用,那就自己加扩展,加到自己满意为止。

好比说,现在某个api只对MIME类型为application/jsonappliaction/xml的请求进行处理,其他的一概不理。

这个时候,常规有效的做法就是直接用WithMediaRangeModel这个扩展方法

return Negotiate
        .WithMediaRangeModel(new MediaRange("application/json"), person)
        .WithMediaRangeModel(new MediaRange("application/xml"), person);     

这样的写法并没有什么问题,但是并不那么简洁,这个时候我们就可以通过写扩展来让它变得简洁一些。

public static class NegotiateExtensions
{
    public static Negotiator ReturnJson(this Negotiator negotiator, object model)
    {
        return negotiator.WithMediaRangeModel(new MediaRange("application/json"), model);
    }

    public static Negotiator ReturnXml(this Negotiator negotiator, object model)
    {
        return negotiator.WithMediaRangeModel(new MediaRange("application/xml"), model);
    }

    public static Negotiator ReturnJsonAndXml(this Negotiator negotiator, object model)
    {
        return negotiator.ReturnJson(model).ReturnXml(model);
    }
}

使用的时候:

return Negotiate.ReturnJsonAndXml(person);

这样是不是很方便和简洁呢?

总结

内容协商的作用可大可小,如果能多加利用,或许能成为一把利刃。

本文简单的分析了一下内容协商在Nancy中是如何实现的,以及我们平时的开发中是如何使用的。

当然其中有许多相关的细节在文中也没有特别体现出来,如果园友们觉得与这一块密切相关且有必要说明的

可以在评论中指出,也可以私信给我,便于我在后期增加上去。

同样用一张思维导图概括本文:

image

posted @ 2017-03-20 11:15 Catcher8 阅读(...) 评论(...) 编辑 收藏