目录
[一、socket 编程接口](#一、socket 编程接口)
[1.1 sockaddr 结构](#1.1 sockaddr 结构)
[1.2 socket 常见API](#1.2 socket 常见API)
[二、封装 InetAddr](#二、封装 InetAddr)
[四、封装通用 UdpServer 服务端](#四、封装通用 UdpServer 服务端)
[4.1 整体框架](#4.1 整体框架)
[4.2 类的初始化](#4.2 类的初始化)
[4.2.1 socket](#4.2.1 socket)
[4.2.2 bind](#4.2.2 bind)
[4.2.3 创建流式套接字](#4.2.3 创建流式套接字)
[4.2.4 填充结构体](#4.2.4 填充结构体)
[4.3 服务器的运行](#4.3 服务器的运行)
[4.3.1 recvfrom](#4.3.1 recvfrom)
[4.3.2 sendto](#4.3.2 sendto)
[4.3.3 接收数据](#4.3.3 接收数据)
[4.3.4 发送数据](#4.3.4 发送数据)
[4.4 UdpServer.hpp](#4.4 UdpServer.hpp)
[五、封装通用 UdpClient 客户端](#五、封装通用 UdpClient 客户端)
OSI 参考模型与 TCP/IP 分层模型的对比
一、socket 编程接口
1.1 sockaddr 结构
socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、 IPv6、UNIX Domain Socket。然而, 各种网络协议的地址格式并不相同:
它们都定义在 netinet/in.h 中,其中,
struct sockaddr
:
-
它是一个通用的套接字地址结构体,通常在需要传递通用地址结构体指针的地方使用。
-
定义:
cppstruct sockaddr { unsigned short sa_family; // 地址族 char sa_data[14]; // 地址数据 };
-
sa_family
指定地址族,比如AF_INET
、AF_UNIX
等。
struct sockaddr_in
:
-
它专门用于 IPv4 地址的套接字编程。
-
定义:
cppstruct sockaddr_in { short int sin_family; // 地址族 (AF_INET) unsigned short int sin_port; // 端口号 struct in_addr sin_addr; // IP 地址 unsigned char sin_zero[8]; // 填充,使结构体大小与 `struct sockaddr` 一致 }; struct in_addr { unsigned long s_addr; // 32 位的 IP 地址 };
-
sin_family
通常为AF_INET
,表示使用 IPv4;AF_INET6
,表示使用 IPv6 -
sin_port
存储端口号,使用htons
函数转换为网络字节序。 -
sin_addr
存储 IPv4 地址,使用inet_addr
或inet_pton
函数进行设置。 -
in_addr
中的s_addr
初始化时使用 INADDR_ANY
struct sockaddr_un
:
-
它专门用于 UNIX 域套接字编程。
-
定义:
cppstruct sockaddr_un { sa_family_t sun_family; // 地址族 (AF_UNIX) char sun_path[108]; // 路径名 };
sun_family
通常为AF_UNIX
,表示使用 UNIX 域套接字。sun_path
存储文件系统路径名,表示套接字文件的位置。
IPv4、 IPv6地址类型分别定义为常数AF_INET、 AF_INET6。 这样,只要取得某种sockaddr结构体的首地址, 不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容;
socket API可以都用struct sockaddr *类型表示, 在使用的时候需要强制转化成sockaddr_in; 这样的好处是程序的通用性, 可以接收IPv4, IPv6, 以及UNIX Domain Socket各种类型的sockaddr结构体指针做为参数。
1.2 socket 常见API
套接字是通信的端点,允许在网络上的两个主机之间进行数据传输。
每个套接字都与一个特定的地址和端口绑定,以标识唯一的通信端点。
cpp
// 创建 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);
二、封装 InetAddr
InetAddr 将一套接字结构体的服务号与端口号封装成类,以后调用某一套接字的IP地址与端口号时就可以直接使用语言层面的一些数据类型,如 string 、uint16_t ,这样比较统一。
因为我们传入的 ip 地址是 "xxx.xxx.xxx.xxx" ,这是一个 string 类,在服务端的 main 函数中,可以使用 inet_addr 将其传入 struct addr_in 的 s_addr 中。
cpp
class InetAddr
{
private:
struct sockaddr_in _addr;
string _ip;
uint16_t _port;
};
接下来,就是类的成员函数,保证类可以返回 sockaddr_in \ ip \ port 即可。
但是,需要注意的是,struct sockaddr_in 中的 IP 地址与端口号与我们定义的类型不同,系统中也提供了相应的函数便于我们的转化, ntohs 与 inet_ntoa 前者用于网络字节序转化为主机字节序,后者用于将网络字节顺序给出的主机地址转化为IPv4点分十进制的字符串。
cpp
void GetAddr()
{
_ip = inet_ntoa(_addr.sin_addr.s_addr);
_port = ntohs(_addr.sin_port);
}
cpp
#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
class InetAddr
{
private:
void GetAddress()
{
_port = ntohs(_addr.sin_port);
_ip = inet_ntoa(_addr.sin_addr);
}
public:
InetAddr(const struct sockaddr_in &addr) : _addr(addr)
{
GetAddress();
}
std::string Ip()
{
return _ip;
}
uint16_t Port()
{
return _port;
}
~InetAddr()
{
}
private:
struct sockaddr_in _addr;
std::string _ip;
uint16_t _port;
};
三、网络字节序
内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?
换句话说,如果从一个大端存储的计算机传输数据至一个小端存储的计算机,那么如果网络层不进一步优化的话,传过去的数据不就都乱套了吗。
发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址。
TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节。
不管这台主机是大端机还是小端机,都会按照这个TCP/IP规定的网络字节序来发送/接收数据;如果当前发送主机是小端,就需要先将数据转成大端;否则就忽略,直接发送即可。
为使网络程序具有可移植性,使同样的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地址转换后准备发送。
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
四、封装通用 UdpServer 服务端
服务端要负责的是接收客户端的请求并给予客户端一定的响应,对 UdpServer 的封装要包括套接字的建立、服务的绑定、数据的收集、数据的传输等。
4.1 整体框架
首先,我们知道每个Mac都有其独特的IP,那么Mac中有那么多的应用软件,应该如何才能确定当前服务需要在哪个应用中呢?这就引入了端口号,用于标识一台Mac中唯一的应用。所以在UdpServer中,不仅要创建流式套接字,还要有唯一的端口号。除此之外,如果在外部需要停止服务端的响应,可以设置一个布尔类型的变量来标识UdpServer是否在运行。
其次,在编写通用的 UdpServer
类时,构造函数通常不会将套接字文件描述符 (sockfd
) 作为参数进行传递。这是因为套接字文件描述符是在类的内部创建和管理的,而不是由外部提供。
cpp
#include <iostream>
static const int gdefaultsockfd = -1;
class UdpServer
{
public:
UdpServer(uint16_t port):_sockfd(gdefaultsockfd), _port(port), _isrunning(false)
{}
private:
int _sockfd;
uint16_t _port;
bool _isrunning;
};
4.2 类的初始化
上面我们提到编写通用的 UdpServer类时,构造函数通常不需要传入 sockfd ,在后面会将的 TcpServer 也是如此,所以在初始化函数时,就要对套接字进行创建以及与 sockaddr 的绑定。
4.2.1 socket
socket
函数用于创建一个新的套接字。套接字是网络通信的端点。
cpp
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
传入参数
domain
: 指定协议族,如AF_INET
(IPv4)或AF_INET6
(IPv6)等。type
: 指定套接字类型,如SOCK_STREAM
(TCP)或SOCK_DGRAM
(UDP)。protocol
: 通常为 0,表示自动选择合适的协议。如果需要特定协议,可以传递协议编号。
返回值
成功时返回一个文件描述符,失败时返回 -1,并设置 errno
来指示错误。
4.2.2 bind
bind
函数将一个套接字绑定到一个特定的本地地址和端口上。这通常用于服务器端。
cpp
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
传入参数
socket
: 由socket
函数返回的套接字文件描述符。address
: 指向一个struct sockaddr
类型的指针,包含要绑定的地址信息。address_len
: 地址结构体的长度。
返回值
成功时返回 0,失败时返回 -1,并设置 errno
来指示错误。
4.2.3 创建流式套接字
cpp
void UdpInit()
{
// 1.创建流式套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(FATAL, "socket error, %s, %d\n", strerror(errno), errno);
exit(SOCKET_ERROR);
}
}
我们带入了上一节中的日志宏,同时因为 socket 函数可以带出错误信息,所以当套接字创建失败是,可以使用 strerror 打印一下错误信息,并可以通过枚举使 exit 时的信息更明确:
cpp
#include <cstring>
#include "Log.hpp"
enum
{
SOCKET_ERROR = 1,
BIND_ERROR,
USAGE_ERROR
};
4.2.4 填充结构体
这里使用的是 struct sockaddr_in 结构体,首先把结构体成员都初始化为0,这里使用 bezero 函数,sockaddr_in 结构体中的各个成员对 sin_family\sin_addr.s_addr 初始化,初始化的参数详见1.1 sockaddr 结构,然后向其中的 sin_port 填充我们输入的端口号。
cpp
// 2.0创建struct sockaddr_in 并填充
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = INADDR_ANY;
local.sin_port = htons(_port);
// 2.1 bind 将一个套接字绑定到一个特定的本地地址和端口上。这通常用于服务器端。
int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
if (n < 0)
{
LOG(FATAL, "bind error, %s, %d\n", strerror(errno), errno);
exit(BIND_ERROR);
}
LOG(INFO, "socket bind success\n");
cpp
void InitServer()
{
// 1.创建流式套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(FATAL, "socket error, %s, %d\n", strerror(errno), errno); // strerror->#include<string.h>
exit(SOCKET_ERROR);
}
// 2.0创建struct sockaddr_in 并填充
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = INADDR_ANY;
local.sin_port = htons(_port);
// 2.1 bind 将一个套接字绑定到一个特定的本地地址和端口上。这通常用于服务器端。
int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
if (n < 0)
{
LOG(FATAL, "bind error, %s, %d\n", strerror(errno), errno);
exit(BIND_ERROR);
}
LOG(INFO, "socket bind success\n");
}
4.3 服务器的运行
首先,我们希望服务器一直运行,所以需要设置死循环。其次,服务器进行收发信息要使用到函数recvfrom 与 sendto
4.3.1 recvfrom
recvfrom
函数用于从一个UDP套接字接收数据。它可以用于接收来自任意地址的数据,因此特别适合于UDP服务器。
cpp
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvfrom(int socket, void *buffer, size_t length, int flags, struct sockaddr *address, socklen_t *address_len);
传入参数
socket
: 套接字文件描述符,由socket
函数返回。buffer
: 用于存储接收到的数据的缓冲区指针。length
: 缓冲区的长度。flags
: 通常为 0,也可以是一些控制操作行为的标志,例如MSG_DONTWAIT
(非阻塞操作)。address
: 指向struct sockaddr
的指针,用于存储发送数据的源地址。address_len
: 指向socklen_t
的指针,指示address
的大小,并在函数返回时设置为实际地址的长度。
返回值
成功时返回接收到的数据字节数,失败时返回 -1,并设置 errno
来指示错误。
4.3.2 sendto
sendto
函数用于通过一个UDP套接字发送数据。它可以用于发送数据到指定的地址,因此特别适合于UDP客户端和服务器。
cpp
#include <sys/types.h>
#include <sys/socket.h>
ssize_t sendto(int socket, const void *message, size_t length, int flags, const struct sockaddr *dest_addr, socklen_t dest_len);
传入参数
socket
: 套接字文件描述符,由socket
函数返回。message
: 指向要发送的数据缓冲区的指针。length
: 要发送的数据的长度。flags
: 通常为 0,也可以是一些控制操作行为的标志,例如MSG_DONTWAIT
(非阻塞操作)。dest_addr
: 指向struct sockaddr
的指针,包含目标地址信息。dest_len
: 目标地址结构体的长度。
返回值
成功时返回发送的数据字节数,失败时返回 -1,并设置 errno
来指示错误。
4.3.3 接收数据
首先,所有的操作都要定义在一个 while 的死循环中。其次,因为 recvfrom 中需要使用缓冲区,所以还要定义一个缓冲区。同时, recvfrom 可以标明发送数据的源地址,所以可以定义一个 sockaddr_in 的结构体,用于存储发送数据的源地址,当接收成功时,可以使用之前定义的 InetAddr 类来接收该源地址。
cpp
void Start()
{
_isrunning = true;
while (_isrunning)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer), 0, (struct sockaddr*)&peer, &len);
if (n > 0)
{
buffer[n] = 0;
InetAddr addr(peer);
LOG(DEBUG, "get message from [%s:%d]: %s\n", addr.Ip().c_str(), addr.Port(), buffer);
}
}
_isrunning = false;
}
4.3.4 发送数据
既然已经接收到数据了,我们需要让客户端知道服务端已经接收到了数据,所以当接收数据成功时,在使用 sendto 发送数据至客户端。
cpp
void Start()
{
_isrunning = true;
while (_isrunning)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer), 0, (struct sockaddr*)&peer, &len);
if (n > 0)
{
buffer[n] = 0;
InetAddr addr(peer);
LOG(DEBUG, "get message from [%s:%d]: %s\n", addr.Ip().c_str(), addr.Port(), buffer);
sendto(_sockfd, buffer, strlen(buffer), 0, (struct sockaddr *)&peer, len);
}
}
}
以下是为什么服务端需要接收数据还需要传输到客户端的原因,究其原因还是与Udp最初的开发有关:
在 UDP 服务器接收到客户端的信息后使用 sendto
函数发回响应,是为了实现双向通信,使得客户端可以知道服务器已经正确接收到并处理了请求。以下是这种设计背后的主要原因:
1. 确认信息接收
在无连接的 UDP 协议中,数据包的发送和接收是独立的,且没有内建的机制来确认数据包是否成功到达对方。通过服务器发回一个响应,客户端可以确认其发送的信息已经被接收到并处理。
2. 双向通信
多数网络应用需要双向通信,不仅客户端需要向服务器发送数据,服务器也需要向客户端发送数据。比如,客户端发送请求数据,服务器处理后返回相应的结果。这种交互模式在很多应用场景中都是必须的。
3. 应用层协议实现
通过在应用层协议中定义请求-响应模式,可以更好地实现和管理通信过程。服务器接收到请求后返回响应,是许多协议(例如 DNS、DHCP 等)基本工作方式的一部分。
4. 保持通信会话
在某些应用中,客户端和服务器需要保持持续的通信会话。服务器向客户端发回响应,可以作为会话的一部分,确保双方在同一上下文中进行通信。
4.4 UdpServer.hpp
cpp
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <cstring>
#include <netinet/in.h>
#include "Log.hpp"
#include "InetAddr.hpp"
enum
{
SOCKET_ERROR = 1,
BIND_ERROR,
USAGE_ERROR
};
static const int gdefaultsockfd = -1;
class UdpServer
{
public:
UdpServer(uint16_t port) : _sockfd(gdefaultsockfd), _port(port), _isrunning(false)
{
}
void InitServer()
{
// 1.创建流式套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(FATAL, "socket error, %s, %d\n", strerror(errno), errno); // strerror->#include<string.h>
exit(SOCKET_ERROR);
}
// 2.0创建struct sockaddr_in 并填充
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = INADDR_ANY;
local.sin_port = htons(_port);
// 2.1 bind 将一个套接字绑定到一个特定的本地地址和端口上。这通常用于服务器端。
int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
if (n < 0)
{
LOG(FATAL, "bind error, %s, %d\n", strerror(errno), errno);
exit(BIND_ERROR);
}
LOG(INFO, "socket bind success\n");
}
void Start()
{
_isrunning = true;
while (_isrunning)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
buffer[n] = 0;
InetAddr addr(peer);
LOG(DEBUG, "get message from [%s:%d]: %s\n", addr.Ip().c_str(), addr.Port(), buffer);
sendto(_sockfd, buffer, strlen(buffer), 0, (struct sockaddr *)&peer, len);
}
}
}
~UdpServer()
{
}
private:
int _sockfd;
uint16_t _port;
bool _isrunning;
};
如果以后有其他的业务,可以在类内定义一个回调函数成员指针或者使用 function 封装一个回调函数,在构造函数中传入该回调函数,并在 Start 中执行相应的回调函数即可,大致思路如下,具体改动的是 Start 中 sendto 的传入参数。
cpp
using func_t = std::function<std::string(const std::string&, bool &ok)>;
class UdpServer
{
public:
UdpServer(uint16_t port, func_t func) : _sockfd(defaultfd), _port(port), _isrunning(false), _func(func)
{
}
void Start()
{
while ()
{
if ()
{
std::string response = _func(request, ok);
sendto(_sockfd, response.c_str(), response.size(), 0, (struct sockaddr *)&peer, len);
}
}
}
private:
int _sockfd;
uint16_t _port;
bool _isrunning;
// 给服务器设定回调,用来让上层进行注册业务的处理方法
func_t _func;
};
五、封装通用 UdpClient 客户端
在 C/C++ 中,argc
和 argv
是命令行参数的标准输入参数,用于在程序启动时获取命令行参数。
argc
(argument count): 表示命令行参数的个数,包括程序名本身。argv
(argument vector): 是一个字符指针数组,包含了命令行输入的参数。argv[0]
通常是程序的名称,argv[1]
到argv[argc-1]
是实际的命令行参数。
当程序正确启动时,应输入以下参数
cpp
./UdpClient 127.0.0.1 8080
argc
的值为 3。argv
的内容如下:argv[0]
是"./UdpClient"
,程序名。argv[1]
是"127.0.0.1"
,服务器 IP。argv[2]
是"8080"
,服务器端口。
cpp
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
void Usage(std::string proc)
{
std::cout << "Usage:\n\t" << proc << " serverip serverport\n"
<< std::endl;
}
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]);
// 1. 创建socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
std::cerr << "socket error" << std::endl;
}
// 构建目标主机的socket信息
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());
std::string message;
// 2. 直接通信即可
while(true)
{
std::cout << "Please Enter# ";
std::getline(std::cin, message);
sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server));
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
char buffer[1024];
ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
buffer[n] = 0;
std::cout << "server echo# " << buffer << std::endl;
}
}
return 0;
}