要開始建造我們的高性能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 %dn", SD_PORT); client_addr_len = sizeof(client_addr); client_fd = accept(sd_listener_fd, (struct sockaddr *)&client_addr, &client_addr_len); printf("Client connectedn"); for (;;) { if ((ret = read(client_fd, buf, 1024)) == 0) { close(client_fd); printf("Client closedn"); break; } else { write(client_fd, buf, ret); } } } void sd_down () { close(sd_listener_fd); printf("Server shutdownn"); } 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服務器"來說是遠遠不夠的,但是這些是基礎的基礎,至少我們已經邁開了第一步。