服务器怎样处理连接超时和数据拼接

近期实现了一个linux epoll 服务器, 大致功能就是接收客户端数据,分析数据,按字段存入数据库, 是一个短链接服务, 从中收获良多,这里谈一下, 服务器开发中常遇问题和解决方案。
源码: https://github.com/BambooAce/Repository/tree/master/network/server_epoll 大致模型

至于epoll 模型原理和使用网上有很多教材, 推荐man page epoll 这里详细描述了epoll的用法,或者 http://blog.csdn.net/yusiguyuan/article/details/24833339 。

这里就谈一谈以下几点:
1. 客户端与服务器 定义何种消息格式?
2. 如何避免恶意的长期链接?
3. 网络延迟造成的数据不完整怎样解决?
4. 使用阻塞还是非阻塞?
5. read write 读写 使用过程中的问题.

1. 客户端与服务器 定义何种消息格式?
正如我们常见的http协议 , 在服务器响应的时候在其头部会有正文的长度:

Content-Length: 24255
Accept-Ranges: bytes
Date: Tue, 22 Aug 2017 07:14:55 GMT

在一些字符形式的传输过程中,也要在消息头中加入正文段的长度,当然这里若是传输文件可以除外, 这样做的目的就是:可以保证数据完整性, 即使数据被分为多个数据包被发送,或者发生了粘包现象 都有办法可以读到正确数据, 这里一次连消息头都读不完整的情况很少, 一般消息头都非常短。
在游戏开发过程中常常传输二进制, 二进制的好处就在于数据短,但是要求客户端和服务器端对齐方式是一致的, 另外也要有简单的消息头, 包含整个消息的长度, 即使是二进制也存在数据不完整,粘包现象。二进制结构体包括定长结构体 和变长结构体, 这里定长, 只要对齐方式相同可直接转为相关结构体; 若是变长结构体, 那么就要在消息体的各个字段中加入长度信息 如下:

struct package{
    int strlen;
    char *str;
    int str2len;
    char *str2;
}

这里的strlen 和 str2len 都是下面字符串的长度信息, 这里注意的是char 指针类型 , 并不能够直接将结构体数据发送,send的时候要将全部结构体内容存入buff, 再将buff 发送至服务器。 对于接收数据,要按照结构体严格解析, 如先读取四个字节读出strlen大小, 再读strlen长度的字符串以此类推。变长结构体确实有点复杂。
在二进制传输中, 还有大端小端问题, 不过现在的主流机器都是小端。
这里我们可以总结为无论是字符流还是二进制, 包含消息长度信息是十分必要的。

2. 如何避免恶意的长期链接?
一个连接怎样定义为恶意的长期连接, 无论是长连接和短连接都会发生这样的问题, 比如一个客户端只 connect 然后就sleep 或者 大批量的connect 后就陷入阻塞, 那么服务器accept返回大量的描述符,占用系统资源,然而他们并不发送数据, 当文件描述符被占用完时, 就造成了一种拒绝服务攻击, 为避免这种恶意连接, 在服务器端要对每个连接保存客户端信息,如 { {文件描述符, 客户端IP, 客户端端口号},连接时间 }

struct SingleConn{
    struct sockaddr_in cliaddr;
    int clifd;
    bool header;//以下四个与数据拼接有关
    int size;
    char *data;
    int haveRecved;
};
typedef struct sc{
    SingleConn sgc;  //客户端 文件描述符信息 IP PORT
    time_t t_time;    //客户端连接上的时间
}SC;

当服务器accept成功之后, 就记录这一个连接并将记录放入类似链表的数据结构中。

for(;;)
{
    int res = select/poll/epoll(,,, timeout)
    if(res)
    {
        delete_unnormal();
    }
    if(lisfd 可读){
    int clifd = accept()
    Record_client(clifd):   //这里会记录客户端信息 连接上的时间。
    delete_unnormal();
    }
    if(其他可读){
    read()
    delete_unnormal();
    if(read == 0)
    {
        delete_client();
        close(clifd);       
    }
    }
    if(其他可写){
    write();
    delete_unnormal();
    }
}

其中delete_unnormal() 删除异常连接在每个阶段都会被调用, 其实现就是: 得到当前时间, 遍厉链表中所有客户端连接信息,拿当前时间 - 每个客户端的连接时间 看是超过timeout,若某个客户端超过了这个时间, 就将其踢掉。若客户端发送过数据就将连接时间变为发送数据的时间。 这样对于短链接是十分有效的, 对于长连接来说, 一般刚连接上都会发送消息头信息, 可以在结构体中加入已经收到过信息的标志位, 若发送过消息头就直接跳过。

typedef struct sc{
    SingleConn sgc;  //客户端 文件描述符信息 IP PORT
    time_t t_time;    //客户端连接上的时间
    int flags;//是否发送过数据
}SC;

如上方法基本上就可以解决了恶意的connect, 这里说明以下当踢除恶意连接时,要发送RST,不要close掉连接, 这样的话服务器端才不会产生大量的TIME_WAIT. 遍厉所有连接判断是否超时, 消耗并不算大。

void send_RST(int clifd)
{
    struct linger lger;
    lger.l_onoff = 1;
    lger.l_linger = 0;
    if(clifd){
        setsockopt(clifd, SOL_SOCKET, SO_LINGER, &lger, sizeof(lger));
        close(clifd);
    }
}

3. 网络延迟造成的数据不完整怎样解决?

struct {
    struct sockaddr_in cliaddr;
    int clifd;
    bool header;//以下四个与数据拼接有关
    int size;
    char *data;
    int haveRecved;
};

服务器端用类似以上结构保存连接, 就可实现发送数据不完整时完成拼接, 这里的header 是指 是否受到了消息头, size指消息头里面正文的长度, haveRecved 就是指这个连接已经收到了多少数据, data即使保存不完整数据的结构, 待数据完整后将数据交给其他部分处理。

int fillIntoData(char *buff, int len)
{
    if(size < (haveRecved + len)){ //数据过大则截取
        memcpy(data+haveRecved, buff, size - haveRecved);
        haveRecved = size;
        return 1;
    }
    if(data == NULL)
    {
        data = (char *)malloc(size);
    }
    memcpy(data + haveRecved, buff, len);
    haveRecved += len;
    if(haveRecved == size)
        return 1;
    return 0;
}

大致模型如下:
read() 读取客户端数据
check(data) //简单检查数据是否有消息头
if() //如果数据包含头并不完整
{
    存入客户端结构体的data内
}
else if()//如果不是头
{
    if()//如果接收过头
    {
        拼接数据
    }
    else //没有接收到头信息
    {
        踢除这个连接 清空buff
    }
}

如若是短链接, 网络不好数据发送了一半就超时了,踢除连接的同时要清理data。

4. 使用阻塞还是非阻塞?
至于这部分的话, 非阻塞的好处还是大于阻塞的
详细可参考: http://blog.csdn.net/hzhsan/article/details/23650697

5. read write 读写 使用过程中的问题.
这里主要说一说非阻塞情况下的 read 和 write
如下:

ssize_t                     /* Read "n" bytes from a */  
readn(int fd, void *vptr, size_t n)  
{  
    size_t  nleft;  
    ssize_t nread;  
    char    *ptr;  

    ptr = vptr;  
    nleft = n;  
    while (nleft > 0) {  
        if ( (nread = read(fd, ptr, nleft)) < 0) {  
            if (errno == EINTR)  
                nread = 0;      /* and call read() again */  
            else  
                return(-1);  
        } else if (nread == 0)  
            break;              /* EOF */  

        nleft -= nread;  
        ptr   += nread;  
    }  
    return(n - nleft);      /* return >= 0 */  
} 

《unix 网络编程》书中的readn 写法, 这样写的原因就时并不是一下就可以将全部数据读出, 如果接收数据buffer 不够时, 读出数据就比要求数据要少, 所以要这样做直到读取规定大小。

但是考虑如下情况:
服务是长连接, read 的size_t n 一直没有够, 比如说发到n的一半不发送了也不断开连接, 想象一下, 接下来就会一直阻塞在read那里, 单线程的话, 服务器就不再处理其他连接了, 一直阻塞直到发送数据达到n 返回, 那么可以将之改为非阻塞:

    while (nleft > 0) {  
        if ( (nread = read(fd, ptr, nleft)) < 0) {  
            if (errno == EINTR)  
                nread = 0;
            else if(errno == EAGAIN)
                continueelse  
                return(-1);  
        } else if (nread == 0)  
            break;              /* EOF */  

        nleft -= nread;  
        ptr   += nread;  
    }

这里改为了非阻塞, 但是应对以上情况也是会陷入循环之中从而不能处理其他连接。此函数退出情况只有中断, 满足了size , nread == 0 也就是收到FIN, 但是对于长连接而言没能满足size 也没有断开, 这就陷入了尴尬的地步。
对于循环read模型

while(1)
{
    int size = read();
    if(size == 0)
    {
        break;
    }
}

若如这种模型用于传输大文件, 那么也会造成其他连接不能及时处理的情况。 若是小数据流:

            if(events[i].events && EPOLLIN)
            {
REREAD:
                int size = read(fd, buff, 1024);
                if(errno == EINTR)
                    goto REREAD;
                if(size){
                    //数据接收拼接
                }
                if(size == 0)
                {
                    close(fd);
                }
            }

在拥有消息头的情况下,这种方式无论长连接短连接, 数据达不到size不至于陷入循环, 但是用此模型传输大文件,效率又太低。。。 适用于较小的流。
总之 IO事件处理方式要根据具体流量来定义, 不能够因单个阻塞, 或在陷入循环不能及时处理其他连接。

你可能感兴趣的:(TCP/IP,&,网络编程,C/C++,Linux,kernel)