目录
[1. 预备知识](#1. 预备知识)
[1.1. 端口号](#1.1. 端口号)
[1.2. 认识TCP协议](#1.2. 认识TCP协议)
[1.3. 认识UDP协议](#1.3. 认识UDP协议)
[1.4. 网络字节序](#1.4. 网络字节序)
[2. socket](#2. socket)
[2.1. socket 常见系统调用](#2.1. socket 常见系统调用)
[2.1.1. socket 系统调用](#2.1.1. socket 系统调用)
[2.1.2. bind 系统调用](#2.1.2. bind 系统调用)
[2.1.3. recvfrom 系统调用](#2.1.3. recvfrom 系统调用)
[2.1.4. sendto系统调用](#2.1.4. sendto系统调用)
[2.3. 其他相关接口](#2.3. 其他相关接口)
[2.3.1. bzero](#2.3.1. bzero)
[2.3.2. 网络字节序和主机字节序的相关转换接口](#2.3.2. 网络字节序和主机字节序的相关转换接口)
[2.3.2. IPV4地址信息的转换处理](#2.3.2. IPV4地址信息的转换处理)
[2.4. sockaddr结构](#2.4. sockaddr结构)
[1. Log.hpp 日志](#1. Log.hpp 日志)
[2. Date.hpp 时间处理](#2. Date.hpp 时间处理)
[3. Makefile](#3. Makefile)
[3. UDP demo1](#3. UDP demo1)
[3.1. Udp_Server.cc](#3.1. Udp_Server.cc)
[3.2. Udp_Server.hpp](#3.2. Udp_Server.hpp)
[3.3. Udp_Client.cc](#3.3. Udp_Client.cc)
[3.4. 细节总结](#3.4. 细节总结)
1. 预备知识
在网络基础中,我们已经知道了IP地址。再IP数据包中,有两个IP地址,分别叫做源IP地址,目的IP地址, 再数据包转发过程中,源IP和目的IP地址通常不会发生改变。
IP地址 (公网IP) 用来标识主机的唯一性。
通常情况下,当有了IP地址,主机就可以将数据发送给另一台主机,可是,把数据发送给另一台主机是通信的目的吗?答案:并不是,一般的应用级软件,都会有用户客户端软件和服务器软件。而客户端软件和服务器软件本质上不就是进程吗? 因此,客户端软件我们称之为客户端进程,服务器软件就是服务器进程。
以抖音客户端和服务器为例,当用户在手机端的抖音客户端软件上访问时,实际上是在与抖音服务器上运行的特定进程通信,向服务器发送请求并接收响应,从而获取和共享视频内容。
即互联网上的通信本质上是由运行在不同计算机上的进程(或者称为应用程序)之间的通信。
因此**,网络通信的目的是确保不同计算机上的进程能够相互通信和交换数据** 。底层的网络传输过程(比如IP数据包的转发)只是为了实现这一目的而采取的手段,真正的通信实体是运行在计算机上的进程。网络通信确保数据能够从一个进程传输到另一个进程,从而实现用户所期望的功能和服务。
现在,我们知道,网络通信本质上还是进程间通信,但又由于,网络通信是跨主机的,因此在进程间通信之前,我们需要完成主机间的数据转发,而这是为了达到进程间通信这一目的手段。当我们完了主机间的数据转发之后,就需要将该数据发送给指定的进程, 可是问题来了, 如何确定这个进程呢?
因此需要端口号,端口号就是解决如何定位目标进程的问题。
1.1. 端口号
端口号 (port) 是传输层协议的内容。
端口号是一个2字节(16位)的整数。
端口号用来标识特定主机上的唯一的一个进程。
而IP地址标识了主机的唯一性,因此IP地址 + 端口号就可以标识网络中某一台机器中的某一个进程,且是全网唯一的。
未来, 任何一个发出的报文,必须包含: IP地址,port端口号。
一个端口号只能绑定一个进程, 因为要标识进程的唯一性,但是一个进程可以绑定多个端口号。
在我们学习系统编程时,也说过进程的PID,其也是用来表示进程的唯一性啊,为什么这里不用PID来标识它的唯一性呢?首先,PID是进程管理模块的内容,如果网络也采用PID来标识进程唯一性,那么可能导致网络模块和进程管理模块的紧耦合,提高了系统复杂度;
其次,PID是在操作系统层面被分配和管理的,而网络通信可能涉及到多个主机和操作系统的情况。在不同操作系统和主机上,相同的进程可能拥有不同的PID,因此在网络通信中使用PID来唯一标识进程可能会带来差异化,提高了管理和维护成本。
相比之下,使用端口号来标识进程的唯一性更加灵活和可靠。端口号是通过网络套接字(socket)来管理的,属于网络通信相关的范畴,可以在不同操作系统和主机上保持一致。这样设计可以降低系统复杂度,达到功能解耦的目的,确保网络通信模块和进程管理模块之间的独立性。
因此,采用端口号而非PID来标识进程的唯一性是出于系统设计和功能解耦的考虑,符合模块化设计的原则,使得网络通信模块能够独立管理进程的通信需求,降低了系统的复杂度和耦合度。
传输层协议 (TCP和UDP) 的数据段中有两个端口号, 分别是源端口号和目的端口号。源端口号 是指发送数据的进程或应用程序所使用的端口号,表示数据的发送者。
目的端口号 则是数据需要发送到的进程或应用程序所使用的端口号,表示数据的接收者。
通过源IP + 源端口号,可以锁定特定主机的唯一进程;通过目的IP + 目的端口号, 可以锁定特定主机的唯一进程。
因此网络通信,本质上就是进程间通信。
而我们将 { SRC_IP (源IP) , SRC_PORT (源端口号) } 称之为套接字!
{ DST_IP(目的IP) ,DST_PORT (目的端口号) } 也称之为套接字!
因此,我们也称之为套接字编程。
1.2. 认识TCP协议
我们这里只是初识 TCP(Transmission Control Protocol 传输控制协议) ,后面详细介绍。
- 传输层协议
- 有连接
- 可靠传输
- 面向字节流
1.3. 认识UDP协议
我们这里只是初识 UDP(User Datagram Protocol 用户数据报协议),后面详细介绍。
- 传输层协议
- 无连接
- 不可靠传输
- 面向数据报
在这里就只解释一点:我们可以清楚的看到, TCP是可靠传输的,传输数据不会发生丢包问题;而UDP是不可靠传输的,传输数据可能发生丢包问题。 有人一听,那我们还学UDP干什么呢?
TCP协议提供可靠的数据传输,使用了各种机制来确保数据的准确性、完整性和顺序性,例如序列号、确认应答、重传等。这些机制在保证数据可靠性的同时,也增加了协议的复杂性和维护成本。TCP适用于对数据准确性要求较高的场景,例如文件传输、Web页面的请求和响应等。
而UDP协议则是一种不可靠的数据传输协议。它只提供了一种简单的数据传输机制,不具备重传、确认和流量控制等功能。UDP在传输过程中可能发生丢包、乱序等问题,但相应地,它的开销较小,处理逻辑简单,适合一些对实时性要求较高的场景。像直播、音视频传输等实时应用,对于偶尔的丢包用户可能会有一定的容忍度。
因此,对于不同的应用场景,选择TCP还是UDP取决于可靠性、实时性和处理成本的权衡。如果数据的完整性和顺序性是关键,且可以承受一定的处理成本,那么TCP是一个更好的选择。而如果实时性和传输效率更重要,且可以容忍一些数据丢失,那么UDP可能更适合。
1.4. 网络字节序
大端字节序: 数据的高位字节存储在低位地址,低位字节存储在高位地址。
小端字节序: 数据的低位字节存储在低位地址,高位字节存储在高位地址。
为什么要谈论这个问题呢?首先,我们知道,不同的计算机可能是以不同的字节序存储的 (大端 / 小端), 那么在进行主机数据转发时, 如果一方主机是小端存储,另一方主机是大端存储, 那么此时转发数据就会有问题,导致接收方可能无法获得正确信息。
因此, 为了解决这个问题,网络规定:所有网络数据都必须是大端的。
相关接口:
cpp
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong); // 主机 ---> 网络 (uint32_t)
uint16_t htons(uint16_t hostshort); // 主机 ---> 网络 (uint16_t)
uint32_t ntohl(uint32_t netlong); // 网络 ---> 主机 (uint32_t)
uint16_t ntohs(uint16_t netshort); // 网络 ---> 主机 (uint16_t)
这些函数名很好记,h表示host (本地/主机),n表示network (网络),l表示32位长整数,s表示16位短整数。
例如 htonl 表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
2. socket
2.1. socket 常见系统调用
2.1.1. socket 系统调用
cpp
int socket(int domain, int type, int protocol);
RETURN VALUE
On success, a file descriptor for the new socket is returned.
On error, -1 is returned, and errno is set appropriately.
socket 函数是用于创建套接字(socket)的系统调用,其作用是在操作系统中创建一个套接字对象,以便进程能够通过网络进行通信。
- domain:指定套接字的协议域(protocol family),常见的有 AF_INET (IPv4 地址)和 AF_INET6(IPv6 地址)等,表示套接字将使用的是哪种地址类型,即代表着你想创建哪一类别的套接字(域间、网络套接字?)。
- type:指定套接字的类型,常见的有 SOCK_STREAM (流式套接字 ,提供面向连接的、可靠的数据传输,如 TCP)和 SOCK_DGRAM (数据报套接字,提供无连接的、不可靠的数据传输,如 UDP)等。
- protocol:指定协议类型,通常为 0 表示根据 domain 和 type 参数选择默认协议。
返回值:
如果成功创建套接字,返回新的文件描述符 (file descriptor);
如果失败,返回 -1,并设置 errno变量以指明错误原因。
2.1.2. bind 系统调用
cpp
int bind(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
bind 函数是用于将一个本地地址(local address)绑定到一个已创建的套接字(socket)。
bind 函数的作用是告诉操作系统,将指定的本地地址与指定的套接字关联起来,使得该套接字可以使用该地址进行通信。
一般在服务器端创建套接字后,需要使用 bind 函数将套接字与服务器端的特定 IP 地址和端口绑定在一起,以便客户端可以连接到该地址,并且服务器端可以接受客户端的连接请求。
参数:
- sockfd:要进行地址绑定的套接字的文件描述符。
- addr:指向要绑定的本地地址(sockaddr 结构体)的指针。 注意, 如果是网络套接字,那么需要使用 sockaddr_in,然后通过类型转换再传参。
- addrlen:表示本地地址结构体的长度。
返回值:函数执行成功时返回 0,否则返回 -1 并设置 errno 变量以指明错误原因。
2.1.3. recvfrom 系统调用
cpp
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
recvfrom 函数用于接收数据报文,并把数据存放到指定的缓冲区中 。该函数允许从指定的套接字接收数据,并同时获取数据发送方的地址信息 。以下是对该函数的参数介绍:
- sockfd: 表示要接收数据的套接字的文件描述符。
- buf: 指向存放接收数据的缓冲区的指针。
- len: 表示接收数据缓冲区的大小。
- flags: 用于指定接收操作的额外选项,通常可以设为 0。如果 flags为0,表示采用默认的阻塞方式接收数据。在阻塞模式下,如果没有接收到数据,进程会一直等待直到接收到数据,否则函数调用会一直阻塞,直到有数据可读或者发生错误。
- src_addr: 指向 socfaddr 结构体的指针,用于存放发送方的地址信息,如果是网络通信,一般是传递 socfaddr_in类型,然后强转为 sockaddr 类型 (输出型参数)。
- addrlen: 一个指向整数的指针,在调用函数时指定发送方地址结构体的长度,接收时将被改变为实际的发送方地址结构体的长度 (可以理解为输入输出型参数),。
recvfrom 函数的作用是接收数据报文,一般用于 UDP 套接字的数据接收 。它从指定的套接字接收数据,并将数据存储在指定的缓冲区中,在接收数据的同时可以获取发送方的地址信息。通常在接收到数据后,可以通过 src_addr 和 addrlen 获取发送方的地址信息,以便进程进一步处理数据。
补充:
除了阻塞模式 (flags == 0),flags 还支持一些其他选项,如 MSG_DONTWAIT 和 MSG_WAITALL。
MSG_DONTWAIT 表示采用非阻塞方式接收数据,即使当前没有数据可读也会立即返回;
MSG_WAITALL 表示需要一次性接收完所有的数据。
2.1.4. sendto系统调用
cpp
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
sendto 函数用于向指定的套接字发送数据。它通过指定目标地址和目标端口号来将数据发送给对应的主机和进程。
参数介绍
- sockfd:表示要发送数据的套接字的文件描述符。
- buf:指向存放要发送数据的缓冲区的指针。
- len:表示要发送数据的长度。
- flags:用于指定发送数据的额外选项,通常可以设为 0,表示阻塞的发送。
- dest_addr:指向 sockaddr 结构体的指针,用于指定目标地址和端口号。
- addrlen:整数类型,指定目标地址结构体的长度。
一般在使用前,我们需要对 dest_addr 进行初始化, 然后填充相关信息 (例如 sa_family、sin_addr、sin_port)。
sendto 函数的作用是向指定的套接字发送数据报文。它将缓冲区中的数据发送到指定套接字,并传递目标地址、目标端口号等信息以便于数据到达正确的目的地进程。一般在使用 UDP 协议时,可以使用该函数向其他主机发送数据报文。
2.3. 其他相关接口
2.3.1. bzero
cpp
void bzero(void *ptr, size_t n);
bzero 将指定的一段空间的内容设置为0, 即将该内存块的每个字节都设置为0。
- ptr: 这是一个指向要清零的内存块的指针。
- n:要清零的内存块的大小,以字节为单位
2.3.2. 网络字节序和主机字节序的相关转换接口
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 代表 net, 即 网路字节序。
l 代表 long, 即 uint32_t。
s 代表 short, 即 uint16_t。
比如,htons, 就是将16位的数据从主机字节序转化为网络字节序。
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
如果主机是大端字节序,这些函数不做转换,并将参数原封不动地返回。
2.3.2. IPV4地址信息的转换处理
cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
in_addr_t inet_addr(const char *cp);
char *inet_ntoa(struct in_addr in);
inet_addr :这个函数用于将点分十进制表示的IPv4地址转换为网络字节顺序的32位二进制形式的IPv4地址。
简而言之这个函数就做两件事:
1、 将点分十进制的数据转换为32位的整形地址。
2、 并将数据转换为网络字节序。
如果输入的IPv4地址字符串无效,函数将返回INADDR_NONE(通常是-1)。
inet_ntoa :该函数用于将一个网络字节序的32位整数 (通常是表示IPv4地址的in_addr结构体中的s_addr成员)转换为主机字节顺序的点分十进制形式的字符串表示的IP地址。简而言之这个函数就做两件事:
1、 将32位的整形IP地址转化为点分十进制的IP。
2、 并将网络字节序转为主机序列。
同时,我们发现, 这个函数返回的是一个 char* ,那么很显然, 这个函数内部是维护了一段空间的, 那么这段空间需要我们释放吗?
答案是: 不需要, 因为这段空间并不是动态存储的, 而是位于静态区的, 这意味着每次调用 inet_ntoa 返回的指针都会指向相同的内存位置,因此在多次调用该函数后,之前返回的地址会被覆盖。
因此,如果您需要在多个地方使用返回的 IP 地址字符串,或者需要保留返回值的内容,应该在使用结果之前立即将其复制到另一个缓冲区中。如果不进行复制,之后对 inet_ntoa 的调用可能会使之前保存的地址无效。
此外,静态区缓冲区的生命周期是伴随整个进程的, 故我们不需要显示释放。
那么既然是静态缓存区, 那么很显然该函数是不可重入的函数, 那么在多线程场景下, 有可能存在着线程安全的问题。
2.4. sockaddr结构
- 域间套接字(AF_UNIX/AF_LOCAL套接字)
域间套接字也被称为UNIX套接字,是一种用于实现本地进程间通信的套接字 ,可在同一台计算机上的进程之间传递数据。该套接字通过一个文件系统路径来标识,打开时它会创建一个文件,在通信结束后会自动将该文件删除。因此域间套接字通常被用在本地进程间的通信,比如X Window、数据库和Web服务器等。
- 原始套接字(Raw Socket)
原始套接字也被称为原始套接字,其可以接受和发送数据链路层数据包,允许用户构造自己的协议报文,适用于网络安全、网络监视和网络协议开发等方面。可以使用原始套接字来进行网络数据包的抓取、欺骗和注入等操作,同时也可以用于开发新的通信协议,网络协议栈的实现等。
- 网络套接字(AF_INET/AF_INET6套接字)
网络套接字也被称为Internet套接字,用于在网络上实现进程间通信,是Linux中最常用的一种套接字。网络套接字可协同使用传输层协议TCP和UDP,以及网络层协议IP和IPv6,用于实现应用层协议,例如HTTP、FTP、SMTP、SSH等。网络套接字的地址由IP地址和端口号组成,可以通过网络传递消息,实现分布式系统中的通信。
由于有三种套接字,理论上,是三种应用场景, 对应的应该是三套套接字接口!但是Linux不想设计过多的套接字接口!因此将所有的套接字接口进行了统一。
我们可以将 struct sockaddr 理解为一个基类。
struct sockaddr 类型前两个字节标识我是什么套接字,例如,如果前两个字节是AF_INET (本质上是一个宏),那么代表着是网络套接字; 如果前两个字节是AF_UNIT(本质上是宏),那么代表是域间套接字。
换言之,就好比通过 struct sockaddr 这个基类模拟多态。根据前两个字节确定是网络通信还是本地通信。
因此,在未来,因为我们编写的是网络套接字,那么我们使用的就是 struct sockaddr_in,通过类型转换传参给socket系统调用。
如果要使用通用型接口,为什么不使用 void* 呢?因为在设计出网路这套接口时, C语言还不支持 void*, 现在已经无法更改了(向前兼容);
通用文件
1. Log.hpp 日志
cpp
#pragma once
#include "Date.hpp"
#include <iostream>
#include <map>
#include <string>
#include <cstdarg>
#define LOG_SIZE 1024
// 日志等级
enum Level
{
DEBUG, // DEBUG信息
NORMAL, // 正常
WARNING, // 警告
ERROR, // 错误
FATAL // 致命
};
void LogMessage(int level, const char* format, ...)
{
// 如果想打印DUBUG信息, 那么需要定义DUBUG_SHOW (命令行定义, -D)
#ifndef DEBUG_SHOW
if(level == DEBUG)
return ;
#endif
std::map<int, std::string> level_map;
level_map[0] = "DEBUG";
level_map[1] = "NORAML";
level_map[2] = "WARNING";
level_map[3] = "ERROR";
level_map[4] = "FATAL";
std::string info;
va_list ap;
va_start(ap, format);
char stdbuffer[LOG_SIZE] = {0}; // 标准部分 (日志等级、日期、时间)
snprintf(stdbuffer, LOG_SIZE, "[%s],[%s],[%s] ", level_map[level].c_str(), Xq::Date().get_date().c_str(), Xq::Time().get_time().c_str());
info += stdbuffer;
char logbuffer[LOG_SIZE] = {0}; // 用户自定义部分
vsnprintf(logbuffer, LOG_SIZE, format, ap);
info += logbuffer;
std::cout << info ;
fflush(stdout);
va_end(ap);
}
2. Date.hpp 时间处理
没啥特别需要说明的点。
cpp
#ifndef __DATE_HPP_
#define __DATE_HPP_
#include <iostream>
#include <ctime>
namespace Xq
{
class Date
{
public:
Date(size_t year = 1970, size_t month = 1, size_t day = 1)
:_year(year)
,_month(month)
,_day(day)
{}
std::string& get_date()
{
size_t num = get_day();
while(num--)
{
operator++();
}
char buffer[32] = {0};
snprintf(buffer, 32, "%ld/%ld/%ld", _year,_month, _day);
_data = buffer;
return _data;
}
private:
Date& operator++()
{
size_t cur_month_day = month_day[_month];
if((_month == 2) && ((_year % 400 == 0 )|| (_year % 4 == 0 && _year % 100 != 0)))
++cur_month_day;
++_day;
if(_day > cur_month_day)
{
_day = 1;
_month++;
if(_month > 12)
{
_month = 1;
++_year;
}
}
return *this;
}
// 获得从1970.1.1 到 今天相差的天数
size_t get_day()
{
return (time(nullptr) + 8 * 3600) / (24 * 60 * 60);
}
private:
size_t _year;
size_t _month;
size_t _day;
static int month_day[13];
std::string _data;
};
int Date::month_day[13] = {
0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31
};
class Time
{
public:
Time(size_t hour = 0, size_t min = 0, size_t second = 0)
:_hour(hour)
,_min(min)
,_second(second)
{}
std::string& get_time()
{
size_t second = time(nullptr) + 8 * 3600;
_hour = get_hour(second);
_min = get_min(second);
_second = get_second(second);
char buffer[32] = {0};
snprintf(buffer, 32, "%ld:%ld:%ld", _hour, _min, _second);
_time = buffer;
return _time;
}
private:
size_t get_hour(time_t second)
{
// 不足一天的剩余的秒数
size_t verplus_second = second % (24 * 60 * 60);
return verplus_second / (60 * 60);
}
size_t get_min(time_t second)
{
// 不足一小时的秒数
size_t verplus_second = second % (24 * 60 * 60) % (60 * 60);
return verplus_second / 60;
}
size_t get_second(time_t second)
{
// 不足一分钟的秒数
return second % (24 * 60 * 60) % (60 * 60) % 60;
}
private:
size_t _hour;
size_t _min;
size_t _second;
std::string _time;
};
}
#endif
3. Makefile
bash
.PHONY:all
all:Client Server
Client:Udp_Client.cc
g++ -o $@ $^ -std=gnu++11
Server:Udp_Server.cc
g++ -o $@ $^ -std=gnu++11
.PHONY:clean
clean:
rm -f Client Server
3. UDP demo1
第一个版本:echo 服务器, 客户端向服务器发送消息, 服务端原封不动的返回给客户端。
3.1. Udp_Server.cc
cpp
#include "Udp_Server.hpp"
void standard_usage(void)
{
printf("please usage: ./Server port\n");
}
int main(int argc, char* argv[])
{
// 服务端我们不用显式传递IP了, 默认用INADDR_ANY
// 因此, 我们只需要两个命令行参数
if(argc != 2)
{
standard_usage();
exit(1);
}
// 传递端口号即可
Xq::udp_server* server = new Xq::udp_server(atoi(argv[1]));
server->init_server();
server->start();
delete server;
return 0;
}
3.2. Udp_Server.hpp
cpp
#ifndef __UDP_SERVER_HPP_
#define __UDP_SERVER_HPP_
#include "Date.hpp"
#include "Log.hpp"
#include <iostream>
#include <string>
#include <cstring>
// 下面的这四个头文件,我们称之为网络四件套
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
// 服务端缓冲区大小
#define SER_BUFFER_SIZE 1024
namespace Xq
{
class udp_server
{
public:
// 需要显示传递服务器的 port
udp_server(uint16_t port, const std::string ip = "")
:_ip(ip)
, _port(port)
, _sock(-1)
{
}
void init_server(void)
{
//1. 创建套接字 --- socket
// AF_INET 是一个宏值, 在这里代表着网络套接字
// SOCK_DGRAM, 标定这是数据报套接字
// protocol 默认情况下都是0
_sock = socket(AF_INET, SOCK_DGRAM, 0);
if (_sock == -1)
{
// 套接字创建失败对于网络通信而言是致命的
LogMessage(FATAL, "%s\n", "socket failed");
exit(1);
}
//2. 绑定端口号 --- bind
// bind 将相应的ip和port在内核中与指定的进程强关联
// 服务器跑起来就是一个进程, 因此需要通过
// 服务器的IP + port 绑定服务器这个进程
// 因此我们需要通过 sockaddr_in 设置地址信息
struct sockaddr_in server;
// 我们可以初始化一下这个对象
// 通过bzero(), 对指定的一段内存空间做清0操作
bzero(static_cast<void*>(&server), sizeof(server));
// 初始化完毕后, 我们就需要填充字段
// sockaddr_in 内部成员
// in_port_t sin_port; --- 对port的封装
// struct in_addr sin_addr; --- 对ip的封装, 这里面的Ip实际上就是一个32位 (uint32_t) 的整数。
// sin_family sa_family; --- 如果我们是网络套接字, 那么填充 AF_INET
// 我们要知道, 0.0.0.0 这种IP地址我们称之为"点分十进制" 字符串风格的IP地址
// 每个点分割的区域数值范围 [0, 255];
// 四个区域代表着四个字节, 理论上标识一个IP地址, 其实四字节就足够了
// 点分十进制的字符串风格的IP地址是给用户使用的
// 在这里我们需要将其转成32位的整数 uint32_t
server.sin_family = AF_INET;
// 当我们在网络通信时, 一方不仅要将自己的数据内容告诉对方
// 还需要将自己的IP地址以及端口号告诉对方。
// 即服务器的IP和端口号未来也是要发送给对方主机的特定进程(客户端进程)
// 那么是不是我需要先将数据从 本地 发送到 网络呢?
// 答案: 是的, 因此我们还需要注意不同主机内的大小端问题
// 因此, 我们在这里统一使用网络字节序
server.sin_port = htons(_port);
// 而对于IP地址而言, 也是同理的
// 只不过此时的IP地址是点分十进制的字符串
// 因此我们需要先将其转为32位的整数, 在转化为网络字节序
// 而 inet_addr() 这个接口就可以帮助我们做好这两件事
//server.sin_addr.s_addr = inet_addr(_ip.c_str());
// 作为 server 服务端来讲,我们不推荐绑定确定的IP,
// 我们推荐采用任意IP的方案,即INADDR_ANY(是一个宏值), 本质就是((in_addr_t) 0x00000000)
// 作为服务器, 我们可以不用暴露IP, 只暴露端口号即可。
// 通常使用 INADDR_ANY 来 bind 服务器的套接字,从而使服务器能够接收来自任意IP地址的客户端连接
// INADDR_ANY可以让服务器,在工作过程中,可以从任意IP中获取数据
// 如果我们在服务器端 bind 了一个固定IP, 那么此时这个服务器就只能
// 收取某个具体IP的消息, 但如果我们采用INADDR_ANY
// 那么就是告诉操作系统, 凡是给该主机的特定端口(_port)的数据都给我这个服务端
// 有了这样的认识之后,服务端只需要端口,不需要传递IP了 (默认设置为 INADDR_ANY)、
server.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
// 填充 struct sockaddr_in 结束
// 这里的 socklen_t 本质上就是 unsigned int
// 得到这个缓冲区 (地址信息) 的大小
socklen_t server_addr_len = sizeof(server);
if (bind(_sock, reinterpret_cast<const struct sockaddr*>(&server), server_addr_len) == -1)
{
// 如果 bind 失败, 对于服务器而言是致命的
LogMessage(FATAL, "%s\n", "bind error");
exit(2);
}
// 初始化done
LogMessage(NORMAL, "%s\n", "init_server success");
}
// 启动服务器 --- start
// 第一个简单版本: echo 服务器, 客户端向服务器发送消息, 服务端原封不动的返回给客户端
// 站在网络视角, 作为一款网络服务器, 永远不退出
// 站在操作系统视角, 服务器本质上就是一个进程,
// 因此对于这种永远不退出的进程我们也称之为常驻进程,
// 永远在内存中存在, 除非系统挂了或者服务器宕机了。
// 因此针对服务器我们要特别注意内存问题。绝不能内存泄露。
void start(void)
{
char buffer[SER_BUFFER_SIZE] = { 0 };
for (;;)
{
// 这里的 client 作 输出型参数, 当客户端发送数据给服务端时, 得到客户端的地址信息
// 这里的 client_addr_len 作 输出(输入)型参数
struct sockaddr_in client;
bzero(static_cast<void*>(&client), sizeof(client));
socklen_t client_addr_len = sizeof(client);
buffer[0] = 0;
// 1. 读取客户端数据 --- recvfrom
// 当服务器收到客户端发送的数据
// 那么是不是服务端还需要将后续的处理结果返回给客户端呢?
// 答案: 是的. 因此除了拿到数据之外, 服务端是不是还需要客户端的地址信息(IP + port)
// 因此, 我们就可以理解为什么 recvfrom 系统调用会要后两个参数了
// struct sockaddr *src_addr 是一个输出型参数, 用来获取客户端的地址信息
// socklen_t *addrlen 是一个输入型参数、 输出型参数 如何理解
// 输入型: 这个缓冲区 src_addr 的初始值大小,做输入型参数
// 输出型: 这个缓冲区 src_addr 的实际值, 填充sockaddr_in的实际大小,做输出型参数
// flags == 0 代表阻塞式的读取数据
ssize_t real_read_size = recvfrom(_sock, buffer, SER_BUFFER_SIZE - 1, 0, \
reinterpret_cast<struct sockaddr*>(&client), &client_addr_len);
if (real_read_size > 0 /* 代表读取成功 */)
{
// 我们将这个数据当作字符串处理
buffer[real_read_size] = 0;
// 1. 获取发送方的地址信息, 即客户端的IP 和 port
// 当我们通过recvfrom 成功读取了数据之后,
// 那么我们可以获取发送方的信息 (对于服务端而言,那么发送方就是客户端,即客户端向服务端发送信息),
// 但是这个数据是客户端通过网络发送过来的,其遵守网络字节序 (大端数据),
// 因此我们需要将其由网络序列 转化为 主机序列
// 而recvfrom 中的clien (struct sockaddr_in), 不就是客户端的地址信息吗?
// 因此我们提取client中的信息即可, 不过此时这个信息是网络字节序的
// 获取客户端的IP地址, 我们用点分十进制的字符串表示
// inet_ntoa 就可以将一个网络字节序的32位整形
// 转换为主机序列的点分十进制字符串式的IP地址
std::string client_ip = inet_ntoa(client.sin_addr);
// 获取客户端的端口号, 需要从网络 -> 本地
uint16_t client_port = ntohs(client.sin_port);
// 2. 可以显示打印一下, 发送方 (在这里就是客户端的地址信息, IP + port),以及数据信息
printf("client[%s][%d]: %s\n", client_ip.c_str(), client_port, buffer);
}
// 2. 向客户端写回数据 --- sendto
// 既然我们要向客户端写回数据
// 那么是不是需要, 客户端的IP、port
// 我们不用过多处理, 因为 recvfrom 已经有了客户端的地址信息
// 而我们就将客户端传过来的数据, 重发给客户端即可
ssize_t real_write_size = sendto(_sock, buffer, strlen(buffer), 0, \
reinterpret_cast<const struct sockaddr*>(&client), client_addr_len);
if (real_write_size < 0)
{
LogMessage(ERROR, "%s\n", "write size < 0");
exit(3);
}
}
}
~udp_server(){
if (_sock != -1)
{
close(_sock);
}
}
private:
// IP地址, 这里之所以用string, 是因为想表示为点分十进制的字符串风格的IP地址
std::string _ip;
// 端口号, 16位整数
uint16_t _port;
// 套接字, socket系统调用的返回值,代表返回一个新的文件描述符
int _sock;
};
}
#endif
3.3. Udp_Client.cc
cpp
#include "Date.hpp"
#include "Log.hpp"
#include <iostream>
#include <string>
#include <cstring>
// 下面的这四个头文件,我们称之为网络四件套
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#define CLIENT_BUFFER 1024
void Usage(void)
{
printf("please usage: ./Client ServerIp ServerPort\n");
}
int main(int arg, char* argv[])
{
if (arg != 3)
{
Usage();
exit(-2);
}
// 客户端创建套接字
// 这里的PF_INET 是 AF_INET的封装
int client_sock = socket(PF_INET, SOCK_DGRAM, 0);
if (client_sock == -1)
{
LogMessage(FATAL, "%s\n", "client create sock failed");
exit(1);
}
// 这里有一个问题, 客户端需不需要bind呢?
// 答案: 肯定是需要的, 但是一般 client 不会显示的bind。换言之,程序员一般不会在客户端 bind。
// client 是一个客户端, 是普通用户下载安装启动使用的, 如果程序员自己bind了,
// 那么是不是就要求客户端一定bind了一个固定的ip和port,
// 那么万一其他的客户端提前占用了这个port呢?那不就会导致bind失败吗?
// 因为一个端口号只能绑定一个进程。
// 因此,客户端一般不需要显式的bind指定port,而是让OS自动bind;
// 可是操作系统是什么时候做的呢?
// 1. 客户端向服务端发送数据
// 因为客户端是向服务器发送数据,因此需要服务器的地址信息 IP + port;
// 即需要服务器的端口和IP,通过命令行参数 (注意是 服务器的IP和port)。
// 注意, 我们这里都是主机数据, 因此要转化为网络字节序。
sockaddr_in server;
memset(&server, 0, sizeof(server));
// 填充sin_family
server.sin_family = AF_INET;
// 填充sin_addr(服务器的IP)
server.sin_addr.s_addr = inet_addr(argv[1]);
// 填充sin_port(服务器的端口)
server.sin_port = htons(atoi(argv[2]));
socklen_t server_len = sizeof(server);
char buffer[CLIENT_BUFFER] = { 0 };
while (true)
{
std::string client_message;
std::cout << "client: " << "请输入信息" << std::endl;
std::getline(std::cin, client_message);
// 如果客户端输入 "quit" , 退出客户端
if (client_message == "quit")
break;
// 当client 首次发送消息给服务器的时候,
// OS会自动给客户端 bind 它的套接字以及IP和port (即绑定客户端的 ip + port);
// 即第一次sendto的时候,操作系统会自动 bind
ssize_t real_client_write = sendto(client_sock, client_message.c_str(), client_message.size(), 0, \
reinterpret_cast<const struct sockaddr*>(&server), server_len);
if (real_client_write < 0)
{
LogMessage(ERROR, "client write size < 0\n");
exit(2);
}
// 2. 读取返回数据 (服务端发送给客户端的数据)
buffer[0] = 0;
// 因为我们的目的是 echo, 服务器发送给客户端的数据,客户端还是原封不动的打印一下。
// 因为 sockaddr_in 是一个输出型参数, 因此调用完后,其实它就是发送方的地址信息
// 以及发送方的这个结构体(缓冲区)的长度 (输入输出型参数)
sockaddr_in server;
bzero(&server, sizeof server);
socklen_t server_addr_len = 0;
ssize_t real_client_read = recvfrom(client_sock, buffer, CLIENT_BUFFER - 1, 0, \
reinterpret_cast<struct sockaddr*>(&server), &server_addr_len);
if (real_client_read > 0)
{
// 当返回值 > 0, 代表着读取成功
// 客户端原封不动的打印一下这个信息
buffer[real_client_read] = 0;
printf("server: %s\n", buffer);
}
}
if (client_sock >= 0)
close(client_sock);
return 0;
}
3.4. 细节总结
1、 AF_INET 是一个宏值,代表着网络套接字。
2、 SOCK_DGRAM, 标定这里是数据报套接字。
3、 socket 创建一个套接字,成功返回一个文件描述符。
4、 bind 主要目的是地址信息 (struct sockaddr_in) 与特定的套接字绑定起来。
5、 主机序列和网络字节序,在网络通信时,要注意数据的字节序。例如,传输到网络的数据,需要使用网络字节序,特别是填充 struct sockaddr_in,和提取struct sockaddr_in 的特定属性时,要格外注意。
6、 服务端一般情况下不用确定的IP bind 服务器的套接字, 一般我们采用任意IP的方案 (INADDR_ANY) 。使用任意IP,可以使服务器能够接受来自任意IP地址的客户端连接,只要端口号一定, 凡是给我这台主机的数据,我都可以收到,因此,服务端一般只需要指明端口,IP采用任意地址方案。
7、 网络服务器永不退出,除非服务器宕机了或者系统挂了。
8、recvfrom 会从发送方 (客户端 / 服务端) 进程获取数据,并且获得发送方的地址信息, 因此这里的地址信息就是输出型参数 ,当成功调用后,此时的地址信息就是发送方的地址信息。 例如: 客户端向服务端发送 (sendto) 数据、服务端接收 (recvfrom) 数据,那么服务端调用完毕后,recvfrom 里面的地址信息 (在使用时要根据情况进行类型转换,如果是网络通信,那么我们需要将 struct sockaddr_in 转为 struct sockaddr) 就是客户端的地址信息。
9、 sendto 会将数据发送给指定地址信息的 (客户端 / 服务端) 进程,因此这里的地址信息我们需要提前确定好。例如: 客户端向服务端发送 (sendto) 数据、服务端接收 (recvfrom) 数据,那么 sendto 中填的就是服务端的地址信息 (在使用时要根据情况进行类型转换) ,这个地址信息必须是在调用之前就确定好的。
10、客户端一般情况下不需要显示的绑定端口和IP,而是由操作系统自动绑定。一般操作系统是在客户端第一次 sendto 或者类似的发送数据函数时,操作系统会自动选择一个可用的本地端口,并分配一个临时的IP地址(通常是本地机器的IP地址)进行绑定。
这种行为是因为客户端通常不需要被直接访问,而是通过服务器来提供服务。因此,操作系统负责为客户端分配临时端口,并在通信结束后释放这些资源。这样的自动绑定机制使得客户端编程更加简洁,无需手动管理端口和IP地址。
需要注意的是,服务器端需要显式地绑定一个固定的端口和IP地址,以便客户端能够连接到服务器。客户端的临时端口在通信结束后会被释放,而服务器端的端口通常是固定的,以便客户端知道在哪里找到服务器。
11、 127.0.0.1这个IP地址我们称之为本地环回 (local host) 。本地环回代表着 client 和 server 传输数据只在本地协议栈中进行数据流动,不会将我们的数据发送到网络中。**这种通信方式我们称之为 "本地通信" 或 "本地回环通信" 。**本地回环通信中,数据在传输到网络层之前就被重新定向回应用层,因此数据只在本地主机上流动,不会传输到网络中去。通过使用本地环回地址,可以确保在本地主机上进行数据传输,而无需经过网络。
12、 如果服务端和客户端 IP 地址一致, 那么则说明, 访问服务器的客户端就在我本机。因为 IP 标识主机的唯一性。而客户端和服务端的端口号不一致,即代表着不同的进程。 服务器进程和客户端进程各自有一个端口号,且客户端的端口号是操作系统自动绑定的端口号。
13、 云服务器无法 bind 公网IP,也不建议。除开 127.0.0.1 或者 0.0.0.0 这种的IP,云服务无法绑定。
未完, 续篇 套接字编程 --- 二 。