【Linux】套接字编程
LinAlpaca 2024-07-19 14:07:04 阅读 65
目录
套接字
IP + PORT
TCP和UDP的介绍
TCP
UDP
网络字节序
转换接口
UDP服务器的编写
服务器的初始化
socket
bind
sockaddr 结构
服务器的运行
数据的收发
业务处理
客户端的编写
运行效果
拓展
套接字
🌸首先,我们先思考一个问题,数据从 A 主机发送到 B 主机是网络通信的最终目的吗?
🌸显然不是的,我们进行网络通信是为了二者能通过某种协同方式,共同完成一个任务。因此,数据传输到 B 主机的传输层后并不能就此结束,还要向上交付给应用层。
🌸同时,我们还应该注意到,客户端与服务端本质上都是运行起来的服务,即二者都是正在运行的进程。
🌸因此,网络通信的本质是进程间通信。
IP + PORT
🌸所以在网络通信的过程中必定经历这两个步骤。
先将数据通过OS,将数据发送到目标主机。再将本主机收到的数据,推送给自己上层的指定进程。
🌸我们知道通过 IP 地址定位一台主机,而在网络中我们使用 port 即端口号(2字节)来定位主机上的进程。
🌸这时候我们突然想起来,之前在系统中不是使用了 pid 作为进程的唯一标识符吗?那这里为什么不继续使用 pid 标识进程呢?
🌸我们需要知道的是,pid 是属于操作系统部分的概念,若直接在网络中使用 pid 则会增加操作系统和网络直接的耦合度,当数据需要修改时则牵一发而动全身。
🌸因此,使用 IP + PORT就可以定位到互联网中的唯一进程,即网络通信的本质是通过 IP 和 PORT 构建进程的唯一性,基于网络的进程间通信。
🌸而通过IP和PORT来标志进程唯一性的方案就叫做套接字通信(socket)。
TCP和UDP的介绍
🌸在传输层我们有两个十分常见的传输协议,分别是 TCP 和 UDP 协议,下面就分别介绍一下二者的区别。
TCP
🌸TCP是一种有连接,可靠传输且面向字节流的一种传输协议。
🌸面向字节流就好比家里的自来水,你要用多少就接收多少,而还未使用的数据就以流的形式存放在缓冲区中。
🌸而可靠传输体现在 TCP 需要保证对方收到对应消息,若未收到就会进行重发。
UDP
🌸UDP则是无连接,不可靠传输且面向数据报的传输协议。
🌸面向数据报的形式就像是我们收快递那样,一次至少接收一个完整的快递,不能收半个快递。
🌸需要注意的是,可靠性是一个中性词,并没有谁好谁坏,因为 UDP 在传输过程中并不关心对方是狗收到对应的报文,所以传输的过程中若丢失了对应的数据报就是真的丢失了。
🌸TCP 保证可靠性自然需要做更多的工作来维护,因而使用成本较高,而 UDP 并不保证,因此使用起来比较简单,二者并无谁优谁劣。
网络字节序
🌸我们都知道多字节的数据在内存中存放具有大小端之分,不同主机间的存储方式也不同,那么在网络通信过程中该如何解决这个问题呢?
🌸TCP/IP 协议规定,网络数据流统一采用大端字节序,因此若当前发送的主机是小端机就需要先将数据转成大端,再进行通信。
转换接口
🌸对于整数的转换,有以下的接口,函数的名字和作用很好记,h 代表 host 即当前主机,n 代表 net 即网络序列,后面的 s 为 16 位整数,l 为 32 位整数。如 htons 就是将 16 位整数由当前主机序列转化为网络序列。
UDP服务器的编写
🌸接下来我们一起来学习一下 UDP 服务器是如何编写的吧。
🌸为了方便管理,这里直接将服务器封装起来了,对于一个服务器对象需要将其初始化,接着才能让它运行起来,因此一开始的类中,便需要以下几个成员函数。
<code>namespace Alpaca
{
class UdpServer
{
public:
UdpServer()
{}
~UdpServer()
{}
void InitServer()
{}
void Start()
{}
};
}
🌸而在主函数中,只需要创建一个服务器的对象就能让他运行起来了。
int main(int argc, char *argv[])
{
unique_ptr<UdpServer> usvr(new UdpServer());
usvr->InitServer();
usvr->Start();
return 0;
}
服务器的初始化
socket
🌸首先介绍的便是 socket 函数,其用于创建套接字打开网络文件。
🌸其中第一个参数用于选择通信的协议族,有以下几种可以选择,一般我们进行网络通信填 AF_INET 即可。
🌸其二的 type 参数用于指定通信语义,还记得我们上面讲的 TCP 是面向字节流的一种协议,而 UDP 则是面向数据报的一种协议,因此若是使用 TCP 协议直接使用 SOCK_STREAM 即可,而使用 UDP 则填入 SOCK_DGRAM。
🌸最后一个参数默认为 0 即可。
🌸而 socket 函数的返回值是一个文件描述符,就像我们在文件系统那样,需要先存起来,之后还会用到。
<code>_sock = socket(AF_INET, SOCK_DGRAM, 0); //用成员先存起来
🌸因为返回的值表示为文件描述符,所以当返回值 < 0 时则说明创建套接字失败,便不能进行接下来的操作,直接结束进程。
if (_sock < 0)
{
std::cout << "create socket error: " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
🌸这里返回的错误码另外定义就行,这里我是使用枚举来定义的。
enum
{
USAGR_ERR = 1,
SOCKET_ERR,
BIND_ERR
};
🌸如此,我们便成功创建了套接字。
bind
🌸成功创建套接字后,我们需要将套接字与 IP 和端口进行绑定,使用的便是 bind 函数。
🌸第一个参数自然就是先前创建的文件描述符,而第二个参数则需要接下来着重介绍了。
sockaddr 结构
🌸sockaddr 这个结构就类似于使用C语言的方法简单实现了一个多态的处理方式,当头部的地址类型为 AF_INET 就以 struct addr_in 的方式解析结构体,若是 AF_UNIX 则使用 struct addr_un 的方式解析。
🌸而 AF_INET 和 AF_UNIX 在上方 socket 函数就介绍过了,即我们需要进行网络通信时则填充 struct addr_in,而要本地通信则填充 struct addr_un,强转后传给 bind 函数即可。
🌸接下来我们便需要对 sockaddr_in 结构体进行填充,需要注意的是,这里填入数据需要以网络字节序的形式,同时我们也有对应的接口协助我们进行转换,端口的转换使用 htons ,而 IP 则可以使用 inet_adddr 进行转换。
🌸但由于这里我使用的是云服务器,因此并不需要绑定 IP 地址,因此这里填入的便是 INADDR_ANY,若是使用虚拟机便需要绑定对应的 IP 地址。
<code>struct sockaddr_in local;
bzero(&local, sizeof local); //清空操作可选可不选
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(_sock, (sockaddr*)&local, sizeof(local))) //传参时需要强转
{
std::cout << "bind socket error: " << strerror(errno) << std::endl;
exit(BIND_ERR);
}
🌸同时,绑定的端口我们可以自己默认设置,或者使用命令行参数进行传入。
🌸这里我将从命令行参数里面提取对应的端口,然后通过构造函数构造出对应的服务器对象。
void usage(string proc) //使用提示
{
cout << "Usage:\n\t" << proc << " port" << endl;
}
int main(int argc, char* argv[])
{
if (argc != 2)
{
usage(argv[0]);
exit(USAGR_ERR);
}
uint16_t port = atoi(argv[1]);
unique_ptr<UdpServer> usvr(new UdpServer(port));
usvr->InitServer();
usvr->Start();
return 0;
}
🌸由此,我们便完成了 UDP 网络通信的前期准备,接下来只要根据业务运行服务器即可。
服务器的运行
🌸完成了服务器的初始化,接下来就是服务器运行函数的实现了。下面我们就简单地实现一个收发操作吧。
数据的收发
🌸对于数据的接收,我们使用的是 recvfrom 这个函数,使用的情况与文件操作中的读取操作并无太大区别,但值得注意的是后面两个参数为输入输出型参数,用于接收发送者的相关信息,我们便能够从中提取出对应的 IP 与端口。
🌸同样的,为了读取的数据接下来使用,在读取时要给 \0 留一个位置,读取完对应的内容再加上,同时,我们服务器提供的服务是时刻运行的,因此还需要持续不断的循环。
<code>void Start()
{
char buffer[1024];
while (true)
{
// 接收信息
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int n = recvfrom(_sock, buffer, sizeof(buffer) - 1, 0, (sockaddr*)&peer, &len);
if (n > 0)
buffer[n] = '\0';
else
continue;
}
}
🌸而发送信息则使用 sendto 这个函数,最后两个函数代表我们要将这个消息发送给谁。
<code>sendto(_sock, buffer, strlen(buffer), 0, (sockaddr *)&peer, sizeof(peer));
🌸我们便可以将接收到的数据打印出来,再发回给客户端,完成一个简单的交互。
🌸而客户端的相关数据就存在返回回来的 sockaddr_in 结构体中,经过转换就能够直接输出了。
void Start()
{
char buffer[1024];
while (true)
{
// 接收信息
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int n = recvfrom(_sock, buffer, sizeof(buffer) - 1, 0, (sockaddr*)&peer, &len);
if (n > 0)
buffer[n] = '\0';
else
continue;
// 提取客户端信息
std::string clientip = inet_ntoa(peer.sin_addr);
uint16_t port = ntohs(peer.sin_port);
std::cout << "get message from " << clientip << "-" << port << ": " << buffer << std::endl;
// 发送信息
sendto(_sock, buffer, strlen(buffer), 0, (sockaddr*)&peer, sizeof(peer));
}
}
业务处理
🌸客户端向服务器发送数据,一定需要服务器提供某种服务,自然不是简单的收发操作。
🌸因此,我们再外部定义一个函数,将其作为服务器类中的一个成员,当需要使用服务时就回调对应的函数。
🌸为了方便,我们事先使用了包装器,对函数类型进行包装。
using func_t = std::function<std::string(std::string)>;
🌸这里我们可以简单写一个服务用于将小写字符转成大写。
std::string transString(std::string request)
{
for (auto& c : request)
{
if (islower(c))
c = toupper(c);
}
return request;
}
🌸接着在构造的时候传入类中即可。
unique_ptr<UdpServer> usvr(new UdpServer(transString, port));
🌸最后,当我们收到对应的数据时,先对其进行处理,经过服务后再发回客户端。
void Start()
{
char buffer[1024];
while (true)
{
// 接收信息
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int n = recvfrom(_sock, buffer, sizeof(buffer) - 1, 0, (sockaddr*)&peer, &len);
if (n > 0)
buffer[n] = '\0';
else
continue;
// 提取客户端信息
std::string clientip = inet_ntoa(peer.sin_addr);
uint16_t port = ntohs(peer.sin_port);
std::cout << "get message from " << clientip << "-" << port << ": " << buffer << std::endl;
std::string resp = _service(buffer);
// 发送信息
sendto(_sock, resp.c_str(), resp.size(), 0, (sockaddr*)&peer, sizeof(peer));
}
}
客户端的编写
🌸完成了服务器的编写,客户端的流程也有异曲同工之处,由于服务器已经封装过了,这里的客户端我们便直接在主函数中写了。
🌸首先确定的一点便是,客户端一定是知道服务器 IP 和端口号的,因此我们可以在客户端启动的时候从命令行里获取。
void usage(std::string proc)
{
cout << "Usage:\n\t" << proc << " serverip serverport" << endl;
}
int main(int argc, char* argv[])
{
if (argc != 3)
{
usage(argv[0]);
return USAGR_ERR;
}
std::string serverip = argv[1];
uint16_t serverport = atoi(argv[2]);
}
🌸接着,便是开始网络通信的前期准备,即创建套接字,和上面讲过的方式并无差别,这里就直接跳过了。
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0)
{
std::cerr << "create socket error: " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
🌸值得注意的一点来了,虽然客户端也要进行 bind ,但并不需要我们自己 bind ,也不要自己 bind ,操作系统会自动给我们 bind。
🌸主要原因是,如果明确写死了端口号便可能与其他客户端的端口发生冲突,因此由 OS 为客户端分配空闲的端口。
🌸既然不用绑定端口,那么接下来我们就可以进行数据发送的准备工作了,在上面因为我们是直接拿接收下来的发送方的信息作为对象发送数据。
🌸这里需要先将服务端的信息填充进结构体。
sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
server.sin_addr.s_addr = inet_addr(serverip.c_str());
🌸接着便能进入循环的服务中,我们可以通过命令行获取要发送的信息,再使用一个缓冲区接收服务端发送回的数据,当收到数据时就将对应的数据输出即可。
while (true)
{
std::string message;
cout << "please Enter# ";
getline(cin, message);
sendto(sock, message.c_str(), message.size(), 0, (sockaddr*)&server, sizeof(server));
char buffer[1024];
struct sockaddr_in tmp;
socklen_t len = sizeof(tmp);
int n = recvfrom(sock, buffer, sizeof(buffer) - 1, 0, (sockaddr*)&tmp, &len);
if (n > 0)
{
buffer[n] = '\0';
cout << "server echo# " << buffer << std::endl;
}
}
运行效果
🌸将服务器和客户端运行起来,尝试向服务器发送信息,便成功收到其回应,同时当我们输入的信息有小写的字符时便会将其转换成大写。
拓展
🌸好了,这下我们搭建的服务器已经初具雏形了,接着可以往几个方向进行拓展,比如增加多线程的模块,分配一个线程专门进行数据的接收,而另一个线程则进行数据的发送。同时,可以将发送过信息的 IP 与端口加入注册表中,用 hash 进行维护,一旦接收到信息就向其中所有用户进行广播,便可以构成一个小型的聊天组。
🌸好了,我们今天的内容到这里就结束了,如果这篇文章对你有用的话还请留下你的三连加关注。
声明
本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。