最近一个月复习了初中学习的C语言,刚好学校C语言要做一个期末设计的作业,作为一个web开发者总想着在自己的代码里加一点跟服务器有关的东西,那么,不如用C语言实现一波js的前后端交互吧。
0x01 首先我们来看一下js的前后端交互是如何完成的
我们举一个比较简单的例子,来看看js的前后端交互是如何完成的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| function postAjax(url, data, success) { var xhr = new XMLHttpRequest(); xhr.open('POST', url, true); xhr.onreadystatechange = function() { if (xhr.readyState == 4 && xhr.status == 200) { success(xhr.responseText); } } xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); xhr.send(data); return xhr; }
function success(response) { console.log(response); } postAjax("http://example.com", "a=1&b=2", success);
|
上面这段代码使用ES5标准原生发送了Ajax Post请求,本质上这是一个http请求。
我们主要看第11行,xhr.send(data);
这一行代码的作用是将构造好的http请求发送到example.com。整个发送过程是完全封装的,XMLHttpRequest对象的send方法帮助我们完成了整个发送过程,不需要我们自己实现。但是如果是C语言,封装程度比较低,我们就需要自己完成一些请求的发送过程了。
0x02 一个请求的发送过程中都会发生什么
我们大致分析一下请求的发送过程都会经历那些步骤:
1.解析URL
2.DNS查询
3.封包
4.发送请求
5.服务器请求处理
那么也就说明我们的xhr.send(data);
这一行代码完成了请求发送过程的前四步。
(关于请求发送过程详见我的另一篇文章https://su29029.github.io/2019/10/20/当在浏览器中输入www.baidu.com并按下回车后-都发生了什么.html)
0x03 借助套接字完成前台与服务器的链接
这里我们省去解析URL和DNS查询的过程,直接向某一ip地址发送请求。
使用套接字完成服务器端构建一共需要8步:创建套接字,设置套接字地址结构,绑定套接字,设置监听,接收请求,获取请求内容,响应客户端请求,关闭套接字。
这里我们只简略的讲述每一步的过程,这其中每一步的操作都非常复杂,单独出来都能写一篇很长的文章,所以这里只简要介绍,想要深入了解C语言 UNIX 套接字编程,可以阅读《UNIX网络编程》一书。
第一步 创建套接字
函数原型:int socket(int family,int type,int protocol);
其中:
family: family指的是协议簇,可选的有AF_INET,PF_INET,AF_INET6,PF_INET6 前两个代表使用ipv4协议,后两个代表使用ipv6协议。还有些不常使用的AF_LOCAL,AF_ROUTE,AF_KEY等。
type: type代表使用的套接字类型,可选的有SOCK_STREAM(字节流套接字),SOCK_DGRAM(数据报套接字),SOCK_SEQPACKET(有序分组套接字),SOCK_RAW(原始套接字)。
protocol: protocol指传输层协议类型,可选的有IPPROTO_TCP(TCP协议),IPPROTO_UDP(UDP协议),IPPROTO_SCTP(SCTP协议)。
注意:socket函数的三个参数不能随意组合,常用的组合如下:
SOCK_STREAM+AF_INET/AF_INET6 –> TCP/SCTP协议 (最常用)
SOCK_DGRAM+AF_INET/AF_INET6 –> UDP协议
SOCK_SEQPACKET+AF_INET/AF_INET6 –> SCTP协议
函数成功执行返回非负描述符,出错返回-1。
第二步 设置套接字地址结构
结构原型:
1 2 3 4 5 6 7 8 9 10 11 12 13
| #include <netinet/in.h>
struct sockaddr_in { short sin_family; unsigned short sin_port; struct in_addr sin_addr; char sin_zero[8]; };
struct in_addr { unsigned long s_addr; };
|
这里sin_family指的是协议类型,sin_port指端口,sin_addr.s_addr指远程ip地址。我们举个例子:
1 2 3 4 5
| struct sockaddr_in saddr; //定义一个套接字地址结构 memset(&saddr,0,sizeof(saddr)); //这里也可使用bzero() [bzero(&saddr,sizeof(saddr))] saddr.sin_family = AF_INET; //指定ipv4协议簇 saddr.sin_port = htons(PORT); //端口 htons()作用是将主机字节序转化为网络字节序 saddr.sin_addr.s_addr = htonl(INADDR_ANY); //允许的ip地址 INADDR_ANY 即任意地址
|
以上代码即设置了一个服务器端的套接字地址结构。
第三步 绑定套接字
函数原型:int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
sockfd: 套接字描述符
addr: 套接字地址结构
addrlen: 地址结构长度
函数的作用是把一个本地协议地址赋予一个套接字。对于网际网协议,协议地址是32位的ipv4地址或128位的ipv6地址与16位的TCP或UDP端口号的组合。
调用bind函数可以选择指定IP或端口号,可以都指定,也可以都不指定。指定或不指定(上一步设置套接字地址结构的时候选择是否指定sin_port和sin_addr.s_addr)的预期结果如下:
IP地址 |
端口 |
结果 |
通配地址 |
0 |
内核选择IP地址和端口 |
通配地址 |
非0 |
内核选择IP地址,进程指定端口 |
本地IP地址 |
0 |
进程指定IP地址,内核选择端口 |
本地IP地址 |
非0 |
进程指定IP地址和端口 |
第四步 设置监听
函数原型:int listen(int sockfd,int backlog);
sockfd: 套接字描述符
backlog: 允许的最大连接数量
这个函数有两个作用:①将我们创建的主动套接字转换为被动套接字,以接收客户端请求;②规定内核应该为响应套接字排队的最大连接个数。
第五步 接收请求
函数原型:int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
sockfd:服务器端套接字描述符
cliaddr 和 addrlen 用来返回已连接的对端进程(客户端)的协议地址。
如果accept成功,函数返回一个全新描述符,代表与所返回客户的TCP连接。
第六步 接收客户端请求内容
函数原型:
1 2 3 4 5 6 7 8 9 10
| ssize_t read(int fd, void *buf, size_t count);
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t recv(int sockfd, void* buf, size_t len, int flags);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
|
这里只分析recv函数,其他函数的具体用法和注意事项自行谷歌。(篇幅限制内容太多)
1
| ssize_t recv(int sockfd,void* buf,size_t len,int flags);
|
这个函数用于获取客户端发送的数据,并存入buf中。
sockfd:这里需要填写的是客户端套接字描述符
buf:客户端发来的数据将存入buf中
len:读取数据的长度
flags:接收参数,可能的取值如下:
flags |
说明 |
MSG_DONTROUTE |
绕过路由表查找 |
MSG_DONTWAIT |
本次操作非阻塞 |
MSG_OOB |
发送或接收带外数据 |
MSG_PEEK |
窥看外来消息 |
MSG_WAITALL |
等待所有数据 |
第七步 响应客户端请求
函数原型:
1 2 3 4 5 6 7 8 9 10
| ssize_t write(int fd, const void *buf, size_t count);
ssize_t writev(int fildes, const struct iovec *iov, int iovcnt);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
|
这里只分析send函数,其他函数的具体用法和注意事项自行谷歌。(篇幅限制内容太多)
1
| ssize_t send(int sockfd, const void *buf, size_t len, int flags);
|
这个函数用于将buf中的数据传回客户端。
sockfd: 客户端套接字描述符
buf: 欲传回客户端的数据
len: 传回数据的长度
flags: 发送参数,可能的取值同recv()
第八步 关闭套接字
函数原型:int close(int fd);
这个函数用来关闭套接字,并终止TCP连接。
0x04 我们来写一个完整的服务器端代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| #include <netinet/in.h> #include <arpa/inet.h> #include <sys/socket.h> #include <sys/types.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <strings.h> #include <unistd.h> #include <fcntl.h> #include <errno.h>
#define BACKLOG 5 //允许的连接队列长度 #define PORT 7546 //连接端口 #define RECVBUFF 1048576 //接收缓冲区长度 #define SENDBUFF 1048576 //发送缓冲区长度
char ReceiveBuffer[RECVBUFF]; //接收缓冲区 char SendBuffer[SENDBUFF]; //发送缓冲区
int main(int argc,char** argv){ int client; //对端(客户端)套接字文件描述符 int sfd = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP); //创建套接字 ipv4协议簇 字节流套接字 使用TCP协议 (若第三个参数置零,则默认TCP/SCTP协议) if (sfd == -1){ perror("create socket failed."); return -1; } struct sockaddr_in saddr; //服务器端套接字地址结构 struct sockaddr_in peer_saddr; // 对端(客户端)套接字地址结构 socklen_t peer_len = sizeof(struct sockaddr); //由peer_saddr指定的套接字地址结构长度 memset(&saddr,0,sizeof(saddr)); //这里也可使用bzero()函数 saddr.sin_family = AF_INET; //指定ipv4协议簇 saddr.sin_port = htons(PORT); //端口 由主机字节序转化为网络字节序 saddr.sin_addr.s_addr = INADDR_ANY; //允许的ip地址 INADDR_ANY 即任意地址 if(bind(sfd,(struct sockaddr*)&saddr,sizeof(struct sockaddr)) == -1){ //bind(). 绑定套接字 sfd 文件描述符 和 saddr 套接字地址结构 perror("bind socket error!"); return -1; } if(listen(sfd,BACKLOG) == -1){ //listen(). 启动监听 本步将sfd主动套接字转化为被动监听套接字 perror("listen error!"); return -1; } while(1){ //无限循环接收客户端请求 client = accept(sfd,(struct sockaddr*)&peer_saddr,&peer_len); //收到客户端请求,接受请求 否则本步阻塞监听 if (client == -1){ perror("error in accept."); return -1; } else { printf("accept client:%s:%d\n",inet_ntoa(peer_saddr.sin_addr),peer_len); } memset(ReceiveBuffer,'\0',sizeof(ReceiveBuffer)); memset(SendBuffer,'\0',sizeof(SendBuffer)); recv(client,ReceiveBuffer,RECVBUFF,0); //若客户端发送了数据,接收客户端发来的数据 否则本步阻塞接收数据 if((strcmp(ReceiveBuffer,"")) == 0){ continue; } printf("recv:%s\n",ReceiveBuffer);
strcpy(SendBuffer,Analysis(ReceiveBuffer)); //分析数据
if (strcmp(SendBuffer,"") != 0){ send(client,SendBuffer,SENDBUFF,0); //向客户端回返数据 printf("send:%s\n",SendBuffer); } } close(sfd); //关闭套接字 return 0;
}
|
其中第23行完成创建套接字,33-35行完成套接字地址结构设定,36行完成套接字绑定,41行完成套接字监听,47行完成接收客户端请求,58行完成接收客户端请求内容,64行完成客户端请求处理,67行完成客户端请求响应,71行完成关闭套接字。
0x05 客户端程序
服务器端写好了,客户端怎么写呢?其实客户端比服务器端容易很多,设置完套接字后无需绑定和监听的过程,可以直接选择连接服务器端。
使用套接字完成客户端构建一共需要6步:创建套接字,设置套接字地址结构,连接服务器,发送请求,接收服务器端响应,关闭套接字。
这里只对第三步进行解释,其他的步骤与服务器端相似,不再赘述。
第三步 连接服务器
函数原型:int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
sockfd: 客户端套接字描述符
addr: 服务器端套接字地址结构
addrlen: 套接字地址结构长度
这个函数用于客户端与服务器端建立连接,执行connect函数将激发TCP三次握手。按照TCP状态转换,connect()函数将会使当前套接字从CLOSED状态转移到SYN_SENT状态,若函数执行成功,则再转移到ESTABLISHED状态。
0x06 举一个客户端的例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| #include <netinet/in.h> #include <arpa/inet.h> #include <sys/socket.h> #include <sys/types.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <strings.h> #include <unistd.h> #include <fcntl.h> #include <errno.h>
#define PORT 7546 //连接端口 #define BUFF 1048576
char buf[BUFF]; int main() { int client_socket = socket(AF_INET, SOCK_STREAM, 0); //创建和服务器连接套接字 if(client_socket == -1){ perror("socket"); return -1; }
struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; /* Internet地址族 */ addr.sin_port = htons(PORT); /* 端口号 */ addr.sin_addr.s_addr = htonl(INADDR_ANY); /* IP地址 */ inet_aton("127.0.0.1", &(addr.sin_addr)); int addrlen = sizeof(addr);
//连接服务器 int listen_socket = connect(client_socket, (struct sockaddr *)&addr, addrlen); if(listen_socket == -1) { perror("connect"); return -1; }
printf("成功连接到服务器\n");
char buf[SIZE] = {0};
while(1){ printf("请输入:"); scanf("%s", buf); send(client_socket, buf, strlen(buf),0); int ret = recv(client_socket, buf, strlen(buf),0); printf("buf = %s", buf); printf("\n"); if(strncmp(buf, "END", 3) == 0) //当输入END时客户端退出 { break; } } close(listen_socket); return 0; }
|
0x07 总结一下
我们成功地实现了使用C语言完成类似js的前后端交互。不过我们并没有使用http的应用层协议,而是仅仅发送数据,处理数据,接收数据。网络编程的内容多且非常复杂,文章写出来的仅仅是冰山一角。例如,我们现在写的服务器没有太多的错误处理,健壮性不足,不能满足高并发需求,等等。