什麼是資料報套接字?
資料報套接字 => Datagram Socket 串流套接字 => Stream Socket
建立一個資料報套接字與建立一個串流套接字類似,主要的差別在於資料報套接字使用 UDP 協定而非 TCP 協定,這表示客戶端和伺服器在傳送資料之前不需要建立連線。
這對於不需要維護連線的應用程式很有用,因為客戶端和伺服器可以在不必先建立連線的情況下傳送和接收資料。 例如,DNS 伺服器使用 UDP 協定,因為它不需要維護連線,只需要傳送資料並接收回應。
工作流程

image
圖片來源: UDP Server-Client implementation in C
Server 如何運作?
Listener 透過建立一個套接字,將其繫結到一個通訊埠,並等待傳入的訊息來運作。
首先,listener 建立一個 hints 結構體,該結構體包含了建立套接字所需的資訊,例如協定族、套接字類型以及通訊埠號碼。隨後呼叫 getaddrinfo() 函式取得位址資訊,儲存到 servinfo 鏈結串列中。
然後,listener 遍歷 servinfo 鏈結串列,直到找到可用的位址資訊,然後透過 socket() 函式建立套接字,如果成功,回傳套接字描述子,如果失敗,回傳 -1。
然後,listener 呼叫 bind 函式將套接字繫結到指定的通訊埠。這允許 listener 接收傳送到該通訊埠的訊息。
在套接字繫結成功後,程式將監聽來自 recvfrom 函式的傳入訊息。recvfrom 函式會阻塞程式,直到收到訊息,將訊息儲存在 buf 緩衝區中,並將傳送者的位址資訊儲存在 their_addr 中,最後將收到的訊息和傳送者的 IP 位址列印到螢幕上。
最後,listener 關閉套接字並退出。
Client 如何運作?
Talker 透過設定套接字、向監聽器傳送訊息、關閉套接字來完成工作。
首先,talker 建立一個 hints 結構體,用於儲存位址資訊,例如協定族、套接字類型以及通訊埠號碼。隨後呼叫 getaddrinfo() 函式取得位址資訊,儲存到 servinfo 鏈結串列中。
然後,talker 遍歷 servinfo 鏈結串列,直到找到可用的位址資訊,然後透過 socket() 函式建立套接字,如果成功,回傳套接字描述子,如果失敗,回傳 -1。
在成功建立套接字後,talker 呼叫 sendto() 函式透過套接字傳送訊息,如果成功,回傳傳送的位元組數,如果失敗,回傳 -1。
最後,talker 關閉套接字。
服務端 demo
/*
** listener.c: a datagram sockets "server" demo - 資料報套接字監聽端
** from Beej's Guide to Network Programming Using Internet Sockets
** https://beej.us/guide/bgnet/html/#datagram
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#define MYPORT "4950" /* 監聽通訊埠號碼,即伺服器通訊埠號碼,客戶端要連線的通訊埠號碼 */
#define MAXBUFLEN 100 /* 最大資料封包大小 */
/* 根據協定族,回傳 sockaddr 結構體中的 IPv4 或 IPv6 位址 */
void *get_in_addr(struct sockaddr *sa)
{
// 判斷位址族
if (sa->sa_family == AF_INET)
{
// 回傳 IPv4 位址結構體
return &(((struct sockaddr_in *)sa)->sin_addr);
}
// 回傳 IPv6 位址結構體
return &(((struct sockaddr_in6 *)sa)->sin6_addr);
}
/* 伺服器端主函式 */
int main(void)
{
int sockfd; // 監聽套接字
struct addrinfo hints; // hints 用於 getaddrinfo() 函式 - 用於指定位址資訊
struct addrinfo *servinfo; // servinfo 用於儲存 getaddrinfo() 函式回傳的鏈結串列 - 用於儲存位址資訊
struct addrinfo *p; // 用於遍歷 servinfo 鏈結串列
int rv; // 用於儲存 getaddrinfo() 函式回傳值 - 錯誤代碼
int numbytes; // 用於儲存 recvfrom() 函式回傳值 - 接收到的位元組數
struct sockaddr_storage their_addr; // 用於儲存客戶端位址資訊 - sockaddr_storage 可用於儲存任何位址 IPv4 或 IPv6
char buf[MAXBUFLEN]; // 用於儲存接收到的資料 - 資料封包
socklen_t addr_len; // 用於儲存客戶端位址資訊的長度 - sockaddr_storage 結構體長度
char s[INET6_ADDRSTRLEN]; // 用於儲存客戶端位址資訊 - 位址字串
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_INET6; // 使用 IPv6
hints.ai_socktype = SOCK_DGRAM; // 使用 UDP 協定
hints.ai_flags = AI_PASSIVE; // 使用本機 IP
// 建構位址資訊 servinfo
if ((rv = getaddrinfo(NULL, MYPORT, &hints, &servinfo)) != 0)
{
// gai_strerror 用於將錯誤代碼轉換為錯誤資訊字串
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rv));
return 1;
}
// 遍歷 servinfo 鏈結串列,直到找到可用的位址資訊
for (p = servinfo; p != NULL; p = p->ai_next)
{
// 建立監聽套接字
if ((sockfd = socket(p->ai_family, p->ai_socktype,
p->ai_protocol)) == -1)
{
perror("listener: socket");
continue;
}
// 繫結套接字
if (bind(sockfd, p->ai_addr, p->ai_addrlen) == -1)
{
close(sockfd);
perror("listener: bind");
continue;
}
break;
}
// 如果遍歷完 servinfo 鏈結串列,仍然沒有找到可用的位址資訊,報錯退出
if (p == NULL)
{
fprintf(stderr, "listener: failed to bind socket\n");
return 2;
}
// 釋放 servinfo 鏈結串列
freeaddrinfo(servinfo);
printf("listener: waiting to recvfrom...\n");
// addr_len 用於儲存客戶端位址資訊的長度
addr_len = sizeof their_addr;
// 接收資料封包並儲存到 buf 中,儲存的資料封包的長度儲存到 numbytes 中
if ((numbytes = recvfrom(sockfd, buf, MAXBUFLEN - 1, 0,
(struct sockaddr *)&their_addr, &addr_len)) == -1)
{
perror("recvfrom");
exit(1);
}
// inet_ntop 用於將客戶端位址資訊轉換為字串
printf("listener: got packet from %s\n",
inet_ntop(their_addr.ss_family,
get_in_addr((struct sockaddr *)&their_addr),
s, sizeof s));
printf("listener: packet - 然後,listener is %d bytes long\n", numbytes);
// 將資料封包的末尾設定為 '\0',以便於列印
buf[numbytes] = '\0';
// 列印資料封包
printf("listener: packet contains \"%s\"\n", buf);
// 關閉套接字
close(sockfd);
return 0;
}
客戶端 demo
/*
** talker.c -- a datagram "client" demo - 資料報套接字傳送端
** from Beej's Guide to Network Programming Using Internet Sockets
** https://beej.us/guide/bgnet/html/#datagram
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#define SERVERPORT "4950" // 伺服器通訊埠
int main(int argc, char *argv[])
{
int sockfd; // 套接字描述子
struct addrinfo hints; // hints 用來指定我們想要的位址類型
struct addrinfo *servinfo; // servinfo 用來指向回傳的位址鏈結串列
struct addrinfo *p; // p 用來指向 servinfo 的每一個位址
int rv; // rv 用來儲存 getaddrinfo 的回傳值,用於判斷是否出錯
int numbytes; // numbytes 用來儲存 sendto 的回傳值, 記錄傳送的位元組數
// 檢查參數個數
if (argc != 3)
{
fprintf(stderr, "usage: talker hostname message\n");
exit(1);
}
memset(&hints, 0, sizeof hints); // 初始化 hints 結構體
hints.ai_family = AF_INET6; // 使用 IPv6 協定
hints.ai_socktype = SOCK_DGRAM; // 使用 UDP 協定
// getaddrinfo() 用來取得位址資訊, 並將結果儲存在 servinfo 中
if ((rv = getaddrinfo(argv[1], SERVERPORT, &hints, &servinfo)) != 0)
{
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rv));
return 1;
}
// 迴圈遍歷 servinfo 中的每一個位址, 並嘗試建立套接字
for (p = servinfo; p != NULL; p = p->ai_next)
{
// 建立套接字
if ((sockfd = socket(p->ai_family, p->ai_socktype,
p->ai_protocol)) == -1)
{
perror("talker: socket");
continue;
}
break;
}
// 如果 p 為 NULL, 則說明沒有找到可用的位址
if (p == NULL)
{
fprintf(stderr, "talker: failed to create socket\n");
return 2;
}
// 傳送資料, 並記錄傳送的位元組數
if ((numbytes = sendto(sockfd, argv[2], strlen(argv[2]), 0,
p->ai_addr, p->ai_addrlen)) == -1)
{
perror("talker: sendto");
exit(1);
}
// 釋放 servinfo
freeaddrinfo(servinfo);
// 列印傳送的位元組數
printf("talker: sent %d bytes to %s\n", numbytes, argv[1]);
// 關閉套接字
close(sockfd);
return 0;
}
潛在問題
錯誤處理:兩個程式都沒有健全的錯誤處理機制。例如,在客戶端程式中,如果 sendto 回傳錯誤,它只會列印錯誤訊息並退出,而不是嘗試從錯誤中復原。
超時:如果伺服器沒有執行,或者訊息在傳輸過程中遺失,客戶端程式將會無限等待,等待一個永遠不會到來的回應。為避免這種情況,為 sendto 和 recvfrom 呼叫設定超時可能是個好主意。
訊息大小限制:這些程式只允許最多 100 個位元組的訊息。如果您需要傳送更大的訊息,則需要將其分割成較小的區塊並分別傳送。
缺乏安全性:這些程式沒有使用任何形式的加密或驗證,因此網路上的任何人都可能攔截或偽造訊息。如果您需要保護您的通訊安全,應考慮使用 TLS 或 SSH 來加密訊息。
程式編譯
gcc -o talker talker.c
gcc -o listener listener.c
執行與輸出
./listener
# 在執行 talker 之前, listener 會一直等待
listener: waiting to recvfrom...
# 在另一個終端機執行 talker 程式之後, listener 會輸出以下內容
listener: got packet from ::1
listener: packet is 5 bytes long
listener: packet contains "hello"
# 另一個終端機執行 talker 程式
./talker localhost hello
talker: sent 5 bytes to localhost
本文總結
本文的程式碼實作了一個簡單的 UDP 客戶端和伺服器端程式,客戶端程式是 talker,伺服器端程式是 listener。
執行 listener 程式,然後執行 talker 程式。listener 程式會等待來自 talker 程式的訊息。當 talker 程式執行時,它會傳送一個訊息給 listener 程式,listener 程式會列印這個訊息。
如果沒有執行 listener 程式,talker 程式會傳送訊息,然後退出。這些訊息會遺失,因為沒有程式來接收它們。
UDP 協定是一個簡單、輕量級的協定,它很容易使用。但是,它不提供任何錯誤檢查或復原,並且不能保證將訊息傳遞到目的地,因此它不適合需要可靠通訊的應用程式。