「前言」文章是关于网络编程的socket套接字方面的,上一篇是网络编程socket套接字(一),下面开始讲解!
「归属专栏」网络编程
「笔者」枫叶先生(fy)
「座右铭」前行路上修真我
「枫叶先生有点文青病」
「每篇一句」
我认为,每个人都有一个觉醒期,但觉醒的早晚决定个人的命运。
——路遥《平凡的世界》
目录
三、简单的UDP网络程序
3.1 服务端创建
3.1.1 创建套接字
3.1.2 绑定端口
3.1.3 sockaddr_in结构体
3.1.4 字符串IP和整数IP说明
3.1.5 绑定好端口号的服务端代码
3.1.6 服务端代码
3.2 客户端创建
3.2.1 关于客户端的绑定问题
3.2.2 客户端代码
3.3 服务端和客户端测试
接下来进行编写socket套接字代码,先使用的是UDP,边写代码边讲一下socket的接口,还有一些原理
首先明确,这个简单的UDP网络程序分客户端和服务端,所以我们要生成两个可执行程序,一个是客户端的,另一个是服务端的,服务端充当的是服务器,暂时实现的功能是客户端和服务端简单进行通信,服务端要可以收到客户端发送给服务端的信息,目前就先简单实现这样的功能
下面进行编写服务端的代码
先介绍创建套接字的函数socket
socket函数
socket函数的作用是创建套接字,TCP/UDP 均可使用该函数进行创建套接字,man 2 socket查看:
create an endpoint for communication:创建通信端点,即创建通信的一端
函数:socket
头文件:
#include
#include
函数原型:
int socket(int domain, int type, int protocol);
参数:
第一个参数domain:创建套接字的域,即创建套接字的类型
第二个参数type:创建套接字时提供的服务类型
第三参数protocol:创建套接字的协议类别
返回值:
套接字创建成功返回一个文件描述符,创建失败返回-1,错误码被设置
下面介绍socket函数的参数
(1)socket函数的第一个参数是domain,用于创建套接字的类型,该参数就相当于 struct sockaddr结构体的前16位,即2字节
该domain参数的选项已经设置好了,我们直接选用即可。该参数的选项很多,我们常用的也就几个:
(2)socket函数的第二个参数是type,用于创建套接字时提供的服务类型
该参数的选项也是已经设置好了,我们直接选用即可。该参数的选项很多,我们常用的也就几个:
(3)socket函数的第三个参数是protocol,用于创建套接字的协议类别。
socket函数返回值问题
套接字创建成功返回一个文件描述符,创建失败返回-1,同时错误码被设置
解释套接字创建成功返回一个文件描述符的问题
明确一点
服务端创建套接字编写代码暂时如下:
udpServer.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
using namespace std;
// 错误类型枚举
enum
{
SOCKET_ERR = 2
};
const static string defaultIp = "0.0.0.0";
class udpServer
{
public:
udpServer(const uint16_t &port, const string ip = defaultIp)
: _port(port), _ip(ip)
{}
// 初始化服务器
void initServer()
{
// 1.创建套接字
_socket = socket(AF_INET, SOCK_DGRAM, 0);
if (_socket == -1)
{
cerr << "socket error: " << errno << " : " << strerror(errno) << endl;
exit(SOCKET_ERR);
}
// 2.绑定端口
}
// 运行服务器
void start()
{}
~udpServer()
{}
private:
uint16_t _port; // 端口号
string _ip; // ip地址
int _socket; // 文件描述符
};
udpServer.cc
#include "udpServer.hpp"
#include
int main()
{
std::unique_ptr usvr(new udpServer()); // TODO
usvr->initServer(); // 初始化服务器
usvr->start(); // 运行服务器
return 0;
}
下面进行绑定端口
绑定端口的函数是bind函数
bind函数
bind函数的作用是绑定端口号,TCP/UDP 均可使用进行该函数绑定端口,man 2 bind查看:
bind a name to a socket:将名称绑定到套接字
函数:bind
头文件:
#include
#include
函数原型:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
第一个参数sockfd:文件描述符
第二个参数addr:网络相关的属性信息
第三参数addrlen:传入的addr结构体的长度
返回值:
绑定成功返回0,绑定失败返回-1,同时错误码会被设置
下面介绍bind函数的参数
(1)bind函数的第一个参数是sockfd,用于绑定套接字创建成功返回的文件描述符
(2)bind函数的第二个参数是addr,用于填充网络相关的属性信息,比如IP地址、端口号等
该参数addr的类型是:struct sockaddr *,也就是如图的结构体:
我们要做的工作就是:定义一个 sockaddr_in 的结构体,也就是上图的第二个结构体,然后对该结构体进行内容填充,填完就把给结构体传给 第二个参数addr,需要强制类型转换
我们看一下 sockaddr_in 结构体的定义:
可以看到,sockaddr_in 有以下几个成员类型:
剩下的字段不关注
其中 __SOCKADDR_COMMON 是一个宏
#define __SOCKADDR_COMMON(sa_prefix) sa_family_t sa_prefix##family
如图:
sockaddr_in结构体的成员变量 sin_port 是端口号,类型是 in_port_t,16位的整数
sockaddr_in结构体的成员变量 sin_addr,sin_addr里面的内容是32位的整数。sin_addr 自己就是一个结构体,sin_addr 结构体类型是 in_addr
实际就是想说明 IP的类型直接就可以用 int 接收, 端口号需要用 uint16_t 接收
为什么网络传输中使用的是整数IP??
但是我们人看一串数字又不方便,比如:123002033200,所以我们人一般使用的是字符串IP
即存在需要把字符串IP转整数IP,整数IP转字符串IP
这些工作不用我们自己做,调用库函数即可
字符串IP和整数IP相互转换的方式
字符串IP转换成整数IP
inet_addr函数
in_addr_t inet_addr(const char *cp);
只需传入待转换的字符串IP,该函数返回的就是转换后的整数IP
函数做了两件工作:
整数IP转换成字符串IP
inet_ntoa函数
char *inet_ntoa(struct in_addr in);
需要注意的是,传入 inet_ntoa函数的参数类型是 in_addr ,因此我们在传参时不需要选中 in_addr结构当中的32位的成员传入,直接传入in_addr 结构体即可
这两个函数的头文件都是:
#include
#include
#include
网络字节序与主机字节序之间的转换函数(上一节已经谈过)
#include
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
服务端代码暂时如下:
udpServer.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
// 错误类型枚举
enum
{
UAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR
};
const static string defaultIp = "0.0.0.0";
class udpServer
{
public:
udpServer(const uint16_t &port, const string &ip = defaultIp)
: _port(port), _ip(ip)
{}
// 初始化服务器
void initServer()
{
// 1.创建套接字
_socket = socket(AF_INET, SOCK_DGRAM, 0);
if (_socket == -1)
{
cerr << "socket error: " << errno << " : " << strerror(errno) << endl;
exit(SOCKET_ERR);
}
// 2.绑定端口
// 2.1 填充 sockaddr_in 结构体
struct sockaddr_in local;
bzero(&local, sizeof(local)); // 把 sockaddr_in结构体全部初始化为0
local.sin_family = AF_INET; // 未来通信采用的是网络通信
local.sin_port = htons(_port); // htons(_port)主机字节序转网络字节序
local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 1.string类型转int类型 2.把int类型转换成网络字节序 (这两个工作inet_addr已完成)
// 2.2 绑定
int n = bind(_socket, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&local
if (n == -1)
{
cerr << "bind error: " << errno << " : " << strerror(errno) << endl;
exit(BIND_ERR);
}
//UDP server 预备工作完成
}
// 启动服务器
void start()
{
// 服务器的本质就是一个死循环
for(;;)
{
sleep(1);
}
}
~udpServer()
{}
private:
uint16_t _port; // 端口号
string _ip; // ip地址
int _socket; // 文件描述符
};
udpServer.cc
#include "udpServer.hpp"
#include
// 使用手册
// ./udpServer port ip
static void Uage(string proc)
{
cout << "\nUage:\n\t" << proc << "local_ip local_port\n\n";
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Uage(argv[0]);
exit(UAGE_ERR);
}
uint16_t port = atoi(argv[2]); // string to int
string ip = argv[1];
std::unique_ptr usvr(new udpServer(port, ip));
usvr->initServer(); // 初始化服务器
usvr->start(); // 启动服务器
return 0;
}
暂时可以进行编译了
运行
需要按照手册使用,IP随便填一个,端口号暂时用8080, 端口号有讲究的,后面再说
报错:无法分配请求的地址,说明 IP 也不是可以乱填的
下面进行介绍几个IP
ifconfig:显示和配置网络接口的信息
注:ifconfig全称:interface configuration接口配置
注:"inet" 是Internet Protocol(IP)的简写
先说第二个 IP,什么是本地环回??
我们写的代码在应用层,使用该IP进行通信贯穿不了物理层,通信只在物理层以上进行环回,只能进行本主机通信。通常用这个 IP用于同一台计算机上运行客户端和服务器程序进行通信测试
内网IP到 IP协议再解释
我们暂时先使用本地环回,进行简单测试
服务端已经可以跑起来了
使用命令进行查看该服务端的信息
netstat 命令
netstat是一个用于显示网络连接、路由表和网络接口信息的命令行工具
netstat:network statistics网络统计
常用选项:
-a
:all (显示所有连接和监听端口)-t
:tcp (仅显示TCP连接)-u
:udp (仅显示UDP连接)-n
:numeric (以数字形式显示IP地址和端口号)-p
:program (显示与连接关联的进程信息)-r
:route (显示路由表信息)-s
:statistics (显示网络统计信息)netstat -nuap 进行查看
Foreign Address:(外部地址)是指与本地计算机建立网络连接的远程计算机的IP地址和端口号,也就是客户端连服务器
0.0.0.0:*
表示任意IP地址、任意的端口号的程序都可以访问当前进程
netstat -uap 进行查看 (不以数字显示)
如果我们想让别人可以连到我们的服务端,服务端需要给全网提供服务,IP就要使用公网IP(连云服务器的那个IP)
注:云服务器是虚拟化的服务器,不能直接绑你的公网IP,如果是虚拟机或独立的Linux则可以
需要去到云服务器的控制台打开相应的UDP端口
依旧是绑定失败,所以云服务器是不支持的绑定公网IP的,使用虚拟机或者独立Linux操作系统,那个IP地址就是支持你绑定的
实际上,一款网络服务器,不建议指明绑定一个IP,上面的服务端指定绑定一个IP是错误的用法
比如你运行服务端的机器上有几个网卡,意味着你的服务端上有多个IP, 一台服务器上端口号为8080的服务只有一个。这台服务器在接收数据时,这里的多张网卡在底层实际都收到了数据,如果这些数据也都想访问端口号为8080的服务。此时如果服务端在绑定的时候是指明绑定的某一个IP地址,那么此时服务端在接收数据的时候就只能从绑定IP对应的网卡接收数据
如果你只绑定指明的一个,其他IP收到的数据包就被丢弃的,这不是我们想要的
实际上,服务器绑定的IP是:INADDR_ANY,这是一个宏,代表 0.0.0.0,叫做任意地址绑定。绑定了该IP,只要是发送给端口号为8080的服务的数据,系统都会可以将数据自底向上全部交给该服务端
修改代码
udpServer.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
// 错误类型枚举
enum
{
UAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR
};
const static string defaultIp = "0.0.0.0";
class udpServer
{
public:
udpServer(const uint16_t &port, const string &ip = defaultIp)
: _port(port), _ip(ip)
{}
// 初始化服务器
void initServer()
{
// 1.创建套接字
_socket = socket(AF_INET, SOCK_DGRAM, 0);
if (_socket == -1)
{
cerr << "socket error: " << errno << " : " << strerror(errno) << endl;
exit(SOCKET_ERR);
}
// 2.绑定端口
// 2.1 填充 sockaddr_in 结构体
struct sockaddr_in local;
bzero(&local, sizeof(local)); // 把 sockaddr_in结构体全部初始化为0
local.sin_family = AF_INET; // 未来通信采用的是网络通信
local.sin_port = htons(_port); // htons(_port)主机字节序转网络字节序
// 绑定IP方法1:INADDR_ANY
// local.sin_addr.s_addr = INADDR_ANY;//服务器的真实写法
// 绑定IP方法2:把外部的构造函数传参去掉,使用我们自己定义的string defaultIp = "0.0.0.0";
local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 1.string类型转int类型 2.把int类型转换成网络字节序 (这两个工作inet_addr已完成)
// 2.2 绑定
int n = bind(_socket, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&local
if (n == -1)
{
cerr << "bind error: " << errno << " : " << strerror(errno) << endl;
exit(BIND_ERR);
}
// UDP server 预备工作完成
}
// 启动服务器
void start()
{
// 服务器的本质就是一个死循环
for (;;)
{
sleep(1);
}
}
~udpServer()
{}
private:
uint16_t _port; // 端口号
string _ip; // ip地址
int _socket; // 文件描述符
};
udpServer.cc
#include "udpServer.hpp"
#include
// 使用手册
// ./udpServer port
static void Uage(string proc)
{
cout << "\nUage:\n\t" << proc << " local_port\n\n";
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
Uage(argv[0]);
exit(UAGE_ERR);
}
uint16_t port = atoi(argv[1]); // string to int
//不需要传IP了
std::unique_ptr usvr(new udpServer(port));
usvr->initServer(); // 初始化服务器
usvr->start(); // 启动服务器
return 0;
}
编译运行,端口号8080
netstat -nuap 进行查看
任意地址已经绑定成功,此时我们的服务器才能够被外网访问,意味着该UDP服务器可以在本地主机上,读取发送给端口8080的任何一张网卡里面的数据
接下来就是补充完整服务端的代码了。
服务端要接收客户端发送的消息,接收信息的函数是recvfrom
recvfrom函数
recvfrom函数的作用是接收信息
receive a message from a socket:从套接字接收消息
函数:recvfrom
头文件:
#include
#include
函数原型:
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
参数:
第一个参数sockfd:文件描述符,从哪个套接字去读数据
第二个参数buf:代表读上来的数据放到哪个缓冲区里面
第三参数len:缓冲区的长度
第四个参数flags:读取方式,0代表阻塞式读取
第五个参数src_addr:下面解释
第六个参数addrlen:src_addr结构体的长度
返回值:
成功返回接收到的字节数,失败返回-1,同时错误码会被设置。对等方执行有序关闭后,返回值将为0
socklen_t 是一个32位的无符号整数
recvfrom函数的第五个参数src_addr,src_addr是一个结构体,类型是 struct sockaddr *
第五个参数src_addr 和第六个参数addrlen 是一个输入输出型参数。
第五个参数src_addr用于返回发送数据一方的信息,比如IP、端口号等。就好比别人发消息给你,你得知道对方是谁
我们要做的也是定义一个 sockaddr_in 的结构体,初始化该结构体,把结构体传给第五个参数src_addr,需要强制类型转换
服务端代码如下
udpServer.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
// 错误类型枚举
enum
{
UAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR
};
const static string defaultIp = "0.0.0.0";
const static int gnum = 1024;
class udpServer
{
public:
udpServer(const uint16_t &port, const string &ip = defaultIp)
: _port(port), _ip(ip)
{}
// 初始化服务器
void initServer()
{
// 1.创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd == -1)
{
cerr << "socket error: " << errno << " : " << strerror(errno) << endl;
exit(SOCKET_ERR);
}
cout << "socket success: " << _sockfd << endl;
// 2.绑定端口
// 2.1 填充 sockaddr_in 结构体
struct sockaddr_in local;
bzero(&local, sizeof(local)); // 把 sockaddr_in结构体全部初始化为0
local.sin_family = AF_INET; // 未来通信采用的是网络通信
local.sin_port = htons(_port); // htons(_port)主机字节序转网络字节序
// 绑定IP方法1:INADDR_ANY
// local.sin_addr.s_addr = INADDR_ANY;//服务器的真实写法
// 绑定IP方法2:把外部的构造函数传参去掉,使用我们自己定义的string defaultIp = "0.0.0.0";
local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 1.string类型转int类型 2.把int类型转换成网络字节序 (这两个工作inet_addr已完成)
// 2.2 绑定
int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&local
if (n == -1)
{
cerr << "bind error: " << errno << " : " << strerror(errno) << endl;
exit(BIND_ERR);
}
// UDP server 预备工作完成
}
// 启动服务器
void start()
{
// 服务器的本质就是一个死循环
char buffer[gnum];
for (;;)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t s = recvfrom(_sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&peer, &len);
if (s > 0) // 接收成功
{
buffer[s] = 0;
// 发消息对方的IP
string clientip = inet_ntoa(peer.sin_addr); // 直接传sin_addr结构体,整数IP 转 字符串IP(点分十进制IP)
// 发消息对方的端口号
uint16_t clientport = ntohs(peer.sin_port); // ntohs:网络字节序转主机字节序
// 发送的消息
string message = buffer;
// 打印
cout << clientip << "[" << clientport << "]" << "# " << message << endl;
}
}
}
~udpServer()
{}
private:
uint16_t _port; // 端口号
string _ip; // ip地址
int _sockfd; // 文件描述符
};
udpServer.cc 没有发生改变
客户端的功能是可以发送消息给服务端,目前就先简单实现这样的功能
客户端在初始化时只需要创建套接字就行了,而不需要进行显示绑定操作
客户端代码如下
udpClient.hpp
class udpClient
{
public:
udpClient(const string &serverip, const uint16_t serverport)
: _ip(serverip), _port(serverport), _socket(-1)
{}
// 初始化客户端
void initClient()
{
// 1.创建套接字
_socket = socket(AF_INET, SOCK_DGRAM, 0);
if (_socket == -1)
{
cerr << "socket error: " << errno << " : " << strerror(errno) << endl;
exit(2);
}
// 2.绑定
// 客户端必须要进行bind绑定,但是不需要我们自己bind,OS帮我们完成
}
// 启动客户端
void run()
{}
~udpClient()
{}
private:
uint16_t _port; // 端口号
string _ip; // ip地址
int _socket; // 文件描述符
};
udpClient.cc
#include "udpClient.hpp"
#include
// 使用手册
// ./udpClient ip port
static void Uage(string proc)
{
cout << "\nUage:\n\t" << proc << " server_ip server_port\n\n";
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Uage(argv[0]);
exit(1);
}
// 客户端需要服务端的 IP 和 port
string serverip = argv[1];
uint16_t serverport = atoi(argv[2]); // string to int
std::unique_ptr ucli(new udpClient(serverip, serverport));
ucli->initClient(); // 初始化服务器
ucli->run(); // 启动服务器
return 0;
}
明确一点
关于客户端的绑定问题
接下来就是补充完整客户端的代码了。
客户端要发送消息给服务端,发送消息的函数是sendto
sendto函数
sendto函数的作用是发送消息
send a message on a socket:在套接字上发送消息
函数:sendto
头文件:
#include
#include
函数原型:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
参数:
第一个参数sockfd:文件描述符,从哪个套接字去发送消息
第二个参数buf:待写入数据的存放位置
第三参数len:写入数据的长度
第四个参数flags:写入方式,0代表阻塞式写入
第五个参数dest_addr:下面解释
第六个参数addrlen:dest_addr结构体的长度
返回值:
成功返回写入的字节数,失败返回-1,同时错误码会被设置
socklen_t 是一个32位的无符号整数
第五个参数dest_addr和第六个参数addrlen 是一个输入型参数
第五个参数dest_addr用于发送客户端的IP、端口号数据,发给服务端
我们要做的工作也是定义一个 sockaddr_in 的结构体,然后对该结构体进行内容填充,填完就把给结构体传给第五个参数dest_addr,需要强制类型转换
客户端代码
udpClient.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
class udpClient
{
public:
udpClient(const string &serverip, const uint16_t serverport)
: _serverip(serverip), _serverport(serverport), _sockfd(-1), _quit(false)
{}
// 初始化客户端
void initClient()
{
// 1.创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd == -1)
{
cerr << "socket error: " << errno << " : " << strerror(errno) << endl;
exit(2);
}
cout << "socket success: " << _sockfd << endl;
// 2.绑定
// 客户端必须要进行bind绑定,但是不需要我们自己bind,OS帮我们完成
}
// 启动客户端
void run()
{
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(_serverport);//主机转网络序列
server.sin_addr.s_addr = inet_addr(_serverip.c_str());// 1.string类型转int类型 2.把int类型转换成网络字节序 (这两个工作inet_addr已完成)
string message;
while ((!_quit))
{
cout << "Please Enter# ";
cin >> message;
sendto(_sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server));
}
}
~udpClient()
{}
private:
uint16_t _serverport; // 端口号
string _serverip; // ip地址
int _sockfd; // 文件描述符
bool _quit;
};
注:后面全部改了一下_socket 的命名(_sockfd)
udpClient.cc 没有发生改变
然后进行整体编译,编译没有问题
新建窗口,先运行服务端,再启动客户端,客户端先用本地环回进行测试,测试成功
运行程序后可以看到套接字是创建成功的,对应获取到的文件描述符就是3,这也很好理解,因为0、1、2默认被标准输入流、标准输出流和标准错误流占用了,此时最小的、未被使用用的文件描述符就是3
关掉客户端,再次运行,发送消息给服务端,发现客户端的端口号已经发送改变,也就证明了操作系统会自动给当前客户端生产一个唯一的端口号并且进行绑定
网络测试
可以将生成的客户端的可执行程序发送给你的其他朋友,进行网络测试,也就是跨主机通信
先使用 sz
命令将该客户端可执行程序下载到本地
当你的朋友收到这个客户端的可执行程序后,可以通过 rz
命令或拖拽的方式将这个可执行程序上传到他的云服务器上,然后通过 chmod
命令给该文件加上可执行权限
加可执行权限 chmod +x 文件名
然后运行客户端,给服务端发送消息,客户端需要服务端的IP和端口号
简单的测试就成功了
注:云服务器的端口默认都是关闭的,需要手动打开,在控制台里面
--------------------- END ----------------------
「 作者 」 枫叶先生
「 更新 」 2023.6.18
「 声明 」 余之才疏学浅,故所撰文疏漏难免,
或有谬误或不准确之处,敬请读者批评指正。