一 学习准备
1.1 IP地址
在 前文中我们提到过: IP 是全球网络的基础,使用 IP
地址来标识公网环境下主机的唯一性,我们可以根据 目的IP地址 进行跨路由器的远端通信。
但是我们也提到了,通过 IP 地址,只能寻找到目标主机,难道我们的网络通信,是主机与主机之间互相通信吗?显然不是这样的。
我们要让主机接收到任务,并且还需要解包执行任务,主机中什么事物能做这一步呢?
答案是,进程。
目标主机中存在很多进程,网络通信实际是不同主机中的进程在进行通信,并非主机与主机直接通信。
在通过 IP 地址定位到 目标主机后,我们通过端口号,定位到需要进行通信的进程。
1.2 端口号
端口号 是一个用于标识网络进程唯一性的标识符,是一个 2
字节的整数,取值范围为 [0, 65535]
,可以通过 端口号 定位主机中的目标进程。
大家是不是感觉很熟悉,将信息从主机 A
中的进程 A
发送至主机 B
中的 进程 B
,这不就是 进程间通信 吗?之前学习的 进程间通信 是通过 匿名管道、命名管道、共享内存 等方式实现,而如今的 进程间通信 则是通过 网络传输 的方式实现。
需要进行网络通信的进程有很多,为了方便进行管理,就诞生了 端口号 这个概念,同进程的 PID
一样,端口号 也可以用于标识进程。
服务器中的防火墙其实就是端口号限制,只有开放的端口号,才允许进程用于 网络通信。
1.3 端口号与进程PID
端口号 用于标识进程,进程 PID
也是用于标识进程,为什么在网络中,不直接使用进程 PID
呢?
- 进程
PID
隶属于操作系统中的进程管理,如果在网络中使用PID
,会导致网络标准中被迫中引入进程管理相关概念(进程管理与网络强耦合)。 - 进程管理 属于
OS
内部中的功能,OS
可以有很多标准,但网络标准只能有一套,在网络中直接使用PID
无法确保网络标准的统一性。 - 并不是所有的进程都需要进行网络通信(如单机主机),如果端口号、
PID
都使用同一个解决方案,无疑会影响网络管理的效率
所以综上所述,网络中的 端口号 需要通过一种全新的方式实现,也就是一个 2
字节的整数 port
,进程 A
运行后,可以给它绑定 端口号 N
,在进行网络通信时,根据 端口号 N
来确定信息是交给进程 A
的。
所以将之前的结论再具体一点:IP + Port 可以标识公网环境下,唯一的网络进程。
- 目的
IP
:需要把信息发送到哪一台主机 - 源
IP
:信息从哪台主机中发出 - 目的
Port
:将信息交给哪一个进程 - 源
Port
:信息从哪一个进程中发出
注意: 端口号与进程 PID
并不是同一个概念
进程
PID
就好比你的身份证号,端口号 相当于学号,这两个信息都可以标识唯一的你,但对于学校来说,使用学号更方便进行管理。
一个进程可以绑定多个 端口号 吗?一个 端口号 可以被多个进程绑定吗?
端口号 的作用是配合 IP
地址标识网络世界中进程的唯一性,如果一个进程绑定多个 端口号,依然可以保证唯一性(因为无论使用哪个 端口号,信息始终只会交给一个进程);但如果一个 端口号 被多个进程绑定了,在信息递达时,是无法分辨该信息的最终目的进程的,存在二义性
所以一个进程可以绑定多个端口号,一个 端口号 不允许被多个进程绑定,如果被绑定了,可以通过 端口号 顺藤摸瓜,找到占用该 端口号 的进程
如果某个端口号被使用了,其他进程再继续绑定是会报错的,提示 该端口已被占用。
主机(操作系统)是如何根据 端口号 定位具体进程的?
这个实现起来比较简单,创建一张哈希表,维护 <端口号, 进程 PID
> 之间的映射关系,当信息通过网络传输到目标主机时,操作系统可以根据其中的 [目的 Port
],直接定位到具体的进程 PID
,然后进行通信。
1.4 传输层协议
关于网络的层状结构,我们初级程序员应该主要关注传输层和应用层,因为其它层次太过底层,我们接触不了,更改不了,但是却可以在应用层和传输层借用系统的接口,从而编写程序。
主流的传输层协议有两个:TCP
和 UDP
两个协议各有优缺点,可以采用不同的协议,实现截然不同的网络程序,关于 TCP
和 UDP
的详细信息将会放到后面的博客中详谈,先来看看简单这两种协议的特点。
TCP
协议:传输控制协议
- 传输层协议
- 有连接
- 可靠传输
- 面向字节流
字节流就像水龙头,用户可以根据自己的需求获取水流量
UDP
协议:用户数据协议
- 传输层协议
- 无连接
- 不可靠传输
- 面向数据报
数据报则是相当于包裹,用户每次获取的都是一个或多个完整的包裹
关于 可靠性 :
T
CP
的可靠传输并不意味着它可以将数据百分百递达,而是说它在数据传输过程中,如果发生了传输失败的情况,它会通过自己独特的机制,重新发送数据,确保对端百分百能收到数据。至于
UDP
就不一样,数据发出后,如果失败了,也不会进行重传,好在UDP
面向数据报,并且没有很多复杂的机制,所以传输速度很快。
总结起来就是:TCP
用于对数据传输要求较高的领域,比如金融交易、网页请求、文件传输等,至于 UDP
可以用于短视频、直播、即时通讯等对传输速度要求较高的领域
如果不知道该使用哪种协议,优先考虑
TCP
,如果对传输速度又要求,可以选择UDP。
(你无敌了,孩子)
1.5.网络字节序
在学习网络字节序相关知识前,先回顾一下大小端字节序
预备知识
- 数据拥有高权值位和低权值位,比如在
32
位操作系统中,十六进制数0x11223344
,其中的11
称为 最高权值位,44
称为 最低权值位- 内存有高地址和低地址之分
如果将数据的高权值存放在内存的低地址处,低权值存放在高地址处,此时就称为 大端字节序.
反之则称为 小端字节序,这两种字节序没有好坏之分,只是系统设计者的使用习惯问题,比如VS2022在存储数据时,采用的就是 小端字节序 方案.
在网络出现之前,使用大端或小端存储都没有问题,网络出现之后,就需要考虑使用同一种存储方案了,因为网络通信时,两台主机存储方案可能不同,会出现无法解读对方数据的问题。
如果你是网络标准的设计者,你会如何解决?
解决方案1:数据发送前,给报文中添加大小端的标记字段,待数据递达后,对端在根据标志位进行解读,再进行转换。
这个方案实现起来不太方便,并且给每一个报文都添加标记字段这个行为比较浪费。
解决方案2:书同文,车同轨,直接统一标准。 这种解决方案就很彻底了,直接从根源上解决问题,也更方便。
顶层设计者采用了解决方案2,TCP/IP
协议规定:网络中传输的数据,统一采用大端存储方案,也就是网络字节序, 现在大端/小端称为 主机字节序
发送数据时,将 主机字节序 转化为 网络字节序,接收到数据后,再转回 主机字节序 就好了,完美解决不同机器中的大小端差异,同时,每个主机根据自己的大小端存储策略,都应该有对应的转换函数,
在发送/接收时,调用库函数进行转换即可。
#include <arpa/inet.h>
// 主机字节序转网络字节序
uint32_t htonl(uint32_t hostlong); // l 表示32位长整数
uint32_t htons(uint32_t hostshort); // s 表示16位短整数
// 网络字节序转主机字节序
uint32_t ntohl(uint32_t netlong); // l 表示32位长整数
uint32_t ntohs(uint32_t netshort); // s 表示16位短整数
二 网络的基本通信 -- socket套接字
2.1 socket常见API
socket
套接字提供了下面这一批常用接口,用于实现网络通信。
#include <sys/types.h>
#include <sys/socket.h>
// 创建socket文件描述符(TCP/UDP 服务器/客户端)
int socket(int domain, int type, int protocol);
// 绑定端口号(TCP/UDP 服务器)
int bind(int socket, const struct sockaddr* address, socklen_t address_len);
// 开始监听socket (TCP 服务器)
int listen(int socket, int backlog);
// 接收连接请求 (TCP 服务器)
int accept(int socket, struct sockaddr* address, socklen_t* address_len);
// 建立连接 (TCP 客户端)
int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
等等,上面既然出现了文件描述符这几个字,那么,网络在linux中到底是什么呢?
网络在linux中其实就是个文件,网络通信其实就是服用了文件描述符的解决方案,但肯定有其独特的接口,我们继续讲解。
可以看到在这一批 API
中,频繁出现了一个结构体类型 sockaddr
,该结构体支持网络通信,也支持本地通信
socket
套接字就是用于描述sockaddr
结构体的字段,复用了文件描述符的解决方案
2.2.sockaddr 结构体
socket
这套网络通信标准隶属于 POSIX
通信标准,该标准的设计初衷就是为了实现 可移植性,程序可以直接在使用该标准的不同机器中运行,但有的机器使用的是网络通信,有的则是使用本地通信,socket
套接字为了能同时兼顾这两种通信方式,提供了 sockaddr
结构体。
由 sockaddr
结构体衍生出了两个不同的结构体:sockaddr_in
网络套接字、sockaddr_un
域间套接字,前者用于网络通信,后者用于本地通信。
- 可以根据
16
位地址类型,判断是网络通信,还是本地通信 - 在进行网络通信时,需要提供
IP
地址、端口号 等网络通信必备项,本地通信只需要提供一个路径名,通过文件读写的方式进行通信(类似于命名管道)
socket
提供的接口参数为 sockaddr*
,我们既可以传入 &sockaddr_in
进行网络通信,也可以传入 &sockaddr_un
进行本地通信,传参时将参数进行强制类型转换即可,这是使用 C语言 实现 多态 的典型做法,确保该标准的通用性。
为什么不将参数设置为
void*
? 因为在该标准设计时,C语言还不支持void*
这种类型,为了确保向前兼容性,即便后续支持后也不能进行修改了 。
UDP网络程序
接下来实现一批基于 UDP
协议的网络程序
三 字符串回响
3.1 核心功能
分别实现 客户端与服务端.
客户端向服务端发送消息
服务端收到消息后 回响(向客户端发出)给客户端,有点类似于 echo
指令。
该程序的核心在于 使用 socket
套接字接口,以 UDP
协议的方式实现简单网络通信。
3.2 程序结构
注意,这里我们的客户端和服务端,分别是两个不同的进程,以此来模拟真正的网络通信。
因此,我们的程序应该最少分 两个源文件组成,但是这里我们为了更加深入的写入 ,程序由 server.hpp
、server.cc两个文件组成服务端,
client.hpp
、client.cc
组成客户端。
3.3 基本框架
3.3.1 server.hpp 文件框架
创建 server.hpp
服务端头文件
在这里,我们只需写一个初始化和启动文件,因为服务端是几乎永不关闭的,
- 服务端的初始化是必须的。
- 在服务器启动完毕之后,应该一直循环执行任务,我们把循环执行的任务放在启动函数中,就可以模拟服务器,一直接收文件了。
因此,只需要两个额外函数,文件的框架就基本上完善了
#pragma once
#include<iostream>
namespace My_server{
class server
{
private:
/* data */
public:
//构造函数
server() {
}
//析构函数
~server(){
}
//初始化服务器
void InitServer(){
}
//启动服务器
void StartServer(){
}
};
}
3.3.2 server.cc 文件框架
根据上文讲到的 server.hpp 文件,我们知道,程序只需要做两件事
- 初始化服务端
- 启动服务端
#include<memory>
#include"server.hpp"
using namespace My_server;
int main()
{
std::unique_ptr<server> msvr(new server());
//初始化服务器
msvr->InitServer();
//启动服务器
msvr->StartServer();
return 0;
}
3.3.3 client.hpp 文件框架
在客户端程序中,我们本来应该模拟一个通话进程,这里我建议和服务端保持一致,让客户端一直打开,然后一直进行通信。
因为这样简单,方便,也不脱离我们项目的核心功能。
#pragma once
#include<iostream>
namespace My_client{
class client{
private:
/* data */
public:
//构造函数
client(){
}
//析构函数
~client(){
}
// 初始化客户端
void InitClient() {
}
// 启动客户端
void StartClient() {
}
};
}
3.3.4 client.cc 文件框架
这个就没啥好说的了吧,速度写完,我要玩太刀。
3.3.5 Makefile文件
直接上代码,别问,问就是赶时间。
.PHONY:all
all:server client
server:server.cc
g++ -o $@ $^ -std=c++14
client:client.cc
g++ -o $@ $^ -std=c++14
.PHONY:clean
clean:
rm -rf server client
3.4 服务端
3.4.1 创建套接字(打开文件描述符)
创建套接字使用 socket
函数
#include <sys/types.h>
#include <sys/socket.h>
// 创建套接字(TCP/UDP 服务器/客户端)
int socket(int domain, int type, int protocol);
参数解读
domain
创建套接字用于哪种通信(网络/本地)type
选择数据传输类型(流式/数据报)protocol
选择协议类型(支持根据参数2自动推导)
返回值:创建成功后,返回套接字(文件描述符,int类型),失败返回 -1
因为这里是使用 UDP
协议实现的 网络通信,参数1 domain
选择 AF_INET
(基于 IPv4
标准),参数2 type
选择 SOCK_DGRAM
(数据报传输),参数3设置为 0
,可以根据 SOCK_DGRAM
自动推导出使用 UDP
协议.
第一个参数 可选
AF_INET6
基于IPv6
标准
接下来在 server.hpp
的 InitServer()
函数中创建套接字,并对创建成功/失败后的结果做打印 。
#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <cstring>
#include <cerrno>
#include <cstdlib>
namespace My_server{
// 自己规定错误码
enum
{
SOCKET_ERR = 1
};
class server
{
private:
/* data */
int _sock; // 套接字
public:
//构造函数
server() {
}
//析构函数
~server(){
}
//初始化服务器
void InitServer(){
//1 创建套接字
_sock =socket(AF_INET,SOCK_DGRAM,0);
if(_sock==-1){
std::cout << "Create Socket Fail: " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
// 创建成功
std::cout << "Create Success Socket: " << _sock<< std::endl;
}
//启动服务器
void StartServer(){
}
};
}
文件描述符默认 0、1、2
都已经被占用了,如果再创建文件描述符,会从 3
开始,可以看到,程序运行后,创建的套接字正是 3
,证明套接字本质上就是文件描述符,不过它用于描述网络资源
3.4.2 绑定IP地址和端口号
注意: 我这里的服务器是云服务器,绑定 IP
地址这个操作后面需要修改。
使用 bind
函数进行绑定操作。
#include <sys/types.h>
#include <sys/socket.h>
// 绑定IP地址和端口号(TCP/UDP 服务器)
int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
参数解读
sockfd
创建成功的套接字addr
包含通信信息的sockaddr
结构体地址addrlen
结构体的大小
返回值:成功返回 0
,失败返回 -1。
参数1没啥好说的,重点在于参数2,因为我们这里是 网络通信,所以使用的是 sockaddr_in
结构体,要想使用该结构体,还得包含下面这两个头文件。
#include <netinet/in.h>
#include <arpa/inet.h>
sockaddr_in
结构体的构成如下:
/* Structure describing an Internet socket address. */
struct sockaddr_in
{
__SOCKADDR_COMMON (sin_);
in_port_t sin_port; /* Port number. */
struct in_addr sin_addr; /* Internet address. */
/* Pad to size of `struct sockaddr'. */
unsigned char sin_zero[sizeof (struct sockaddr) -
__SOCKADDR_COMMON_SIZE -
sizeof (in_port_t) -
sizeof (struct in_addr)];
};
首先来看看 16
位地址类型,转到定义可以发现它是一个宏函数,并且使用了 C语言 中一个非常少用的语法 ##
(将两个字符串拼接)。
/* POSIX.1g specifies this type name for the `sa_family' member. */
typedef unsigned short int sa_family_t;
/* This macro is used to declare the initial common members
of the data types used for socket addresses, `struct sockaddr',
`struct sockaddr_in', `struct sockaddr_un', etc. */
#define __SOCKADDR_COMMON(sa_prefix) \
sa_family_t sa_prefix##family
当给 __SOCKADDR_COMMON
传入 sin_
参数后,经过 ##
字符串拼接、宏替换等操作后,会得到这样一个类型.
sa_family_t sin_family;
sa_family_t
是一个无符号短整数,占 16
位,sin_family
字段就是 16
位地址类型 了.
接下来看看 端口号,转到定义,发现 in_port_t
类型是一个 16
位无符号整数,同样占 2
字节,正好符合 端口号 的取值范围 [0, 65535]。
/* Type to represent a port. */
typedef uint16_t in_port_t;
最后再来看看 IP
地址,同样转到定义,发现 in_addr
中包含了一个 32
位无符号整数,占 4
字节,也就是 IP
地址 的大小。
/* Internet address. */
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};
了解完 sockaddr_in
结构体中的内容后,就可以创建该结构体了,再定义该结构体后,需要清空,确保其中的字段干净可用。
将变量置为
0
可用使用bzero
函数(将变量中所有的属性清空)
#include <cstrins> // bzero 函数的头文件
struct sockaddr_in local;
bzero(&local, sizeof(local));
获得一个干净可用的 sockaddr_in
结构体后,可以正式绑定 IP
地址 和 端口号 了。
注:作为服务器,需要确定自己的端口号,我这里设置的是 8888。
注意:
- 需要把主机序列转换为网络序列,可以使用
htons
函数。- 需要把点分十进制的字符串,转换为无符号短整数,可以使用
inet_addr
函数,这个函数在进行转换的同时,会将主机序列转换为网络序列- 绑定IP地址和端口号这个行为并非直接绑定到当前主机中,而是在当前程序中,将创建的
socket
套接字,与目标IP地址与端口号进行绑定,当程序终止后,这个绑定关系也会随之消失
#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <netinet/in.h>
#include <arpa/inet.h>
namespace My_server{
// 自己规定错误码
enum
{
SOCKET_ERR = 1,
BIND_ERR
};
// 端口号默认值
const uint16_t default_port = 8888;
class server
{
private:
/* data */
int _sock; // 套接字
uint16_t _port; // 端口号
std::string _ip; // IP地址(后面需要删除)
public:
//构造函数
server(const std::string ip, const uint16_t port = default_port)
:_port(port)
,_ip(ip)
{
}
//析构函数
~server(){
}
//初始化服务器
void InitServer(){
//1 创建套接字
_sock =socket(AF_INET,SOCK_DGRAM,0);
if(_sock==-1){
std::cout << "Create Socket Fail: " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
// 创建成功
std::cout << "Create Success Socket: " << _sock<< std::endl;
//2 .绑定IP地址和端口号
struct sockaddr_in local;
bzero(&local, sizeof(local)); // 置0
// 填充字段
local.sin_family = AF_INET; // 设置为网络通信(PF_INET 也行)
local.sin_port = htons(_port); // 主机序列转为网络序列
//inet_addr 能将 点分十进制的字符串 转换为 短整数 再转换为网络序列
local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 点分十进制转为短整数,再将主机序列转为网络序列
// 绑定IP地址和端口号
if(bind(_sock, (const sockaddr*)&local, sizeof(local))){
std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
exit(BIND_ERR);
}
// 绑定成功
std::cout << "Bind IP&&Port Success" << std::endl;
}
//启动服务器
void StartServer(){
}
};
}
3.4.3 server.cc 文件的更改
#include<memory>
#include"server.hpp"
using namespace My_server;
int main()
{
std::unique_ptr<server> msvr(new server("1.111.323.455"));
//初始化服务器
msvr->InitServer();
//启动服务器
msvr->StartServer();
return 0;
}
接下来编译并运行程序,可以发现绑定失败了,这是因为当前我使用的是云服务器,云服务器是不允许直接绑定公网 IP
的,解决方案是在绑定 IP
地址时,让其选择绑定任意可用 IP
地址
修改代码
- 云服务器中不需要明确
IP
地址- 构造时也无需传入
IP
地址- 绑定
IP
地址时选择INADDR_ANY
,表示绑定任何可用的IP
地址
更改后的 server.hpp 文件和 server.cc 文件
#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <netinet/in.h>
#include <arpa/inet.h>
namespace My_server{
// 自己规定错误码
enum
{
SOCKET_ERR = 1,
BIND_ERR
};
// 端口号默认值
const uint16_t default_port = 8888;
class server
{
private:
/* data */
int _sock; // 套接字
uint16_t _port; // 端口号
//std::string _ip; // IP地址(后面需要删除)
public:
//构造函数
server( const uint16_t port = default_port)
:_port(port)
{
}
//析构函数
~server(){
}
//初始化服务器
void InitServer(){
//1 创建套接字
_sock =socket(AF_INET,SOCK_DGRAM,0);
if(_sock==-1){
std::cout << "Create Socket Fail: " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
// 创建成功
std::cout << "Create Success Socket: " << _sock<< std::endl;
//2 .绑定IP地址和端口号
struct sockaddr_in local;
bzero(&local, sizeof(local)); // 置0
// 填充字段
local.sin_family = AF_INET; // 设置为网络通信(PF_INET 也行)
local.sin_port = htons(_port); // 主机序列转为网络序列
//inet_addr 能将 点分十进制的字符串 转换为 短整数 再转换为网络序列
//local.sin_addr.s_addr = inet_addr(_ip.c_str());
local.sin_addr.s_addr = INADDR_ANY; // 绑定任何可用IP地址
// 绑定IP地址和端口号
if(bind(_sock, (const sockaddr*)&local, sizeof(local))){
std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
exit(BIND_ERR);
}
// 绑定成功
std::cout << "Bind IP&&Port Success" << std::endl;
}
//启动服务器
void StartServer(){
}
};
}
#include<memory>
#include"server.hpp"
using namespace My_server;
int main()
{
std::unique_ptr<server> msvr(new server());
//初始化服务器
msvr->InitServer();
//启动服务器
msvr->StartServer();
return 0;
}
服务器设置的端口,需要设置为开放状态,如果是本地服务器,可以使用 systemctl start firewalld.service
指令开启防火墙,再使用 firewall-cmd --zone=public --add-port=Port/tcp --permanent
开启指定的端口号 如果是云服务器,就需要通过 控制台,开放对应的端口
3.4.4 启动服务器
当前编写的 回响服务器 需要服务器拥有读取信息,然后回响给客户端的能力
读取信息使用 recvfrom
函数
#include <sys/types.h>
#include <sys/socket.h>
// 读取信息(TCP/UDP 服务器/客户端)
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
这个函数参数比较多,首先来看看前半部分
sockfd
使用哪个套接字进行读取buf
读取数据存放缓冲区len
缓冲区的大小flags
读取方式(阻塞/非阻塞)
前半部分主要用于读取数据,并进行存放,接下来看看后半部分
src_addr
输入输出型参数,对端(这里指客户端)主机的sockaddr
结构体,包含了对端的IP
地址 和 端口号.addrlen
输入输出型参数,对端主机的sockaddr
结构体大小.
这个输入输出型参数就类似于送礼时留下自己的信息,待对方还礼时可以知道还给谁,接收信息也是如此,当服务器获取客户端的
sockaddr
结构体信息后,同样可以给客户端发送信息,双方就可以愉快的进行通信了.
返回值:成功返回实际读取的字节数,失败返回 -1
接收消息步骤:
- 创建缓冲区、对端
sockaddr_in
结构体- 接收信息,判断是否接收成功
- 处理信息
所以接下来编写接收消息的逻辑
注意: 因为 recvfrom
函数的参数 src_addr
类型为 sockaddr
,需要将 sockaddr_in
类型强转后,再进行传递
StartServer()
函数 — 位于server.hpp
服务器源文件中的 server
类
//启动服务器
void StartServer(){
//服务器是永不停息的,所以需要使用一个 while 死循环
char buff[1024]; //缓冲区
while(true){
//1 接收信息
struct sockaddr_in peer; // 客户端结构体
socklen_t len = sizeof(peer); // 客户端结构体大小
// 传入 sizeof(buff) - 1 表示当前传输的是字符串,预留一个位置存储 '\0'
// 传入 0 表示当前是阻塞式读取
ssize_t n = recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)&peer, &len);
if(n > 0){
buff[n] = '\0';
}
else{
continue; // 继续读取
}
// 2.处理数据
std::string clientIp = inet_ntoa(peer.sin_addr); // 获取IP地址
uint16_t clientPort = ntohs(peer.sin_port); // 获取端口号
printf("Server get message from [%c:%d]$ %s\n",clientIp.c_str(), clientPort, buff);
// 3.回响给客户端
// ...
}
}
3.4.5 发送信息
发送信息使用 sendto
函数。
#include <sys/types.h>
#include <sys/socket.h>
// 读取信息(TCP/UDP 服务器/客户端)
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
这个函数的参数也是很多,几乎与 recvfrom
的一模一样
sockfd
使用哪个套接字进行发送buf
发送数据存放缓冲区len
缓冲区的大小flags
发送方式(阻塞/非阻塞)src_addr
对端主机的sockaddr
结构体,包含了对端的IP
地址 和 端口号addrlen
对端主机的sockaddr
结构体大小
返回值:成功返回实际发送的字节数,失败返回 -1。
发送消息时,直接调用 sendto
函数把读取到的信息,回响给客户端即可,如果发送失败了,就简单报个错,为了方便错误码调整,这里顺便把错误码封装成一个单独的 err.hpp
源文件(注意包含头文件)
err.hpp
头文件
#pragma once
// 错误码
enum
{
SOCKET_ERR = 1,
BIND_ERR
};
完整的服务端启动函数
//启动服务器
void StartServer(){
//服务器是永不停息的,所以需要使用一个 while 死循环
char buff[1024]; //缓冲区
while(true){
//1 接收信息
struct sockaddr_in peer; // 客户端结构体
socklen_t len = sizeof(peer); // 客户端结构体大小
// 传入 sizeof(buff) - 1 表示当前传输的是字符串,预留一个位置存储 '\0'
// 传入 0 表示当前是阻塞式读取
ssize_t n = recvfrom(_sock, buff, sizeof(buff) - 1, 0, (struct sockaddr*)&peer, &len);
if(n > 0){
buff[n] = '\0';
}
else{
continue; // 继续读取
}
// 2.处理数据
std::string clientIp = inet_ntoa(peer.sin_addr); // 获取IP地址
uint16_t clientPort = ntohs(peer.sin_port); // 获取端口号
printf("Server get message from [%c:%d]$ %s\n",clientIp.c_str(), clientPort, buff);
// 3.回响给客户端
// ...
n = sendto(_sock, buff, strlen(buff), 0, (const struct sockaddr*)&peer, sizeof(peer));
if(n == -1){
std::cout << "Send Message Fail: " << strerror(errno) << std::endl;
}
}
}
万事具备后,就可以启动服务器了,可以看到服务器启动后,处于阻塞等待状态,这是因为还没有客户端给我的服务器发信息,所以它就会暂时阻塞.
如何证明服务器正在运行?
可以通过 Linux
中查看网络状态的指令,因为我们这里使用的是 UDP
协议,所以只需要输入下面这条指令,就可以查看有哪些程序正在运行.
netstat -nlup
现在服务已经跑起来了,并且如期占用了 8888
端口,接下来就是编写客户端相关代码
0.0.0.0
表示任意IP地址
3.5 客户端
3.5.1 指定IP地址和端口号
客户端在运行时,必须知道服务器的 IP
地址 和 端口号,否则不知道自己该与谁进行通信,所以对于 client
类来说,ip
和 port
者两个字段是肯定少不了的.
client.hpp
客户端头文件
#pragma once
#include<iostream>
#include <string>
#include "err.hpp"
namespace My_client{
class client{
private:
/* data */
std::string server_ip;//服务端 IP 地址
uint16_t server_port;//服务器端口号
public:
//构造函数
client(const std::string& ip,uint16_t port)
:server_ip(ip)
,server_port(port)
{}
//析构函数
~client(){
}
// 初始化客户端
void InitClient() {
}
// 启动客户端
void StartClient() {
}
};
}
这两个参数由用户主动传输,这里就需要 命令行 参数相关知识了,在启动客户端时,需要以 ./client serverIp serverPort
的方式运行,否则就报错,并提示相关错误信息(更新 err.hpp
的错误码)
更新后的错误码:
#pragma once
// 错误码
enum
{
USAGE_ERR=1 ,
SOCKET_ERR,
BIND_ERR
};
client.cc
客户端源文件
#include<memory>
#include"client.hpp"
#include"err.hpp"
using namespace My_client;
void Usage(const char* program){
std::cout<<"Usage:"<<std::endl;
std::cout<<"\t"<<program<<"ServerIP ServerPort" << std::endl;
}
int main(int argc,char *argv[]){
if(argc!=3){
//启动方式是错误的,提升错误信息
Usage(argv[0]);
return USAGE_ERR;
}
std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
std::unique_ptr<client> mcit(new client(ip,port));
//初始化客户端
mcit->InitClient();
//启动客户端
mcit->StartClient();
return 0;
}
如此一来,只有正确的输入 [./client ServerIP ServerPort] 才能启动程序,否则不让程序运行,倒逼客户端启动时,提供服务器的 IP
地址 和 端口号。
其实在浏览网页时输入的
url
网址,在经过转换后,其中也一定会包含服务器的IP
地址 与 端口号,配合请求的资源路径,就能获取服务器资源了。
3.5.2 客户端的初始化
初始化客户端时,同样需要创建 socket
套接字,不同于服务器的是 客户端不需要自己手动绑定 IP
地址与端口号。
这是因为客户端手动指明 端口号 存在隐患:如果恰好有两个程序使用了同一个端口,会导致其中一方的客户端直接绑定失败,无法运行,将绑定 端口号 这个行为交给
OS
自动执行(首次传输数据时自动bind
),可以避免这种冲突的出现。毕竟在现实生活中,一般客户端只有一个,而客户端有成百上千个。
为什么服务器要自己手动指定端口号,并进行绑定? 这是因为服务器的端口不能随意改变,并且这是要公布给广大客户端看的,同一家公司在部署服务时,会对端口号的使用情况进行管理,可以直接避免端口号冲突。
客户端在启动前,需要先知晓服务器的 sockaddr_in
结构体信息,可以利用已知的 IP
地址 和 端口号 构建。
综上所述,在初始化客户端时,需要创建好套接字和初始化服务器的 sockaddr_in
结构体信息
client.hpp
客户端头文件
#pragma once
#include<iostream>
#include <string>
#include "err.hpp"
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstdlib>
#include <cstring>
namespace My_client{
class client{
private:
/* data */
std::string server_ip;//服务端 IP 地址
uint16_t server_port;//服务器端口号
int _sock;
struct sockaddr_in _svr;
public:
//构造函数
client(const std::string& ip,uint16_t port)
:server_ip(ip)
,server_port(port)
{}
//析构函数
~client(){
}
// 初始化客户端
void InitClient() {
//1. 创建套接字
_sock=socket(AF_INET,SOCK_DGRAM,0);
if(_sock==-1){
std::cout << "Create Socket Fail: " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout<<"Create Success Socket:"<<_sock<<std::endl;
//2. 构建服务器的sockaddr_in 结构体信息
bzero(&_svr,sizeof(_svr));
_svr.sin_family=AF_INET;
// 绑定服务器IP地址
_svr.sin_addr.s_addr=inet_addr(server_ip.c_str());
//绑定服务器端口号
_svr.sin_port=htons(server_port);
}
// 启动客户端
void StartClient() {
}
};
}
如此一来,客户端就可以利用该 sockaddr_in
结构体,与目标主机进行通信了。
3.8.启动客户端
接下来就是客户端向服务器发送消息,消息由用户主动输入,使用的是 sendto
函数
发送消息步骤
- 用户输入消息
- 传入缓冲区、服务器相关参数,使用
sendto
函数发送消息
消息发送后,客户端等待服务器回响消息
接收消息步骤:
- 创建缓冲区
- 接收信息,判断是否接收成功
- 处理信息
注:同服务器一样,客户端这里我们设置的也需要不断运行
StartClient()
函数 — 位于client.hpp
中的 client
类
// 启动客户端
void StartClient() {
char buff[1024];
// 1. 启动客户端
while(true){
std::string msg;
std::cout<<"Input Message# ";
std::getline(std::cin,msg);
ssize_t n=sendto(_sock,msg.c_str(),msg.size(),0,(const struct sockaddr*)&_svr, sizeof(_svr));
if(n==-1){
std::cout<<"Send Message Fail: "<<strerror(errno)<<std::endl;
continue;
}
//2 因为是回响 使用也要接收信息
socklen_t len = sizeof(_svr);
n = recvfrom(_sock,buff,sizeof(buff)-1,0,(struct sockaddr *)&_svr,&len);
if(n>0){
buff[n]='\0';
}
else{
continue;
}
//可以再次获取 IP地址和 端口号
std::string ip=inet_ntoa(_svr.sin_addr);
uint16_t port=ntohs(_svr.sin_port);
printf("Client get message from [%s:%d]# %s\n",ip.c_str(), port, buff);
}
}
现在左手 服务器,右手 客户端,直接编译运行,看看效果:
可以看到,服务器和客户端都成功运行了,OS
给客户端分配的 端口号 是 54450
,这是随机分配的,每次重新运行后,大概率都不相同
至此基于 UDP
协议编写的第一个网络程序 字符串回响 就完成了,接下来对其进行改造,编写第二个网络程序