简单服务器实现C++

Posted 狗蛋儿l

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了简单服务器实现C++相关的知识,希望对你有一定的参考价值。

一、思路

此服务器客户端采用的是TCP协议,socket模型创建的流程图如下:
在这里插入图片描述
对于server端,代码编写步骤如下:

socket()建立套接字 bind () 绑定 IP 端口号 (struct sockaddr_in addr 初始化)
listen()指定最大同时发起连接数
accept () 阻塞等待客户端发起连接
read()
将小写字母转化为大写字母
write()给客户端
close();

对于client端,代码编写步骤如下:

socket();
bind();可以依赖 “隐式绑定”
connect();发起连接
write();
read();
close();

二、基本TCP套接字编程

2.1socket函数

为了执行网络 IO,一个进程必须做的第一件事情就是调用socket函数,指定期望的通信协议类型。

Unix/Linux的一个哲学是:所有东西都是文件。socket也不例外,它就是可读、可写、可控制、可关闭的文件描述符。

下面的socket系统调用可创建一个socket:

   #include <sys/types.h>
   #include <sys/socket.h>
   int socket (int domain, int type,int protocol)

其中 domain 参数指明协议族

domain:

AF_INET 这是大多数用来产生socket的协议,使用TCP或UDP来传输,用IPv4的地址

AF_INET6 与上面类似,不过是来用IPv6的地址

AF_UNIX 本地协议,使用在Unix和Linux系统上,一般都是当客户端和服务器在同一台及其上的时候使用。

type:

SOCK_STREAM
这个协议是按照顺序的、可靠的、数据完整的基于字节流的连接。这是一个使用最多的socket类型,这个socket是使用TCP来进行传输。

SOCK_DGRAM 这个协议是无连接的、固定长度的传输调用。该协议是不可靠的,使用UDP来进行它的连接。

SOCK_SEQPACKET该协议是双线路的、可靠的连接,发送固定长度的数据包进行传输。必须把这个包完整的接受才能进行读取。

SOCK_RAW
socket类型提供单一的网络访问,这个socket类型使用ICMP公共协议。(ping、traceroute使用该协议)

SOCK_RDM 这个类型是很少使用的,在大部分的操作系统上没有实现,它是提供给数据链路层使用,不保证数据包的顺序

protocol:

传0 表示使用默认协议。

返回值:

成功:返回指向新创建的socket的文件描述符,失败:返回-1,设置errno

socket() 打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符,应用程序可以像读写文件一样用 read/write 在网络上收发数据,如果 socket() 调用出错则返回-1。对于IPV4,domain 参数指定为 AF_INET。对于TCP协议,type参数指定为SOCK_STREAM,表示面向流的传输协议。如果是 UDP 协议,则 type 参数指定为 SOCK_DGRAM,表示面向数据报的传输协议。protocol 参数指定为 0 即可。

正因为 socket() 会返回文件描述符,所以我们需要创建一个 int 类型的文件描述符去接收。

2.2 bind函数

创建 socket 时,我们给它指定了地址族,但是并未指定使用该地址族中的哪个具体 socket 地址。将一个 socket 与 socket 地址绑定称为给 socket 命名,命名 socket 的系统调用是 bind,其定义如下:

   #include <sys/types.h>
   #include <sys/socket.h>
   int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

sockfd:

socket文件描述符

addr:

构造出IP地址加端口号

addrlen:

sizeof(addr)长度

返回值:

成功返回0,失败返回-1, 设置errno

在服务器程序中,我们通常要命名 socket,因为只有命名后,客户端才能知道该如何连接它(服务器的知名端口和IP,服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接,因此服务器需要调用bind绑定一个固定的网络地址和端口号。)。客户端则通常不需要命名socket,而是采用匿名方式,即使用操作系统自动分配的 socket 地址 (“隐式分配”)。

bind() 的作用是将参数 sockfd 和 addr 绑定在一起,使 sockfd 这个用于网络通讯的文件描述符监听 addr 所描述的地址和端口号。struct sockaddr* 是一个通用指针类型,addr 参数实际上可以接受多种协议的sockaddr 结构体,而它们的长度各不相同,所以需要第三个参数 addrlen 指定结构体的长度。如:

    struct sockaddr_in servaddr;
    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(6666);

首先将整个结构体清零,然后设置地址类型为 AF_INET,网络地址为 INADDR_ANY,这个宏表示本地的任意 IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个 IP 地址,这样设置可以在所有的 IP 地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个 IP 地址,端口号为 6666。

2.3 socket地址结构

每个协议族都定义它自己的 socket 地址结构。这些结构的名字均以 sockaddr_ 开头,并以对应每个协议族的唯一后缀结尾。

IPV4 socket 地址结构,以 sockaddr_in 命名
在这里插入图片描述

各种socket地址结构体的开头都是相同的,前16位表示整个结构体的长度(并不是所有UNIX的实现都有长度字段,如Linux就没有),后16位表示地址类型。这样,只要取得某种 socket 地址结构体的首地址,不需要知道具体是哪种类型的socket 地址结构体,就可以根据地址类型字段确定结构体中的内容。因此,socket API可以接受各种类型的 socket 地址结构体指针做参数,例如bind、accept、connect等函数,这些函数的参数应该设计成void *类型以便接受各种类型的指针,但是sock API的实现早于ANSI C标准化,那时还没有void *类型,因此这些函数的参数都用struct sockaddr *类型表示,在传递参数之前要强制类型转换一下,例如:

    struct sockaddr_in servaddr;
    bind(listen_fd, (struct sockaddr *)&servaddr, sizeof(servaddr));		/* initialize servaddr */

同时,因为多种 socket 地址结构是可变长度的,为了处理长度可变的结构,当我们把指向某个 socket 地址结构的指针作为一个参数传递给某个 socket 函数时,也把该结构的长度作为另一个参数传递给这个函数。

注意:这里的端口号和 IP 地址都需要使用网络字节序,并且是 int 类型的,在下面的代码实现中,我定义了端口号为

   #define SERV_PORT 6666
  
   serv_addr.sin_port = htons(SERV_PORT);

在地址结构中使用时,用了 htons()函数进行转换。 htons 表示 “host to network short”,即将短整型(short)16位的主机字节序数据转化为网络字节序数据(因为端口号就是16位的)。

在转化 IP 地址时稍微复杂一点,因为人们习惯用可读性好的字符串来表示 IP 地址,比如用点分十进制字符串表示 IPV4 地址,

有如下两种方式进行转化:

   #define SERV_IP "127.0.0.1"
   serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); //IP地址
   inet_pton(AF_INET,SERV_IP,&serv_addr.sin_addr);

htonl() 函数 需要整型参数,但是点分十进制的却是字符串,所以这里使用了 INADDR_ANY 这个宏,这个宏表示本地的任意 IP地址,并且是整型的。

第二种方式使用了 inet_pton() 函数:

    #include <arpa/inet.h>
     
   int inet_pton(int af, const char *src, void *dst);

inet_pton 函数将用字符串表示的 IP地址 src(用点分十进制字符串表示的 IPV4 地址或用十六进制字符串表示的 IPV6 地址)转换成网络字节序整数表示的 IP 地址,并把转换结果存储于 dst 指向的内存中。其中,af 参数指定地址族,可以是 AF_INET或者AF_INET6。

有点神奇的是,这里的第三个参数,写 &serv_addr.sin_addr 或者 ,&serv_addr.sin_addr.s_addr 都可以,可能是 sin_addr 结构体中只有 s_addr 这一个数据的缘故。

inet_ntop() 函数:

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

inet_ntop 进行相反的转换,从数值格式 (addrptr)转换到表达式格式(strptr)。size 参数是目标存储单元的大小,以免该函数溢出其调用者的缓冲区。

2.4 listen函数

listen 函数仅由 TCP服务器调用,socket 被命名之后,还不能马上接受客户连接,我们需要使用如下系统调用来创建一个监听队列以存放待处理的客户连接:

    #include <sys/socket.h>
    int listen( int sockfd, int backlog);

sockfd 参数指定被监听的 socket。backlog 参数提示内核监听队列的最大长度。监听队列的长度如果超过 backlog,服务器将不受理新的客户连接,客户端也将收到 ECONNREFUSED 错误信息。

2.5 accept 函数

下面的系统调用从 listen 监听队列中接受一个连接:

    #include <sys/types.h> 		
    #include <sys/socket.h>
    int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);

sockfd 参数是执行过 listen 系统调用的监听 socket。cliaddr 参数用来获取被接受连接的远端(客户) socket 地址,该 socket 地址的长度由 addrlen 参数指出。三次握手完成后,服务器就调用 accept() 接受连接,如果服务器调用 accept() 时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来。

sockfd:

accept() 的参数 sockfd 是先前的监听套接字描述符(暂记为 lfd),而它的返回值是已连接套接字描述符 (暂记为 cfd)。一个服务器通常仅仅创建一个监听套接字,它( lfd )在该服务器的生命期内一直存在。内核为每个由服务器进程接受的客户连接创建一个已连接套接字(cfd,对于它的TCP三次握手过程已经完成),之后与客户端之间就通过这个 cfd 通信。当服务器完成对某个给定客户的服务时,相应的已连接套接字就被关闭,accept 失败时返回 -1。

addr:

addr 是一个传出参数,accept() 返回时传出客户端的地址和端口号。

addrlen:

addrlen 是值—结果参数(传入传出参数),传入的是调用者提供的缓冲区 addr 的长度以避免缓冲区溢出问题,传出的是客户端地址结构的实际长度(有可能没有占满调用者提供的缓冲区)。由于 addrlen 是传入传出参数,所以每次调用 accept() 之前应该重新赋初值。

三、代码实现

server.c

#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <ctype.h>
 
#define SERV_IP "127.0.0.1"
#define SERV_PORT 6666
 
int main(void)
{
    int lfd, cfd; //文件描述符
    struct sockaddr_in serv_addr, clie_addr;
    socklen_t clie_addr_len;
    char buf[BUFSIZ];             //BUFSIZ定义在stdio.h,为8192,一般是会用来做数组的长度。
    int n, i;
 
    lfd = socket(AF_INET, SOCK_STREAM, 0);
 
    /* 创建一个IPV4 socket地址 */
    serv_addr.sin_family = AF_INET;                //协议
    serv_addr.sin_port = htons(SERV_PORT);         //16位端口 
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); //IP地址
    //上一行代码也可被这代替 inet_pton(AF_INET,SERV_IP,&serv_addr.sin_addr);
 
    bind(lfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
 
    listen(lfd, 128);
 
    clie_addr_len = sizeof(clie_addr); //因为clie_addr_len是传入传出参数,使用前必须初始化
 
    //clie_addr是传出参数,因此会在函数外定义,在函数中对该变量操作赋值
    cfd = accept(lfd, (struct sockaddr *)&clie_addr, &clie_addr_len);
 
    printf("client IP:%s, client port:%d\\n",
		inet_ntop(AF_INET,&clie_addr.sin_addr.s_addr,clie_IP,sizeof(clie_IP)),
		ntohs(clie_addr.sin_port));
    
    //连接成功后,循环读取用户的输入
    while (1) {
        n = read(cfd, buf, sizeof(buf));
        for (i = 0; i < n; i++)
            buf[i] = toupper(buf[i]); //将小写字母转换为大写
        write(cfd, buf, n);
    }
 
    close(lfd);
    close(cfd);
 
    return 0;
}

C 库函数 int toupper(int c) 把小写字母转换为大写字母,包含在C 标准库 - <ctype.h>中。

client.c

#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <string.h>
#include <netinet/in.h>
 
#define SERV_IP "127.0.0.1"
#define SERV_PORT 6666
 
int main()
{
    int cfd;
    struct sockaddr_in serv_addr;
    char buf[BUFSIZ];
    int n;
 
    cfd = socket(AF_INET, SOCK_STREAM, 0);
 
    memset(&serv_addr,0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(SERV_PORT);
    inet_pton(AF_INET, SERV_IP, &serv_addr.sin_addr.s_addr);
 
    connect(cfd, (struct sockaddr *)&serv_addr,sizeof(serv_addr));
 
    while(1){
        fgets(buf,sizeof(buf),stdin);
	write(cfd, buf, strlen(buf));
	n = read(cfd,buf,sizeof(buf));
	write(STDOUT_FILENO, buf, n);
    }
    
    close(cfd);
 
    return 0;
	
}

由于客户端不需要固定的端口号,因此不必调用bind(),客户端的端口号由内核自动分配。注意,客户端不是不允许调用bind(),只是没有必要调用bind()固定一个端口号,服务器也不是必须调用bind(),但如果服务器不调用bind(),内核会自动给服务器分配监听端口,每次启动服务器时端口号都不一样,客户端要连接服务器就会遇到麻烦。

fgets 函数:

fgets 函数功能为从指定的流中读取数据,每次读取一行。其原型为:

char *fgets(char *str, int n, FILE *stream);

从指定的流 stream 读取一行,并把它存储在 str 所指向的字符串内。当读取(n-1)个字符时,或者读取到换行符时,或者到达文件末尾时,它会停止。(并且会在最后加上一个\\0)

fgets (buf, sizeof(buf), stdin);  //hello world --- fgets --> "hello world\\n\\0"

这一行代码是从标志输入设备(比如键盘)读入一行字符串,包括空白空格直到换行键(\\n),含换行键。字符串存放到 buf 里。stdin 是标准输入,C标准库里面的一个全局变量。stdin也是FILE类型的,因此在使用FILE类型作为参数的地方,可以使用stdin

memset 函数:

memset是计算机中C/C++语言初始化函数。作用是将某一块内存中的内容全部设置为指定的值, 这个函数通常为新申请的内存做初始化工作。

void *memset(void *s, int ch, size_t n);

函数解释:将 s 中当前位置后面的 n 个字节用 ch 替换并返回 s 。

对STDOUT_FILENO的理解:

write(STDOUT_FILENO, buf, n);

在这行代码中,使用了STDOUT_FILENO,表示标准输出(输出到控制台屏幕)。stdout 等是FILE *类型,属于标准I/O,在<stdio.h>。STDOUT_FILENO等是文件描述符,是非负整数,一般定义为0, 1, 2,属于没有buffer的I/O,直接调用系统调用,在<unistd.h>。 这是因为 write 函数的第一个参数就是文件描述符,int 型。

以上是关于简单服务器实现C++的主要内容,如果未能解决你的问题,请参考以下文章

详细实例说明+典型案例实现 对动态规划法进行全面分析 | C++

如何在 C++ 中为 GLSL 片段着色器实现 iGlobalTime?

这些 C++ 代码片段有啥作用?

有趣的 C++ 代码片段,有啥解释吗? [复制]

以下代码片段 C++ 的说明

C++ 代码片段执行