网络编程套接字
- 预备知识
- 理解源IP地址和目的IP地址
- 理解源MAC地址和目的MAC地址
- 理解源端口号和目的端口号
- PORT VS PID
- 认识TCP协议和UDP协议
- 网络字节序
- socket编程接口
- socket常见API
- sockaddr结构
- 简单的UDP网络程序
- 服务端创建套接字
- 服务端绑定
- 运行服务器
- 客户端代码编写
- 本地测试
- INADDR_ANY
- 多线程情况下实现简易聊天室
预备知识
理解源IP地址和目的IP地址
因特网上的每台计算机都有一个唯一的IP地址,如果一台主机上的数据要传输到另一台主机,那么对端主机的IP地址就应该作为该数据传输时的目的IP地址。但仅仅知道目的IP地址是不够的,当对端主机收到该数据后,对端主机还需要对该主机做出响应,因此对端主机也需要发送数据给该主机,此时对端主机就必须知道该主机的IP地址。因此一个传输的数据当中应该涵盖其源IP地址和目的IP地址,目的IP地址表明该数据传输的目的地,源IP地址作为对端主机响应时的目的IP地址。
在数据进行传输之前,会先自顶向下贯穿网络协议栈完成数据的封装,其中在网络层封装的IP报头当中就涵盖了源IP地址和目的IP地址。而除了源IP地址和目的IP地址之外,还有源MAC地址和目的MAC地址的概念。
理解源MAC地址和目的MAC地址
大部分数据的传输都是跨局域网的,数据在传输过程中会经过若干个路由器,最终才能到达对端主机。
最开始的时候源MAC地址就是主机的MAC地址,目的MAC地址是路由器A的MAC地址,在经过路由器A以后,此时源MAC地址就是路由器A的MAC地址,目的MAC地址是路由器B的MAC地址,如此循环往复…,直到最后到达对端主机。
因此数据在传输的过程中是有两套地址:
- 一套是源IP地址和目的IP地址,这两个地址在数据传输过程中基本是不会发生变化的(存在一些特殊情况,比如在数据传输过程中使用NET技术,其源IP地址会发生变化,但至少目的IP地址是不会变化的)。
- 另一套就是源MAC地址和目的MAC地址,这两个地址是一直在发生变化的,因为在数据传输的过程中路由器不断在进行解包和重新封装。
理解源端口号和目的端口号
首先我们需要明确的是,两台主机之间通信的目的不仅仅是为了将数据发送给对端主机,而是为了访问对端主机上的某个服务。比如我们在用百度搜索引擎进行搜索时,不仅仅是想将我们的请求发送给对端服务器,而是想访问对端服务器上部署的百度相关的搜索服务。
socket通信的本质
通过IP地址和MAC地址我们已经可以向对端主机发送数据,但是我们所需要的不仅仅只是发送数据,而是访问对端主机上的某个服务进程,此时,数据的发送者也不再是主机,而是主机上的某个进程,比如当我们用浏览器访问数据时,实际就是浏览器进程向对端服务进程发起的请求。
socket通信本质上就是两个进程之间在进行通信,只不过这里是跨网络的进程间通信。比如逛淘宝和刷抖音的动作,实际就是手机上的淘宝进程和抖音进程在和对端服务器主机上的淘宝服务进程和抖音服务进程之间在进行通信。
因此进程间通信的方式除了管道、消息队列、信号量、共享内存等方式外,还有套接字,只不过前者是不跨网络的,而后者是跨网络的。
端口号
实际在两台主机上,可能会同时存在多个正在进行跨网络通信的进程,因此当数据到达对端主机后,必须要通过某种方法找到该主机上对应的服务进程,然后将数据交给该进程处理。而当该进程处理完数据后还要对发送端进行响应,因此对端主机也需要知道,是发送端上的哪一个进程向它发送的数据请求。
端口号(port)的作用实际就是标识一台主机上的一个进程。
- 端口号是传输层协议的内容。
- 端口号是一个2字节16位的整数。
- 端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理。
- 一个端口号只能被一个进程占用。
IP地址能够唯一标识公网内的一台主机,而端口号能够唯一标识一台主机上的一个进程,因此用IP地址+端口号就能够唯一标识网络上的某一台主机的某一个进程。
当数据在传输层进行封装时,就会添加上对应源端口号和目的端口号的信息。这时通过源IP地址+源端口号就能够在网络上唯一标识发送数据的进程,通过目的IP地址+目的端口号就能够在网络上唯一标识接收数据的进程,此时就实现了跨网络的进程间通信。
端口号可以在两台不同的主机当中重复,但是在同一台主机上进行网络通信的进程的端口号不能重复。此外,一个进程可以绑定多个端口号,但是一个端口号不能被多个进程同时绑定。
PORT VS PID
进程ID(PID)是用来标识系统内所有进程的唯一性的,它是属于系统级的概念;而端口号(port)是用来标识需要对外进行网络数据请求的进程的唯一性的,它是属于网络的概念。
一台机器上可能会有大量的进程,但并不是所有的进程都要进行网络通信,可能有很大一部分的进程是不需要进行网络通信的本地进程,此时PID虽然也可以标识这些网络进程的唯一性,但在该场景下就不太合适了。所以进行网络通信时为什么不直接用PID来代替port。
底层如何通过port找到对应进程的?
实际底层采用哈希的方式建立了端口号和进程PID或PCB之间的映射关系,当底层拿到端口号时就可以直接执行对应的哈希算法,然后就能够找到该端口号对应的进程。
认识TCP协议和UDP协议
网络协议栈是贯穿整个体系结构的,在应用层、操作系统层和驱动层各有一部分。当我们使用系统调用接口实现网络数据通信时,不得不面对的协议层就是传输层,而传输层最典型的两种协议就是TCP协议和UDP协议。
TCP协议
TCP协议叫做传输控制协议(Transmission Control Protocol),TCP协议是一种面向连接的、可靠的、基于字节流的传输层通信协议。如果两台主机之间想要进行数据传输,那么必须要先建立连接,当连接建立成功后才能进行数据传输。其次,TCP协议是保证可靠的协议,数据在传输过程中如果出现了丢包、乱序等情况,TCP协议都有对应的解决方法。
UDP协议
UDP协议叫做用户数据报协议(User Datagram Protocol),UDP协议是一种无需建立连接的、不可靠的、面向数据报的传输层通信协议。两台主机之间想要进行数据传输,那么直接将数据发送给对端主机就行了,但这也就意味着UDP协议是不可靠的,数据在传输过程中如果出现了丢包、乱序等情况,UDP协议本身是不知道的。
为什么UDP协议不可靠但却存在呢?
我们知道TCP作为一个可靠的协议,他的实现就会比较复杂,对于丢包,乱序等情况会有相应的处理办法;而UDP协议不可靠,实现就会比较简单,因为出现丢包和乱序他可能就不知道,能够快速传输数据,所以,我们对于一些有严格要求可靠性的数据,我们就使用TCP协议,而一些可以出现丢包和乱序的数据,我们就可以使用UDP协议。
网络字节序
网络中的大小端问题
- 大端模式: 数据的高字节内容保存在内存的低地址处,数据的低字节内容保存在内存的高地址处。
- 小端模式: 数据的高字节内容保存在内存的高地址处,数据的低字节内容保存在内存的低地址处。
对于同一台主机上,数据存储的方式一样,所以无论是大端存储还是小端存储,并不会出现问题,但是涉及到网络传输以后,在多台机器之间进行数据传输,可能一台机器存储方式为大端存储,另一台机器的存储方式为小端存储,在进行数据传输以后原本大端存储的是数据会被按小端方式识别出来,而原本小端存储的是数据会被按大端方式识别出来,导致数据识别出现错误;
所以网络当中传输的数据必须考虑大小端问题。因此TCP/IP协议规定,网络数据流采用大端字节序,即低地址高字节。无论是大端机还是小端机,都必须按照TCP/IP协议规定的网络字节序来发送和接收数据。
- 如果发送端是小端,需要先将数据转成大端,然后再发送到网络当中。
- 如果发送端是大端,则可以直接进行发送。
- 如果接收端是小端,需要先将接收到数据转成大端后再进行数据识别。
- 如果接收端是大端,则可以直接进行数据识别。
为什么网络字节序采用的是大端
该问题有很多不同说法,下面列举了两种说法:
- 说法一:TCP在Unix时代就有了,以前Unix机器都是大端机,因此网络字节序也就采用的是大端,但之后人们发现用小端能简化硬件设计,所以现在主流的都是小端机,但协议已经不好改了。
- 说法二: 大端序更符合现代人的读写习惯。
网络字节序与主机字节序之间的转换
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,系统提供了四个函数,可以通过调用以下库函数实现网络字节序和主机字节序之间的转换。
#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位长整数从主机字节序转换为网络字节序。
- 如果主机是小端字节序,则这些函数将参数做相应的大小端转换然后返回。
- 如果主机是大端字节序,则这些函数不做任何转换,将参数原封不动地返回。
socket编程接口
socket常见API
创建套接字:(TCP/UDP,客户端+服务器)
int socket(int domain, int type, int protocol);
绑定端口号:(TCP/UDP,服务器)
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
监听套接字:(TCP,服务器)
int listen(int sockfd, int backlog);
接收请求:(TCP,服务器)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
建立连接:(TCP,客户端)
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockaddr结构
套接字不仅仅支持网络间通信,还支持本地间通信,,提供了对于网络通信 sockaddr_in
接口,对于本地通信则提供了sockaddr_un
接口。
为了让套接字的网络通信和本地通信能够使用同一套函数接口,于是就出现了sockeaddr
结构体,该结构体与sockaddr_in
和sockaddr_un
的结构都不相同,但这三个结构体头部的16个比特位都是一样的,这个字段叫做协议家族。
此时当我们在传递在传参时,就不用传入sockeaddr_in
或sockeaddr_un
这样的结构体,而统一传入sockeaddr
这样的结构体。在设置参数时就可以通过设置协议家族这个字段,来表明我们是要进行网络通信还是本地通信,在这些API内部就可以提取sockeaddr
结构头部的16位进行识别,进而得出我们是要进行网络通信还是本地通信,然后执行对应的操作。此时我们就通过通用sockaddr
结构,将套接字网络通信和本地通信的参数类型进行了统一。
为什么没有用void代替struct sockaddr类型?
我们可以将这些函数的struct sockaddr参数类型改为void,此时在函数内部也可以直接指定提取头部的16个比特位进行识别,最终也能够判断是需要进行网络通信还是本地通信,那为什么还要设计出sockaddr这样的结构呢?
实际在设计这一套网络接口的时候C语言还不支持void*,于是就设计出了sockaddr这样的解决方案。并且在C语言支持了void*之后也没有将它改回来,因为这些接口是系统接口,系统接口是所有上层软件接口的基石,系统接口是不能轻易更改的,否则引发的后果是不可想的,这也就是为什么现在依旧保留sockaddr结构的原因。
简单的UDP网络程序
首先我们先包装一个日志文件,以便于更好的描述各种状态:
#pragma once#include <iostream>
#include <cstdio>
#include <cstdarg>
#include <ctime>
#include <string>// 日志是有日志级别的
#define DEBUG 0
#define NORMAL 1
#define WARNING 2
#define ERROR 3
#define FATAL 4const char *gLevelMap[] = {"DEBUG","NORMAL","WARNING","ERROR","FATAL"
};#define LOGFILE "./threadpool.log"// 完整的日志功能,至少: 日志等级 时间 支持用户自定义(日志内容, 文件行,文件名)
void logMessage(int level, const char *format, ...)
{
#ifndef DEBUG_SHOWif(level== DEBUG) return;
#endifchar stdBuffer[1024]; //标准部分time_t timestamp = time(nullptr);snprintf(stdBuffer, sizeof stdBuffer, "[%s] [%ld] ", gLevelMap[level], timestamp);char logBuffer[1024]; //自定义部分va_list args;va_start(args, format);vsnprintf(logBuffer, sizeof logBuffer, format, args);va_end(args);printf("%s%s\n", stdBuffer, logBuffer);
}
服务端创建套接字
创建套接字我们需要调用socket
函数,socket
函数的原型如下:
int socket(int domain, int type, int protocol);
参数说明:
domain
:创建套接字的域或者叫做协议家族,也就是创建套接字的类型。该参数就相当于cpp struct sockaddr
结构的前16个位。如果是本地通信就设置为AF_UNIX
,如果是网络通信就设置为AF_INET
(IPv4)或AF_INET6
(IPv6)。type
:创建套接字时所需的服务类型。其中最常见的服务类型是SOCK_STREAM
和SOCK_DGRAM
,如果是基于UDP的网络通信,我们采用的就是SOCK_DGRAM
,叫做用户数据报服务,如果是基于TCP的网络通信,我们采用的就是SOCK_STREAM
,叫做流式套接字,提供的是流式服务。protocol
:创建套接字的协议类别。你可以指明为TCP或UDP,但该字段一般直接设置为0就可以了,设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议。
返回值说明:
- 套接字创建成功返回一个文件描述符,创建失败返回-1,同时错误码会被设置。
socket函数属于什么类型的接口?
网络协议栈是分层的,按照TCP/IP四层模型来说,自顶向下依次是应用层、传输层、网络层和数据链路层。而我们现在所写的代码都叫做用户级代码,也就是说我们是在应用层编写代码,因此我们调用的实际是下三层的接口,而传输层和网络层都是在操作系统内完成的,也就意味着我们在应用层调用的接口都叫做系统调用接口。
socket函数是被谁调用的?
socket这个函数是被程序调用的,但并不是被程序在编码上直接调用的,而是程序编码形成的可执行程序运行起来变成进程,当这个进程被CPU调度执行到socket函数时,然后才会执行创建套接字的代码,也就是说socket函数是被进程所调用的。
当我们在进行初始化服务器创建套接字时,就是调用socket函数创建套接字,创建套接字时我们需要填入的协议家族就是AF_INET
,因为我们要进行的是网络通信,而我们需要的服务类型就是SOCK_DGRAM
,因为我们现在编写的UDP服务器是面向数据报的,而第三个参数之间设置为0即可。
由于绑定时需要用到IP地址和端口号,因此我们需要在服务器类当中引入IP地址和端口号,在创建服务器对象时需要传入对应的IP地址和端口号,此时我们就可以根据传入的IP地址和端口号对对应的成员进行初始化。
class UdpServer
{
public:UdpServer(uint16_t port, std::string ip = "") : _port(port), _ip(ip), _sock(-1){}bool initServer(){// 1.创建套接字_sock = socket(AF_INET, SOCK_DGRAM, 0);if (_sock < 0){logMessage(FATAL, "%d:%s", errno, strerror(errno));exit(1);}logMessage(NORMAL, "init UdpServer done....%s", strerror(errno));return true;}private:uint16_t _port;std::string _ip;int _sock;
};#endif
服务端绑定
套接字已经创建成功了,但作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不知道是要将数据写入到磁盘还是刷到网卡,此时该文件还没有与网络关联起来。
由于现在编写的是不面向连接的UDP服务器,所以初始化服务器要做的第二件事就是绑定:
bind函数
绑定的函数叫做bind,该函数的函数原型如下:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明:
sockfd
:绑定的文件的文件描述符。也就是我们创建套接字时获取到的文件描述符。addr
:网络相关的属性信息,包括协议家族、IP地址、端口号等。addrlen
:传入的addr结构体的长度。
返回值说明:
- 绑定成功返回0,绑定失败返回-1,同时错误码会被设置。
struct 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)];};
通过查看struct sockaddr_in
结构的定义,我们可以看到,struct sockaddr_in
当中的成员如下:
- sin_family:表示协议家族。
- sin_port:表示端口号,是一个16位的整数。
- sin_addr:表示IP地址,是一个32位的整数。
其中__SOCKADDR_COMMON
就表示 sin_family
,本质上就是一个宏定义:
#define __SOCKADDR_COMMON(sa_prefix) \sa_family_t sa_prefix##family
其中sin_addr
的类型是struct in_addr
,实际该结构体当中就只有一个成员,该成员就是一个32位的整数,IP地址实际就是存储在这个整数当中的。
/* Internet address. */
typedef uint32_t in_addr_t;
struct in_addr{in_addr_t s_addr;};
如何理解绑定?
在进行绑定的时候需要将IP地址和端口号告诉对应的网络文件,此时就可以改变网络文件当中文件操作函数的指向,将对应的操作函数改为对应网卡的操作方法,此时读数据和写数据对应的操作对象就是网卡了,所以绑定实际上就是将文件和网络关联起来。
套接字创建完毕后我们就需要进行绑定了,但在绑定之前我们需要先定义一个struct sockaddr_in结构,将对应的网络属性信息填充到该结构当中。由于该结构体当中还有部分选填字段,因此我们最好在填充之前对该结构体变量里面的内容进行清空,然后再将协议家族、端口号、IP地址等信息填充到该结构体变量当中。
需要注意的是,在发送到网络之前需要将端口号设置为网络序列,由于端口号是16位的,因此我们需要使用前面说到的htons函数将端口号转为网络序列。此外,由于网络当中传输的是整数IP,我们需要调用inet_addr函数将字符串IP转换成整数IP,然后再将转换后的整数IP进行设置。
当网络属性信息填充完毕后,由于bind函数提供的是通用参数类型,因此在传入结构体地址时还需要将struct sockaddr_in强转为struct sockaddr类型后再进行传入。
class UdpServer
{
public:UdpServer(uint16_t port, std::string ip = "") : _port(port), _ip(ip), _sock(-1){}bool initServer(){// 1.创建套接字_sock = socket(AF_INET, SOCK_DGRAM, 0);if (_sock < 0){logMessage(FATAL, "%d:%s", errno, strerror(errno));exit(1);}// 2.bind将将用户设置的ip与port在内核中与我们当前进程强关联struct sockaddr_in local;bzero(&local, sizeof(local));local.sin_family = AF_INET;//服务器的IP和端口未来也是要发送给对方主机的 -> 先要将数据发送到网络local.sin_port = htons(_port);//先将点分十进制IP风格 -> 4字节//4字节主机序列 -> 网络序列local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());if(bind(_sock, (const sockaddr*)&local, sizeof(local)) < 0){logMessage(FATAL, "%d:%s", errno, strerror(errno));exit(2);}logMessage(NORMAL, "init UdpServer done....%s", strerror(errno));return true;}~UdpServer(){if(_sock >= 0)close(_sock);}private:uint16_t _port;std::string _ip;int _sock;
};
inet_addr函数
将字符串IP转换成整数IP的函数叫做inet_addr,该函数的函数原型如下:
in_addr_t inet_addr(const char *cp);
inet_ntoa函数
将整数IP转换成字符串IP的函数叫做inet_ntoa,该函数的函数原型如下:
char *inet_ntoa(struct in_addr in);
需要注意的是,传入inet_ntoa
函数的参数类型是in_addr
,因此我们在传参时不需要选中in_addr
结构当中的32位的成员传入,直接传入in_addr结构体即可。
运行服务器
服务器初始化完毕后我们就可以启动服务器了,服务器实际上就是在周而复始的为我们提供某种服务,服务器之所以称为服务器,是因为服务器运行起来后就永远不会退出,因此服务器实际执行的是一个死循环代码。由于UDP服务器是不面向连接的,因此只要UDP服务器启动后,就可以直接读取客户端发来的数据。
recvfrom函数
UDP服务器读取数据的函数叫做recvfrom,该函数的函数原型如下:
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
:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。addrlen
:调用时传入期望读取的src_addr结构体的长度,返回时代表实际读取到的src_addr结构体的长度,这是一个输入输出型参数。
返回值说明:
- 读取成功返回实际读取到的字节数,读取失败返回-1,同时错误码会被设置。
注意:
- 由于UDP是不面向连接的,因此我们除了获取到数据以外还需要获取到对端网络相关的属性信息,包括IP地址和端口号等。
- 在调用recvfrom读取数据时,必须将addrlen设置为你要读取的结构体对应的大小。
- 由于recvfrom函数提供的参数也是struct sockaddr* 类型的,因此我们在传入结构体地址时需要将structsockaddr_in*类型进行强转。
现在服务端通过recvfrom函数读取客户端数据,我们可以先将读取到的数据当作字符串看待,将读取到的数据的最后一个位置设置为'\0'
,此时我们就可以将读取到的数据进行输出,同时我们也可以将获取到的客户端的IP地址和端口号也一并进行输出。
需要注意的是,我们获取到的客户端的端口号此时是网络序列,我们需要调用ntohs函数将其转为主机序列再进行打印输出。同时,我们获取到的客户端的IP地址是整数IP,我们需要通过调用inet_ntoa函数将其转为字符串IP再进行打印输出。
#define SIZE 1024void start(){char buffer[SIZE];for (;;){// 纯输出型参数struct sockaddr_in peer;bzero(&peer, sizeof(peer));// 输入: peer 缓冲区大小// 输出: 实际读到的peersocklen_t len = sizeof(peer);ssize_t s = recvfrom(_sock, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);if (s > 0){buffer[s] = 0;uint16_t cli_port = ntohs(peer.sin_port); // 从网络中来std::string cli_ip = inet_ntoa(peer.sin_addr); // 4字节的网络序列的IP->本主机的字符串风格的IPprintf("[%s:%d]# %s\n", cli_ip.c_str(), cli_port, buffer);}// 分析处理数据,写回数据sendto(_sock, buffer, sizeof(buffer), 0, (const sockaddr *)&peer, len);}}
引入命令行参数
构造服务器时需要传入IP地址和端口号,我们这里可以引入命令行参数。此时当我们运行服务器时在后面跟上对应的IP地址和端口号即可。
由于云服务器的原因,后面实际不需要传入IP地址,因此在运行服务器的时候我们只需要传入端口号即可,目前我们就手动将IP地址设置为127.0.0.1。IP地址为127.0.0.1实际上等价于localhost表示本地主机,我们将它称之为本地环回,相当于我们一会先在本地测试一下能否正常通信,然后再进行网络通信的测试。
#include <iostream>
#include "udp_server.hpp"static void usage(std::string proc)
{std::cout << "\nUsage: " << proc << " port\n" << std::endl;
}
int main(int argc, char* argv[])
{if(argc != 2){usage(argv[0]);exit(3);}std::string ip = "127.0.0.1";uint16_t port = atoi(argv[1]);UdpServer* svr = new UdpServer(port, ip);svr->initServer();svr->start();return 0;
}
此时带上端口号运行程序就可以看到套接字创建成功、绑定成功,现在服务器就在等待客户端向它发送数据。
我们可以通过netstat
命令来查看当前网络的状态,这里我们可以选择携带nlup
选项。
netstat
常用选项说明:
-n
:直接使用IP地址,而不通过域名服务器。-l
:显示监控中的服务器的Socket。-t
:显示TCP传输协议的连线状况。-u
:显示UDP传输协议的连线状况。-p
:显示正在使用Socket的程序识别码和程序名称。
客户端代码编写
客户端其实跟服务端编代码编写差距并不太大,我们只需要按照服务端的套路进行编写就可以,最大的区别就是客户端需不需要考虑绑定问题。
关于客户端的绑定问题
对于网络通信来说,通信的双方都需要找到对方,所以客户端和服务端都需要对方的IP地址以及端口号,但是服务端就需要绑定,而客户端就不需要。
原因是服务器就是为别人服务的,就必须让别人知道IP地址以及端口号,所以服务端就必须是一个众所周知的端口号,选定后也就不能轻易发生改变,因此服务端就需要绑定端口号,绑定以后端口号才属于自己,一个端口号只能被一个进程所绑定,此时服务器也就独占这个端口号了。
虽然客户端也需要端口号,但是不需要绑定,因为如果客户端进行绑定,那么这个端口号就只能给此客户端使用,客户端不运行,端口号就无法给别人使用,别人使用了此端口号,客户端就无法运行,所以客户端就无需绑定端口号,只需要调用sendto这样的接口,操作系统就会自动分配一个端口号给客户端。
sendto函数
UDP客户端发送数据的函数叫做sendto,该函数的函数原型如下:
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:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:传入dest_addr结构体的长度。
返回值说明:
- 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。
接下来我们就可以完成客户端代码编写,在这儿我们就不进行包装了:
#include <cstdio>
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <cerrno>
#include <cstring>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>static void usage(std::string proc)
{std::cout << "\nUsage: " << proc << " serverIp serverPort\n"<< std::endl;
}
int main(int argc, char *argv[])
{if (argc != 3){usage(argv[0]);exit(1);}int sock = socket(AF_INET, SOCK_DGRAM, 0);if(sock < 0){std::cerr << "socket" << std::endl;exit(2);}std::string message;struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(atoi(argv[2]));server.sin_addr.s_addr = inet_addr(argv[1]);char buffer[1024];while(true){std::cout << "请输入你的信息#:";std::getline(std::cin, message);if(message == "quit") break;// 当client首次发送消息给服务器的时候,OS会自动给client bind他的IP和PORTsendto(sock, message.c_str(), message.size(), 0, (const sockaddr*)&server, sizeof(server));struct sockaddr_in tmp;socklen_t len = sizeof(tmp);ssize_t s = recvfrom(sock, buffer, sizeof buffer, 0, (sockaddr*)&tmp, &len);if(s > 0){buffer[s] = 0 ;std::cout << "server echo# " << buffer << std::endl;}}return 0;
}
本地测试
现在服务端和客户端的代码都已经编写完毕,我们可以先进行本地测试,此时服务器没有绑定外网,绑定的是本地环回。现在我们运行服务器时指明端口号为8080,再运行客户端,此时客户端要访问的服务器的IP地址就是本地环回127.0.0.1,服务端的端口号就是8080。户端运行之后提示我们进行输入,当我们在客户端输入数据后,客户端将数据发送给服务端,此时服务端再将收到的数据打印输出,这时我们在服务端的窗口也看到我们输入的内容。
此时使用netstat
命令查看网络信息,可以看到服务端的端口是8080,客户端的端口是41634。这里客户端能被netstat
命令查看到,说明客户端也已经动态绑定成功了,这就是我们所谓的网络通信。
INADDR_ANY
由于云服务器的IP地址是由对应的云厂商提供的,这个IP地址并不一定是真正的公网IP,这个IP地址是不能直接被绑定的,如果需要让外网访问,此时我们需要bind 0。系统当当中提供的一INADDR_ANY
,这是一个宏值,它对应的值就是0。
如果我们不绑定INADDR_ANY
,此时将服务端设置的本地环回改为我的公网IP,就会发现此时服务端就会绑定失败,因此如果我们需要让外网访问,那么在云服务器上进行绑定时就应该绑定INADDR_ANY,此时我们的服务器才能够被外网访问。
绑定INADDR_ANY的好处
当一个服务器的带宽足够大时,一台机器接收数据的能力就约束了这台机器的IO效率,因此一台服务器底层可能装有多张网卡,此时这台服务器就可能会有多个IP地址,但一台服务器上端口号为8080的服务只有一个。这台服务器在接收数据时,这里的多张网卡在底层实际都收到了数据,如果这些数据也都想访问端口号为8080的服务。此时如果服务端在绑定的时候是指明绑定的某一个IP地址,那么此时服务端在接收数据的时候就只能从绑定IP对应的网卡接收数据。而如果服务端绑定的是INADDR_ANY,那么只要是发送给端口号为8080的服务的数据,系统都会可以将数据自底向上交给该服务端。
此时当我们再用netstat命令查看时会发现,该服务器的本地IP地址变成了0.0.0.0
,这就意味着该UDP服务器可以在本地读取任何一张网卡里面的数据。
多线程情况下实现简易聊天室
我们上述情况都是在单线程情况下进行讨论,接下来我们来看一下多线程情况下服务端与客户端通信,来实现一个见聊天室,首先,我们对线程进行封装:
#pragma once#include <iostream>
#include <pthread.h>
#include <string>
#include <functional>typedef void *(*func_t)(void *);class ThreadData
{
public:std::string name_;void *args_;
};class Thread
{
public:Thread(int num, func_t callback, void *args) : func_(callback){char nameBuffer[64];snprintf(nameBuffer, sizeof(nameBuffer), "Thread-%d", num);name_ = nameBuffer;tdata_.args_ = args;tdata_.name_ = name_;}void start(){pthread_create(&tid_, nullptr, func_, (void*)&tdata_);}std::string name(){return name_;}~Thread(){}private:std::string name_;func_t func_;ThreadData tdata_;pthread_t tid_;
};
此时服务端代码就更改为:
udp_server.hpp
#pragma once#ifndef _UDP_SEVER_HPP_
#define _UDP_SEVER_HPP_#define SIZE 1024#include "log.hpp"
#include <cstdio>
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <cerrno>
#include <cstring>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <unordered_map>
#include <queue>class UdpServer
{
public:UdpServer(uint16_t port, std::string ip = "") : _port(port), _ip(ip), _sock(-1){}bool initServer(){// 1.创建套接字_sock = socket(AF_INET, SOCK_DGRAM, 0);if (_sock < 0){logMessage(FATAL, "%d:%s", errno, strerror(errno));exit(1);}// 2.bind将将用户设置的ip与port在内核中与我们当前进程强关联struct sockaddr_in local;bzero(&local, sizeof(local));local.sin_family = AF_INET;// 服务器的IP和端口未来也是要发送给对方主机的 -> 先要将数据发送到网络local.sin_port = htons(_port);// 先将点分十进制IP风格 -> 4字节// 4字节主机序列 -> 网络序列local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());if (bind(_sock, (const sockaddr *)&local, sizeof(local)) < 0){logMessage(FATAL, "%d:%s", errno, strerror(errno));exit(2);}logMessage(NORMAL, "init UdpServer done....%s", strerror(errno));return true;}void start(){char buffer[SIZE];char key[64];for (;;){// 纯输出型参数struct sockaddr_in peer;bzero(&peer, sizeof(peer));// 输入: peer 缓冲区大小// 输出: 实际读到的peersocklen_t len = sizeof(peer);ssize_t s = recvfrom(_sock, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);if (s > 0){buffer[s] = 0;uint16_t cli_port = ntohs(peer.sin_port); // 从网络中来std::string cli_ip = inet_ntoa(peer.sin_addr); // 4字节的网络序列的IP->本主机的字符串风格的IP// printf("[%s:%d]# %s\n", cli_ip.c_str(), cli_port, buffer);snprintf(key, sizeof(key), "%s-%u", cli_ip.c_str(), cli_port);logMessage(NORMAL, "key:%s", key);auto it = _users.find(key);if(it == _users.end()){logMessage(NORMAL, "add new users:%s", key);_users.insert({key, peer});}}// 分析处理数据,写回数据//sendto(_sock, buffer, sizeof(buffer), 0, (const sockaddr *)&peer, len);for(auto iter : _users){std::string sendMessage = key;sendMessage += "#";sendMessage += buffer;logMessage(NORMAL, "push message to:%s", iter.first.c_str());sendto(_sock, sendMessage.c_str(), sendMessage.size(), 0, (const sockaddr*)&iter.second, sizeof(iter.second));}}}~UdpServer(){if (_sock >= 0)close(_sock);}private:uint16_t _port;std::string _ip;int _sock;std::unordered_map<std::string, sockaddr_in> _users;
};#endif
udp_server.cc
此时创建我们的对象就可以采取智能指针的方式:
#include <iostream>
#include <memory>
#include "udp_server.hpp"static void usage(std::string proc)
{std::cout << "\nUsage: " << proc << " port\n" << std::endl;
}
int main(int argc, char* argv[])
{if(argc != 2){usage(argv[0]);exit(3);}uint16_t port = atoi(argv[1]);std::unique_ptr<UdpServer> svr(new UdpServer(port));svr->initServer();svr->start();return 0;
}
udp_client.cc
#include <cstdio>
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <cerrno>
#include <cstring>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <memory>
#include "thrad.hpp"uint16_t serverport = 0;
std::string serverip;static void usage(std::string proc)
{std::cout << "\nUsage: " << proc << " serverIp serverPort\n"<< std::endl;
}static void *udpSend(void *args)
{int sock = *(int *)((ThreadData *)args)->args_;std::string name = ((ThreadData *)args)->name_;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;while (true){std::cerr << "请输入信息#";std::getline(std::cin, message);if (message == "quit")break;sendto(sock, message.c_str(), message.size(), 0, (const sockaddr *)&server, sizeof(server));}return nullptr;
}static void *udpRcev(void *args)
{int sock = *(int *)((ThreadData *)args)->args_;std::string name = ((ThreadData *)args)->name_;char buffer[1024];while (true){memset(buffer, 0, sizeof(buffer));struct sockaddr_in temp;socklen_t len = sizeof(temp);ssize_t s = recvfrom(sock, buffer, sizeof buffer, 0, (struct sockaddr *)&temp, &len);if (s > 0){buffer[s] = 0;std::cout << buffer << std::endl;}}
}
int main(int argc, char *argv[])
{if (argc != 3){usage(argv[0]);exit(1);}int sock = socket(AF_INET, SOCK_DGRAM, 0);if (sock < 0){std::cerr << "socket" << std::endl;exit(2);}serverport = atoi(argv[2]);serverip = argv[1];std::unique_ptr<Thread> sender(new Thread(1, udpSend, (void *)&sock));std::unique_ptr<Thread> recver(new Thread(2, udpRcev, (void *)&sock));sender->start();recver->start();sender->join();recver->join();close(sock);return 0;
}
最终我们运行我们的程序,此时我们打开3个客户端,分别输入内容,我们会发现每个客户端都可以接收到对方所发送的内容:
此时我们调用netstat命令查看,就会发现有3个服务端端口,而我们的客户端端口仅有8080一个,此时我们也就相当于搭建了一个简易的聊天室。