【LINUX网络】网络socet接口的基本使用以及实现简易UDP通信
根据本系列上两篇关于网络的初识介绍,现在我们开始实现一个UDP接口,以加强对该接口的理解。
1 . 服务器端
在本篇中,主要按照下面内容来实现:
创建并封装服务端:了解创建服务端的基本步骤
创建并封装客户端,测试客户端和服务端通信:了解创建客户端的基本步骤和二者通信
测试云服务器与本地进行通信:从本地通信到真正实现网络通信
根据上面的内容,本次设计的服务器功能就是接受客户端发送的信息并向客户端返回服务端收到的信息
老规矩,先设计整体的makefile,目的是生成两个可执行文件。
CC=g++
LDFLAGE=-o
FLAGE=-std=c++17 -lpthreadServer_src=UDP_Server.cc
Client_src=UDP_Client.cc# SRC=UDP_Client.cc UDP_Server.cc
# OBJ=$(SRC:.cc=.o)Server=UDP_Server
Client=UDP_Client.PHONY:all
all:$(Server) $(Client)$(Server):$(Server_src)@$(CC) $^ $(LDFLAGE) $@ $(FLAGE)$(Client):$(Client_src)@$(CC) $^ $(LDFLAGE) $@ $(FLAGE)@echo "compilation success".PHONY:clean
clean:@rm -rf $(Server) $(Client)@echo "clean done"
然后搭建Server端的框架:
服务器端首先肯定需要被初始化,然后再永不停息的start起来(预判里面可能会使用while(1)的死循环)
1.1 socket sockaddr
什么是socket套接字:
套接字是通信的端点,它是一种用于在网络上进程间通信的机制。可以把它想象成一个管道或者接口,应用程序通过这个管道可以发送和接收网络数据。
套接字是操作系统提供的一种抽象概念,它屏蔽了底层网络通信的复杂细节,使得程序员可以方便地进行网络编程。例如,当我们在浏览器中访问一个网页时,浏览器和网页服务器之间就通过套接字进行数据传输,包括请求网页内容和返回网页数据等操作。
现在就要正式开始对服务器的相关信息、接口进行设置。首先需要创建socket文件描述,可以使用
socket
接口,该接口原型如下:int socket(int domain, int type, int protocol);
首先来学习socket接口及其参数,先把两个头文件加上去。
第一个参数表示这个socket套接字想选择的域或者协议家族(domain),domain(域) 是套接字的一个重要属性,它指定了套接字所使用的协议族(protocol family)。协议族决定了套接字能够与哪些类型的网络地址进行通信(不同类型的网络地址遵循不同的协议,以下是常用的协议族:
AF_UNIX AF_LOCAL都是用于本地通信的协议族,AF_INET AF_INET是适用于网络的协议族,前者适应的是IPv4,后者是IPv6。
第二个参数type:
比如常见的,TCP是面向字节流的传输方式,type选SOCK_STREAM ; UDP是数据包传送方式,选择SOCK_DGRAM。可以说,我们今天是在实现UDP,所以先认识了UDP的特性——事实上,是SOCK_DGRAM的特性决定了UDP的特性
根据Linux手册描述:
SOCK_STREAM:Provides sequenced, reliable, two-way, connection-based byte streams(提供序列化的、可靠的、双工的、面向有连接的字节流)
SOCK_DGRAM:Supports datagrams (connectionless, unreliable messages of a fixed maximum length)(支持数据包,即无连接、不可靠的固定长度信息)
通信模式可分为:
全双工:双方同时收发,如同电话通话。
半双工:双方均可收发,但同一时间只能一方传输,类似对讲机。
单工:数据单向传输,如电视台广播。
第三个参数
第三个参数表示指定采用的具体协议。通常传入0表示让系统自动选择适合
domain
和type
参数的默认协议返回值
该接口返回值为一个新套接字的文件描述符(LINUX下一切皆文件,套接字也是一个文件),否则返回-1并设置错误码
理解socket和sockaddr的关系:
初始化一个socket后,我们已经有了这样一个endpoint for communication(通信端点),但是需要把这个用文件描述符描述的通信端点 绑定 到一个具体的网络地址(网络上的一个地址,包括IP地址和端口号等信息。),所以我们还要绑定现在这个socket到我们具体的sockaddr上去。
地址多种多样,可能是AF_INET的地址,也可能是AF_UNIX的地址。
本来AF_INET是用socketaddr_in标记,AF_UNIX用socketaddr_un标记,但是为了实用性把他们两个给继承到了一个统一的socketaddr里,所以就变成了socketaddr来标记所有的套接字,在使用的时候通过强转来找到对应的结构。
在逻辑上再次理解domain域和type
由AF_INET+SOCK_DGRAM形成的UDP
由AF_UNIX+TCP形成的本地通信。
![]()
代码实现:
void InitServer(){ENABLE_FILE_LOG;//日志文件使能//1.创建套接字int sock_fd = ::socket(AF_INET,SOCK_DGRAM,0);if(sock_fd<0){Die(1);LOG(LogLevel::FATAL)<<"socket: "<<strerror(errno);}//创建成功,看看套接字LOG(LogLevel::INFO)<<"socket success , socket fd is :"<<sock_fd;//2.填充网络信息并bind}
关于LOG:【LINUX操作系统】日志系统——自己实现一个简易的日志系统-CSDN博客
可以转到socket参数的宏中去看一下:
1.2 关于bind
该接口的第一个参数表示需要绑定的套接字对应的文件描述符,第二个参数表示套接字结构,第三个参数表示套接字结构的大小
如果绑定成功,该接口返回0,否则返回-1并设置错误码
对于第一个参数来说,就是希望被绑定的套接字;第三个参数表示传入的第二个sockaddr的长度,因为第二个参数一般情况下存在强转,需要知道具体这个信息标签有多长。
下面就第二个参数详细介绍:
在[Socket编程基础]部分提到sockaddr可以理解为sockaddr_in结构和sockaddr_un的父类,而因为本次创建的是网络通信,所以要使用的结构就是sockaddr_in(in后缀表示inet,un后缀表示unix),既然参数部分是sockaddr结构而不是sockaddr_in,那么在传递实参时就需要进行强制类型转换。
简单实现一个强转的宏
#define CONV(addr) (const sockaddr*)(addr)
现在,我们已经在自己的函数栈上创建好了套接字,也完成了绑定。唯独就是in_addr的信息还没有绑定。
那么,既然需要用户传递sockaddr_in结构,那么这个结构中就存在一些属性需要用户去设置————地址类型、端口号、IP地址共三种字段需要我们去设置。
注意,此时我们还在“自己和自己玩”,还和其他机器通信毫无关系。sockaddr的这个“派生类”描述的都是自己这个套接字的信息!填充位留在那里就好了
观察一下sockaddr_in(红字中的派生类)的结构,知道结构才能赋值。
其中关于sin(sock inet)有一个宏,结构如下:
传进去的是sin,##表示将两边的符号相结合,所以第一个宏定义的参数其实是sin_family
,表示协议簇。也就是我们说的domain。
明明socket的时候都填了一次,怎么还要填一次?
创建套接字的时候域或者协议簇选择一次(告诉操作系统套接字类型),sockaddr_in内部再赋值一次,两个要一样才能让操作系统绑定上!
最后,关于第三个结构体sin_addr
C语言不支持结构体直接整体赋值,因此我们需要内部数据一个一个赋值,不过此处的in_addr只有一个数据,就很方便直接赋值。
作为服务器,自己的端口号和IP地址肯定是会被知道的:
![]()
注意,socketaddr_in还需要引入头文件<netinet/in.h> <arpa/inet.h>,结合前面两个,构成网络四大头文件
可以用指令man inet_addr查到。
不过可不能简单把_port写进去,还需要满足网络字节序
![]()
IP用十进制点分法的string表示只是方便观察,但内核存储不应该是4字节?所以肯定要想办法改变。既要从字符串变成一个uint32_t的数据,还要符合网络字节序列,只使用一个简单的htonl(host to net long)肯定是不现实的。
新的接口 inet_addr:直接将一个const char*的东西返回成一个符合网络字节序和规则的IP地址。
inet_addr_t是in_addr的返回值,也就是IP在网络字节序的存在形式,也是
也是对应结构体的数据类型
现在只需要我们把初始的port和IP设置进来即可。
127.0.0.1
是一个特殊的 IPv4 地址,称为 回环地址(Loopback Address)。它用于标识本地计算机本身:
127.0.0.1
用于在本地计算机上进行网络通信,而无需通过外部网络。(相当于这个IP发出去时:自己的网卡放出去然后不进入网络,直接又自己的网卡接受信息)
它常用于测试和调试网络应用程序,确保程序能够在本地环境中正常运行。
选择端口号8080,这是一个不会被使用的端口(0-1023都是绑定好的协议端口号)
为什么sockaddr需要有端口号和IP?
因为报文是需要返回的,返回的时候需要知道是哪个socket发出来的(寄包裹总得知道是谁寄出去的)
所以报头必须有原IP和原端口号,服务器一定会把这两个信息也推送给对方。
现在终于完成了bind。bind之后,这个套接字才算设置进内核中
最后,在进行填数据之前,因为有各种占位的原因,建议先把这个sockaddr_in清空。
1.3 Start
Start的整体思路:一个isrunning的状态标记是否启动,然后需要在一个while(true)下面,不停的通过recvfrom接口和sendto接口以收发消息(还需要一个缓冲数组首发传的消息)。(这是一个全双工的接口,支持又读又写)
在while true里进行这个接受。
1.4 recvfrom 与 sendto
recvfrom
和sendto
是两个用于网络编程的系统调用,它们是针对数据报套接字(如使用SOCK_DGRAM
类型的套接字)进行通信的接口。
src_addr addrlen都是输出型参数,recvfrom作为接受方,需要知道谁来连接了我们这个socket(收到包裹的时候,都会有一个快递单号,上面写了谁寄出来的)。flags设置为0表示是阻塞式接受,所以如果我们设置为0,进程会卡在原地等待此次网络通信。
sendto:
两者的返回值都是成功接受/发送的长度
想从服务器回消息到主机去,就需要一个sendto接口回消息。
void Start(){_isrunning = true;while(true){char inbuffer[SIZE];sockaddr_in* peer;socklen_t peer_len = sizeof(peer);int n = recvfrom(_sockfd,inbuffer,sizeof(inbuffer)-1,0,CONV(peer),&peer_len);if(n>0){std::string echo = "#echo: ";LOG(LogLevel::DEBUG)<<inbuffer;echo += inbuffer;int n = sendto(_sockfd,echo.c_str(),echo.length(),0,CONV(peer),peer_len);}}_isrunning = false;}
顺便修正一下之前的一个小bug:之前是直接使用的指针,没有开辟空间。
下次别这么玩了。直接sockaddr_in一个该多好,整的又是段错误又是绑定失败的..........
补充:可以说,recvfrom和sendto的最后两个参数都是去描述通信的另一端的,前面的sock_fd都是描述自己这一端的
验证实验成果:
结合指令:netstat -unap
netstat
是一个非常有用的网络工具,用于显示网络连接、路由表、接口统计信息、伪装连接以及多播成员信息等。
2. 客户端
服务器是被别人联的,所以刚刚的而客户端的peer根本不需要知道是谁,直接当作输出型参数即可。而客户端必须知道服务器的IP和端口,客户端需要主动给服务器发数据,去申请内容。
这样的模式称之为CS(Client Server )模式:
CS模式是一种基本的网络通信模型,它定义了客户端和服务器之间的通信关系。这种模式在现代网络应用中非常普遍,因为它提供了一种可靠、可扩展和安全的方式来组织网络服务。通过CS模式,客户端可以方便地访问服务器提供的服务,而服务器则可以集中管理和控制服务的提供。
此处我们不在使用.hpp去封装他,直接写到main函数里:
首先,客户端必须要拿到具体的服务器IP和服务器端口号(实际中可能这两个东西是封装在客户端内部的)
发现此时还是要封装Die等宏,所以索性都引进到一个Commn头文件里。
之前的sockaddr_in中的结构体也做过类似思路的行为:
按照之前的逻辑,我们完成如下:
int main(int argc,char* argv[])
{ ENABLE_CONSOLE_LOG;if(argc!=3){LOG(LogLevel::ERROR)<<"Usage "<<argv[0]<<": 127.0.0.1 8080";Die(1);}//0.获取服务器的套接字信息std::string server_ip = argv[1];uint16_t server_port = std::stoi(argv[2]);//1.创建套接字int sock_fd = socket(AF_INET,SOCK_DGRAM,0);if(sock_fd<0){LOG(LogLevel::FATAL)<<"socket fail";Die(2);}//2.填写server信息//因为后续要在sendto中直接给server通信sockaddr_in server_socket;server_socket.sin_family = AF_INET;server_socket.sin_port = ::htons(server_port);server_socket.sin_addr.s_addr = ::inet_addr(server_ip.c_str());// //3.进行bind,并且设置进内核// int ret = bind(sock_fd,CONV(&server_socket),sizeof(server_socket));while(true){std::cout<<"Please Enter"<<std::endl;std::string Message;std::getline(std::cin,Message);int n = ::sendto(sock_fd,Message.c_str(),Message.length()-1,0,CONV(&server_socket),sizeof(server_socket));}}
注意,我们注释掉了bind那一步:
客户端不需要bind
实际上,客户端并不需要绑定IP地址和端口。
注意,不需要不绑定表示:不需要程序员手动绑定,而是由OS自动绑定
理由如下:
比如你的手机,同时有淘宝客户端,LOL客户端,美团客户端,如果三个公司的工程师A\B\C描述自己的sockaddr的时候,都把同一个端口号描述成自己客户端的端口号,就会出现矛盾。
如果客户端由程序员绑定,那么假设有两个公司上线的客户端使用的端口是一样的,就会出现一个软件先打开之后可以正常收到服务器发送的数据,但是另外一个软件的服务器就无法正确发送信息到对应的软件上,即一个端口只能对应一个进程,但是一个进程可以有多个端口。
那么,客户端难道不需要端口吗?并不是,如果客户端没有端口,那么服务器只能通过IP地址找到具体客户端设备,但是找不到对应的进程,既然如此,客户端的端口怎么确定?实际上这个端口由操作系统自行随机分配。所以,端口号会在第一次sendto之后自动绑定。
那么服务器端又为什么需要程序员手动绑定端口号?
因为服务器端口号如果是随机的,而软件中请求服务器的端口号是固定的,那么一个软件可能在某一天可以正常收到服务器发送的数据,但是下一次因为服务器端口号是变化的,就无法正常收到信息。也就是说,端口号高概率会内置在客户端中,如果服务器一直变化,就很难通信。
综上所述,服务器端需要程序员手动绑定IP地址和端口号,而客户端不需要程序员手动绑定IP地址和端口号,由操作系统自行分配并绑定启动
服务器端口号不仅不改变,还要尽量做到“众所周知”,方便大家来连接。
一般在公司中,一个准备上线的项目都需要去公司后台申请,申请到了对应的端口才能使用。
所以在这段代码中,sock_fd描述的是发送方的动作,server_socket是希望接受发送动作的信息的地址。
因此,两个东西本来含义就不匹配,在含以上肯定不能bind。结合上面的内容我们可以知道,这个sock_fd最终被OS自动绑定。
最终调试可运行代码效果:
第一版本到此结束,源码在这里:
EchoServer_V1 · lsnmjp/code of cpp Linux 算法 - 码云 - 开源中国
现在能本地通信了,从此我们似乎以及不再需要之前的SYSTEM V体系了.......
准备联入网络。
3. demo代码优化
3.1 从sockaddr_in中取数据
作为服务器,我们希望在传输出型变量peer出去之后将peer利用起来。
所以我们学习使用接口:
其中ntohs肯定可以用来获得port
其次,想要将IP转化为char*方便我们看,所以有:inet_ntoa
所以:
int n = recvfrom(_sockfd,inbuffer,sizeof(inbuffer)-1,0,CONV(&peer),&peer_len);//此时peer已经获得了对应的数据 uint16_t client_port = ::ntohs(peer.sin_port); //网络字节序列转主机序列 std::string client_IP = ::inet_ntoa(peer.sin_addr);//转换到主机序列;转换成字符串
再在服务器echo的时候包装一下:
57817就是我们的客户端自动被OS绑定的端口号!
3.2 是否需要IP地址
修改服务器端的绑定网络:
刚刚一直使用的是127.0.0.1的本地网卡,现在试试手动输入一个——如果以机器的公网IP为例输入会发现:
之所以出现这个问题,是因为云服务器的公网IP地址是不允许用户自行绑定的
解决这个问题之前,我们思考一下,启动一个服务器真的必须要绑定IP吗?
一台服务器可能有多个IP地址,此时如果服务器固定IP地址,那么此时就会出现服务器只能接收传送到固定IP地址的信息,就算服务器有很多IP地址也只有一个IP地址可以使用,很明显这个效果并不符合UDP协议的特点,因为UDP协议是面向无连接的,既然都不需要连接,为什么还需要指定IP地址,所以启动服务器不需要指定IP地址。
![]()
宏INADDR_ANY表示可以接受该机器所拥有的所有IP。注意,并不是通过INADDR_ANY去找机器,找机器的时候还是通过客户端所内置的IP地址。但是在找到之后,在服务器的视角,只要是属于自己的IP,都照单全收。
并且在构造函数和private下面可以不再需要IP变量。
此时的状态是,server不需输入IP,但是client依然靠已知的IP去找server。
当下代码,已经可以在多个LInux上跑了,也就是可以让多个Linux机器之间互动。
甚至也可以在Linux和win下通过网络通信(了解):
win的内核层一模一样:
3.3 字节序列以及设置地址封装
为了便于之后的sockaddr_in地址和我们希望看到的string ip与uint16_t port更直观,我们进行以下封装:
#pragma once#include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h>namespace Inet_Addr {class InetAddr{private:void Host2Net()//如果先拿到主机,我们希望转向网络{_net_addr.sin_family = AF_INET;_net_addr.sin_port = ::htons(_port);_net_addr.sin_addr.s_addr = INADDR_ANY;}//如果先拿到网络,我们希望将地址转向主机void PortNet2Host(){_port = ::ntohs(_net_addr.sin_port);}void IPNet2Host(){_ip = }public:InetAddr(){}InetAddr(const struct sockaddr_in& net_addr):_net_addr(net_addr)//如果先拿到网络,我们希望将地址转向主机{PortNet2Host();IPNet2Host();}InetAddr(uint16_t port):_port(port)//如果先拿到主机,我们希望将地址转向网络{Host2Net();}private:struct sockaddr_in _net_addr; // 网络字节标准uint16_t _port; // 主机标准std::string _ip; // 主机标准}; }
此处的ip本可以用之前学习的inet_ntoa转换。
但其实,作为一个返回char*的C语言函数并不安全。指针会在inet_ntoa函数内部维护一段静态空间,在多线程情况,这个空间可能被覆盖。
3.3.1 线程安全接口
我们采用一种更加线程安全的inet_ntop方法:
void IPNet2Host(){char buffer[64];_ip = ::inet_ntop(AF_INET,&_net_addr.sin_addr,buffer,sizeof(_net_addr));}
会把转换好的数据先放在buffer里,然后再赋值给_ip。
因为buffer是在线程栈是创造的,所以不会矛盾。
继续封装:
#pragma once
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Commn.hpp"namespace Inet_Addr
{class InetAddr{private:void Host2Net()//如果先拿到主机,我们希望转向网络{_net_addr.sin_family = AF_INET;_net_addr.sin_port = ::htons(_port);_net_addr.sin_addr.s_addr = INADDR_ANY;}//如果先拿到网络,我们希望将地址转向主机void PortNet2Host(){_port = ::ntohs(_net_addr.sin_port);}void IPNet2Host(){char buffer[64];_ip = ::inet_ntop(AF_INET,&_net_addr.sin_addr,buffer,sizeof(_net_addr));}public:InetAddr(){}InetAddr(const struct sockaddr_in& net_addr):_net_addr(net_addr)//如果先拿到网络,我们希望将地址转向主机{PortNet2Host();IPNet2Host();}InetAddr(uint16_t port):_port(port)//如果先拿到主机,我们希望将地址转向网络{Host2Net();}uint16_t GetPort(){return _port;}std::string& GetIP(){return _ip;}struct sockaddr* NetAddr(){return CONV(&_net_addr);}socklen_t NetAddrLen(){return sizeof(_net_addr);}private:struct sockaddr_in _net_addr; // 网络字节标准uint16_t _port; // 主机标准std::string _ip; // 主机标准};
}
到客户端和服务器中去封装:
改动后的完整代码:
#ifndef UDP_SERVER__HPP #define UDP_SERVER__HPP#include <iostream> #include <memory> #include <string> #include <string.h>#include "InetAddr.hpp" #include "Log.hpp" #include "Commn.hpp"using namespace LogModule; using namespace Inet_Addr;// global int gsockfd = -1; const static std::string gdefaultIP = "127.0.0.1"; const static uint16_t gdefaultport = 8080;class UdpServer { public:// UdpServer( const std::string& IP = gdefaultIP,uint16_t port = gdefaultport)UdpServer(uint16_t port = gdefaultport): _sockfd(gsockfd), _inetaddr(port)//,_port(port)//,_IP(IP){}void InitServer(){// ENABLE_FILE_LOG;//日志文件使能ENABLE_CONSOLE_LOG;// 1.创建套接字_sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);if (_sockfd < 0){Die(1);LOG(LogLevel::FATAL) << "socket: " << strerror(errno);}// 创建成功,看看套接字LOG(LogLevel::INFO) << "socket success , socket fd is :" << _sockfd;// 2.填充网络信息并bind : 设置进了内核中// struct sockaddr_in* in_addr_;// in_addr_ = new sockaddr_in();// 2.1 填充in_addr的信息// bzero(in_addr_, sizeof(sockaddr_in));//清理// in_addr_->sin_family = AF_INET;// in_addr_->sin_port = ::htons(_port);//端口号// //in_addr_->sin_addr.s_addr = ::inet_addr(_IP.c_str());//1. string ip->4bytes 2. network order// in_addr_->sin_addr.s_addr = INADDR_ANY;// bindint n = bind(_sockfd, CONV(_inetaddr.NetAddr()), _inetaddr.NetAddrLen());if (n < 0){LOG(LogLevel::ERROR) << "bind fail";exit(1);}LOG(LogLevel::INFO) << "bind success";}void Start(){_isrunning = true;while (true){char inbuffer[SIZE];sockaddr_in peer;socklen_t peer_len = sizeof(peer);int n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, CONV(&peer), &peer_len);// // 此时peer已经获得了对应的数据// uint16_t client_port = ::ntohs(peer.sin_port); // 网络字节序列转主机序列// std::string client_IP = ::inet_ntoa(peer.sin_addr); // 转换到主机序列;转换成字符串if (n > 0){InetAddr client(peer);inbuffer[n] = 0;LOG(LogLevel::DEBUG) << "Client says@ " << inbuffer;std::string echo = "#echo: ";std::string backinfo = client.GetIP() + " " + std::to_string(client.GetPort()) + echo;// std::string client_info = client_IP;// client_info += " : ";// client_info += std::to_string(client_port);// client_info += ' ';// echo += client_info;backinfo += inbuffer;int ret = sendto(_sockfd, backinfo.c_str(), backinfo.length(), 0, CONV(client.NetAddr()), client.NetAddrLen());// if(ret>0)// {// LOG(LogLevel::DEBUG)<<"server sendto success";// }// else// {// LOG(LogLevel::DEBUG)<<"server sendto fail";// }}}_isrunning = false;}~UdpServer(){if (_sockfd != gsockfd)close(_sockfd);}private:int _sockfd; // 访问套接字的文件描述符InetAddr _inetaddr;// uint16_t _port; //端口号// std::string _IP;//十进制点分IP地址bool _isrunning = false; };#endif
全部代码:
EchoServer_V2 · lsnmjp/code of cpp Linux 算法 - 码云 - 开源中国
白框上面是xshell打开的client,白框下面是vscode打开的server端。
4. 下集预告
下一集我们将利用上述思维和代码进行一些简单的业务,比如字典、聊天室等