开始看C下边的网络编程了。前边的一堆系统编程算是刚消化完基础使用。现在要来看网络编程了。
简单的服务器
网络编程么,只要不是想直接控制TCP/IP协议,一般都会选择抽象程度比较高的套接字来通信。
先来写一个简单的服务器,分这么几步:
- 创建套接字
- 把套接字绑定端口
- 开始监听
- 接受连接
- 写套接字信息
创建套接字
C里边的套接字在<sys/socket.h>
库中,不能在Windows下使用。
先来创建一个套接字:
#include <sys/socket.h> int listener_d = socket(PF_INET, SOCK_STREAM, 0); if (listener_d == -1) error("无法打开套接字");
这里的参数PF_INET和SOCK_STREAM都预定义好的宏,第一个表示本地的网络地址,第二个表示一个流对象,目前都不用深究。这个socket
函数返回一个套接字文件描述符。
如果为-1,则表示无法创建套接字。
绑定端口
之后需要把套接字绑定端口,这样程序才能通过端口和其他服务进行基于套接字的网络通信(本机进程通信除了管道,也可以使用套接字文件)。
#include <arpa/inet.h> struct sockaddr_in name; name.sin_family = PF_INET; name.sin_port = (in_port_t)htons(30000); name.sin_addr.s_addr = htonl(INADDR_ANY); int c = bind (listener_d, (struct sockaddr *) &name, sizeof(name)); if (c == -1) error("无法绑定端口");
前边的几行代码通过一个结构,创建了一个30000端口的结构,然后使用bind
函数,将这个30000端口与套接字绑定起来。这里要把sockaddr_in
的指针转换成sockaddr
指针。
开始监听
监听需要使用一个系统函数listen()
,第一个参数是套接字描述符,第二个参数是队列长度,表示可以同时连接服务器的客户端数量。小于等于长度的连接可以成功连接,但未必会立刻得到响应。大于连接数字的连接会被直接告知服务器忙。
if (listen(listener_d, 10) == -1) error("无法监听");
接受连接
这里需要使用另外一个系统函数accept()
,一旦有连接,就会创建一个新的套接字对象,可以使用新的套接字对象进行通信了。
//保存连接的详细信息 struct sockaddr_storage client_addr; socketlen_t address_size = sizeof(client_addr); //connect_d是新的套接字描述符 int connect_d = accept(listener_d, (struct sockaddr *)&client_addr, address_size); if (connect_d == -1) error("无法打开新套接字");
通信
现在先知道使用send()系统函数向套接字内写内容:
char *msg = "Internet Knock-Knock Protocol Server\r\nVersion 1.0\r\nKnock! Knock!\r\n> "; if (send(connect_d, msg, strlen(msg), 0) == -1) error("send");
send()的第一个参数是套接字描述符,第二个参数是消息,第三个参数是消息的长度。最后一个参数是高级选项,目前先填写0。
和很多函数一样,错误也会返回-1,要及时判断。
设置套接字立刻可重用
端口在使用后,操作系统默认30秒不会让其他程序绑定到该端口,但可以在绑定之前进行一些设置即可:
if (bind (listener_d, (struct sockaddr *) &name, sizeof(name)) == -1) error("无法绑定端口"); int reuse = 1; if (setsockopt(listener_d, SOL_SOCKET, SO_REUSEADDR, (char *)&reuse, sizeof(int)) == -1) error("无法设置套接字的“重新使用端口”选项");
之后再绑定就不会出现提示套接字不可用了。
简单的服务器完整程序
这个简单的服务器,连接的时候自动发一条信息,然后就关闭连接。
#include <sys/socket.h> #include <string.h> #include <arpa/inet.h> #include <stdlib.h> int main() { char *advice[] = { "Take smaller bites\r\n", "Go for the tight jeans. No they do NOT make you look fat.\r\n", "One word: inappropriate\r\n", "Just for today, be honest. Tell your boss what you *really* think\r\n", "You might want to rethink that haircut\r\n" }; int listener_d = socket(PF_INET, SOCK_STREAM, 0); int reuse = 1; if (setsockopt(listener_d, SOL_SOCKET, SO_REUSEADDR, (char *) &reuse, sizeof(int)) == -1) { puts("unablkjdskjaffjkldsajlkf"); exit(1); } struct sockaddr_in name; name.sin_family = PF_INET; name.sin_port = (in_port_t) htons(30000); name.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(listener_d, (struct sockaddr *) &name, sizeof(name)) == -1) { puts("Unable to bind port"); exit(1); } listen(listener_d, 10); puts("Waiting for connection"); while (1) { struct sockaddr_storage client_addr; unsigned int address_size = sizeof(client_addr); int connect_d = accept(listener_d, (struct sockaddr *) &client_addr, &address_size); char *msg = advice[rand() % 5]; send(connect_d, msg, strlen(msg), 0); close(connect_d); } return 0; }
这个服务器现在是向服务端发送消息。现在来看看如何读取消息。
可以用telnet
命令来登录,看看效果:
[root@localhost c]# telnet 127.0.0.1 30000 Trying 127.0.0.1... Connected to 127.0.0.1. Escape character is '^]'. One word: inappropriate Connection closed by foreign host. [root@localhost c]# telnet 127.0.0.1 30000 Trying 127.0.0.1... Connected to 127.0.0.1. Escape character is '^]'. Just for today, be honest. Tell your boss what you *really* think Connection closed by foreign host.
telnet是基于TCP/IP协议的,可以默认连接到标准的套接字通信。
从套接字读取消息
读取套接字用recv
函数,这个函数的参数依次是(描述符,缓冲区,读几个字节,0)。
telnet输出文本的时候,客户端按下回车,发送过来的最后一个字符是\r\n
而不是\0
。
如果发生错误返回-1
,如果关闭了连接就返回0
。
最关键的一点是,有缓冲区的概念,不一定一次能够全部读完,所以一般用一个while
函数反复读取直到没有字符可以读或者读到了'\n'
一般读取的时候用一个函数反复读取:
int read_in(int socket, char *buf, int len) { char *s = buf; int slen = len; int c = recv(socket, s, slen, 0); //判断读入的字符大于0,和最后一个字符不是\n while (c > 0 && (s[c - 1] != '\n')) { //指针指向缓冲区长度c之后的空白 s += c; //剩余字符数减去已经读取的字符数 slen -= c; //继续读取剩下的字符 c = recv(socket, s, slen, 0); } //读入完成之后处理,有错就返回-1,如果是0就直接设置是0,如果正常读入,将最后一位设置为\0 if (c < 0) { return c; } else if (c == 0) { buf[0] = '\0'; } else s[c-1]='\0'; //返回已经读取的字符数 return len - slen; }
这个函数就是将内容读入到一个足够长的缓冲区里,每次收不到消息或者遇到\n字符就会停止,然后返回读取的字符数量。
配合telnet就可以反复读取和显示字符。
简单一次性服务器例子:
#include <stdio.h> #include <string.h> #include <errno.h> #include <stdlib.h> #include <sys/socket.h> #include <arpa/inet.h> #include <unistd.h> #include <signal.h> //#include "chapter11/server.h" int listener_d; void error(char* msg) { fprintf(stderr, "%s: %s\n", msg, strerror(errno)); exit(1); } int open_listener_socket() { int s = socket(PF_INET, SOCK_STREAM, 0); if (s == -1) error("Can't open socket"); return s; } void bind_to_port(int socket, int port) { struct sockaddr_in name; name.sin_family = PF_INET; name.sin_port = (in_port_t)htons(30000); name.sin_addr.s_addr = htonl(INADDR_ANY); int reuse = 1; if (setsockopt(socket, SOL_SOCKET, SO_REUSEADDR, (char *)&reuse, sizeof(int)) == -1) error("Can't set the reuse option on the socket"); int c = bind (socket, (struct sockaddr *) &name, sizeof (name)); if (c == -1) error("Can't bind to socket"); } int say(int socket, char* s) { int result = send(socket, s, strlen(s), 0); if (result == -1) fprintf(stderr, "%s: %s\n", "Error talking to the client", strerror(errno)); return result; } void handle_shutdown(int sig) { if (listener_d) close(listener_d); fprintf(stderr, "Bye!\n"); exit(0); } int read_in(int socket, char *buf, int len) { char *s = buf; int slen = len; int c = recv(socket, s, slen, 0); while ((c > 0) && (s[c-1] != '\n')) { s += c; slen -= c; c = recv(socket, s, slen, 0); } if (c < 0) return c; else if (c == 0) buf[0] = '\0'; else s[c-1]='\0'; return len - slen; } int main(int argc, char* argv[]) { if (signal(SIGINT, handle_shutdown) == SIG_ERR) error("Can't set the interrupt handler"); listener_d = open_listener_socket(); bind_to_port(listener_d, 30000); if (listen(listener_d, 10) == -1) error("Can't listen"); struct sockaddr_storage client_addr; unsigned int address_size = sizeof client_addr; puts("Waiting for connection"); char buf[255]; for(;;) { int connect_d = accept(listener_d, (struct sockaddr *)&client_addr, &address_size); if (connect_d == -1) error("Can't open secondary socket"); if (say(connect_d, "Internet Knock-Knock Protocol Server\r\nVersion 1.0\r\nKnock! Knock!\r\n> ") != -1) { read_in(connect_d, buf, sizeof(buf)); if (strncasecmp("Who's there?", buf, 12)) say(connect_d, "You should say 'Who's there?'!"); else { if (say(connect_d, "Oscar\r\n> ") != -1) { read_in(connect_d, buf, sizeof(buf)); if (strncasecmp("Oscar who?", buf, 10)) say(connect_d, "You should say 'Oscar who?'!\r\n"); else say(connect_d, "Oscar silly question, you get a silly answer\r\n"); } } } close(connect_d); } return 0; }
除了套接字要反复使用之外,对套接字的设置,绑定端口,其中所使用的变量都无需设置为全局或者是main函数内的局部变量。
反复使用的就是从accept的初始套接字,以及从此的来的用于通信的新套接字。
这里由于把套接字的变量写成了全局变量,所以同一时刻只能有一个连接,连接排队为10。经过尝试也可以发现,同时开两个连接,只有一个会跳出输入提示符,关闭连接之后,另外一个才会连接成功。
所以剩下的问题就是要解决给每个连接开启一个进程的问题了。
为每个客户端开启进程
核心是主进程的套接字只有一个,而子进程的套接字每次就使用从accept中返回的来进行处理。
核心的代码修改如下:
int main(int argc, char* argv[]) { if (signal(SIGINT, handle_shutdown) == SIG_ERR) error("Can't set the interrupt handler"); listener_d = open_listener_socket(); bind_to_port(listener_d, 30000); if (listen(listener_d, 10) == -1) error("Can't listen"); struct sockaddr_storage client_addr; unsigned int address_size = sizeof client_addr; puts("Waiting for connection"); char buf[255]; for(;;) { int connect_d = accept(listener_d, (struct sockaddr *)&client_addr, &address_size); if (connect_d == -1) error("Can't open secondary socket"); //这里fork一个子进程 //子进程执行完对话之后退出 if(!fork()){ close(listener_d); if (say(connect_d, "Internet Knock-Knock Protocol Server\r\nVersion 1.0\r\nKnock! Knock!\r\n> ") != -1) { read_in(connect_d, buf, sizeof(buf)); if (strncasecmp("Who's there?", buf, 12)) say(connect_d, "You should say 'Who's there?'!"); else { if (say(connect_d, "Oscar\r\n> ") != -1) { read_in(connect_d, buf, sizeof(buf)); if (strncasecmp("Oscar who?", buf, 10)) say(connect_d, "You should say 'Oscar who?'!\r\n"); else say(connect_d, "Oscar silly question, you get a silly answer\r\n"); } } } close(connect_d); exit(0); } //这是主进程,关闭子进程的连接,在下一个新循环里又重新初始化一个变量 close(connect_d); } return 0; }
只使用一次的全局套接字,变量可以定义为只有一个。而每次都要使用的新套接字,变量需要定义在循环内部,每次初始化。这么简单的道理才刚刚想明白。
之后就可以同时多个连接了。
简单的客户端
对于客户端,工作简化了一些,只需要两部就可以获取套接字:
- 创建套接字
- 连接至远程端口
连接成功之后,就可以使用send()和recv()来发送和收取数据了。
创建套接字
创建套接字有两种方法,一种是直接根据IP地址创建,一种是根据域名创建。
先来看根据IP地址创建:
int s = socket(PF_INET, SOCK_STREAM, 0); struct sockaddr_in si; //memset用于清空结构对应的内存区域 memset(&si, 0, sizeof(si)); si.sin_family = PF_INET; si.sin_addr.s_addr = inet_addr("60.205.47.148"); si.sin_port = htons(80); //与服务器套接字的bind函数不同,这里是connect函数 connect(s, (struct sockaddr *) &si, sizeof(si));
根据域名来创建,则需要使用getaddrinfo()
函数。
#include <netdb.h> struct addrinfo *res; struct addrinfo hints; memset(&hints, 0, sizeof(hints)); hints.ai_family = PF_UNSPEC; hints.ai_socktype = SOCK_STREAM; getaddrinfo("www.conyli.cc", "80", &hints, &res); int s = socket(res->ai_family, res->ai_socktype, res->ai_protocol); connect(s, res->ai_addr, res->ai_addrlen);
由于名字资源在堆上创建,所以在使用完毕还必须释放内存:
freeaddrinfo(res);
这段样板代码没有看很懂到底是什么意思,估计getaddrinfo
创建的名字资源的指针就是res
。
一个简单的HTTP客户端
知道了如何使用套接字,也将其包裹在函数里形成简便的操作,然后就可以来使用了。
#include <stdio.h> #include <string.h> #include <errno.h> #include <stdlib.h> #include <sys/socket.h> #include <arpa/inet.h> #include <unistd.h> #include <netdb.h> void error(char* msg) { fprintf(stderr, "%s: %s\n", msg, strerror(errno)); exit(1); } int open_socket(char *host, char *port) { struct addrinfo *res; struct addrinfo hints; memset(&hints, 0, sizeof(hints)); hints.ai_family = PF_UNSPEC; hints.ai_socktype = SOCK_STREAM; if (getaddrinfo(host, port, &hints, &res) == -1) error("Can't resolve the address"); int d_sock = socket(res->ai_family, res->ai_socktype, res->ai_protocol); if (d_sock == -1) error("Can't open socket"); int c = connect(d_sock, res->ai_addr, res->ai_addrlen); freeaddrinfo(res); if (c == -1) error("Can't connect to socket"); return d_sock; } int say(int socket, char* s) { int result = send(socket, s, strlen(s), 0); if (result == -1) fprintf(stderr, "%s: %s\n", "Error talking to the server", strerror(errno)); return result; }
这其中只有一个open_socket函数是全新的,使用了域名连接。
之后来可以来编写一个不断读入文件直到结束的简单客户端:
int main(int argc, char* argv[]) { int client_sock; client_sock = open_socket("http://conyli.cc", "80"); char buf[255]; //HTTP协议的请求行 sprintf(buf, "GET /archives/%s http/1.1\\r\\n", argv[1]); say(client_sock, buf); //请求头的Host键值对 say(client_sock, "Host: www.conyli.cc"); char receive[256]; //先读取一次,然后不断重复读取,在最后加上\0当成字符串 int bytesReceived = recv(client_sock, receive, 255, 0); while (bytesReceived) { if (bytesReceived == -1) { error("无法读取数据"); } receive[bytesReceived] = '\0'; printf("%s", receive); bytesReceived = recv(client_sock, receive, 255, 0); } close(client_sock); return 0; }
执行这个程序,后边加上文章编号,就可以从http://www.conyli.cc/archives/编号
获取html代码。
实际执行了一下:
[root@localhost clearn]# ./client 2861 >>index.html
发现还成功下载了。客户端的套路就是用一个缓冲区,先读再循环判断,直到读完为止。
这里注意参数有空格,需要替换成下划线,或者采用urlencoded方式。
服务器里主要是很多样板代码中的参数意义不是很明白,简单的服务器本身的逻辑还算容易理解。
系统编程现在开了个头,还是很多东西要探索啊。