《Unity3D高级编程之进阶主程》第六章,网络层(四) - 封装HTTP

HTTP协议原理

HTTP俗称短连接,由于其平均连接的时间较短,不受前端所控制,所以在游戏圈内通常被冠以‘短’字开头。

在Unity3D中编写短连接可以用.NET库来编写,也可以用Unity3D的内置API的WWW来写,差别不是很大。WWW对.Net做了封装,其功能已经完全够用在游戏开发上了,即使有情况不够用再用.NET补充也是相对容易的事。然而作者在编写书本时,WWW已经在2018及以后的版本中都被废弃了,取代它的是UnityWebRequest,Unity3D对网络请求重新做了封装。

WWW与.NET两者的区别主要还是WWW把.NET库封装后再加了层协程的封装,这会使我们开发者在使用时更加的便捷,而.NET库则直接用了线程,开发者需要关注主线程与子线程的资源抢占情况。总的来说WWW经过封装后使用更佳便捷,使用.NET编写HTTP则需要我们自己关注更多细节。

不管协程还是线程,其实两边都需要用到缓存和队列这样的缓存机制,倘若没有缓存机制当网络请求量放大时就会出现混乱以及数据丢失的情况,因此缓存机制在网络层是不可或缺的。

===

我们所说的HTTP,最形象的描述就是网页(Web)形式的请求与回调,它被运用最多的就是网页(Web)请求上。HTTP是一种 请求/响应式 的协议。也就是说,请求通常是由像浏览器这样的用户代理User- Agent发起的,当用户接收到服务器的响应数据后,通过这些数据处理相应的逻辑再反应到画面上,其特点为每个请求都是一一对应的,一个请求有且最多只能得到一个响应。

我们来看看浏览器是如何用HTTP工作的,当我们要展示一个网页内容时,浏览器首先向服务器发送一个HTTP请求并且带上参数,目的是从服务器获取页面的HTML内容,获得HTML美容后再解析其中的资源信息接着发送获取这些资源信息的请求,例如获取可执行脚本、CSS样式资源的请求,以及一些其它页面资源(如图片和视频等)。最终浏览器会将这些资源通过HTML语言规则的形式整合到一起,从而展示一个完整的网页。除了这些浏览器执行的脚本JavaScript也可以在之后的阶段不断发起更多的请求来获取更多信息和资源,不断的叠加、更新到当前的网页内容上。

我们常常会在游戏项目中把这套 请求/响应式 协议搬到了游戏中运用,其最重要的原因是HTTP简单易用,程序员们上手的成本低,功能扩展难度也低,因此被广大互联网程序员所喜爱。

HTTP是应用层上的协议,它需要底层的传输层协议是可靠的,因此 HTTP 依赖于面向连接的TCP协议进行消息传递。HTTP是应用层上的协议就是因为它本身并没有检测是否连接,数据是否传输准确,是否有数据到达等的机制,这些都依赖于传输层的协议TCP协议,由TCP协议来做这些传输层的事情,而HTTP只是在TCP之上制定了自己的规则。

我们来看看 HTTP 在TCP之上制定了哪些规则:

HTTP制定了自己的协议格式,协议分为HEAD和BODY两块
    GET /root1/module?name1=value1&name2=value2 HTTP/1.1
    Host: localhost:8080
    Accept-Language: fr

    //这里 HEAD 和 BODY 用空行隔开

    body content

如上协议所示其中 GET 为向服务器请求的参数方式,HTTP有2种参数请求方式,分别是GET 和 POST。GET请求方式把参数值以 Key-Value 的键值对形式放在地址中传输给服务器,它的格式是

    ?Key1=Value1&Key2=Value2&...

以问号’?’开头作为Get参数格式的开始,接下去每个Key=Value之间用与’&‘符号作为分隔,所有的表达式都是以字符串形式存在。如上文中

    GET /root1/module.php?name1=value1&name2=value2 HTTP/1.1
    Host: localhost:8080
    Accept-Language: fr

就是个很好例子,表达了访问的客户端要访问 Host地址为 localhost:8080 的服务器,采用GET形式传参,访问子地址为 /root1/module 并且参数为 ?name1=value1&name2=value2,采用HTTP1.1的协议逻辑,最后 Accept-Language: fr 客户端支持法语。

POST则是把参数值放在协议数据包中,存放也同样以Key=Value的字符串形式存在,不一样的地方为POST可以使用二进制作为参数值并且存放在Body上,body content是主要存储请求内容的,POST内容都放在这里,而GET则必须以字符串形式明文显性的展示在地址上。

    POST /root1/module.php HTTP/1.1
    Host: localhost:8080

    name1=value1&name2=value2

如上述POST例子所展示的那样,其实两者的实质是一样的,都是以Key1=Value1&Key2=Value2的形式作为请求内容,并且请求的参数都会被写入请求包体中,只是Get必须暴露在地址上而已。

服务器在收到客户端请求并处理相应逻辑后发送响应数据,其数据格式为:

    HTTP/1.1 200
    Date:Mon,31Dec200104:25:57GMT
    Server:Apache/1.3.14(Unix)
    Content-type:text/html
    Last-modified:Tue,17Apr200106:46:28GMT
    Content-length:xxx
    
    //这里HEADS 和 BODY用空行隔开
    body content

例子是服务器端响应客户端请求的处理后的应答数据,里面包含了HTTP的版本,Date日期,Server服务器类型,Content-type内容类型,Content-length内容长度,body content内容主体,其中200为响应后代表请求成功的错误码,其他常用的错误码可以简单理解为 404 找不到请求页,500 服务器程序报错,400 访问请求参数错误,403 被拒绝访问。

HTTP1.0,1.1,2.0的简述

上文的例子中 HTTP/1.1为HTTP协议的版本号,我们现在常用的协议为1.1,也有少部分仍然在使用HTTP1.0。我们常用的HTTP1.1兼容了1.0,并且在1.0之上改进了诸多内容,比如同一个地址不同host,增加了cache特性,增加Chunked transfer-coding标志切割数据块等。而2.0由于和1.1差别比较大,它并不能兼容1.0和1.1,因此HTTP的世界就像被分成了两块,HTTP2.0被运用在HTTPS上,而HTTP1.1和1.0则运行在原有的HTTP上。

腾讯高级工程师杨良聪在他的一篇文章中对HTTP不同版本的问题做了很好的解释。在HTTP1.0中,一个请求就独占一条TCP连接,要并行的获取多个资源就需要建立多条连接。HTTP1.1则引入了持久连接和管线化,允许在应答回来之前就按顺序的发送多个请求,但是服务器端按照请求的顺序发送应答。在同一个TCP连接上,及时后面的请求先处理完,也必须等待前面的应答发送完毕后,才能发送后面的应答,这就是HTTP1.1的队首阻塞问题。HTTP1.0,1.1都是用了文本形式的协议,也就是所有数据都是以字符串形式存在,这样做的导致协议传输和解析效率不高。不过对于这种文本形式的协议体,我们可以通过压缩来减少数据传输量,但对于协议头我们无法压缩,这使得对于那些Body内容比较少的应答数据来说,很可能协议头的传输成为了首要的性能瓶颈。

HTTP2.0则引入了Sream概念,这使得一个TCP连接可以被多个Stream共享,每个Stream上都可以跑单独的请求与应答,从而实现了TCP连接的复用,即单个TCP连接上可以并行传输多个请求与应答数据。另外HTTP2.0不再使用文本协议而采用了二进制协议这使得协议更加紧凑,协议头也可以做到压缩后再传输,减少了数据传输带来的开销。不仅如此,HTTP2.0还可以支持Server端的主动Push,进一步减少了交互流程,以及支持流量控制,并引入了流的优先级和依赖关系,这使得我们能够对流量和资源进行较为细致的控制。

HTTP无状态连接

HTTP的无状态是指对于事务处理没有记忆能力,前后两次的请求并没有任何相关性,可以是不同的连接,也可以是不同的客户端,服务器在处理HTTP请求时,只关注当下这个连接请求时的可获取到的数据,不会去关心也没有记忆去关联上一次请求的参数数据。

HTTP的访问请求一般都会有软硬件做负载均衡来决定访问哪台物理服务器,进而两次同样地址的访问请求,有可能处理这两次请求的服务器是不同的。而无状态很好的匹配了这种近乎随机的访问方式,也就是说HTTP客户端可以任意选择一个部署在不同区域的服务器进行访问,得到的结果是相同的。

HTTP每次请求访问结束都有可能断开连接

在HTTP1.0和1.1中,HTTP是依据什么来断开连接的呢?答案是content-length。content-length为 heads 上的标记,表示 body 内容长度。带有content-length 标签的请求,其body内容长度是可知的,客户端在接收服务器回应过来的数据body内容时,就可以依据这个长度来接受数据。在接受完毕后这个请求就完毕了,客户端将主动调用close进入四次挥手断开连接。假如没有content-length 标记,那么body内容长度是不可知的,客户端会一直接受数据,直到服务端主动断开。

HTTP1.1在这个断开规则之上又扩展了一种新规则,即增加了Transfer-encoding标记。如果Transfer-encoding为chunked,则表示body是流式输出,body会被分成多个块,每块的开始会标识出当前块的长度,此时body不需要通过content-length长度来指定了。如果HEADS上带有Transfer-encoding:chunked 就表示body被分成很多块,每块的长度也是可知的,当客户端根据长度接受完毕数据后再主动断开连接。假如说Transfer-encoding 和 content-length 这两个标记都没有,那么就只能一直接受数据直到服务器主动断开连接。

那么可不可以使用HTTP协议又不断开连接的方式?还有HEAD里的keep-alive标识,keep-alive标识会让客户端与服务器的连接保持状态,直到服务器发现空闲时间结束而断开连接,在结束时间内我们仍然能发送数据。也在就是说,可以减少多次的与服务器3次握手建立连接的消耗,以及多次与服务器4次握手断开连接的消耗,提高了连接效率。

另外一面在服务器端上,Nginx的 keepalive_timeout,和Apache的 KeepAliveTimeout 上都能设置 Keep-alive 的空闲时间大小,当httpd守护进程发送完一个响应后,理应马上主动关闭相应的TCP连接,但设置 keepalive_timeout后,httpd守护进程会说:”再等等吧,看看客户端还有没有请求过来”,这一等,便是 keepalive_timeout时间。如果守护进程在这个等待的时间里,一直没有收到客户端发过来HTTP请求,则关闭这个HTTP连接。不过也不一定说使用 keep-alive 标识能提高效率,有时也会反而降低了效率,比如经常会没有数据需要发送,导致长时间的Tcp连接保持导致系统资源无效占用,浪费系统资源,巨量的保持连接状态就会浪费大量的连接资源。

倘若我们客户端使用keep-alive做连续发送,则需要在连续发送数据时使用同一个HTTP连接实例。并且在发送完毕后要记录空闲时间,以便再次发送时,可以判断是否继续使用该连接,因为通常服务器端主动断开连接后并没有被客户端及时的得知,所以自行判断是否有可能已经被服务器端断开连接为好。还有一个问题是,如果网络环境不好导致发送请求无法到达时,则要尽可能的自己记录和判断,哪些数据是需要重发的。这几个问题增加了HTTP作为keep-alive来保持连接的操作难度,将本来简单便捷的HTTP,变得使用更加困难,因此这也是keep-alive并不常使用在游戏项目中。

在Unity3D中的HTTP封装

Unity3D的2018版本中将原本经常用的WWW类废弃了,取而代之的是UnityWebRequest。其实API改了但功能都是一样的,我们主要还是围绕HTTP的原理来写程序,也只有这样才能真正写出程序的精髓。

UnityWebRequest里有几个接口对我们来说比较重要,一个是

    Post(string uri, WWWForm postData)接口

用来创建一个带有地址和Post数据的UnityWebRequest实例,一个是

    SendWebRequest()

用来开始发送请求和迭代请求的接口,另一个是

    SetRequestHeader(string name, string value)

调用它可以设置HTTP的标签头,其中Unity3D官方说下面这些HEAD标记在UnityWebRequest中并不支持:

    These headers cannot be set with custom values on any platform: accept-charset, access-control-request-headers, access-control-request-method, connection, date, dnt, expect, host, keep-alive, origin, referer, te, trailer, transfer-encoding, upgrade, via.

我们来看看这些标签都是代表什么功能:

    accept-charset: 用于告诉服务器,客户机采用的编码格式

    access-control-request-headers: 在预检请求中,用于通知服务器在真正的请求中会采用哪些请求首部

    access-control-request-method: 在预检请求中,用于通知服务器在真正的请求中会采用哪种 HTTP 方法

    connection: 处理完这次请求后是否断开连接还是继续保持连接

    date: 当前时间值

    dnt:  (Do Not Track) 表明了用户对于网站追踪的偏好。

    expect: 是一个请求消息头,包含一个期望条件,表示服务器只有在满足此期望条件的情况下才能妥善地处理请求。服务器开始检查请求消息头,可能会返回一个状态码为 100 (Continue) 的回复来告知客户端继续发送消息体,也可能会返回一个状态码为417 (Expectation Failed) 的回复来告知对方要求不能得到满足。

    host: 请求头指明了服务器的域名(对于虚拟主机来说),以及服务器监听的TCP端口号。

    keep-alive: 允许消息发送者暗示连接的状态,还可以用来设置超时时长和最大请求数。

    origin: 指示了此次请求发起者来自于哪个站点。

    referer: 表示当前页面是通过此来源页面里的链接进入的,与origin相似。

    te: 指定用户代理希望使用的传输编码类型

    trailer: 允许发送方在分块发送的消息后面添加额外的元信息,这些元信息可能是随着消息主体的发送动态生成的,比如消息的完整性校验,消息的数字签名,或者消息经过处理之后的最终状态等。

    transfer-encoding: 指明了将 entity 安全传递给用户所采用的编码形式。transfer-encoding是一个逐跳传输消息首部,即仅应用于两个节点之间的消息传递,而不是所请求的资源本身。一个多节点连接中的每一段都可以应用不同的Transfer-Encoding 值。

    upgrade: 升级为其他协议

    via: 代理服务器相关的信息

以上这些由HEAD扩展的HTTP功能都不能进行自主的选择,其中包括了我们比较关心的标识 connection 和 keep-alive 保持连接的功能,这同时说明我们无法用UnityWebRequest来实现一次连接发送多次数据的需求。另外我们比较关心的content-length也不能被自定义设置,它会由API本身来自动设置。与原来WWWW的API不同的是,UnityWebRequest更加简洁方便,但也失去了一些自定义的功能。接下来我们就使用UnityWebRequest来封装HTTP网络层。

我们先设计一个类,假如建个名称为HTTPRequest的类,它最重要的功能是向指定服务器发送请求并接受响应数据,每次请求服务器都调用这个类的方法来处理一个请求的操作。我们可以通过 HTTPRequest 这个类把地址、参数、回调句柄传进去,然后等待服务器响应数据,当收到响应数据时就调用相应游戏逻辑做处理从而反应到画面上。

HTTP需要的接口相对较少,其中POST(以POST方式发送数据)、GET(以GET方式发送数据)、HEAD(修改某个HEAD标签值)、Start(开始发送请求) 这几个接口比较重要,这些我们都可以在 UnityWebRequest 中对应的是函数句柄。

POST: UnityWebRequest UnityWebRequest.Post(string uri, WWWForm postData) (静态函数)

GET: UnityWebRequest UnityWebRequest.Get(string uri) (静态函数)

HEAD: UnityWebRequest.SetRequestHeader(string name, string value) (非静态函数)

Start: UnityWebRequest.SendWebRequest() (非静态函数)

用这四个API实现HTTP基本的发送与接收足以。

在使用 UnityWebRequest 发送请求时可以用协程也可以在逻辑更新中进行判断收发过程。只是根据我个人经验在协程中不太可控,每次都起一个协程来发送请求数据也有点浪费,在一些特殊需求下协程结束时随着函数调用结束而结束的,而我们时常会有需要暂停、而后继续操作等无法满足。所以一般都会把HTTP的收发判断移到脚本更新函数Update里去,这样做更容易掌控也更容易理解。

下面我们建立一个类来封装 UnityWebRequest 的收发过程:

1.建立实例开始连接和发送请求,设置好这次请求回应的回调句柄。

void StartRequest(string url, Callback _callback)
{
    this.web_request = UnityWebRequest.Get(url);
    this.Callback =  _callback;
    this.web_request.SendWebRequest();
}

or POST

void StartRequest(string url, WWWForm wwwform, Callback _callback)
{
    this.web_request = UnityWebRequest.POST(url, wwwform);
    this.Callback =  _callback;
    this.web_request.SendWebRequest();
}

上述代码中我们实例化了一个HTTP请求,并且把地址和参数内容传给了实例,然后设置数据响应时的回调句柄,最后发送请求。

2.判断是否完成或者说发送请求是否完毕,并且调用回调函数

void Update()
{
    if(web_request != null)
    {
        if(web_request.isDone)
        {
            ProcessResponse(web_request);
            web_request.Dispose();
            web_request = null;
        }
    }
}

这里我们在脚本更新函数 Update 中不断去判断当前的请求实例是否完成并有响应数据,当收到响应数据时则调用处理函数对数据进行逻辑处理。

3.处理数据,收到响应数据后先判断是否有错误存在,没有错误再对数据进一步处理。客户端与服务器之间的数据传输都需要有协商好的协议,通常HTTP上使用Json格式的比较多,我们在收到响应数据后对数据进行解析然后变为了具体实例,再传给相应的函数句柄进行调用,所以数据格式协议也是比较重要的关键,关于数据协议我们会在下面的章节中详细的讨论。

void ProcessResponse(UnityWebRequest _WebRequest)
{
    if(_WebRequest.error != null)
    {
        NetworkErrorReport(_www.error);
        return;
    }

    NetData net_data = ParseJson(_WebRequest.downloadHandler.text)

    CallbackResponse(net_data);
}

以上是用UnityWebRequest做HTTP请求的基本步骤。在具体项目中看似简单的连接、发送、接收过程有不少事情需要我们扩展,特别是游戏逻辑中大量多次频繁发送HTTP请求导致的效率问题需要我们更多的关注。

多次请求时连续发送HTTP请求引起的问题

多次或者连续请求HTTP在具体项目中比较常见,例如客户端向服务器请求角色信息,并同时请求军团信息,并同时请求每日任务信息,收到所有数据后将数据呈现在主界面上。像这样的连续多次的发出HTTP请求,会同时触发多个线程向服务器做请求操作,每个请求都包括建立连接、发送数据、接受数据、关闭连接这几个步骤,多个请求导致多个线程最后得到服务器响应的返回数据时,却不知道哪个在先哪个在后,例如军团信息有可能先得到响应,然后再是每日任务信息得到了响应,再是角色信息得到了响应,因为是多个线程发起的多个连接,服务端接收到的数据也会因为受到网络的干扰而导致顺序不正确,即使服务器端接收的请求数据顺序刚好没有被打乱顺序,在处理和回调的数据时也是不可知的,这导致回调的顺序不可知,所以多连接导致不能确定响应的顺序与请求的顺序是否一致。

当接收响应数据的顺序无法确定时,我们还用顺序接收数据的方式处理就会发生问题,例如前面我们说的在发送,军团,任务,角色数据请求后,希望能够在三者都到齐的情况下执行某个程序逻辑,倘若得到的数据是顺序的话,我们在收到角色数据后就能确定所有数据都已经到齐,到时我们再执行逻辑程序也会是正确的。

那么怎么在多开请求连接的情况下保证顺序呢?

解决方案1:多个连接同时发送请求数据,等待所有数据到齐后再调用执行逻辑。

每个HTTP(也可以认为是UnityWebRequest)请求都会开一个线程来向服务器请求数据,多个HTTP请求同时开启,相当于多个线程同时工作这样能提高网络利用率,但同时也有数据响应顺序混乱的隐患。多个请求后等待数据全部到齐后再处理逻辑,是一个比较普遍的解决多请求数据响应的方式。不过我们仍然要假设多次请求之间没有且不需要逻辑顺序,逻辑只需要它们收集齐数据后再做执行操作,只有这样我们才可以使用同时发起多次HTTP请求后等待所有请求结束后再做逻辑处理。

多次请求的响应数据后等待处理的解决方案其本质是‘如何判断我们需要的多条数据是否已经都到达’。为了能更快的、更高效的得到HTTP请求数据,在同一时间同时向服务器发起多个HTTP请求,并且等待所有请求都得到响应后再执行逻辑程序。

这种方式我们可以用伪代码及注释来表述得更清楚些:

class Multiple_Request
{
    void StartRequest( request_list, call_back )
    {
        lst_req = new List<UnityWebRequest>();
        for( url,wwwform in request_list )
        {
            //开启多个HTTP请求
            UnityWebRequest req = UnityWebRequest.Post(url, wwwform);
            req.SendWebRequest();

            //记录请求实例
            lst_req.Add(req);
        }

        //记录回调函数
        Callback = call_back;
    }

    void Update()
    {
        if(lst_req == null || lst_req.Count <= 0) return;

        for( UnityWebRequest req in lst_req)
        {
            //判断是否完成该请求,只要有一个没完成就继续等待
            if(!req.isDone)
            {
                return;
            }
        }

        //当全部请求都完成时,执行回调
        Callback(lst_req);

        //请求结束并清空
        lst_req.Clear();
    }
}

上述伪代码中同时开启多个HTTP连接来向服务器请求数据,提高了网络传输效率和服务器端的CPU利用率,请求彼此之间没有顺序关系,只有这样服务器端的执行顺序才无序担心。

如果请求之间是有顺序要求的,服务器端执行顺序无法保障则容易出现逻辑顺序混乱的问题。例如我们同时发起多个如下这样的请求,先购买物品、再出售物品、最后使用物品,这三个请求同时一起被发起,由于网络波动原因服务器接受到的请求顺序有可能是,使用物品,出售物品,再购买物品,当顺序不同时就有可能存在逻辑问题,这与我们原本设想的逻辑存在偏差,实例物品可能先被使用或出售,而不是被先购买,导致没有物品可被使用或出售,逻辑错位混乱是必然的。

我们在同时发起多个HTTP请求连接时,虽然提高了网络效率和服务器的CPU利用率,却无法保证预期的逻辑顺序,这会使得一些业务遭遇问题。因此这种解决方案在需要逻辑顺序严格执行的业务上无法正常运作,它只能运用在不需要逻辑顺并且需要同时发起多个请求的业务上。

解决方案2:逐个发起请求,保证顺序

为了解决顺序问题,我们可以逐个发起请求,这种方式比较普遍。在网页端、安卓原生端、苹果原生端都会使用这种方式来做请求,即每次只处理一个请求,当这个请求结束后再发起下一个请求,每个请求对应一个功能模块,结束当前功能模块后再发起下一个模块的请求逻辑。

我们再用前面所描述的例子来描述当前的解决方案,我们对HTTP做请求角色信息、再请求军团信息,再请求每日任务信息,这次我们不再同时发起请求,而是每次只做一件事,在收到角色信息数据后,再发起请求军团信息,收到军团信息后,再发起请求任务信息,收到任务信息后再显示在界面,每个请求收到响应数据后再处理当前请求的业务逻辑,当某个请求得到响应后都处理各自的事情。

也就是说在我们的例子中,请求角色信息的得到响应后处理角色信息相关的逻辑,请求任务信息的得到响应后处理任务信息相关的逻辑,以此类推由于客户端按顺序发送,结束时每个数据也都可以独立运行,结束后再继续下一个请求,并且所有请求响应的逻辑都可以程序来实时的自定义。可以理解为如下伪代码为:

void on_button_click()
{
    //请求角色数据
    function_request_roleinfo( callback_function1 ) 
}

void callback_function1( data )
{
    //记录角色信息
    role_info.Add(data);

    //请求军团信息
    function_request_groupinfo( callback_function2 )
}

void callback_function2( data )
{
    //记录军团信息
    group_info.Add( data );

    //请求任务信息
    function_request_task( callback_funciton3 );
}

void callback_function3( data )
{
    //记录任务信息
    task_info.Add( data);

    //展示UI
    show_ui();
}

上述伪代码描述了发送请求的逻辑顺序,这种逐个发送请求的方式,保证了逻辑顺序,同时也满足了对多样化功能的扩展,同一个请求可以在不同逻辑处拥有自己的自定义的处理逻辑。

我们说的单个连接的方式确实保证了顺序,但也降低了网络连接效率,多个请求时需要逐个发起TPC连接,每个连接都需要等待上一个连接请求完毕后才能开始下一个请求,并且在每次收到消息后都会发起4次TCP挥手来断开连接,在每次发起请求时又必须进行3次握手来建立连接,这种方式消耗了网络资源降低了收发效率。

除了上面我们说的这种自定义式的逐个发送请求外,另外还有一种逐个发送请求的方式,它为了保证请求的顺序进行,使用了发送队列和接收队列来确保发送与接收的顺序。其中为发送所做的队列在请求和响应中起到了缓冲的作用,在连续使用HTTP请求和连续收到HTTP响应时,能够做到依次处理相应的逻辑。

这种方式全程只有1个线程在做请求处理,所以并不需要线程锁之类的操作。发送时向队列推送请求实例,在逻辑更新函数Update上判断是否有请求在队列里,有的话推出一个请求做HTTP连接操作,同时将当前请求的信息暂时存放在内存中,当得到服务器响应时调用请求信息中的回调函数处理回调数据。

我们也用伪代码来描述这种发送和接收队列的过程:

//将请求推入队列
void RequestHttp(Request req)
{
    ListRequest.Push(req);
}

//逻辑更新
void Update()
{
    //是否完成HTTP
    if( IsHttpFinished() )
    {
        //开始新的请求,推出一个请求数据,存起来,并开始发送HTTP连接
        Request  req = ListRequest.Pop();
        mCurrentRequest = req;
        StartHttpRequest(req);
    }
    else
    {
        //是否收到响应
        if(HttpIsDone())
        {
            //根据响应数据处理逻辑
            ProcessResponse(data);

            //完成HTTP
            FinishHttp();
        }
    }
}

//根据数据处理逻辑
void ProcessResponse(Response data)
{
    if(mCurrentRequest != null
        && mCurrentRequest.Callback != null)
    {
        //回调句柄,根据数据处理相关逻辑
        mCurrentRequest.Callback(data);
        mCurrentRequest = null;
    }
}

以上过程阐述了在发送HTTP请求时的推入队列操作和在收到请求后的句柄响应操作,队列使我们能够逐个请求以及逐个响应。

解决方案3:多连接与逐个发送的混合使用。

多连接提高网络请求效率但没有一致的响应顺序,逐个发送有一致的响应顺序网络请求效率则不够好。两者之间其实没有排斥关系,我么可以混合使用,这样既提高了网络效率又保证了顺序。

我们来看看该如何混合使用它们,我们仍然使用前面解决方案1中的类 Multiple_Request ,原本的 Multiple_Request 类可以传入多个请求数据,并等待全部数据响应结束后再执行回调,接着我们在回调逻辑中也可以用 Multiple_Request 来执行下一组请求。我们用伪代码描述一下:

//界面按钮
void on_click()
{
    //新建一个请求对象
    request1 = new request();
    request1.url = url1;
    request1.wwwform = new wwwform();
    request1.wwwform.AddField("name1","value1");

    //加入请求列表
    lst_request.Add(request1);

    //兴建一个请求对象
    request2 = new request();
    request2.url = url2;
    request2.wwwform = new wwwform();
    request2.wwwform.AddField("name21","value21");

    //加入请求列表
    lst_request.Add(request2);

    //新建一个请求对象
    request2 = new request();
    request2.url = url3;
    request2.wwwform = new wwwform();
    request2.wwwform.AddField("name22","value22");

    //加入请求列表
    lst_request.Add(request3);

    //新建多请求实例
    multiple_request req = new multiple_request();

    //开始多个请求对象开启HTTP请求
    req.StartRequest(lst_request, callback_function1);
}

void callback_function1(lst_request)
{
    //做些逻辑
    do_some_function(lst_request);

    //新建一个请求对象
    request1 = new request();
    request1.url = url1;
    request1.wwwform = new wwwform();
    request1.wwwform.AddField("name1","value1");

    //加入请求列表
    lst_request.Add(request1);

    //兴建一个请求对象
    request2 = new request();
    request2.url = url2;
    request2.wwwform = new wwwform();
    request2.wwwform.AddField("name21","value21");

    //新建多请求实例
    multiple_request req = new multiple_request();

    //开始多个请求对象开启HTTP请求
    req.StartRequest(lst_request, callback_function2);
}

void callback_function2(lst_request)
{
    //将信息展示到UI
    show_ui(lst_request);
}

上述伪代码描述了如何多请求与逐个请求的并行使用,将多个请求看成一个请求的变体,将多个请求看成一个完整的功能块,就有了多请求与逐个请求的混合使用过程。在现实项目中,我们可以为每个网络功能新建一个类,即继承 Multiple_Request 类,并且将所有参数写进子类中后一并发送,并且可以多次使用在多个业务逻辑中。我们用伪代码描述这种情况:

//新建个父类继承 multiple_request 专门用来做某个功能,这个功能里需要请求多条数据
class do_something : multiple_request
{
    void StartRequest( int val1 , int val2, int val3, call_back )
    {
        //新建一个请求对象
        request1 = new request();
        request1.url = url1;
        request1.wwwform = new wwwform();
        request1.wwwform.AddField("name1","value1");

        //加入请求列表
        request_list.Add(request1);

        //兴建一个请求对象
        request2 = new request();
        request2.url = url2;
        request2.wwwform = new wwwform();
        request2.wwwform.AddField("name21","value21");

        //加入请求列表
        request_list.Add(request2);

        //新建一个请求对象
        request2 = new request();
        request2.url = url3;
        request2.wwwform = new wwwform();
        request2.wwwform.AddField("name22","value22");

        base.StartRequest(request_list, call_back);
    }
}

//ui上的按钮
void on_click()
{
    //实例化功能对象
    do_something = new do_something();

    //开始请求
    do_something.StartRequest(1,2,3, call_back_function1);
}

//回调1
void call_back_function1(lst_request)
{
    //做点事
    do_something(lst_requset);

    //实例化功能2的对象
    do_something2 = new do_something2();

    //开始请求
    do_something2.StartRequest(3,2, call_back_function2);
}

//回调2
void call_back_function2(lst_request)
{
    //展示UI
    show_ui(lst_request);
}

上述伪代码描述的,在实际项目中可以用子类继承父类功能的方法复用了请求的步骤,使得需要某个功能时使用起来更加便捷。

解决方案4:合并请求,并逐个发送合并后的请求包。

多个请求与逐一发送混合的解决方案,确实既提高了发送效率,又保证了顺序,但任然有几个缺点。

    缺点1,多开连接导致连接数增多,频繁的建立连接与断开连接导致网络效率仍然不够高。

    缺点2,虽然自定义空间很大但必须手动建立新功能类,逻辑变得庞大后分层不容易清晰。

    缺点3,多功能间的顺序必须手动排列顺序。

客户端程序员在使用时更希望的是多次请求能够被同时发出,同时收到的响应顺序也要按原来请求的顺序响应,并且要可以满足自定义回调句柄满足多样化的扩展需求。

如此一来合并请求就成了更适合的解决方案,即合并多个请求的数据为一个请求,当发起请求时一次性打包多个请求,只用一个连接就能全部发送并响应所有数据,这样一来发送这个合并数据包可以做到与发起多个连接同样的效果,并且响应也可以做到顺序一致性。

合并请求的方式做HTTP,减少了连接数量也提高了网络效率,同时还保证了请求顺序与响应顺序的准确性。

那么如何实现呢?这次我们需要服务端程序员同学一些配合,服务端同学需要将原本多个地址,每个地址对应的功能块的模式,改为同一个地址,并且改为使用ID(我们暂且称它为Commoand ID) 字段来对应不同的功能块。

这里我们以Json数据协议为例来说明这个过程(因为Json格式比较好理解),HTTP请求数据使用Json格式,服务器响应数据也是Json格式(通常都是这种做法,并且这在互联网非常普遍)。客户端发送Json格式的请求数据给服务器,服务器收到后解析Json格式数据提取数据后处理逻辑,接着将要返回的数据反序列化为Json格式给客户端。

现在为了合并Json数据,我们在对多个请求发起时,将多个Json请求格式推入到一个大的Json数据中,同时在合并时为每个合入进去的Json数据添加一个序列号,代表合入前的顺序,请看如下Json合并过程:

//请求1
{
    "request-order" : 1,
    "command" : 1001,
    "data1" : "i am text",
    "data2" : "i am num",
}

//请求2
{
    "request-order" : 2,
    "command" : 2011,
    "book" : "i am text",
    "chat" : "i am num",
    "level" : 1,
}

//请求3
{
    "request-order" : 3,
    "command" : 3105,
    "image" : "i am text",
    "doc" : "i am num",
}

//将1,2,3合并后的请求数据为
{
    "data":
    [
        {
            "request-order" : 1,
            "command" : 1001,
            "data1" : "i am text",
            "data2" : "i am num",
        }
        ,
        {
            "request-order" : 2,
            "command" : 2011,
            "book" : "i am text",
            "chat" : "i am num",
            "level" : 1;
        }
        ,
        {
            "request-order" : 3,
            "command" : 3105,
            "image" : "i am text",
            "doc" : "i am num",
        }
    ],
}

上述中合并时多个Json被推入到Json数组中,接着将它们一并发送给服务器端,服务器端程序接受到数据后对data字段中的数据进行提取、解析、处理,并且处理顺序按照request-order从小到的顺序来做,每个数据块都有一个command字段,用这个字段来判断使用哪个程序功能模块来处理逻辑,接着将数据传给该功能模块处理。

在每个功能模块处理完毕后,将所有响应数据都推入同一个Json实例中,同时附上与请求数据中相同的request-order,以便客户端识别与请求数据对应的响应数据,最后把整个响应数据一并发送给客户端。当客户端收到数据时,通过request-order就能知道响应数据的顺序,以及是哪个请求发起后响应的数据,于是客户端在按照request-order从小到大的顺序排序后进行解析和回调,得到了与请求顺序一致的响应顺序。

我们来看看客户端收到服务器端的合并响应数据是怎样的:

//响应数据1
{
    "response-order" : 1,
    "command" : 1001,
    "data1" : "response text",
    "data2" : "response num",
    "error_code" : "0", 
}

//响应数据2
{
    "response-order" : 2,
    "command" : 2011,
    "data1" : "response text",
    "data2" : "response num",
    "error_code" : "1",
}

//响应数据3
{
    "response-order" : 3
    "command" : 3105,
    "data1" : "response text",
    "data2" : "response num",
    "error_code" : "2",
}

//合并后的响应数据
{
    "error_code" : 0,
    "data":
    [
        {
            "response-order" : 1,
            "command" : 1001,
            "data1" : "response text",
            "data2" : "response num",
            "error_code" : "0", 
        }
        ,
        {
            "response-order" : 2,
            "command" : 2011,
            "data1" : "response text",
            "data2" : "response num",
            "error_code" : "1",
        }
        ,
        {
            "response-order" : 3
            "command" : 3105,
            "data1" : "response text",
            "data2" : "response num",
            "error_code" : "2",
        }
    ]
}

如上所描述的,在得到合并后的响应数据后,客户端要先提取data数据中的所有数据,以respose-order为基准从小到大进行排序,从头上最小序号的开始处理。

到现在合并请求的部分我们描述了单个连接多个请求的方式,如果多个合并请求一同发送也会遇到大麻烦,同样会遇到顺序问题,因此我们依然需要用队列来规避这个问题。用了队列的方式保证多个请求的有序性,与前面用队列逐个发送的方式相比,我们除了用队列暂时存储请求数据外还在队列之上进行操作请求合并,这样能更有效更方便的做到合并以及有序发送。

我们来看看队列与合并数据包相结合的方式用伪代码描述:

//将请求推入队列
void RequestHttp(Request req)
{
    ListRequest.Push(req);
}

//逻辑更新
void Update()
{
    //是否完成HTTP
    if( IsHttpFinished() )
    {
        //推出多个请求进行合并
        lst_combine_req.Clear();
        for(int i = 0 ; i<10 ; i++)
        {
            Request  req = ListRequest.Pop();
            lst_combine_req.Add(req);
        }

        //合并多个请求
        combine_request = CombineRequest(lst_combine_req);
        StartHttpRequest(req);
    }
    else
    {
        //是否收到响应
        if(HttpIsDone())
        {
            //按ID从小到大排序
            sort(combine_request.lst_response);

            //循环处理每个响应逻辑
            for(int i = 0 ; i<combine_request.lst_response.count ; i++)
            {
                //根据响应数据处理逻辑
                data = combine_request.lst_response[i];
                ProcessResponse(data);
            }

            //完成HTTP
            FinishHttp();
        }
    }
}

//根据数据处理逻辑
void ProcessResponse(Response data)
{
    if(mCurrentRequest != null
        && mCurrentRequest.Callback != null)
    {
        //回调句柄
        mCurrentRequest.Callback(data);
        mCurrentRequest = null;
    }
}

来看看从按钮点击事件开始的具体逻辑的操作:

//按钮事件
void on_click()
{
    //请求玩家数据
    request_roleinfo( call_back_function1 );

    //请求军团数据
    request_groupinfo( call_back_function2 );

    //请求任务数据
    request_taskinfo( call_back_function3 );
}

//先回调的是这个句柄
void call_back_function1( data )
{
    //保存玩家数据
    save_roleinfo(data);
}

//再回调的是这个句柄
void call_back_function2( data )
{
    //保存军团数据
    save_groupinfo(data);
}

//最后收到任务数据时,表明前面的数据都已经收到了,就可以做一些逻辑处理
void call_back_function3( data )
{
    //保存任务数据
    save_taskinfo(data);

    do_something();
}

上述伪代码中描述的在具体业务逻辑中,我们客户端在编写网络请求时可以随意的调用发送请求而不再需要关心顺序和发送效率的问题,大大提高了程序员的网络逻辑编程效率。

我们对多个HTTP请求进行了合并数据的操作后减少请求的次数,对速度的提升很有大的效果,但这里还有一些细节问题需要我们关注,比如多少个开始进行合并,或者多少时间内合并一次。

为了让HTTP最大效率的得到提升,必须每次请求得到响应后就应该立即进行下一次HTTP请求,如果有等待合并间隔,反而减低了网络效率。不过每次合并数量需要做一些限制,如果有100个请求同时被发起,我们不能统统合并了,这也是为了保证发送数据大小和回调数据大小合适,数据包越大丢包重发的概率也就越大,因此我们必须限制一次性合并的个数,我们可以选择每次最多合并10个请求数据包,以保证减少连接次数减少的情况下,发送和接收的数据包不会过大。

· 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》第六章,网络层(四) - 封装HTTP

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)