C 基於UDP實現一個簡易的聊天室
引言
本文是圍繞Linux udp api 構建一個簡易的多人聊天室.重點看思路,幫助我們加深
對udp開發中一些api瞭解.相對而言udp socket開發相比tcp socket開發注意的細節要少很多.
但是水也很深. 本文就當是一個demo整合幫助開發者回顧和繼續瞭解 linux udp開發的基本流程.
首先我們來看看 linux udp 和 tcp的異同.
/* 這裡簡單比較一下TCP和UDP在程式設計實現上的一些區別: TCP流程 建立一個TCP連線需要三次握手,而斷開一個TCP則需要四個分節。當某個應用程序呼叫close(主動端)後(可以是伺服器端,也可以是客戶 端),這一端的TCP傳送一個FIN,表示資料傳送完畢;另一端(被動端)傳送一個確認,當被動端待處理的應用程序都處理完畢後,傳送一個FIN到主動 端,並關閉套介面,主動端接收到這個FIN後再發送一個確認,到此為止這個TCP連線被斷開。 UDP套介面 UDP套介面是無連線的、不可靠的資料報協議;既然他不可靠為什麼還要用呢? 其一:當應用程式使用廣播或多播是隻能使用UDP協議; 其二:由於它是無連線的,所以速度快。因為UDP套介面是無連線的,如果一方的資料報丟失,那另一方將無限等待,解決辦法是設定一個超時。 在編寫UDP套介面程式時,有幾點要注意:建立套介面時socket函式的第二個引數應該是SOCK_DGRAM,說明是建立一個UDP套介面; 由於UDP是無連線的,所以伺服器端並不需要listen或accept函式; 當UDP套介面呼叫connect函式時,核心只記錄連線放的IP地址 和埠,並立即返回給呼叫程序.*/
參照
linux udp api簡介 http://blog.csdn.net/wocjj/article/details/8315559
tcp 和udp區別 http://www.cnblogs.com/Jessy/p/3536163.html
這裡簡單引述一下 udp相比tcp 用到的兩個api . recvfrom()/sendto() 具體細節如下
#include <sys/types.h> #include <sys/socket.h> /* * 這兩個函式基本等同於 一個 send 和 recv . 詳細引數解釋如下 * s : 檔案描述符,等同於 socket返回的值 * buf : 資料其實地址 * len : 傳送資料長度或接受資料緩衝區最大長度 * flags : 傳送標識,預設就用O.帶外資料使用 MSG_OOB, 偷窺用MSG_PEEK ..... * addr : 傳送的網路地址或接收的網路地址 * alen : sento標識地址長度做輸入引數, recvfrom表示輸入和輸出引數.可以為NULL此時addr也要為NULL * : 返回0表示執行成功,否則返回<0 . 更多細節查詢man手冊*/ extern int sendto (int s, const void *buf, int len, unsigned int flags, const struct sockaddr *addr, int alen); extern int recvfrom(int s, void *buf, int len, unsigned int flags, struct sockaddr *addr, int *alen);
上面就是兩個函式的大致用法. 具體可以檢視linux api幫助手冊. 最好就用 man sendto / man recvfrom 把那一系列函式都看看.
現在很多文章都是轉載,但是找不見轉載的地址, 下面會舉一個簡易的UDP回顯伺服器的demo加深理解.
前言
首先看設計圖
有點low. 簡單看看吧. 那我們先看 客戶端程式碼 udpclt.c 程式碼
#include <stdio.h> #include <stdlib.h> #include <errno.h> #include <unistd.h> #include <fcntl.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/types.h> #include <sys/stat.h> #include <sys/socket.h> // 測試埠和網路地址 #define _INT_PORT (8088) #define _INT_BUF 1024 // udp 伺服器主函式 int main(int argc, char* argv[]) { int sd, len; struct sockaddr_in addr = { AF_INET }; socklen_t alen = sizeof addr; char msg[_INT_BUF]; //建立伺服器socket 地址,客戶端給它傳送資訊 if((sd = socket(PF_INET, SOCK_DGRAM, 0)) < 0) { perror("main socket "); exit(sd); } // 這裡簡單輸出連線資訊 printf("udp server start [%d][0.0.0.0][%d] -------> \n", sd, _INT_PORT); //拼接對方地址 addr.sin_port = htons(_INT_PORT); addr.sin_addr.s_addr = INADDR_ANY; if(bind(sd, (struct sockaddr*)&addr, sizeof addr) < 0){ perror("main bind "); exit(-1); } // 迴圈處理訊息讀取傳送到客戶端 while((len = recvfrom(sd, msg, sizeof msg - 1, 0, (struct sockaddr*)&addr, &alen))>0){ msg[len] = '\0'; printf("read [%s:%d] mag-->%s\n",inet_ntoa(addr.sin_addr), ntohs(addr.sin_port), msg); //這裡傳送資訊過去, 也可以事先connect這裡就不綁定了 sendto(sd, msg, len, 0, (struct sockaddr*)&addr, alen); } close(sd); puts("udp server end ------------------------------<"); return 0; }
編譯是
gcc -g -Wall -o udpclt.out udpclt.c
udp 伺服器 udpsrv.c
#include <stdio.h> #include <stdlib.h> #include <errno.h> #include <unistd.h> #include <fcntl.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/types.h> #include <sys/stat.h> #include <sys/socket.h> // 測試埠和網路地址 #define _INT_PORT (8088) #define _INT_BUF 1024 // udp 伺服器主函式 int main(int argc, char* argv[]) { int sd, len; struct sockaddr_in addr = { AF_INET }; socklen_t alen = sizeof addr; char msg[_INT_BUF]; //建立伺服器socket 地址,客戶端給它傳送資訊 if((sd = socket(PF_INET, SOCK_DGRAM, 0)) < 0) { perror("main socket "); exit(sd); } // 這裡簡單輸出連線資訊 printf("udp server start [%d][0.0.0.0][%d] -------> \n", sd, _INT_PORT); //拼接對方地址 addr.sin_port = htons(_INT_PORT); addr.sin_addr.s_addr = INADDR_ANY; if(bind(sd, (struct sockaddr*)&addr, sizeof addr) < 0){ perror("main bind "); exit(-1); } // 迴圈處理訊息讀取傳送到客戶端 while((len = recvfrom(sd, msg, sizeof msg - 1, 0, (struct sockaddr*)&addr, &alen))>0){ msg[len] = '\0'; printf("read [%s:%d] mag-->%s\n",inet_ntoa(addr.sin_addr), ntohs(addr.sin_port), msg); //這裡傳送資訊過去, 也可以事先connect這裡就不綁定了 sendto(sd, msg, len, 0, (struct sockaddr*)&addr, alen); } close(sd); puts("udp server end ------------------------------<"); return 0; }
編譯是
gcc -g -Wall -o udpsrv.out udpsrv.c
後面執行結果如下 udp伺服器如下 (Ctrl + C 退出)
udp 客戶端如下 (Ctrl + D 結束輸入)
到這裡將上面程式碼 敲一遍基本上udp 一套api就會使用了. 後面進入正題設計聊天室程式碼.
正文
首先看客戶端設計程式碼. 主要思路是子程序處理資料的輸出, 父程序處理伺服器資料的接收. 具體設計如下(畫的圖有點low就不畫了.../(ㄒoㄒ)/~~)
udpmulclt.c
#include <stdio.h> #include <stdlib.h> #include <errno.h> #include <string.h> #include <unistd.h> #include <sys/types.h> #include <signal.h> #include <sys/wait.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/socket.h> // 名字長度包含'\0' #define _INT_NAME (64) // 報文最大長度,包含'\0' #define _INT_TEXT (512) //4.0 控制檯列印錯誤資訊, fmt必須是雙引號括起來的巨集 #define CERR(fmt, ...) \ fprintf(stderr,"[%s:%s:%d][error %d:%s]" fmt "\r\n",\ __FILE__, __func__, __LINE__, errno, strerror(errno),##__VA_ARGS__) //4.1 控制檯列印錯誤資訊並退出, t同樣fmt必須是 ""括起來的字串常量 #define CERR_EXIT(fmt,...) \ CERR(fmt,##__VA_ARGS__),exit(EXIT_FAILURE) /* * 簡單的Linux上API錯誤判斷檢測巨集, 好用值得使用 */ #define IF_CHECK(code) \ if((code) < 0) \ CERR_EXIT(#code) // 傳送和接收的資訊體 struct umsg{ char type; //協議 '1' => 向伺服器傳送名字, '2' => 向伺服器傳送資訊, '3' => 向伺服器傳送退出資訊 char name[_INT_NAME]; //儲存使用者名稱字 char text[_INT_TEXT]; //得到文字資訊,空間換時間 }; /* * udp聊天室的客戶端, 子程序傳送資訊,父程序接受資訊 */ int main(int argc, char* argv[]) { int sd, rt; struct sockaddr_in addr = { AF_INET }; socklen_t alen = sizeof addr; pid_t pid; struct umsg msg = { '1' }; // 這裡簡單檢測 if(argc != 4) { fprintf(stderr, "uage : %s [ip] [port] [name]\n", argv[0]); exit(-1); } // 下面對接資料 if((rt = atoi(argv[2]))<1024 || rt > 65535) CERR("atoi port = %s is error!", argv[2]); // 接著判斷ip資料 IF_CHECK(inet_aton(argv[1], &addr.sin_addr)); addr.sin_port = htons(rt); // 這裡拼接使用者名稱字 strncpy(msg.name, argv[3], _INT_NAME - 1); //建立socket 連線 IF_CHECK(sd = socket(PF_INET, SOCK_DGRAM, 0)); // 這裡就是傳送登入資訊給udp聊天伺服器了 IF_CHECK(sendto(sd, &msg, sizeof msg, 0, (struct sockaddr*)&addr, alen)); //開啟一個程序, 子程序處理髮送資訊, 父程序接收資訊 IF_CHECK(pid = fork()); if(pid == 0) { //子程序,先忽略退出處理防止成為殭屍程序 signal(SIGCHLD, SIG_IGN); while(fgets(msg.text, _INT_TEXT, stdin)){ if(strcasecmp(msg.text, "quit\n") == 0){ //表示退出 msg.type = '3'; // 傳送資料並檢測 IF_CHECK(sendto(sd, &msg, sizeof msg, 0, (struct sockaddr*)&addr, alen)); break; } // 洗嘜按傳送普通訊息 msg.type = '2'; IF_CHECK(sendto(sd, &msg, sizeof msg, 0, (struct sockaddr*)&addr, alen)); } // 處理結算操作,並殺死父程序 close(sd); kill(getppid(), SIGKILL); exit(0); } // 這裡是父程序處理資料的讀取 for(;;){ bzero(&msg, sizeof msg); IF_CHECK(recvfrom(sd, &msg, sizeof msg, 0, (struct sockaddr*)&addr, &alen)); msg.name[_INT_NAME-1] = msg.text[_INT_TEXT-1] = '\0'; switch(msg.type){ case '1':printf("%s 登入了聊天室!\n", msg.name);break; case '2':printf("%s 說了: %s\n", msg.name, msg.text);break; case '3':printf("%s 退出了聊天室!\n", msg.name);break; default://未識別的異常報文,程式直接退出 fprintf(stderr, "msg is error! [%s:%d] => [%c:%s:%s]\n", inet_ntoa(addr.sin_addr), ntohs(addr.sin_port), msg.type, msg.name, msg.text); goto __exit; } } __exit: // 殺死並等待子程序退出 close(sd); kill(pid, SIGKILL); waitpid(pid, NULL, -1); return 0; }
這裡主要需要注意的是
// 傳送和接收的資訊體 struct umsg{ char type; //協議 '1' => 向伺服器傳送名字, '2' => 向伺服器傳送資訊, '3' => 向伺服器傳送退出資訊 char name[_INT_NAME]; //儲存使用者名稱字 char text[_INT_TEXT]; //得到文字資訊,空間換時間 };
傳輸和接收的資料格式, type表示協議或行為. 我這裡細心了處理 name, text最後一個字元必須是 '\0'. 其它都是業務程式碼.再扯一點
struct sockaddr_in addr = { AF_INET };
等價於
struct sockaddr_in addr; memset(&addr, 0, sizeof addr); addr.sin_family = AF_INET;
也是一個C開發中技巧吧. 再扯一點linux上提供 bzero函式, 但是window上沒有. 寫了個通用的如下
//7.0 置空操作 #ifndef BZERO //v必須是個變數 #define BZERO(v) \ memset(&v,0,sizeof(v)) #endif/* !BZERO */
可以試試吧畢竟跨平臺....
好了那我們說 udp 聊天室的伺服器設計思路. 就是伺服器會維護一個客戶端連結串列. 有資訊來就廣播. 好簡單吧.就是這樣.正常的事都簡單.
簡單的是美的. 好了看程式碼總設計和實現. udpmulsrv.c
#include <stdio.h> #include <stdlib.h> #include <errno.h> #include <string.h> #include <unistd.h> #include <sys/types.h> #include <signal.h> #include <sys/wait.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/socket.h> // 名字長度包含'\0' #define _INT_NAME (64) // 報文最大長度,包含'\0' #define _INT_TEXT (512) //4.0 控制檯列印錯誤資訊, fmt必須是雙引號括起來的巨集 #define CERR(fmt, ...) \ fprintf(stderr,"[%s:%s:%d][error %d:%s]" fmt "\r\n",\ __FILE__, __func__, __LINE__, errno, strerror(errno),##__VA_ARGS__) //4.1 控制檯列印錯誤資訊並退出, t同樣fmt必須是 ""括起來的字串常量 #define CERR_EXIT(fmt,...) \ CERR(fmt,##__VA_ARGS__),exit(EXIT_FAILURE) /* * 簡單的Linux上API錯誤判斷檢測巨集, 好用值得使用 */ #define IF_CHECK(code) \ if((code) < 0) \ CERR_EXIT(#code) // 傳送和接收的資訊體 struct umsg{ char type; //協議 '1' => 向伺服器傳送名字, '2' => 向伺服器傳送資訊, '3' => 向伺服器傳送退出資訊 char name[_INT_NAME]; //儲存使用者名稱字 char text[_INT_TEXT]; //得到文字資訊,空間換時間 }; // 維護一個客戶端連結串列資訊,記錄登入資訊 typedef struct ucnode { struct sockaddr_in addr; struct ucnode* next; } *ucnode_t ; // 新建一個結點物件 static inline ucnode_t _new_ucnode(struct sockaddr_in* pa){ ucnode_t node = calloc(sizeof(struct ucnode), 1); if(NULL == node) CERR_EXIT("calloc sizeof struct ucnode is error. "); node->addr = *pa; return node; } // 插入資料,這裡head預設頭結點是當前伺服器結點 static inline void _insert_ucnode(ucnode_t head, struct sockaddr_in* pa) { ucnode_t node = _new_ucnode(pa); node->next = head->next; head->next = node; } // 這裡是有使用者登入處理 static void _login_ucnode(ucnode_t head, int sd, struct sockaddr_in* pa, struct umsg* msg) { _insert_ucnode(head, pa); head = head->next; // 從此之後才為以前的連結串列 while(head->next){ head = head->next; IF_CHECK(sendto(sd, msg, sizeof(*msg), 0, (struct sockaddr*)&head->addr, sizeof(struct sockaddr_in))); } } // 資訊廣播 static void _broadcast_ucnode(ucnode_t head, int sd, struct sockaddr_in* pa, struct umsg* msg) { int flag = 0; //1表示已經找到了 while(head->next) { head = head->next; if((flag) || !(flag=memcmp(pa, &head->addr, sizeof(struct sockaddr_in))==0)){ IF_CHECK(sendto(sd, msg, sizeof(*msg), 0, (struct sockaddr*)&head->addr, sizeof(struct sockaddr_in))); } } } // 有人退出群聊 static void _quit_ucnode(ucnode_t head, int sd, struct sockaddr_in* pa, struct umsg* msg) { int flag = 0;//1表示已經找到 while(head->next) { if((flag) || !(flag = memcmp(pa, &head->next->addr, sizeof(struct sockaddr_in))==0)){ IF_CHECK(sendto(sd, msg, sizeof(*msg), 0, (struct sockaddr*)&head->next->addr, sizeof(struct sockaddr_in))); head = head->next; } else { //刪除這個退出的使用者 ucnode_t tmp = head->next; head->next = tmp->next; free(tmp); } } } // 銷燬維護的物件池,沒有往復雜的考慮了簡單處理退出了 static void _destroy_ucnode(ucnode_t* phead) { ucnode_t head; if((!phead) || !(head=*phead)) return; while(head){ ucnode_t tmp = head->next; free(head); head = tmp; } *phead = NULL; } /* * udp聊天室的伺服器, 子程序廣播資訊,父程序接受資訊 */ int main(int argc, char* argv[]) { int sd, rt; struct sockaddr_in addr = { AF_INET }; socklen_t alen = sizeof addr; struct umsg msg; ucnode_t head; // 這裡簡單檢測 if(argc != 3) { fprintf(stderr, "uage : %s [ip] [port]\n", argv[0]); exit(-1); } // 下面對接資料 if((rt = atoi(argv[2]))<1024 || rt > 65535) CERR("atoi port = %s is error!", argv[2]); // 接著判斷ip資料 IF_CHECK(inet_aton(argv[1], &addr.sin_addr)); addr.sin_port = htons(rt); //埠要採用網路位元組序 // 建立socket IF_CHECK(sd = socket(PF_INET, SOCK_DGRAM, 0)); // 這裡bind繫結設定的地址 IF_CHECK(bind(sd, (struct sockaddr*)&addr, alen)); //開始監聽了 head = _new_ucnode(&addr); for(;;){ bzero(&msg, sizeof msg); IF_CHECK(recvfrom(sd, &msg, sizeof msg, 0, (struct sockaddr*)&addr, &alen)); msg.name[_INT_NAME-1] = msg.text[_INT_TEXT-1] = '\0'; fprintf(stdout, "msg is [%s:%d] => [%c:%s:%s]\n", inet_ntoa(addr.sin_addr), ntohs(addr.sin_port), msg.type, msg.name, msg.text); // 開始判斷處理 switch(msg.type) { case '1':_login_ucnode(head, sd, &addr, &msg);break; case '2':_broadcast_ucnode(head, sd, &addr, &msg);break; case '3':_quit_ucnode(head, sd, &addr, &msg);break; default://未識別的異常報文,程式把其踢走 fprintf(stderr, "msg is error! [%s:%d] => [%c:%s:%s]\n", inet_ntoa(addr.sin_addr), ntohs(addr.sin_port), msg.type, msg.name, msg.text); _quit_ucnode(head, sd, &addr, &msg); break; } } // 這段程式碼是不會執行到這的, 可以加一些控制讓其走到這. 看人 close(sd); _destroy_ucnode(&head); return 0; }
這裡主要圍繞的結構就是
// 維護一個客戶端連結串列資訊,記錄登入資訊 typedef struct ucnode { struct sockaddr_in addr; struct ucnode* next; } *ucnode_t ;
註冊新增登入廣播退出等.這裡再扯一下. 關於C static開發技巧. C中有一種 *.h 開發模式, 全部採用static 內嵌程式碼段. 這樣
可以省略*.c 檔案. 小巧的封裝可以使用. 繼續扯一點. 開發也寫C++,雖然鄙視. C++ 中有個 *.hpp檔案. 比較好. 它表達的意思
是這個程式碼是開源的. 全部採用充血模型. 類中程式碼都放在類中實現.非常值得提倡. 這也是學boost的時候學到的. 很實在.
好了說程式碼吧. 也比較隨大流. 看看也都明白了. 簡單分析一處吧
// 這裡是有使用者登入處理 static void _login_ucnode(ucnode_t head, int sd, struct sockaddr_in* pa, struct umsg* msg) { _insert_ucnode(head, pa); head = head->next; // 從此之後才為以前的連結串列 while(head->next){ head = head->next; IF_CHECK(sendto(sd, msg, sizeof(*msg), 0, (struct sockaddr*)&head->addr, sizeof(struct sockaddr_in))); } }
因為我採用的頭查法. 那就除了剛插入的頭的下一個結點都需要傳送登入資訊. 比較精巧.
好看編譯命令
gcc -g -Wall -o udpmulsrv.out udpmulsrv.c gcc -g -Wall -o udpmulclt.out udpmulclt.c
最後測試截圖如下
很好玩,歡迎嘗試.到這裡基本上udp基礎api 應該都瞭解了.從上面程式碼也許能看出來. 設計比較重要. 設計決定大思路.
下次有機會 要麼分享開源的網路庫,要麼分享資料庫開發.