网络编程一直都是最吸引人、最有挑战的编程领域。从这篇文章开始,达达将同大家一起向这个领域出发,并接受各种难题的挑战,你准备好了吗?
写在开始之前
在开始之前,达达有一些题外话想先跟大家说说。
在阅读这一系列的文章时,我希望大家始终记住以下几点:
1. 软件开发没有银弹,人们总是试图找到问题的唯一解和最优解,但事实是每个问题都有N种解,并且在不同情况下最优解是不一样的,如果非要说软件开发有银弹,那么这颗银弹就是人的心,是否找能到最优解,在于你是否能把握住了所有事情的平衡点。所以,请不要说某某机制最好、某某算法最优、某某架构万能、不需要再了解其他了。也请不要自以为目空一切技术,商业和盈利至上,实现途径和方式无所谓。请抱着一切皆有可能的心态看待所有事物,才有更多机会看到平衡点。
2. 语言、平台、API只是迷人眼的东西,它们好像什么都是,其实什么都不是。解决问题的关键在于设计者的心,设计者是否对要解决的问题和问题的上下文了然于心。不要把学习语言、学习平台、学习API当作目标,它们只是泥沙和工具,最终我们要建造的是房屋,所以请把目标放得更远。也不要把它们当成阻碍,因为它们生来就是要让人使用的东西,不可能形成阻碍,如果你觉得它们是阻碍,那实际上那个阻碍只在你心里。
3. 记得:Do one thing, and do it well。一次只做一件事,并且做好它。这一系列文章的基本开发和测试环境是Linux,编译器是gcc。如果你之前对Linux不是很熟悉,我建议你安装一个VMware,并安装Ubuntu桌面系统,然后apt-get install build-essential,这样你的系统里就有完整的开发环境了,gnome自带的gedit很好用,我也是这么做的。不要一上来就Linux命令行界面加vi编辑器,没必要为难自己也不要搞得自己像黑客一样。请记住,我们当前要做的是高性能socket服务器,只做这一件事,并且做好它!不是研究Linux命令行或者vi编辑器,那些等有空再慢慢研究还来的及。
4. 师傅请进门修行靠个人。文章和教程的内容其实都是转之由转的东西,如果要了解原汁原味的内容,首选应该去阅读操作系统的代码,其次是系统文档,再次才是网络教程。而经验是世界上最难传达的东西,文字只能让你形成记忆,不能让你获得经验,它只是像买彩票一样给你提供一个机会,让在你实践过程中可能会有那么一下的灵光一现,然后得出自己的结论,那才是你的真正经验。如果把生活比喻成RPG,造物主怎么可能让经验可以在玩家之间传递呢?那不是乱了套了,打RPG我们可以学各种技能,但是要得到经验就得打怪做任务,生活其实也是一样的道理。
好了废话就到此结束。
让我们开始吧!
别急,别急,勿在浮沙筑高台~~!
要开始建造我们的高性能socket服务器大厦之前,还是让我们先从泥水匠做起吧,先来了解以下泥沙和工具吧。
记得前面说的吗?一次只做一件事,并且做好它。现在我们就抛开所有杂念和对高性能socket服务器的各种猜想,先做一个最基本的socket服务器端程序。
等我们逐步熟悉了泥沙和工具,我们再杀回来逐个干掉高深莫测的服务器架构设计,这就是我们的行动计划。
这里先贴出本章的示例代码,我再根据这个代码跟大家逐步讲解socket编程的关键知识点:
socketd.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define SD_PORT 10086
#define SD_BACK_LOG 10
int sd_listener_fd;
void
sd_init ()
{
int reuse = 1;
struct sockaddr_in addr;
if ((sd_listener_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
{
perror("Create listener socket failed");
exit(-1);
}
if (setsockopt(sd_listener_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) == -1)
{
perror("Setup listener socket failed");
exit(-1);
}
bzero(&(addr.sin_zero), 8);
addr.sin_family = AF_INET;
addr.sin_port = htons(SD_PORT);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(sd_listener_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1)
{
perror("Bind listener socket address failed");
exit(-1);
}
if (listen(sd_listener_fd, SD_BACK_LOG) == -1)
{
perror("Listen port failed");
exit(-1);
}
}
void
sd_loop ()
{
char buf[1024];
int ret = 0;
int client_fd;
int client_addr_len;
struct sockaddr_in client_addr;
printf("Waiting connect on port %d\n", SD_PORT);
client_addr_len = sizeof(client_addr);
client_fd = accept(sd_listener_fd, (struct sockaddr *)&client_addr, &client_addr_len);
printf("Client connected\n");
for (;;)
{
if ((ret = read(client_fd, buf, 1024)) == 0)
{
close(client_fd);
printf("Client closed\n");
break;
}
else
{
write(client_fd, buf, ret);
}
}
}
void
sd_down ()
{
close(sd_listener_fd);
printf("Server shutdown\n");
}
int
main (int argc, char *argv[])
{
sd_init();
sd_loop();
sd_down();
return 1;
}
上面的代码是一个简单的echo服务器,它一次只处理一个连接,在客户端退出时服务器端也跟着关闭
你可以复制上面的代码保存为socketd.c,然后打开终端,切换到文件所在目录,输入:
cc socketd.c -o socketd
不出意外的话,我们的最原始版socket服务器就编译好了。然后输入:
./socketd
服务器就启动了。另外再开一个终端,输入:
telnet localhost 10086
这时候telnet应该能连上socket服务器,你可以在telnet里面输入一些文字,然后回车,服务器应该会将你发送的内容原样返回。
当你玩腻了,就在telnet界面按住Ctrl键,然后输入“]“,回车。这时候telent会切换到命令界面,输入q,回车,退出telent。
telnet退出后,相当于客户端断开了连接,按代码逻辑上面的示例程序应该会跟着退出。
顺便说一下,像上面示例这样接受并原样返回客户端请求内容的socket服务器叫做echo服务器,名字谁取的我不知道,反正大家都这么叫。
下面我们来分析一下这段代码,我们从大结构分析入手,再深入到每个函数的介绍。
阅读这段代码要从main函数开始,main函数逐步调用了三个sd_开头的函数,sd_init() 初始化服务器 -> sd_loop() 服务器循环处理请求 -> sd_down() 服务器关闭。
sd_init 函数中的代码是典型的服务器端socket初始化过程,socket() 创建套接字 -> bind() 绑定地址 -> listener() 开始监听端口。
sd_loop 函数中的代码则是一个简单的接收客户端请求并回发数据的示例,accept() 接受新连接 -> read() 接受请求数据 -> write()发送数据。
sd_down 函数中的代码演示了如何关闭套接字,close() 就这么简单。
下面我以函数注释的方式一一注释上面代码涉及到的系统函数,这样可以不需要附加太多废话的描述并条理清晰。
/*
* 功能:创建socket
* 返回:成功时,返回socket文件描述符;失败时,返回-1,可以通过errno获取错误类型
* 参数:
* domain - 地址种类,较常用的有AF_INET和AF_INET6,分别对应IPv4协议和IPv6协议
* type - 套接字类型,较常用的有SOCK_STREAM和SOCK_DGRAM,分别对应TCP/IP协议和UDP协议
* protocol - 协议,一些特殊的套接字类型下可能会用到,但是做TCP或者UDP编程时不会用到此参数,所以我们通常传递0
*/
int socket(int domain, int type, int protocol);
/*
* 功能:将socket绑定到指定的地址
* 返回:成功时,返回0;失败时,返回-1,可以通过errno获取错误类型
* 参数:
* sockfd - 套接字文件描述符,就是socket函数成功时返回的那个
* addr - 所要绑定到的地址,其中包含地址种类、协议族IP地址和端口号,IP地址和端口号在赋值时分别需要用htonl和htons函数进行大小端转换
* addrlen - 地址长度,因为我们通常用的是sockaddr_in类型地址,所以这个参数就是sizeof(struct sockaddr_in)
*/
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
/*
* 功能:监听套接字上的连接
* 返回:成功时,返回0;失败时,返回-1,可以通过errno获取错误类型
* 参数:
* sockfd - 套接字文件描述符,就是socket函数成功时返回的那个
* backlog - 等待连接完成的队列大小,当服务器繁忙时可能没办法一次响应所有连接请求,
* 这时候连接请求会被放入队列等待处理,队列满的时候,客户端才真正无法连接
*/
int listen(int sockfd, int backlog);
/*
* 功能:接受一个新的连接
* 返回:成功时,返回新连接的socket文件描述符;失败时,返回-1,可以通过errno获取错误类型
* 参数:
* sockfd - 监听的套接字文件描述符,就是listen函数用的那个
* addr - 新连接的地址信息(指针返回)
* addrlen - 新连接的地址长度(指针返回)
*/
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
上面的函数介绍并不是最详细也不是最权威的,我建议大家不妨在命令行下面用man命令查阅各个函数的详细文档,用法是man [函数名]。
在bind之前我们创建socket地址的时候,用到了htonl和htons函数:
/*
* 功能:Host to Network (long),将主机上的长整型数据进行大小端转换,以适应网络规范
*/
uint32_t htonl(uint32_t hostlong);
/*
* 功能:Host to Network (short),将主机上的短整型数据进行大小端转换,以适应网络规范
*/
uint16_t htons(uint16_t hostshort);
分别与这两个函数对应但没有出现在代码中的还有:
/*
* 功能:Network to Host (long),将网络规范格式的长整型数据进行大小端转换,以适应主机
*/
uint32_t ntohl(uint32_t netlong);
/*
* 功能:Network to Host (short),将网络规范格式的短整型数据进行大小端转换,以适应主机
*/
uint16_t ntohs(uint16_t netshort);
为什么数据需要进行大小端的转换呢?大小端转换又是什么呢?这就要涉及到计算机组织原理的知识了。
简单说来,我们的数据在计算机中是以二进制字节数据表示的,二进制字节数据保存在内存中时就涉及到一个实现问题,比如十进制数1,对应的字节是应该表示成1000 0000还是0000 0001呢?到底是高位在前还是低位在前对计算机来说是一个实现的问题,而我们平时阅读和书写的习惯是高位在前,所以成为大端模式,即0000 0001格式,而反之则称为小端格式。在不同厂商的CPU上,数据的存储格式是不一样的,比如IBM和SUN用的是大端格式,Intel用的则是小端格式。而当不同数据格式的主机,在网络间进行数据传输时,这个实现问题就演变成了兼容问题,而RFC规范中规定了网络通讯时的字节格式是大端格式,所以系统就提供了相应的转换函数提高程序兼容性。
对于大小端格式的详细信息,大家如果有兴趣了解可以在网上搜索,下面是一个关于大小端的有趣故事:
端模式(Endian)的这个词出自Jonathan Swift书写的《格列佛游记》。
这本书根据将鸡蛋敲开的方法不同将所有的人分为两类,从圆头开始将鸡蛋敲开的人被归为Big Endian,从尖头开始将鸡蛋敲开的人被归为Littile Endian。
小人国的内战就源于吃鸡蛋时是究竟从大头(Big-Endian)敲开还是从小头(Little-Endian)敲开。在计算机业Big Endian和Little Endian也几乎引起一场战争。
在计算机业界,Endian表示数据在存储器中的存放顺序。
示例代码中,在创建监听的套接字文件描述符后,还执行了这样一句代码:
setsockopt(sd_listener_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
setsockopt函数是用来设置socket的参数的,这些参数决定socket的一些表现,例如后面的章节中我们使用这个函数设置socket为无阻塞模式。而上面这行代码则是用来运行socket重用端口的,所以它必须执行在bind之前。这么设置为了防止程序在意外退出后,系统没有释放端口而导致程序无法再使用原来端口
本章的示例代码只需要走马观花看一遍,熟悉一下socket编程的大概流程,以后自己亲手实验的时候不记得怎么做了再回来查阅就可以。
没必要死记硬背这些API,只需要知到这些函数存在,它们大概干什么用的。盖楼嘛,你背各种泥沙学名和化学成分有什么用?只要懂得辨别,需要时能从手册找到查阅到具体信息就可以了。
本章总结
本章演示了一个简单的echo服务器,它只支持一次处理一个连接,并且在连接退出时,服务器端也跟着关闭。
通过这个简单的例子,我们学习了基本的socket初始化过程和连接的响应方式。
当然这些知识对于我们的远大目标“高性能socket服务器”来说是远远不够的,但是这些是基础的基础,至少我们已经迈开了第一步。
下一章我将向大家介绍如何利用IO重用,在一个进程中同时处理多个连接的请求,敬请期待。