【计算机网络】套接字编程(套接字API/UDP和TCP服务器)

目录

认识端口号

[浅谈 TCP 协议和 UDP 协议](#浅谈 TCP 协议和 UDP 协议)

网络字节序

套接字编程

[创建 socket](#创建 socket)

[填充 sockaddr 结构](#填充 sockaddr 结构)

各种转化函数

[绑定 socket](#绑定 socket)

[UDP 专用 API](#UDP 专用 API)

[recvfrom() ------ 接收数据报](#recvfrom() —— 接收数据报)

[sendto() ------ 发送数据报](#sendto() —— 发送数据报)

[UDP 服务器和客户端](#UDP 服务器和客户端)

[TCP 专用 API](#TCP 专用 API)

[服务端:listen() ------ 启动监听](#服务端:listen() —— 启动监听)

[服务端:accept() ------ 接受连接](#服务端:accept() —— 接受连接)

[客户端:connect() ------ 发起连接](#客户端:connect() —— 发起连接)

[数据收发:send() / recv()](#数据收发:send() / recv())

[TCP 服务器和客户端](#TCP 服务器和客户端)

[netstat 指令](#netstat 指令)

[telnet 指令](#telnet 指令)


网络通信的本质是进程间通信

在进行网络通信时,并不是两台机器在通信,而是两个进程在通信。用户先把应用层的软件启动形成进程,进程通过使用 TCP/IP 协议栈进行数据的发送和接收。TCP/IP 协议栈的下三层(传输层、网络层、数据链路层)主要解决的是如何将数据安全可靠的发送到远端机器的问题。一台主机可能运行了很多进程,当主机收到数据,数据经过 TCP/IP 协议栈一路向上,到达传输层时,怎么知道将该数据交给哪个进程呢?这个工作可以由端口号解决。

认识端口号

  • 端口号(port)是传输层协议的内容.
  • 端口号是一个 2 字节 16 位的整数;
  • 端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理;
  • IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;两个"IP地址 + 端口号"就能标识维二的两台主机的两个进程,这种技术叫做socket 套接字
  • 一个端口号只能被一个进程占用,一个进程可以占用多个端口号
  • 数据在传输层封装时,就会在报头添加源端口号和目标端口号的字段。

端口号 VS 进程 PID

既然进程的 PID 已经可以唯一标识一台主机的一个进程,那为什么还要用端口号来唯一标识,直接使用 PID 不行吗?

  • 主要的原因是为了将操作系统与网络解耦。防止由于系统部分的改变而牵一发动全身。
  • PID 是动态的, 如果远程服务器(比如微信服务器)试图通过 PID 给你发消息,但你刚好重启了微信,PID 变了,服务器就不知道该把数据发给谁了。它会以为你掉线了。而端口号是相对静态的 大多数网络服务都使用固定的知名端口号。例如,Web 服务器永远监听 80 或 443 端口。
  • PID 是主机内部的标识,端口号是网络层的统一标识
  • 一个进程可以占用多个端口号,但一个进程只能有一个 PID

问题:客户端怎么知道服务端的端口号?

1. 核心机制:知名端口号与IANA的约定

这是最主流、最基础的方式。互联网号码分配局(IANA)将端口号分成了三个范围,其中最关键的是知名端口号(0-1023)

  • 默认约定 :当你在浏览器输入 https://www.baidu.com 时,虽然没有指定端口,但浏览器知道 HTTPS 协议默认对应的端口是 443

  • 常见服务

    • Web服务:HTTP(80)、HTTPS(443)

    • ......

这就是客户端知道端口号的第一种方式:协议默认。

2. 带外传递:通过其他渠道告知

在一些复杂的系统架构中,服务端的端口不是标准端口(比如你写了一个后端服务,监听在 8080 端口,或者游戏服务器监听在 27015),客户端不可能预知这个数字。

这时就需要通过带外(Out-of-Band) 的方式把端口号告诉客户端:

  • 手动配置

    • 在游戏里,你需要手动输入服务器的 IP 和端口才能连接。

    • 在数据库管理工具(如 Navicat)里,你需要手动填写数据库的 IP 和端口(比如 MySQL 的 3306,但如果你改成了 3307,就需要手动改配置)。

  • 配置文件下发

    • 很多大型软件(如企业内部应用、手机 App),在启动时会向一个固定的配置中心(或者通过 DNS 的 SRV 记录)请求最新的服务列表。这个列表里就包含了 IP 地址和端口号。
  • 注册中心

    • 在微服务架构(如 Spring Cloud、Dubbo)中,服务启动时会将自身的 IP 和端口注册到注册中心(如 Nacos、Eureka)。客户端要去调用某个服务时,先去注册中心查一下:这个服务现在有哪些实例可用?它们的端口是多少?

3. 自动协商与协议设计

有些协议在建立连接的过程中,会先连上一个众所周知的端口,然后通过控制指令协商一个新的端口进行数据传输。这常见于一些早期的协议。

  • FTP(文件传输协议)的两种模式

    • 控制连接 :客户端先连接服务器的 21 端口(这是约定的控制端口),发送用户名密码和指令。

    • 数据连接

      • 主动模式(FTP Active):服务器主动从 20 端口(约定的数据端口)连接客户端告知的一个随机端口。

      • 被动模式(FTP Passive):服务器告诉客户端:"你连我的另一个端口吧,比如 51234",然后客户端再去连那个新的端口。

  • SIP(会话发起协议):用于 VoIP 电话,先连 5060 端口协商通话参数,然后告诉对方:"把语音流发到我的 10000 端口吧"。

4. 动态发现与广播

在局域网环境中,客户端可能一开始完全不知道服务端的存在和端口。

  • 广播/组播:客户端向局域网所有设备发一个消息(例如:"谁是谁是打印机?请告诉我你的端口。")。服务端(打印机)收到后,会单播回复自己的端口号。

    • 应用实例:网络打印机发现、Apple 的 Bonjour 协议(零配置网络)、NAS 设备的发现。
  • UPnP(通用即插即用):设备加入网络时,可以自动告知路由器自己需要哪些端口映射,客户端通过路由器发现设备。

浅谈 TCP 协议和 UDP 协议

TCP(传输控制协议)

TCP 是一种面向连接的、可靠的传输协议。

  • 工作机制 :在通信前需要通过"三次握手 "建立连接,通信结束后通过"四次挥手"断开连接。

  • 核心特点

    • 可靠性高:确保数据包按顺序到达,且不丢失、不重复。如果发现丢包,会自动重传。

    • 流量控制:根据接收方的处理能力,调整数据的发送速度,防止接收方被淹没。

    • 拥塞控制:当网络拥堵时,自动降低发送速率,避免加剧网络拥塞。

    • 全双工:支持发送信息和接收消息同时进行。因为 TCP 的套接字的发送缓存区和接收缓冲区是分别独立的。

  • 开销:由于复杂的确认/重传和排序机制,头部开销较大(20-60字节),传输速度相对较慢。

  • 应用场景 :适用于对数据完整性要求严格的场景。例如:网页浏览(HTTP/HTTPS)、文件传输(FTP)、电子邮件(SMTP)、远程登录(SSH)、银行转账、手机支付

UDP(用户数据报协议)

UDP 是一种无连接的、不可靠的传输协议。

  • 工作机制:通信前不需要建立连接,直接将数据包(数据报)发往目的地。

  • 核心特点

    • 开销小、速度快:头部固定为8字节,没有复杂的确认和重传机制,是一种"尽最大努力交付"的协议。

    • 支持多播和广播:可以将数据同时发送给多个主机。

  • 可靠性:不保证数据包一定到达,也不保证到达顺序。如果应用需要可靠性,必须由上层应用(如应用层协议)自己实现。TCP 协议和 UDP 协议的可靠性的高低没有褒贬之分,只是应用场景不同罢了。

  • 应用场景 :适用于对实时性要求高、允许少量丢包的场景。例如:视频直播、语音通话(VoIP)、在线游戏、DNS(域名系统)查询

特性 TCP UDP
连接状态 面向连接 无连接
可靠性 高(确认、重传、排序) 低(尽最大努力交付)
传输速度 较慢 较快
数据边界 面向字节流 面向报文(保留消息边界)
头部开销 大(20-60字节) 小(8字节)
流量控制
典型应用 网页、文件、邮件 直播、游戏、DNS

网络字节序

我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?

  • 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
  • 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
  • 因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
  • TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.
  • 不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据;
  • 如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可;
cpp 复制代码
将0x1234abcd写入到以0x0000开始的内存中,则结果为
               big-endian     little-endian
0x0000             0x12            0xcd
0x0001             0x34            0xab
0x0002             0xab            0x34
0x0003             0xcd            0x12

为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。

cpp 复制代码
#include <arpa/inet.h>

uint32 t htonl(uint32_t hostlong); 
uint16 t htons (uint16 t hostshort) ; 
uint32 t ntohl(uint32_t netlong) ; 
uint16 t ntohs (uint16_t netshort);

这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。

例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后发送。

如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回 ;

如果主机是大端字节序,这些 函数不做转换,将参数原封不动地返回。

套接字编程

  • 什么是套接字?套接字(Socket)是网络上两个程序之间进行双向通信的端点。它是操作系统提供的一个编程接口(API),封装了复杂的网络协议,让我们可以像读写文件一样,方便地在网络上发送和接收数据。
  • 一个完整的套接字连接由五元组构成**:** {协议 (TCP/UDP), 源IP地址, 源端口, 目标IP地址, 目标端口}
  • 套接字屏蔽了网络层(IP)和传输层(TCP/UDP)的细节。开发者只需要创建套接字,告诉它"我要连接这个IP和端口",然后就可以直接读写数据,底层的封装、解包、路由等由操作系统内核完成。

创建 socket

cpp 复制代码
// 创建 socket 文件描述符
int socket(int domain, int type, int protocol);
  • domain:协议域,即地址类型。

    • AF_INET:IPv4(最常用)

    • AF_INET6:IPv6

    • AF_UNIX:本地进程通信(Unix域)

  • type:套接字类型。

    • SOCK_STREAM:流式套接字(TCP)

    • SOCK_DGRAM:数据报套接字(UDP)

  • protocol :指定协议。通常传 0,表示由前两个参数自动推导(SOCK_STREAM -> TCP,SOCK_DGRAM -> UDP)。

  • 返回值 :成功返回一个文件描述符(非负整数),失败返回 -1。在 Linux 中,套接字也是一种文件。

填充 sockaddr 结构

为什么需要 sockaddr?

设计理念:统一性与多样性并存

网络编程需要支持多种协议族:

  • IPv4(AF_INET):32位地址

  • IPv6(AF_INET6):128位地址

  • Unix Domain Socket(AF_UNIX):文件路径

  • 其他:如蓝牙、X.25 等

为了用一个统一的API处理所有这些情况,BSD Unix 设计了一个巧妙的方案:所有地址结构都遵循相同的"头部",可以被强制转换为统一的接口类型

cpp 复制代码
// 所有地址结构在API层面的统一接口
struct sockaddr {
    sa_family_t sa_family;    // 地址族类型,告诉系统这是什么协议
    char        sa_data[14];   // 协议特定的地址数据
};

问题:这个结构太"死板"了,14字节的数组无法优雅地存放不同类型的数据。

IPv4专用结构:sockaddr_in(实际使用的)

cpp 复制代码
#include <netinet/in.h>

struct sockaddr_in {
    sa_family_t    sin_family;   // 地址族,必须是 AF_INET
    in_port_t      sin_port;     // 端口号(16位),网络字节序
    struct in_addr sin_addr;     // IPv4地址(32位),网络字节序
    char           sin_zero[8];  // 填充字段,没有什么用,主要保证和sockaddr大小一致
};

struct in_addr {
    in_addr_t s_addr;            // 32位IPv4地址,网络字节序
};

各种转化函数

sin_port (端口号)、IP 地址必须是网络字节序的,我们可以使用下面的函数帮助我们转换。

cpp 复制代码
#include <arpa/inet.h>

// h:host,本地。n:network,网络。l:long ,即 uint32_t。s:short,即 uint16_t

uint32_t htonl(uint32_t hostlong);  // 大端/小端16进制IP->大端16进制IP
uint16_t htons(uint16_t hostshort); // 大端/小端端口号->大端端口号
uint32_t ntohl(uint32_t netlong);   // 大端16进制IP->大端/小端16进制IP
uint16_t ntohs(uint16_t netshort);  // 大端端口号->大端/小端端口号

sin_addr 是 in_addr_t 类型的,即 uint32_t 类型的,用户在输入时,输入的可是类似 "192.12.34.11" 的点分十进制风格的字符串 IP 地址,可是上面的函数处理的是 16 进制的 IP 地址的大小端转化,那就必然存在 uint32_t 类型的 IP 地址和点分十进制风格的字符串 IP 地址之间的相互转换,并且转换后的 uint32_t 类型的 IP 地址还必须是网络字节序(大端),这些功能我们可以自己实现,还可以使用下面的函数帮助我们转换:

cpp 复制代码
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>


// 点分字符 -> uint_32
in_addr_t inet_addr(const char *cp); // 这是旧式的,只能转化 IPv4
int inet_aton(const char *cp, struct in_addr *inp);// 稍微新一点的,只能转化 IPv4
int inet_pton(int af, const char *src, void *dst); // 现代使用,可以转化 IPv4 和 IPv6


// uint32 -> 点分字符
char *inet_ntoa(struct in_addr in); // 只能转化 IPv4,线程不安全
const char *inet_ntop(int af, const void *src,
                             char *dst, socklen_t size);// 可以转化 IPv4 和 IPv6,线程安全

// 以后统一使用 inet_pton 和 inet_ntop 即可

注意:inet_ntoa 函数返回的是 char* 的字符串指针,函数转换后的字符串存储在一个静态的字符数组中,不需要我们手动释放。如果连续使用两次 inet_ntoa 函数,这次使用会覆盖上次的静态的字符数组

cpp 复制代码
#include <stdio.h> 
#include <netinet/in.h> 
#include <arpa/inet.h>
int main()
{
	struct sockaddr_in addr1; 
	struct sockaddr_in addr2; 
	
	addr1.sin_addr.s_addr = 0; 
	addr2.sin_addr.s_addr = 0xffffffff; 
	
	char* ptr1 = inet_ntoa(addr1.sin_addr); 
	char* ptr2 = inet_ntoa(addr2.sin_addr); 
	
	printf("ptr1: %s, ptr2: %s\n", ptr1, ptr2); 
	
	return 0;
}
bash 复制代码
[tangzhong@tz_addr_convert ]$ ./a.out 
ptr1: 255.255.255.255, ptr2: 255.255.255.255

所以 inet_ntoa 不是线程安全的函数。在多线程环境下, 推荐使用 inet_ntop, 这个函数由调用者提供一个缓冲区保存结果, 可以规避线程安全问题;

cpp 复制代码
#include <arpa/inet.h>

const char *inet_ntop(int af, const void *src,
                             char *dst, socklen_t size);
  • af:地址族(AF_INETAF_INET6

  • src:指向二进制 IP 地址的指针(网络字节序)

  • dst:输出型参数,指向输出缓冲区的指针(存储转换后的字符串)

  • size:输出缓冲区的大小

注意:通过网络传输的数据也要求是网络字节序,但是下面的 recvfrom/sendto/send/recv 已经自动帮助我们转化了。

使用实例:

cpp 复制代码
// 1. 创建udp socket 
sotkfd_ = socket(AF_INET, SOCK_DGRAM, 0); // PF_INET 
if (sockfd_ < 0)
{
	log(Fatal, "socket create error, sockfd: %d", sockfd_);
	exit(SOCKET_ERR);
}
log(Info, "socket create success, sockfd: %d", sockfd_); 

// 填充 sockaddr_in 结构体
struct sockaddr_in local; 
bzero(&local, sizeof(local)); // 清零初始化(非常重要)
local.sin_family = AF_INET; 

//需要保证我的端口号是网络字节序列,因为该端口号是要给对方发送的
local.sin_port = htons(port_); 

//1. string -> uint32_t 2. uint32_t必须是网络序列的
local.sin_addr.s_addr = inet_addr(ip_.c_str( ));

IPv6专用结构:sockaddr_in6

cpp 复制代码
#include <netinet/in.h>

struct sockaddr_in6 {
    sa_family_t     sin6_family;   // 地址族,必须是 AF_INET6
    in_port_t       sin6_port;     // 端口号
    uint32_t        sin6_flowinfo; // 流量控制信息
    struct in6_addr sin6_addr;     // IPv6地址(128位)
    uint32_t        sin6_scope_id; // 作用域ID
};

struct in6_addr {
    unsigned char s6_addr[16];     // 128位IPv6地址
};

Unix域专用结构:sockaddr_un

cpp 复制代码
#include <sys/un.h>

struct sockaddr_un {
    sa_family_t sun_family;        // 地址族,必须是 AF_UNIX
    char        sun_path[108];     // 文件路径名
};

为什么可以强制转换?

所有地址结构的前2个字节 都是 sa_family_t(地址族类型)。这使得系统可以:

  1. 先读取前2字节,确定协议类型

  2. 然后根据协议类型,正确解析剩余部分

绑定 socket

创建好 socket 和填充了 sockaddr 结构之后,就要把定义在栈上的 sockaddr 结构设置进内核的 socket 了。

cpp 复制代码
// 绑定端口号
int bind(int socket, const struct sockaddr* address,
	socklen_t address_len);
  • sockfdsocket() 返回的文件描述符。

  • addr :一个指向 sockaddr 结构体的指针,里面存放着IP和端口信息。

    • 注意: 实际编程中多用 sockaddr_in(IPv4)或 sockaddr_in6(IPv6),然后强制类型转换为 sockaddr*。在 bind 函数内部,根据 sockaddr_in sockaddr_in6类的第一个字段判断 sockaddr 具体是那种类型。

云服务器不允许直接绑定 IP

云服务器使用 bind 绑定自己服务器的 IP 时,会被禁止(虚拟机除外),这是因为:

1. 公网IP是稀缺资源,需要"池化"复用

全球的IPv4公网地址非常有限且昂贵。云厂商会构建一个庞大的公网IP地址池,通过网络地址转换技术,让池子里有限的公网IP为海量的云服务器提供上网服务。

  • 不直接绑定:如果每个云服务器都永久独占一个公网IP,不仅成本高得吓人,IP地址也根本不够用。

  • 现在的做法:当你的服务器需要访问外网(如yum安装软件)时,流量会经过云网关,网关将流量的源IP(你的内网IP)临时替换成一个公网IP发出。响应回来时,再转换回内网IP发回给你的服务器。整个过程对服务器来说是透明的。

2. 实现"弹性"和"灵活性"

将IP地址从服务器这个"硬件"中解耦出来,变成了一个可以独立管理、灵活变动的"网络配置",这带来了巨大的便利。

  • 快速更换:如果当前公网IP被攻击或封禁,你不需要销毁服务器,只需在控制台点几下,就能解绑原来的IP,重新分配一个新的,服务可以迅速恢复。

  • 故障迁移:如果服务器A宕机了,你可以把绑定在A上的公网IP直接解绑,然后绑定到服务器B上。对用户而言,服务几乎没有中断,IP地址也没变,但背后的物理服务器已经完成了切换。

3. 增强安全性,构建"缓冲层"

这种架构在安全层面也很有优势。公网IP绑定在云网关或NAT网关上,而不是直接插在服务器上。

  • 隐藏真实资产:外网访问你的服务时,看到的是网关的IP。你的真实服务器的内网IP被隐藏了,避免了被直接针对服务器IP的攻击。

  • 集中防护:云厂商可以在网关层面部署大流量清洗、DDoS防护、访问控制列表等安全措施,为后端所有服务器提供一个集中的安全缓冲层。

正确的做法是绑定 0.0.0.0 IP,它表示任意IP地址,所以在填充 sockaddr 结构时,正确的做法是:

cpp 复制代码
local.sin_addr.s_addr = inet_addr(ip_.c_str()); 
// ip_.c_str() 是 "0.0.0.0"

// 或者
        
// local.sin_addr.s_addr = INADDR_ANY;
// local.sin_addr.s_addr = htons(INADDR_ANY); 
// INADDR_ANY 本身就是全0,大小端表示相同,可以不用 htons

有些端口号也不能随意绑定

一、操作系统层面的限制:特权端口

这是Linux/Unix系统的基本安全机制。

  • 端口范围1 到 1024 之间的端口(例如用于HTTP的80端口、用于FTP的21端口)。

  • 限制原因 :这些端口被认为是"特权端口"。系统只允许具有 **root**权限的进程绑定这些端口,以防止普通用户意外或恶意地启动一些关键的网络服务(比如假冒的Web服务器)。

  • 解决方法 :如果你确实需要用80端口启动服务(比如运行一个Web服务),必须使用 sudo 或以 root 用户身份来启动你的程序。当然,如果你在本地开发或测试,最简单的做法是避开这些端口,使用像 8080、3000、5000 这样的大于 1024 的高位端口(即使是大于 1024 的高位端口,也有一些是专用的,比如 3306 是 mysql)。

二、云平台层面的限制:高危端口

这是云厂商为了保障整个云计算环境的安全,主动进行的一层额外限制,和你程序运行时绑定的权限无关。

云厂商会维护一个高危端口列表 。如果你的程序试图监听这些端口,那么来自公网的访问请求很可能被运营商的骨干网直接拦截,或者在云平台的网关处被阻断。这意味着,即使你在安全组里放行了这些端口,公网也可能无法访问。

主要原因有两点:

  1. 安全风险:这些端口通常是知名服务(如数据库、邮件、远程桌面)的默认端口,极易成为黑客扫描和暴力破解的目标。

  2. 防止滥用:一些端口常被病毒、蠕虫或挖矿程序利用,云厂商在运营商层面进行拦截,可以起到保护所有用户的作用。

当通信双方都创建并绑定对应的 socket 之后,就可以通过 TCP 协议或 UDP 协议进行通信了。

UDP 专用 API

UDP 不需要建立连接,因此没有 listenacceptconnect(虽然 UDP 也能调 connect,但那是绑定默认对端,不是握手)。

recvfrom() ------ 接收数据报

cpp 复制代码
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);
  • sockfd:通过 socket() 创建的 UDP 套接字的返回值(本地套接字)
  • buf:指向存放接收数据的缓冲区,可以是 char 数组
  • len:缓冲区能接收的最大字节数,注意:如果数据报大于len,多余部分会被截断(UDP特性
  • flags:默认为 0 表示阻塞接收
  • src_addr:输出型参数,函数返回时,这里会填上发送方的地址信息,如果传 NULL表示不需要知道发送方是谁,传入时要强制转化为 struct sockaddr * 类型
  • addrlen:输入输出型参数,输入时告诉内核 src_addr 缓冲区的大小,输出时内核告诉实际使用的地址大小
  • 返回值:如果接收成功,返回一个正整数表示接收到的信息的字节数,出错返回 -1

sendto() ------ 发送数据报

cpp 复制代码
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);
  • sockfd:通过 socket() 创建的 UDP 套接字的返回值(本地套接字)
  • buf:指向存放要发送的数据的缓冲区,可以是 char 数组
  • len:发送的最大字节数,注意:UDP 有最大长度限制(通常 65507 字节)
  • flags:默认为 0 表示阻塞发送
  • dest_addr:输入性参数,指定数据要发送给谁(IP和端口),用const 修饰:函数不会修改地址结构不能为 NULL:UDP 每次发送都必须指定目标,传入时要强制转化为 const struct sockaddr * 类型
  • addrlen:输入性参数,表示 dest_addr 结构体的大小
  • 返回值:如果发送成功,返回一个正整数表示发送的信息的字节数,出错返回 -1

UDP 服务器和客户端

有了上面的 API,我们可以写出一个简易的UDP 服务器代码了:UDP_server.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <strings.h>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <functional>
#include "Log.hpp"

// using func_t = std::function<std::string(const std::string&)>;
// 上面的语句等价于
typedef std::function<std::string(const std::string&)> func_t;
// 由用户传入的回调函数,用户定义的对字符串的处理函数

Log lg; // 定义日志对象名如果为 log 可能会命名冲突

enum{
    SOCKET_ERR=1,
    BIND_ERR
};

uint16_t defaultport = 8080;
std::string defaultip = "0.0.0.0";
const int size = 1024;

class UdpServer{
public:
    UdpServer(const uint16_t &port = defaultport, const std::string &ip = 
defaultip):sockfd_(0), port_(port), ip_(ip),isrunning_(false)
    {}
    
    void Init()
    {
        // 1. 创建udp socket
        sockfd_ = socket(AF_INET, SOCK_DGRAM, 0); // PF_INET
        if(sockfd_ < 0)
        {
            lg(Fatal, "socket create error, sockfd: %d", sockfd_);
            exit(SOCKET_ERR);
        }
        lg(Info, "socket create success, sockfd: %d", sockfd_);
        
        // 2. bind socket
        struct sockaddr_in local;
        bzero(&local, sizeof(local));
        local.sin_family = AF_INET;

        //需要保证我的端口号是网络字节序列,因为该端口号是要给对方发送的
        local.sin_port = htons(port_); 
    
        //1. string -> uint32_t 2. uint32_t必须是网络序列的 // ??
        local.sin_addr.s_addr = inet_addr(ip_.c_str()); 
        
        // local.sin_addr.s_addr = htonl(INADDR_ANY);

        if(bind(sockfd_, (const struct sockaddr *)&local, sizeof(local)) < 0)
        {
            lg(Fatal, "bind error, errno: %d, err string: %s", errno, strerror(errno));
            exit(BIND_ERR);
        }
        lg(Info, "bind success, errno: %d, err string: %s", errno, strerror(errno));
    }
    
    void Run(func_t func) // 对代码进行分层
    {
        isrunning_ = true;
        char inbuffer[size];
        while(isrunning_)
        {
            struct sockaddr_in client;
            socklen_t len = sizeof(client);
            ssize_t n = recvfrom(sockfd_, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&client, &len);
            if(n < 0)
            {
                lg(Warning, "recvfrom error, errno: %d, err string: %s", errno, strerror(errno));
                continue;
            }
            inbuffer[n] = 0;
            std::string info = inbuffer;
            std::string echo_string = func(info);
            sendto(sockfd_, echo_string.c_str(), echo_string.size(), 0, (const sockaddr*)&client, len);
        }
    }
    ~UdpServer()
    {
        if(sockfd_>0) close(sockfd_);
    }
private:
    int sockfd_;     // 网路文件描述符
    std::string ip_; // 任意地址bind 0
    uint16_t port_;  // 表明服务器进程的端口号
    bool isrunning_;
};

在命令行用 ./udpserver + 端口号 的方式启动服务器,服务器接收用户发送的字符串,并用的回调函数 Handler(将用户发来的数据当做简单消息处理) 或 ExcuteCommand (将用户发来的数据当做 Linux 指令并执行)处理字符串,将结果返回给用户。用户的有些指令可能不合法,可以检察用户的指令并过滤。

下面的代码还使用了 popen 函数:

cpp 复制代码
#include <stdio.h>

FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);

popen 的执行过程可以概括为以下几个关键步骤:

  1. 创建管道:首先,它会创建一个匿名管道,用于进程间通信。

  2. 创建子进程 :接着,调用 fork() 系统调用创建一个新的子进程。

  3. 执行 Shell :在子进程中,它会调用 /bin/sh 来解释和执行 command 参数指定的命令(相当于执行 sh -c "command")。

  4. 建立数据流 :根据 type 参数,将子进程的标准输入或标准输出连接到管道的一端,而父进程则通过返回的 FILE* 流操作管道的另一端

  • command :一个以 null 结尾的字符串,包含了要执行的 shell 命令。例如:"ls -l""echo 'Hello'"

  • type:指定数据流的方向。

    • "r" :读取模式。父进程可以从返回的 FILE* 流中读取子进程的标准输出。

    • "w" :写入模式。父进程可以通过返回的 FILE* 流向子进程的标准输入发送数据。

    • "e" (Linux 扩展):可以附加在 "r""w" 之后(如 "re""we"),用于在底层的文件描述符上设置执行时关闭标志,这在多线程程序中很有用,可以避免文件描述符泄露给其他子进程。

  • 返回值:调用成功时,返回一个标准的 C 语言文件指针 (FILE*),用于后续的读写操作(如 fgets, fread, fprintf)。如果失败(如无法创建管道、内存不足),则返回 NULL

  • 注意:必须使用**pclose()** 而不是 fclose() 来关闭由 popen() 打开的流。pclose() 会等待由 popen() 创建的子进程终止,并返回该命令的退出状态码。如果直接使用 fclose(),可能会导致僵尸进程

UDP_server_starter.cc:

cpp 复制代码
#include "UdpServer.hpp"
#include <memory>
#include <cstdio>
#include <vector>

// "120.78.126.148" 点分十进制字符串风格的IP地址

void Usage(std::string proc)
{
    std::cout << "\n\rUsage: " << proc << " port[1024+]\n" << std::endl;
}

// std::string Handler(const std::string &info, const std::string &clientip, uint16_t clientport)
// {
//     std::cout << "[" << clientip << ":" << clientport << "]# " << info << std::endl;
//     std::string res = "Server get a message: ";
//     res += info;
//     std::cout << res << std::endl;

//     // pid_t id = fork();
//     // if(id == 0)
//     // {
//     //     // ls -a -l -> "ls" "-a" "-l"
//     //     // exec*();
//     // }
//     return res;
// }

// bool SafeCheck(const std::string &cmd)
// {
//     int safe = false;
//     std::vector<std::string> key_word = {
//         "rm",
//         "mv",
//         "cp",
//         "kill",
//         "sudo",
//         "unlink",
//         "uninstall",
//         "yum",
//         "top",
//         "while"
//     };
//     for(auto &word : key_word)
//     {
//         auto pos = cmd.find(word);
//         if(pos != std::string::npos) return false;
//     }

//     return true;
// }

// std::string ExcuteCommand(const std::string &cmd)
// {
//     std::cout << "get a request cmd: " << cmd << std::endl;
//     if(!SafeCheck(cmd)) return "Bad man";

//     FILE *fp = popen(cmd.c_str(), "r");
//     if(nullptr == fp)
//     {
//         perror("popen");
//         return "error";
//     }
//     std::string result;
//     char buffer[4096];
//     while(true)
//     {
//         char *ok = fgets(buffer, sizeof(buffer), fp);
//         if(ok == nullptr) break;
//         result += buffer;
//     }
//     pclose(fp);

//     return result;
// }

// ./udpserver port
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        Usage(argv[0]);
        exit(0);
    }

    uint16_t port = std::stoi(argv[1]);

    std::unique_ptr<UdpServer> svr(new UdpServer(port));

    svr->Init(/**/);
    svr->Run(Handler);
    // 或 svr->Run(ExcuteCommand);

    return 0;
}

客户端代码

  • 问题:客户端需要绑定 socket 吗?答案是需要,只是不需要用户显式的绑定,由 OS 随机选择,目的是防止端口号冲突,导致客户端无法与服务器通信。客户端的端口号具体是几不重要,重要的是保证唯一性。但服务器的端口号必须是确定的、固定不变的,要保证客户在不同时间都能与服务器通信。那什么时候 OS 随机选择端口号绑定呢?答案是首次使用 sendto 发送数据的时候。
  • 由于 TCP/IP 协议栈是所有操作系统都遵守的通信协议,所以服务器和客户端的代码在 windows、Linux 下都可以运行(代码实现可能略有不同)。可以把 Linux 主机作为服务器,window 主机作为一个客户端。

UDP_clinet_starter.cc:

cpp 复制代码
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

using namespace std;

void Usage(std::string proc)
{
    std::cout << "\n\rUsage: " << proc << " serverip serverport\n"
              << std::endl;
}

// ./udpclient serverip serverport
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(0);
    }
    std::string serverip = argv[1];
    uint16_t serverport = std::stoi(argv[2]);

    struct sockaddr_in server;
    bzero(&server, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(serverport); //?
    server.sin_addr.s_addr = inet_addr(serverip.c_str());
    socklen_t len = sizeof(server);

    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        cout << "socker error" << endl;
        return 1;
    }

    // client 要bind吗?要!只不过不需要用户显示的bind!一般有OS自由随机选择!
    // 一个端口号只能被一个进程bind,对server是如此,对于client,也是如此!
    // 其实client的port是多少,其实不重要,只要能保证主机上的唯一性就可以!
    // 系统什么时候给我bind呢?首次发送数据的时候

    string message;
    char buffer[1024];
    while (true)
    {
        cout << "Please Enter@ ";
        getline(cin, message);

        sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&server, len);
        
        struct sockaddr_in temp;
        socklen_t len = sizeof(temp);

        ssize_t s = recvfrom(sockfd, buffer, 1023, 0, (struct sockaddr*)&temp, &len);
        if(s > 0)
        {
            buffer[s] = 0;
            cout << buffer << endl;
        }
    }

    close(sockfd);
    return 0;
}

上面的服务器和客户端只能做到客户端向服务器发送消息或指令,客户的消息只有服务器才能看到,现在实现一个类似 QQ 群的聊天室的功能:一个用户发送的消息被在群里的所有用户接收。

  • 修改服务器的代码:在服务器用一个哈希表存储群聊用户的 IP 和 端口号,当用户第一次向服务器发送消息时,将用户加入群聊,用户向服务器(群里)发送消息,向所有用户广播。
  • 客户端的代码也需要修改:因为客户端使用 getline 获取客户的键盘输入,如果客户不输入,就会在 getline 阻塞,即使 udp 的套接字是全双工 的(允许同时读写),也无法接收其他用户的消息,所以要将客户端的代码多线程化,一个线程用来接收消息,一个线程用来发送消息。

使用终端模拟图形界面:一个终端用来发送消息,另一个终端用来显示我以及其他人发送的消息,在客户端的用来接收消息的线程中,将它的标准输出重定向到标准错误。在 /dev/pts 目录下可以查看所有终端,通过尝试向终端打印信息来判断当前终端:

bash 复制代码
[hxh@VM-16-12-centos ~]$ ls /dev/pts
0  1  2  3  6  7  8  ptmx
[hxh@VM-16-12-centos ~]$ echo "hello world" > /dev/pts/0
[hxh@VM-16-12-centos ~]$ echo "hello world" > /dev/pts/1
[hxh@VM-16-12-centos ~]$ echo "hello world" > /dev/pts/2
[hxh@VM-16-12-centos ~]$ echo "hello world" > /dev/pts/3
[hxh@VM-16-12-centos ~]$ echo "hello world" > /dev/pts/6
[hxh@VM-16-12-centos ~]$ echo "hello world" > /dev/pts/7
[hxh@VM-16-12-centos ~]$ echo "hello world" > /dev/pts/8
hello world

// 当前终端是 8 号终端

改进后的服务器代码:UDP_server_plus.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <strings.h>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <functional>
#include <unordered_map>
#include "Log.hpp"

// using func_t = std::function<std::string(const std::string&)>;
typedef std::function<std::string(const std::string &, const std::string &, uint16_t)> func_t;

Log lg;

enum{
    SOCKET_ERR=1,
    BIND_ERR
};

uint16_t defaultport = 8080;
std::string defaultip = "0.0.0.0";
const int size = 1024;

class UdpServer{
public:
    UdpServer(const uint16_t &port = defaultport, const std::string &ip = defaultip):sockfd_(0), port_(port), ip_(ip),isrunning_(false)
    {}
    void Init()
    {
        // 1. 创建udp socket
        // 2. Udp 的socket是全双工的,允许被同时读写的
        sockfd_ = socket(AF_INET, SOCK_DGRAM, 0); // PF_INET
        if(sockfd_ < 0)
        {
            lg(Fatal, "socket create error, sockfd: %d", sockfd_);
            exit(SOCKET_ERR);
        }
        lg(Info, "socket create success, sockfd: %d", sockfd_);
        // 2. bind socket
        struct sockaddr_in local;
        bzero(&local, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(port_); //需要保证我的端口号是网络字节序列,因为该端口号是要给对方发送的
        local.sin_addr.s_addr = inet_addr(ip_.c_str()); //1. string -> uint32_t 2. uint32_t必须是网络序列的 // ??
        // local.sin_addr.s_addr = htonl(INADDR_ANY);

        if(bind(sockfd_, (const struct sockaddr *)&local, sizeof(local)) < 0)
        {
            lg(Fatal, "bind error, errno: %d, err string: %s", errno, strerror(errno));
            exit(BIND_ERR);
        }
        lg(Info, "bind success, errno: %d, err string: %s", errno, strerror(errno));
    }
    void CheckUser(const struct sockaddr_in &client, const std::string clientip, uint16_t clientport)
    {
        auto iter = online_user_.find(clientip);
        if(iter == online_user_.end())
        {
            online_user_.insert({clientip, client});
            std::cout << "[" << clientip << ":" << clientport << "] add to online user." << std::endl;
        }
    }

    void Broadcast(const std::string &info, const std::string clientip, uint16_t clientport)
    {
        for(const auto &user : online_user_)
        {
            std::string message = "[";
            message += clientip;
            message += ":";
            message += std::to_string(clientport);
            message += "]# ";
            message += info;
            socklen_t len = sizeof(user.second);
            sendto(sockfd_, message.c_str(), message.size(), 0, (struct sockaddr*)(&user.second), len);
        }
    }

    void Run() // 对代码进行分层
    {
        isrunning_ = true;
        char inbuffer[size];
        while(isrunning_)
        {
            struct sockaddr_in client;
            socklen_t len = sizeof(client);
            ssize_t n = recvfrom(sockfd_, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&client, &len);
            if(n < 0)
            {
                lg(Warning, "recvfrom error, errno: %d, err string: %s", errno, strerror(errno));
                continue;
            }
            
            uint16_t clientport = ntohs(client.sin_port);
            std::string clientip = inet_ntoa(client.sin_addr);
            CheckUser(client, clientip, clientport);

            std::string info = inbuffer;
            Broadcast(info,clientip, clientport);
        }
    }
    ~UdpServer()
    {
        if(sockfd_>0) close(sockfd_);
    }
private:
    int sockfd_;     // 网路文件描述符
    std::string ip_; // 任意地址bind 0
    uint16_t port_;  // 表明服务器进程的端口号
    bool isrunning_;
    std::unordered_map<std::string, struct sockaddr_in> online_user_;
};

改进后的客户端代码:UDP_clinet_starter_plus.cc

cpp 复制代码
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <strings.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h>
#include "Terminal.hpp"

using namespace std;

void Usage(std::string proc)
{
    std::cout << "\n\rUsage: " << proc << " serverip serverport\n"
              << std::endl;
}

struct ThreadData
{
    struct sockaddr_in server;
    int sockfd;
    std::string serverip;
};

void *recv_message(void *args)
{
    OpenTerminal();
    ThreadData *td = static_cast<ThreadData *>(args);
    char buffer[1024];
    while (true)
    {
        memset(buffer, 0, sizeof(buffer));
        struct sockaddr_in temp;
        socklen_t len = sizeof(temp);

        ssize_t s = recvfrom(td->sockfd, buffer, 1023, 0, (struct sockaddr *)&temp, &len);
        if (s > 0)
        {
            buffer[s] = 0;
            cerr << buffer << endl;
        }
    }
}

void *send_message(void *args)
{
    ThreadData *td = static_cast<ThreadData *>(args);
    string message;
    socklen_t len = sizeof(td->server);

    std::string welcome = td->serverip;
    welcome += " comming...";
    sendto(td->sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&(td->server), len);

    while (true)
    {
        cout << "Please Enter@ ";
        getline(cin, message);

        // std::cout << message << std::endl;
        // 1. 数据 2. 给谁发
        sendto(td->sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&(td->server), len);
    }
}

// 多线程
// ./udpclient serverip serverport
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(0);
    }
    std::string serverip = argv[1];
    uint16_t serverport = std::stoi(argv[2]);

    struct ThreadData td;
    bzero(&td.server, sizeof(td.server));
    td.server.sin_family = AF_INET;
    td.server.sin_port = htons(serverport); //?
    td.server.sin_addr.s_addr = inet_addr(serverip.c_str());

    td.sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (td.sockfd < 0)
    {
        cout << "socker error" << endl;
        return 1;
    }

    td.serverip = serverip;

    pthread_t recvr, sender;
    pthread_create(&recvr, nullptr, recv_message, &td);
    pthread_create(&sender, nullptr, send_message, &td);

    pthread_join(recvr, nullptr);
    pthread_join(sender, nullptr);

    close(td.sockfd);
    return 0;
}

TCP 专用 API

服务端:listen() ------ 启动监听

告诉内核,这个套接字可以接受外来连接请求了。

cpp 复制代码
#include <sys/socket.h>
int listen(int sockfd, int backlog);

// 示例
listen(sock_fd, 10);   // 允许最多10个连接请求在队列里等待
  • sockfd:通过 socket() 创建的 UDP 套接字的返回值(本地套接字)

  • backlog:连接请求队列的最大长度.当服务器忙于处理时,新来的连接可以在这里排队。一般不要设置得太大。

  • 返回值:0:启动监听成功,-1:失败,错误码保存在 errno

注意:这个用来监听的套接字并不直接服务客户,而是承担"酒店前台"的角色,它的核心工作是把那些要与本服务器建立连接的客户端套接字放在请求队列里。

服务端:accept() ------ 接受连接

从等待队列中取出一个连接请求,并创建一个新的套接字专门用于和这个客户端通信。

cpp 复制代码
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

// 示例
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(sock_fd, (struct sockaddr*)&client_addr, &client_len);
  • sockfd :通过 socket() 创建的 UDP 套接字的返回值(本地套接字),并且这个套接字已经被上面的 listen 设置过了。

  • 返回值:新创建的专门用于和这个客户端通信的套接字的描述符。后续 send() / recv() 都用这个新的 fd。

  • addr:输出型参数,里面存放了客户端的 IP 和端口信息。

  • 返回值:0:接受连接成功,-1:失败,错误码保存在 errno

客户端:connect() ------ 发起连接

客户端主动向服务器发起连接请求。

cpp 复制代码
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

// 示例
struct sockaddr_in server_addr;
// ... 设置服务器IP和端口 ...
connect(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
  • sockfd:客户端通过 socket() 创建的用来连接服务器的套接字描述符

  • addr:服务器地址结构(IP 和端口)

  • addrlen:地址结构的长度

  • 返回值:0:连接成功,-1:连接失败,错误码保存在 errno 中,获取一个连接失败不至于停止服务器,日志等级为 Warning

数据收发:send() / recv()

cpp 复制代码
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

ssize_t recv(int sockfd, void *buf, size_t len, int flags);
  • sockfd:通过 connect 建立连接或 accept 新创建的套接字描述符

  • buf:要发送/接收的数据缓冲区

  • len:要发送/接收的字节数

  • flags:标志位(通常为 0)

  • 返回值:> 0:实际发送/接收的字节数,= 0:连接已关闭, < 0:发生错误

套接字也是文件,也可以用 write/read 进行数据的收发。

TCP 服务器和客户端

  • 不管是 TCP 还是 UDP,创建、绑定套接字和填充 sockaddr 结构的接口是一样的。
  • 不管是 TCP 还是 UDP,服务器的公网 IP 都不能直接被绑定。
  • 不管是 TCP 还是 UDP,客户端都需要绑定套接字,只是不需要用户显式的绑定。(tcp 在客户端首次 connect 时绑定)。

TCP 服务器:TCP_server.hpp:

下面的 TCP 服务器有四个版本:1、单进程版 2、多进程版 3 、多线程版 4、线程池版。

  • **单进程版:**主线程 accept 成功之后,就一直进行 Service 函数,在Service 函数中一直 echo 用户发送的数据,直到用户退出(rand 函数返回 0),才退出 Service 函数,重新 accept,在次期间的其他用户若试图 connect 服务器,将会被阻塞。
  • 多进程版: 父进程 accept 成功后 fork 出一个子进程,让子进程执行 Service 函数,子进程会继承父进程的所有文件描述符,包括 listensock,子进程可以关闭不需要的文件描述符。accept 成功后创建的专门用来和用户交互的套接字父进程也不需要了,父进程可以关闭。那岂不是父进程还要等待子进程,那和单进程版有什么区别呢? 要解决这个问题有很多方法:1、waitpid 采用非阻塞等待,并且等待任意进程 2、用基于信号的非阻塞等待方式,子进程退出自动释放资源,无需父进程等待。3、**孙子进程:**子进程再创建一个孙子进程,让孙子进程执行 Service 函数,子进程直接退出被父进程回收,孙子进程变成孤儿进程,被操作系统领养,子进程和父进程无需关心孙子进程,这样做可以让父进程和孙子进程实现并发执行。
  • 多线程版:创建一个进程的时间和空间成本比创建一个线程高得多,应该采用创建线程让线程执行执行 Service 函数。为了不让主线程阻塞等待线程,在线程执行的函数中用 pthread_detach(pthread_self()); 分离线程。
  • 线程池版(最终版本):为了快速响应客户的请求,以及应对突发大量的网络请求,应该使用线程池。在这个版本中,实现了一个简易的英译汉服务功能,
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <pthread.h>
#include <signal.h>
#include <signal.h>
#include "Log.hpp"
#include "ThreadPool.hpp"
#include "Task.hpp"
#include "Daemon.hpp"

const int defaultfd = -1;
const std::string defaultip = "0.0.0.0";
const int backlog = 10; // 但是一般不要设置的太大
extern Log lg;

enum
{
    UsageError = 1,
    SocketError,
    BindError,
    ListenError,
};

class TcpServer;

class ThreadData
{
public:
    ThreadData(int fd, const std::string &ip, const uint16_t &p, TcpServer *t): sockfd(fd), clientip(ip), clientport(p), tsvr(t)
    {}
public:
    int sockfd;
    std::string clientip;
    uint16_t clientport;
    TcpServer *tsvr;
};

class TcpServer
{
public:
    TcpServer(const uint16_t &port, const std::string &ip = defaultip) : listensock_(defaultfd), port_(port), ip_(ip)
    {
    }
    void InitServer()
    {
        listensock_ = socket(AF_INET, SOCK_STREAM, 0);
        if (listensock_ < 0)
        {
            lg(Fatal, "create socket, errno: %d, errstring: %s", errno, strerror(errno));
            exit(SocketError);
        }
        lg(Info, "create socket success, listensock_: %d", listensock_);

        int opt = 1;
        setsockopt(listensock_, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, &opt, sizeof(opt)); // 防止偶发性的服务器无法进行立即重启(tcp协议的时候再说)

        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(port_);
        inet_aton(ip_.c_str(), &(local.sin_addr));
        // local.sin_addr.s_addr = INADDR_ANY;

        if (bind(listensock_, (struct sockaddr *)&local, sizeof(local)) < 0)
        {
            lg(Fatal, "bind error, errno: %d, errstring: %s", errno, strerror(errno));
            exit(BindError);
        }

        lg(Info, "bind socket success, listensock_: %d", listensock_);

        // Tcp是面向连接的,服务器一般是比较"被动的",服务器一直处于一种,一直在等待连接到来的状态
        if (listen(listensock_, backlog) < 0)
        {
            lg(Fatal, "listen error, errno: %d, errstring: %s", errno, strerror(errno));
            exit(ListenError);
        }

        lg(Info, "listen socket success, listensock_: %d", listensock_);
    }

    // static void *Routine(void *args)
    // {
    //     pthread_detach(pthread_self());
    //     ThreadData *td = static_cast<ThreadData *>(args);
    //     td->tsvr->Service(td->sockfd, td->clientip, td->clientport);//???
    //     delete td;
    //     return nullptr;
    // }

    void Start()
    {
        Daemon(); // 守护进程化
        ThreadPool<Task>::GetInstance()->Start();
        // for fork();
        // signal(SIGCHLD, SIG_IGN);
        lg(Info, "tcpServer is running....");
        for (;;)
        {
            // 1. 获取新连接
            struct sockaddr_in client;
            socklen_t len = sizeof(client);
            int sockfd = accept(listensock_, (struct sockaddr *)&client, &len);
            if (sockfd < 0)
            {
                lg(Warning, "accept error, errno: %d, errstring: %s", errno, strerror(errno)); //?
                continue;
            }
            uint16_t clientport = ntohs(client.sin_port);
            char clientip[32];
            inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));

            // 2. 根据新连接来进行通信
            lg(Info, "get a new link..., sockfd: %d, client ip: %s, client port: %d", sockfd, clientip, clientport);
            // std::cout << "hello world" << std::endl;
            // version 1 -- 单进程版
            // Service(sockfd, clientip, clientport);
            // close(sockfd);

            // version 2 -- 多进程版
            // pid_t id = fork();
            // if(id == 0)
            // {
            //     // child
            //     close(listensock_); // 关闭不需要的套接字
            //     if(fork() > 0) exit(0);
            //     Service(sockfd, clientip, clientport); //孙子进程, system 领养
            //     close(sockfd);
            //     exit(0);
            // }
            // close(sockfd); // 关闭不需要的套接字
            // // father
            // pid_t rid = waitpid(id, nullptr, 0);
            // (void)rid;

            // version 3 -- 多线程版本
            // ThreadData *td = new ThreadData(sockfd, clientip, clientport, this);
            // pthread_t tid;
            // pthread_create(&tid, nullptr, Routine, td);

            // version 4 --- 线程池版本
            Task t(sockfd, clientip, clientport);
            ThreadPool<Task>::GetInstance()->Push(t);
        }
    }

    // 线程池版本将 Service 函数放在 Tesk.hpp 中
    // void Service(int sockfd, const std::string &clientip, const uint16_t &clientport)
    // {
    //     // 测试代码
    //     char buffer[4096];
    //     while (true)
    //     {
    //         ssize_t n = read(sockfd, buffer, sizeof(buffer));
    //         if (n > 0)
    //         {
    //             buffer[n] = 0;
    //             std::cout << "client say# " << buffer << std::endl;
    //             std::string echo_string = "tcpserver echo# ";
    //             echo_string += buffer;

    //             write(sockfd, echo_string.c_str(), echo_string.size());
    //         }
    //         else if (n == 0)
    //         {
    //             lg(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd);
    //             break;
    //         }
    //         else
    //         {
    //             lg(Warning, "read error, sockfd: %d, client ip: %s, client port: %d", sockfd, clientip.c_str(), clientport);
    //             break;
    //         }
    //     }
    // }
    ~TcpServer() {}

private:
    int listensock_;
    uint16_t port_;
    std::string ip_;
};

线程池中每个线程需要做的任务:Tesk.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include "Log.hpp"
#include "Init.hpp"

extern Log lg;
Init init;

class Task
{
public:
    Task(int sockfd, const std::string &clientip, const uint16_t &clientport)
        : sockfd_(sockfd), clientip_(clientip), clientport_(clientport)
    {
    }
    Task()
    {
    }
    void run()
    {
        // 测试代码
        char buffer[4096];
        // Tcp是面向字节流的,你怎么保证,你读取上来的数据,是"一个" "完整" 的报文呢?
        ssize_t n = read(sockfd_, buffer, sizeof(buffer)); // BUG?
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "client key# " << buffer << std::endl;
            std::string echo_string = init.translation(buffer);

            // sleep(5);
            // // close(sockfd_);
            // lg(Warning, "close sockfd %d done", sockfd_);

            // sleep(2);
            n = write(sockfd_, echo_string.c_str(), echo_string.size()); // 100 fd 不存在
            if(n < 0)
            {
                lg(Warning, "write error, errno : %d, errstring: %s", errno, strerror(errno));
            }
        }
        else if (n == 0)
        {
            lg(Info, "%s:%d quit, server close sockfd: %d", clientip_.c_str(), clientport_, sockfd_);
        }
        else
        {
            lg(Warning, "read error, sockfd: %d, client ip: %s, client port: %d", sockfd_, clientip_.c_str(), clientport_);
        }
        close(sockfd_);
    }
    void operator()()
    {
        run();
    }
    ~Task()
    {
    }

private:
    int sockfd_;
    std::string clientip_;
    uint16_t clientport_;
};

创建一个 Init 类用来将 txt 文本的英译汉信息保存到 unordered_map 中,该类声明在 Init.hpp 中,Init.hpp 可以包含在 Tesk.hpp 中

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <fstream>
#include <unordered_map>
#include "Log.hpp"

const std::string dictname = "./dict.txt";
const std::string sep = ":";

//yellow:黄色...
static bool Split(std::string &s, std::string *part1, std::string *part2)
{
    auto pos = s.find(sep);
    if(pos == std::string::npos) return false;
    *part1 = s.substr(0, pos);
    *part2 = s.substr(pos+1);
    return true;
}

class Init
{
public:
    Init()
    {
        std::ifstream in(dictname);
        if(!in.is_open())
        {
            lg(Fatal, "ifstream open %s error", dictname.c_str());
            exit(1);
        }
        std::string line;
        while(std::getline(in, line))
        {
            std::string part1, part2;
            Split(line, &part1, &part2);
            dict.insert({part1, part2});
        }
        in.close();
    }
    std::string translation(const std::string &key)
    {
        auto iter = dict.find(key);
        if(iter == dict.end()) return "Unknow";
        else return iter->second;
    }
private:
    std::unordered_map<std::string, std::string> dict;
};

TCP 客户端:TCP_client_starter.cc

cpp 复制代码
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>

void Usage(const std::string &proc)
{
    std::cout << "\n\rUsage: " << proc << " serverip serverport\n"
              << std::endl;
}

// ./tcpclient serverip serverport
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    std::string serverip = argv[1];
    uint16_t serverport = std::stoi(argv[2]);

    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(serverport);
    inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr));

    while (true)
    {
        int cnt = 5;
        int isreconnect = false;
        int sockfd = 0;
        sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (sockfd < 0)
        {
            std::cerr << "socket error" << std::endl;
            return 1;
        }
        do
        {
            // tcp客户端要不要bind?1 要不要显示的bind?0 系统进行bind,随机端口
            // 客户端发起connect的时候,进行自动随机bind
            int n = connect(sockfd, (struct sockaddr *)&server, sizeof(server));
            if (n < 0)
            {
                isreconnect = true;
                cnt--;
                std::cerr << "connect error..., reconnect: " << cnt << std::endl;
                sleep(2);
            }
            else
            {
                break;
            }
        } while (cnt && isreconnect);

        if (cnt == 0)
        {
            std::cerr << "user offline..." << std::endl;
            break;
        }

        // while (true)
        // {
            std::string message;
            std::cout << "Please Enter# ";
            std::getline(std::cin, message);

            int n = write(sockfd, message.c_str(), message.size());
            if (n < 0)
            {
                std::cerr << "write error..." << std::endl;
                // break;
            }

            char inbuffer[4096];
            n = read(sockfd, inbuffer, sizeof(inbuffer));
            if (n > 0)
            {
                inbuffer[n] = 0;
                std::cout << inbuffer << std::endl;
            }
            else{
                // break;
            }
        // }
        close(sockfd);
    }

    return 0;
}

我们将 tcpserver 守护进程化,这样即使我退出 shell,服务器依然运行,在 TCP_server.hpp 的start 函数中调用 Daemom 函数:

cpp 复制代码
#pragma once

#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <signal.h>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

const std::string nullfile = "/dev/null";

void Daemon(const std::string &cwd = "")
{
    // 1. 忽略其他异常信号
    signal(SIGCLD, SIG_IGN);
    signal(SIGPIPE, SIG_IGN);
    signal(SIGSTOP, SIG_IGN);

    // 2. 将自己变成独立的会话
    if (fork() > 0)
        exit(0);
    setsid();

    // 3. 更改当前调用进程的工作目录
    if (!cwd.empty())
        chdir(cwd.c_str());

    // 4. 标准输入,标准输出,标准错误重定向至/dev/null
    int fd = open(nullfile.c_str(), O_RDWR);
    if(fd > 0)
    {
        dup2(fd, 0);
        dup2(fd, 1);
        dup2(fd, 2);
        close(fd);
    }
}

TCP 通信是全双工的,

netstat 指令

上面的 UDP 服务器写好之后,怎么知道它是否正常运行呢?netstat 指令是网络编程中最常用、最强大的调试工具之一。它可以显示网络连接、路由表、接口统计等信息

cpp 复制代码
netstat [选项]

# 查看所有监听和建立的连接(最常用)
netstat -anp

# 只看 TCP 连接
netstat -antp

# 只看 UDP
netstat -anup

# 只看监听端口
netstat -lnpt

选项说明

  • -a (all):显示所有连接(包括监听和已建立)

  • -n (numeric):用数字显示IP和端口(不解析域名)

  • -p (program):显示使用连接的进程PID和名称

  • -t (tcp):只显示TCP连接

  • -u (udp):只显示UDP连接

  • -l (listening):只显示监听的端口

  • -r (route):显示路由表

  • -i (interfaces):显示网络接口信息

  • -s (statistics):显示各协议统计信息

telnet 指令

telnet 是一个经典的网络工具,虽然名字来源于"Teletype Network"协议,但在网络编程中,它最重要的用途是作为 TCP 客户端调试工具

基本语法

cpp 复制代码
telnet [选项] [主机IP] [端口]
cpp 复制代码
# 指定端口
telnet 127.0.0.1 22      # 测试 SSH 端口
telnet 127.0.0.1 80      # 测试 HTTP 端口
telnet 127.0.0.1 3306    # 测试 MySQL 端口

# 测试某个端口是否开放
telnet 192.168.1.100 8080

# 如果连接成功:
Trying 192.168.1.100...
Connected to 192.168.1.100.
Escape character is '^]'.

# 如果连接失败:
Trying 192.168.1.100...
telnet: Unable to connect to remote host: Connection refused

# 指定超时时间(需要安装 telnet 的其他版本)
# 某些系统不支持,可以用 nc 替代

# 退出 telnet
# 按 Ctrl + ] 进入命令模式,然后输入 quit

命令模式(按 Ctrl + ] 进入)

cpp 复制代码
telnet> help              # 显示帮助
telnet> quit              # 退出
telnet> close             # 关闭连接
telnet> status            # 显示连接状态
telnet> set echo          # 开启本地回显
telnet> 回车              # 开始向服务器发送数据  

回环地址

为什么用该指令连接自己主机的服务器时,IP 是 127.0.0.1?答案是127.0.0.1回环地址(Loopback Address)是一个特殊的 IP 地址,永远指向本机自己。 (整个 127.0.0.0 网段都指向本机 :127.0.0.1 -> 最常用,127.0.0.2 -> 也指向本机,127.255.255.255 -> 也指向本机)特殊的网络接口:lo,这是一个虚拟网络接口,不连接任何物理网卡,发送到 127.0.0.1 的数据永远不会离开本机.

使用回环地址的数据流向

cpp 复制代码
应用层(你的程序)
    ↓
传输层(TCP/UDP)
    ↓
网络层(IP)
    ↓
回环接口(lo) ← 数据在这里被"折返"
    ↓
传输层(TCP/UDP)
    ↓
应用层(你的程序)
相关推荐
有毒的教程2 小时前
Ubuntu 安装完成后网络配置教程
linux·网络·ubuntu
刚入门的大一新生2 小时前
Linux-Linux的基础指令3
linux·运维·服务器
二等饼干~za8986682 小时前
豆包geo优化系统,源码开发搭建解析
大数据·网络·数据库·人工智能·django
草莓熊Lotso2 小时前
MySQL 复合查询核心指南:多表、子查询与实战技巧
linux·运维·服务器·数据库·人工智能·mysql
小杨啥都学2 小时前
通过ipsec服务端给客户端分配ip
服务器·网络·tcp/ip·ipsec
不一样的故事1262 小时前
线号管并非必须和端子端面绝对齐平
网络·安全
Xiaoweidumpb2 小时前
win10 Windows服务器开放端口防火墙规则 远程控制桌面
运维·服务器·windows
you-_ling2 小时前
网络:4.TCP并发服务器
服务器·网络·tcp/ip
stanlyYP2 小时前
服务器openclaw操作
运维·服务器