目录
前言
在计算机网络中,端口号和 IP 地址是用于网络通信的基本元素,它们在实现网络应用和服务时发挥着至关重要的作用。通过理解 TCP 和 UDP 协议,网络字节序,socket 接口以及端口和 IP 地址的工作方式,我们能够深入掌握网络通信的原理和实现。
通过本篇文章的学习,您将能够对端口号、TCP 和 UDP 协议、socket 编程等概念有更深入的理解,为后续的网络编程打下坚实的基础。
一. 端口号的认识
目前,我们已经接触了网络的基础,在学习中,我们肯定会有或多或少的疑惑
- 在进行网络通信的时候,是不是我们两台机器在进行通信呢?
- 用户使用应用层软件(先把这个软件启动起来也就是变成了进程,比如说刷抖音,逛淘宝),完成数据的发送和接受的
先再思考一个问题,当传输层把数据返回给上层(应用)的时候,是怎么能够准确的发送给对应的应用呢,就好比你正在浏览淘宝,为什么传输层不把数据发送给抖音,而是发给了淘宝呢?这在其中起关键作用的就是端口号了
想象你住在一个大楼里,每个住户都有自己的门牌号。当你寄信时,邮递员需要知道楼房地址和正确的房间号才能把信送到。网络中的IP地址就像楼房的地址,而端口号就像房间号,它帮助数据找到正确的服务。比如,访问网站时,浏览器会通过端口80连接到服务器,这样数据才能正确到达目标服务。
1.1 端口号的作用
端口号的作用是为了让一台设备能够同时处理来自不同应用或服务的数据请求。在计算机中,设备(如服务器)可能运行多个程序或服务,每个服务需要接收不同的数据流。IP地址就像设备的“门牌号”,而端口号则像“房间号”,它告诉数据包应该送到哪个特定的服务或程序。
没有端口号,设备无法区分不同的应用和服务,数据就会混乱,无法正确到达目标应用。
举个例子
服务器可能同时在运行一个网站(使用端口80)和一个邮件服务(使用端口25),端口号帮助区分并确保数据能到达正确的服务。
端口号无论对于服务端还是客户端,都能唯一的标识该主机上的一个网络应用层的进程
端口号是用于区分同一台设备上不同应用程序或服务的数字标识符。在计算机网络中,设备通过IP地址进行识别,但一台设备上可能同时运行多个应用或服务,每个服务需要通过不同的端口号来接收数据。
简单来说,端口号就像是设备的“房间号”,它帮助数据包找到具体的应用程序或服务。每个端口号对应一种特定的协议或服务,例如:
- 80端口:用于HTTP协议,常见于网页浏览。
- 443端口:用于HTTPS协议,安全的网页浏览。
- 25端口:用于SMTP协议,发送电子邮件。
在公网上,IP地址能标识唯一的一台主机。端口号port能标识该主机上的唯一的进程。也就是IP + Port 能够标识全网唯一的一个进程
根据上面谈论的,我们之前学习的有个进程pid 它也是唯一的标识,咱又说网络通信的本质,就是进程间的通信,那么端口号和进程pid有什么联系呢
pid已经能够标识一台主机上进程的唯一性了,为什么还要搞一个端口号呢?
1.并不是所有的进程都要进行网络通信,但是所有的进程都要有pid
(1)首先从技术角度绝对是可以的,但是如果我们把网络和系统都用这个pid,那么一旦系统改了,网络也要跟着改(牵一发而动全身).
所以不如单独设计一套专属于网络的数据来让系统和网络功能解耦(有点像生活中,我们有唯一的身份证,但是在学校要有学号,在公司要有工号) ,也就是说,我们存在的意义相似,但这并不代表我就得和你一样!!
(2)不是所有的进程都需要网络通信,但是所有的进程都要有pid
再深入思考两个问题
一个进程可以绑定多个端口号吗?
一个进程可以绑定多个端口号!例如,一个应用程序可能同时提供多个服务或功能,每个服务都可能绑定到不同的端口上。举个例子,一个服务器程序可能会同时处理HTTP请求(端口80)和HTTPS请求(端口443),这时它会分别监听这两个端口。虽然是同一个进程,但它通过不同的端口号处理不同类型的通信。
一个端口号可以被多个进程绑定吗?
不可以!从哈希表就可以看出来,如果一个端口号绑定了多个进程,那当传输层向应用层传输数据的时候,它到底要往哪个应用发呢,比如说你抖音和淘宝都绑定的一个端口号,那我此时正在浏览抖音短视频呢,它把数据全都发送给了淘宝,这肯定是乱套了的
二. 初识TCP协议和UDP协议
TCP协议(Transmission Control Protocol,传输控制协议)和UDP协议(User Datagram Protocol,用户数据报协议)是两种常见的网络传输协议,它们都属于传输层,用于在网络中传输数据。然而,它们在数据传输的方式、可靠性、速度和适用场景上有显著的不同。
2.1 TCP协议
TCP协议是面向连接的协议,意味着在传输数据之前,通信的双方需要建立一个可靠的连接,确保数据能够可靠地传输到对方。TCP协议提供了数据的顺序控制、错误检测和重传机制,因此它是一种可靠的协议。
TCP的特点
- 可靠性:数据包的传输是可靠的,确保数据正确无误地到达目的地。
- 连接性:传输数据之前需要建立连接(通过三次握手),结束时需要关闭连接(通过四次挥手)。
- 顺序控制:数据按顺序到达,接收方根据序列号将数据按正确的顺序重新组装。
- 流量控制和拥塞控制:TCP协议可以根据网络的状态调整数据的发送速率,避免网络过载。
使用场景
- 适用于需要高可靠性的数据传输,如:文件传输(FTP)、网页浏览(HTTP)、电子邮件(SMTP)、远程登录(SSH)等。
2.2 UDP协议
UDP协议是无连接的协议,在发送数据之前不需要建立连接,也不保证数据的顺序和可靠性。它只是将数据包发送出去,接收方是否成功接收和顺序如何,发送方并不关心。
UDP的特点
- 无连接性:无需建立连接,直接发送数据。
- 不可靠性:不保证数据的顺序,也不保证数据的正确性。如果数据丢失或出错,接收方无法得知。
- 速度快:由于没有建立连接和额外的错误检查,UDP的传输速度较快,适用于实时性要求较高的场景。
- 不进行流量控制和拥塞控制:UDP没有流量控制机制,因此不能像TCP那样保证数据的顺利传输。
使用场景
- 适用于对速度要求高,但对可靠性要求不高的应用,如:实时视频流、在线游戏、VoIP(语音通信)等。
2.3 TCP与UDP的对比
特性 | TCP | UDP |
---|---|---|
连接类型 | 面向连接 | 无连接 |
可靠性 | 提供可靠的数据传输,确保数据无误 | 不保证数据可靠性,数据可能丢失 |
数据顺序 | 保证数据按顺序到达 | 不保证数据顺序 |
传输速度 | 相对较慢(由于需要建立连接和确认) | 较快(没有连接和错误确认机制) |
流量控制 | 有(确保不会发生网络拥塞) | 无 |
适用场景 | 文件传输、网页浏览、邮件传输等 | 实时视频、在线游戏、语音通信等 |
2.4 思考
问题1. 有连接和无连接怎么理解?
连接就好比我们打电话的时候,会先“喂”,其实就是确保连接了之后我们的沟通才是有效的,而无连接就好比我们发送邮件,要么不发要么就发一整块,反正我发了就行,至于你收到没有我并不关心!
问题2. 为什么tcp看起来比udp好这么多,那为啥udp还得存在呢??
计算机中很多词语都是中性的,并无褒贬之意(比如可靠和不可靠) ,就好比我们物理学的惰性气体,惰性金属,只不过是在描述它的特征而已!
可靠是有成本的,而不可靠会更简单。
比如tcp为了可靠所以要能够制定一个策略能够知道数据包是否丢失。然后进行重传,而你在数据包丢出之后在还没确保传输成功之前你必然会把报文信息在传输层一直维护着,否则你拿什么重传呢??又或者需要重传几次呢??万一数据乱序了你是不是还得排序、编序号??
而udp就是一拿到数据包就转手往下扔,他也懒得维护报文信息,因为他压根不关心数据包的发送情况。 所以UDP在设计和维护上会变得非常简单
注意:可靠的前提是网络必须连通!!
2.5 总结
- TCP适用于那些对数据可靠性和顺序有较高要求的场合,虽然它在传输速度上较慢,但能够确保数据完整无误。
- UDP适用于那些要求低延迟、高实时性,但对数据完整性要求不高的应用场景。
三. 网络字节序
3.1 网络字节序的介绍
特性 | 大端模式 (Big Endian) | 小端模式 (Little Endian) |
---|---|---|
存储顺序 | 高位字节在低地址,低位字节在高地址 | 低位字节在低地址,高位字节在高地址 |
示例 | 0x12345678 存储为:12 34 56 78 |
0x12345678 存储为:78 56 34 12 |
- 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
- 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
- 因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
- TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.
- 不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据;
- 如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可;
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换
- 这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
- 例如:htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
- 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回 ;
- 如果主机是大端字节序,这些 函数不做转换,将参数原封不动地返回。
3.2 网络字节序思考
上述介绍了那么多,归根溯源为什么要有网络字节序呢??
在网络还没出现之前,就已经有大端和小端之说了,但是他们谁也说服不了谁,因为都没有一个很成熟的方案能够证明大端好还是小端好,而且当时这个标准即使制定了也没啥收益,所以大家并没有这个动力,因此在市面上大端和小端的机器都是有的!!在以往单机的情况下,其实都不会有很大的影响
但是后来网络产生后,通信双方并不清楚对方是小端还是大端,所以在解析对方的信息的时候就会出现问题(因为大端和小端的解析方法不一样),从而导致发送方和接收方数据不一致的问题。
所以网络说:既然我无法改变你们,那么我就做个规定,我发送报文的时候必须包含当前机器是大端还是小端的信息,这样对方在收到这个数据包之后就可以根据这个字段来采取不同的解析方法。
可是这样也是不行的!!因为大端还是小端决定了解析的方法,所以即使你在报文里提示了当前数据是大端还是小端,我的解析方式如果是错的我也压根提取不到!!!
所以我们网络又说了:既然这样,那我规定不管你机器是大端还是小端的,只要你把这个数据发到网络上,那就必须得是大端的!!所以这就要求小端机器如要想要进行网络通信,就必须得先把自己的数据变成大端的才能往网络里发!!
四. socket接口
4.1 socket 常见API
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
int bind(int socket, const struct sockaddr *address, socklen_t address_len);
// 开始监听socket (TCP, 服务器)
int listen(int socket, int backlog);
int accept(int socket, struct sockaddr* address,
socklen_t* address_len);
int connect(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
4.2 套接字编程的种类
套接字(Socket) 是计算机网络中用于通信的一个抽象概念。它是网络中两个设备之间数据传输的端点,作为应用程序与网络协议之间的接口,提供了应用程序与操作系统之间进行网络通信的手段。
套接字为应用程序提供了一个发送和接收数据的通道,它通过指定通信协议(如 TCP 或 UDP)、IP 地址和端口号来实现不同设备之间的通信。
4.3 sockaddr结构
1.域间套接字编程(同一个机器内) struct sockaddr_un
2.原始套接字编程(编写网络工具)原始套接字一般不关心传输层的东西,他一般是绕过传输层去考虑网络层和链路层,所以他一般被用来封装一些网络工具:比如网络装包、网络诊断……
3.网络套接字编程(用来进行用户间通信)struct sockaddr_in
我们想将网络接口统一抽象化,就必须让参数的类型必须是统一的。所以我们统一使用sockaddr这个类型,然后根据他的前16位来分辨他是哪一种类型的套接字,所以在使用接口的时候要做一个强转
if(address->type == AF_INET) { // 处理 IPv4 地址 } else if(address->type == AF_UNIX) { // 处理 Unix 域套接字地址 }
结合所学的知识,我们就会又有疑问了,Linux接口是据C语言写的。
为什么要用sockaddr这个结构,用void*不好吗?C语言不是经常用他来做任意类型转换吗?然后我们再用short强转一下不就拿到前面的数据了吗??
因为网络接口出来的时候C语言的标准还没有void* 之后再想改也很难改回来了!!
五. UDP的实现
通过上面学习我们知道,UDP是无连接、不可靠的。这就好比你要发送一个包裹给朋友。你使用的是一种简单的邮递服务,不需要确认包裹是否成功送达。你只需要把包裹投递到邮局,然后邮局会尽力将包裹送到朋友家,当然你不会得到一个“送达确认”的反馈。即使包裹丢失了或者在送达途中损坏,你也不会收到任何提醒。
所以,当我们想实现一个UDP就可以模仿这如果实现邮局我们将 UDP 服务器 视为一个 邮局,而 客户端 就是寄件人,邮局接收和处理包裹(数据),并发送回邮寄人响应(回应)。
对于邮局来说,它需要两个步骤:
第一步:先创建打好地基;第二步:也就是开始运行;
5.1 服务端的实现
那么初始化需要什么呢,对于邮局来说,初始化意味着需要创建员工,对于服务端来说,初始化需要
5.1.1 Init-创建服务端
首先要创建套接字,创建 UDP 套接字 就是为这个邮局准备一个“窗口”或“通道”,用于接收和发送包裹(数据)。
//头文件
#include <sys/socket.h>:
#include <sys/types.h>
int socket(int domain, int type, int protocol);
//参数解释:
//domain:指定协议族(Address Family)。
//常见值:
//AF_INET:表示使用 IPv4 地址族。
//AF_INET6:表示使用 IPv6 地址族。
//AF_UNIX:表示使用 Unix 域套接字(本地进程间通信)。
//AF_PACKET:表示在链路层上进行通信,通常用于低层的网络编程。
//type:指定套接字类型(Socket Type)。
//常见值:
//SOCK_STREAM:表示使用 TCP 套接字,提供可靠的、面向连接的通信。
//SOCK_DGRAM:表示使用 UDP 套接字,提供无连接的、不可靠的通信。
//SOCK_RAW:表示使用原始套接字,通常用于捕获或发送原始网络数据包。
//protocol:指定使用的协议。通常可以设置为 0,让操作系统自动选择适当的协议。
//如果选择 SOCK_STREAM,通常使用 IPPROTO_TCP。
//如果选择 SOCK_DGRAM,通常使用 IPPROTO_UDP。
//对于原始套接字,可能会使用 IPPROTO_RAW,用于原始 IP 数据包。
//返回值:
//成功:返回一个非负的整数值(文件描述符)。这个文件描述符可以用来引用套接字。
//失败:返回 -1,并设置 errno 以标识错误类型。通常是因为系统资源不足或参数无效。
代码实例
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_);
这里你就会有新的疑问了,sockaddr是个什么东西呢?struct sockaddr_in local...;是干嘛的呢,就好比是登记信息,比如说小明 男 负责东区,小刚 男 负责西区...
struct sockaddr_in 是一个用于存储 IPv4 地址和端口号的结构体,通常在网络编程中用来指定本地或远程的地址信息。它是 sockaddr 结构的一个扩展,专门处理 IPv4 地址。
给小明分配好了地点之后,然后跟小明说,你在我们这工作了,要打扮的干干净净的。所以也就是在创建好结构体之后,把结构体清空归零
#include <strings.h>
void bzero(void *s, size_t len);
//s:指向需要清零的内存区域的指针。
//len:需要清零的内存区域的大小,以字节为单位。
1. 详谈sockaddr_in结构体
我们进入sockaddr_in定义里面会发现有三个字段 sin_family。sin_port。sin_addr
使用 sockaddr_in 结构体来设置一个本地套接字地址并为其配置相关属性。
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. local.sin_family = AF_INET; 表明这个通用类型是属于网络套接字还是域间套接字
sin_family
是struct sockaddr_in
中的一个字段,用于指定地址族。AF_INET
表示使用 IPv4 地址族。如果是使用 IPv6,则需要将其设置为AF_INET6
。
2. local.sin_port = htons(port_);
sin_port
是struct sockaddr_in
中存储端口号的字段。网络协议要求端口号以 网络字节序(大端字节序)传输。因为端口号必须在两个主机之间流通,所以必须传输到网络中!因此要转成字节序!因此,端口号需要通过htons()
函数转换为网络字节序。
3. local.sin_addr.s_addr = inet_addr(ip_.c_str()); 但是我们用户一般习惯输出的是 点分十进制,所以我们必须把他转化成 4字节类型的整数
问题:如何快速将整数IP和字符串IP相转化??
上述是如何实现相互转换的原理,但是我们的库里面提供了这样的方法!!
aton() ,inet_addr()。用于将一个 IPv4 地址(以字符串形式表示)转换为网络字节序的 32 位无符号整数(
uint32_t
)。它将 IP 地址字符串(例如"192.168.1.1"
)转化为一个 32 位的整数,这个整数表示了该 IP 地址在网络中的大端字节序。
in_addr_t aton(const char *cp);
//cp:指向以空字符结尾的字符串,表示要转换的 IPv4 地址(例如 "192.168.1.1")。
//成功:返回一个 in_addr_t 类型的值,它是一个 32 位的整数,表示转换后的网络字节序 IP 地址。
//失败:返回 0,表示输入的 IP 地址无效。
inet_addr():
//用途:inet_addr() 将点分十进制格式的 IPv4 地址字符串(例如 "192.168.1.1")转换为一个 in_addr_t 类型(即 uint32_t 类型)数值,表示网络字节序的 IPv4 地址。
//返回值:返回一个 in_addr_t 类型(32 位无符号整数)的 IP 地址。
//错误处理:如果 IP 地址无效,inet_addr() 返回 INADDR_NONE(通常是 0xFFFFFFFF),这通常表示一个无效的地址(并非 0.0.0.0),但是它并不会抛出错误或给出详细的错误信息。
local.sin_addr.s_addr = inet_addr(ip_.c_str()); 这里为什么要带s._addr呢
s_addr
: sin_addr
结构体本身并没有直接存放 IP 地址,而是通过 s_addr
成员来存储。s_addr
是一个 uint32_t
类型的变量,它用于存储一个 32 位的 IP 地址。这个成员表示的是 IPv4 地址的具体数值。
2.绑定套接字,就好比邮局招收了一些邮递员并将他们分配到指定区域,比如说小明,性别男,被分配到东区取快递,东区的范围有50公里
//头文件
#include <sys/socket.h>:
#include <sys/types.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
//参数解释:
//sockfd:
//套接字文件描述符,是通过 socket() 系统调用创建的套接字。
//这是一个整数值,它标识了一个由操作系统管理的网络通信端点。
//addr:
//这是一个指向 sockaddr 结构体的指针,它描述了要绑定的本地地址信息(包括 IP 地址和端口)。
//通常,对于 IPv4 地址,会使用 struct sockaddr_in 类型来存储地址信息。
//addrlen:
//这个参数表示 addr 结构体的大小,通常传入 sizeof(struct sockaddr_in)。
//它告诉 bind() 函数地址结构体的大小,以便正确解析结构体中的内容。
//返回值
//成功:返回 0,表示成功将套接字与指定的地址和端口绑定。
//失败:返回 -1,表示绑定失败。如果发生错误,errno 会被设置为相应的错误代码。
实例
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 Init()//创建服务器
{
//1、首先第一步是创建套接字 第一个是套接字的域 第二个是面向数据段 第三个是协议类型
_sockfd=socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd<0)//创建失败
{
lg(Fatal,"socket creat error,sockfd:%d",_sockfd);
exit(SOCKET_ERR);
}
lg(Info,"socket creat success,sockfd:%d",_sockfd);
//2、 绑定套接字 (要先把里面的字段给初始化了)
//先初始化一下字段
struct sockaddr_in local; //创建套接字类型
bzero(&local, sizeof(local)); //将类型都清空 然后我们再填
local.sin_family=AF_INET;//family是用来表明这个类型是网络套接字还是域间套接字
local.sin_port=htons(_port);//端口号必须要先变成网络字节序
local.sin_addr.s_addr=inet_addr(_ip.c_str());// inet_addr () 函数的作用是将点分十进制 的IPv4地址转换成网络字节序列的长整型。
//bind绑定一下
if(bind(_sockfd,(const struct sockaddr *)&local, sizeof(local)) < 0)// socelen_t 类型
{
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));
}
5.1.2 Run-服务器启动
邮局建好了,快递员也招聘登记了,下一步就是开始启动了。那要怎么启动呢,肯定是要先通知大家,我们开业了,然后收到客户发来的邮件和填的快递单,然后发出去。那对于UDP也是一样的,先运行起来,将客户端的套接字信息收回来,拿到客户端的信息,进行加工,处理。然后再把信息发给客户端。
那UDP该通过什么方式收到客户端的信息,又该怎么把它发出去呢
1. 接收客户端信息
recvfrom() 是一个用于接收数据的系统调用,它常用于网络编程,特别是在使用无连接协议(如 UDP)时,也可以用于带连接协议(如 TCP)的套接字接收数据。它不仅用于接收数据,还能够返回发送数据的源地址信息,因此它对于需要知道数据源的应用非常有用。
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
//参数说明:
//sockfd:套接字文件描述符,通过调用 socket() 创建。
//这个套接字描述符用于标识通信通道。它用于指定你想接收数据的套接字。
//buf:指向接收数据的缓冲区的指针。
//数据将存储在这个缓冲区中,接收的数据会填充到这里。
//len:缓冲区的大小。
//这应该是缓冲区 buf 能容纳的最大字节数。如果接收到的数据大于该大小,多余的数据将被丢弃。
//flags:接收数据的标志。
//常用标志有:0(没有标志,普通接收)、MSG_PEEK(查看数据但不移除数据)等。一般情况下,可以传递 0。
//src_addr:指向 sockaddr 结构体的指针,用来存储源地址信息。
//这是一个指向结构体的指针,调用者提供的结构体类型应该根据协议族来定义(如 sockaddr_in 用于 IPv4)。该字段将填充为接收到的数据的源地址。
//addrlen:指向 socklen_t 类型的变量,表示 src_addr 结构的大小。
//在调用前,addrlen 应该包含 src_addr 结构的大小,调用后它会被更新为实际的地址长度。
//返回值:
//成功时:返回接收到的字节数。
//失败时:返回 -1,并将 errno 设置为错误代码。
代码实例
// 1、第一步是要将客户端端的套接字信息(输出型参数)收回来
struct sockaddr_in client; // 定义一个 sockaddr_in 结构体来存储客户端地址信息
socklen_t len = sizeof(client); // 获取 client 结构体的大小(在调用 recvfrom 时需要用到)
// 调用 recvfrom 接收数据
ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&client, &len);
// 检查接收是否出错
if (n < 0) {
// 输出错误信息,包括 errno 错误码和错误描述
lg(Warning, "recvfrom error, errno: %d, err string: %s", errno, strerror(errno));
continue; // 错误发生时,跳过当前循环,继续接收数据
}
这里也可以用回调func的方法处理信息。这样可以的好处可以对代码进行分层
2. 发送信息到客户端
sendto()
是一个用于发送数据的系统调用,通常用于无连接的套接字(如 UDP)或连接型套接字(如 TCP)。在使用 sendto()
时,可以指定目标地址,用于发送数据到特定的客户端或服务器。它通常在使用 UDP(无连接协议)时使用,因为在 UDP 中,数据发送不需要事先建立连接。
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
//参数说明:
//sockfd:套接字文件描述符,通常通过调用 socket() 函数创建。
//用于标识用于发送数据的套接字。
//buf:指向要发送的数据的指针。
//这是一个指向缓冲区的指针,缓冲区中包含将被发送的数据。
//len:数据的长度,以字节为单位。
//这是要发送的数据的字节数,必须小于等于 buf 指向缓冲区的大小。
//flags:发送标志。
//通常设置为 0,表示没有任何特殊标志。可以使用 MSG_DONTWAIT 等标志来调整发送行为。
//dest_addr:指向目标地址的指针。
//这是一个指向 sockaddr 结构的指针,包含接收方的地址信息。对于 IPv4,通常使用 sockaddr_in 结构。
//addrlen:目标地址的大小(字节数)。
//通常设置为 sizeof(struct sockaddr_in),用于指定目标地址结构的大小。
//返回值:
//成功时:返回实际发送的字节数。
//失败时:返回 -1,并设置 errno 来指示错误原因。
代码实例
// 2、我们将接收到的消息当作字符串处理一下,然后返回给客户端
inbuffer[n] = 0; // 确保接收到的消息以 \0 结尾,变成有效的 C 字符串
// 创建一个初始的字符串,表示服务器接收到了一条消息
string res = "Server get a message: ";
// 将接收到的消息 (inbuffer) 转换成 string 类型
string info = inbuffer;
// 将接收到的消息追加到 res 字符串后面
res += info;
// 使用 sendto 函数将处理后的消息返回给客户端
sendto(_sockfd, res.c_str(), res.size(), 0, (const sockaddr*)&client, len);
整体代码:
void Run(func_t func) // Run 函数接受一个函数指针 func,这个函数会处理接收到的消息并返回一个结果
{
isrunning_ = true; // 标记运行状态为 true,表示服务器正在运行
char inbuffer[size]; // 定义一个接收数据的缓冲区 inbuffer,大小为 size
while (isrunning_) // 当 isrunning_ 为 true 时持续运行
{
struct sockaddr_in client; // 定义一个 sockaddr_in 结构体来存储客户端地址信息
socklen_t len = sizeof(client); // 获取客户端地址结构体的大小,以便传递给 recvfrom
// 调用 recvfrom 接收来自客户端的数据
ssize_t n = recvfrom(sockfd_, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&client, &len);
if (n < 0) // 如果接收失败,n 会小于 0
{
// 记录接收错误,并打印错误码和错误信息
lg(Warning, "recvfrom error, errno: %d, err string: %s", errno, strerror(errno));
continue; // 继续下次循环,不做其他处理
}
inbuffer[n] = 0; // 确保接收到的数据是以 '\0' 结尾的 C 字符串
std::string info = inbuffer; // 将接收到的缓冲区数据转换为 std::string 类型,方便处理
std::string echo_string = func(info); // 调用传入的函数 func 对消息进行处理,并获取返回结果
// 调用 sendto 发送处理后的数据 back 给客户端
sendto(sockfd_, echo_string.c_str(), echo_string.size(), 0, (const sockaddr*)&client, len);
}
}
5.2 客户端的实现
5.2.1 先去装快递盒
还是拿上面那个邮递的故事来讲,对于客户来说,也是需要一个盒子去装你的物品的。对于UDP来说就是也需要套接字
代码实例:
// 第一步 创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0); // 创建一个 UDP 套接字
if (sockfd < 0) { // 检查套接字是否创建失败
cout << "socket error" << endl; // 输出错误信息
return 1; // 如果创建失败,返回 1,结束程序
}
服务端进行套接字绑定是为了匹配信息,客户端需要绑定吗?
客户端也需要自己的IP和端口,要不然服务器怎么找到你呢,就好比你不仅需要用盒子去包装你的物品,你还需要在盒子上贴上快递信息,以便为了邮局能找到你核对信息
但是不需要用户显示的bind,一般是由OS自由随机选择!(因为我们多个app的客户端都会在同一个手机上,如果需要自己绑定ip地址的话,那么还需要各大开发商互相协商,因此我们都同意由OS来给我们随机分配)
系统什么时候给我bind呢?
首次发送数据的时候,即当sendto的时候,就绑定了。
一个端口号只能被一个进程bind,对server是如此,对于client,也是如此!
其实client的port是多少,其实不重要,只要能保证主机上的唯一性就可以!为什么要求我们的服务器的端口要由用户来绑定呢,对于服务器来讲,端口号是几,必须是确定的,用户要关心,将来用户是要链接访问我们的服务器的,所以必须要知道服务器的IP地址和端口号,如果你让OS去绑定,那就可能今天一变,明天一变。服务器昨天还好着,今天就不能访问了
5.2.2 将快递发送到邮局
当你包装好快递盒,填写了快递单,那么接下来你要干什么呢?当然是要把这些东西,送到具体邮局手上,比如说你要发顺丰你就寄顺丰,要发韵达,就韵达。你不送给他们咋知道你要干嘛呢,对于UDP来说,步骤就是用户输入数据,然后将数据和套接字类型发给服务端
代码实例:
// 服务器套接字类型
struct sockaddr_in server; // 输出型参数
bzero(&server, sizeof(server)); // 清空 server 结构体中的内容
server.sin_family = AF_INET; // 设置协议族为 AF_INET (IPv4)
server.sin_port = htons(serverport); // 设置端口号,使用 htons 转换为网络字节序
server.sin_addr.s_addr = inet_addr(serverip.c_str()); // 设置 IP 地址,使用 inet_addr 转换为网络字节序
socklen_t len = sizeof(server); // 获取 server 结构体的大小,用于后续传递给 sendto 或 bind 等函数
cout << "please enter@"; // 提示用户输入
getline(cin, message); // 从标准输入获取一行用户输入的消息并存储到 message 变量中
// 1. 数据 2. 给谁发 目标机
sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&server, len);
5.2.3. 查收快递发出信息
当你把快递给了邮局之后,你肯定想知道你的快递到哪里,在路上了没。也就是要接收邮局给你发来的消息。对于UDP来说,就是客户端接收服务端发来的消息
代码实例:
struct sockaddr_in temp; // 定义一个 sockaddr_in 结构体,用于存储客户端的地址信息
socklen_t len = sizeof(temp); // 获取 temp 结构体的大小,用于传递给 recvfrom 函数
// 调用 recvfrom 接收数据,并将发送方的地址信息存储在 temp 中
ssize_t s = recvfrom(sockfd, buffer, 1023, 0, (struct sockaddr*)&temp, &len);
// 检查接收的字节数,如果大于 0,则表示成功接收到数据
if (s > 0) {
buffer[s] = 0; // 确保接收到的数据是一个以 '\0' 结尾的有效 C 字符串
cout << buffer << endl; // 输出接收到的消息
}
5.3 主函数
将外部的方法传进去
#include"UdpServer.hpp"
#include <memory>
void Usage(std::string proc)
{
std::cout << "\n\rUsage: " << proc << " port[1024+]\n" << std::endl;
}
string Handler(const string &str)
{
std::string res = "Server get a message: ";
res += str;
std::cout << res << std::endl;
return res;
}
int main(int argc,char*argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(0);
}
uint16_t port = std::stoi(argv[1]);
unique_ptr<UdpServer> svr(new UdpServer(port));
svr->Init();
svr->Run(Handler);
return 0;
}
六. 关于port和ip
对于port(端口号)来说:[0-1023],一般是系统内定的端口号,有固定的应用协议使用。普通用户一般绑定1024以上的端口
对于ip来说:禁止直接bind公网ip (在虚拟机上可以)(因为服务端的机器可能会有多个网 卡、多个ip,所以我们不能只绑定一个!!)
查看所有活动连接:
netstat
:显示所有当前的网络连接和侦听的端口。
netstat -nulp
//-n:以数字格式显示地址和端口号(避免 DNS 查询)。这意味着 IP 地址和端口号将以数字形式显示,而不是解析为主机名和服务名。
//-l:显示正在监听的套接字(仅显示正在监听的端口)。
//-u:显示 UDP 连接。如果不加 -u,netstat 会默认显示 TCP 连接。
//-p:显示每个连接或套接字对应的进程 ID(PID)和进程名称。
七. 总结
通过本文的学习,我们深入探讨了网络通信中的一些核心概念和技术,包括端口号、TCP 和 UDP 协议、网络字节序、以及 socket 编程接口等内容。了解这些基础概念不仅帮助我们掌握了如何实现网络通信,还为我们进一步深入学习更复杂的网络应用和服务打下了坚实的基础。
无论是实现一个简单的 UDP 服务端,还是理解如何在不同网络环境下进行数据传输,掌握 TCP 和 UDP 的特性及其适用场景,都是网络编程中不可或缺的一部分。在实际应用中,正确选择协议并理解网络字节序,能够大大提高程序的可靠性和效率。
随着网络技术的不断发展,网络通信的需求也在不断变化。希望通过这篇文章,您能够对网络编程有一个清晰的认识,并能够在实际项目中运用所学知识,解决更多复杂的网络问题。网络编程是计算机科学中的一项重要技能,掌握它将为您开辟更多的技术领域和应用场景,帮助您更好地应对未来的挑战。