前言
网络通信的目的
我们已经大致了解了网络通信的过程: 如果主机A想发送数据给主机B, 就需要不断地对本层的协议数据单元(PDU)封装, 然后经过交换设备的转发发送给目的主机, 最终解封装获取数据.
那么网络传输的意义只是将数据由一台主机发送到另一台主机吗?
不是, 将数据从A传送到B只是网络通信的手段, 并不是目的.
真正进行通信的是用户A和用户B主机上的两个应用程序, 实质上是进程之间在通信.
所以网络通信的目的是让两台计算机上的两个进程进行通信.
端口号
我们已经知道, IP地址可以标识计算机在互联网中的唯一性, 但是一台计算机上会存在大量的进程, 那如何保证 A主机某个进程发送的数据 能准确地让 B主机指定的进程 接收到呢?
所以我们需要标识一台计算机上一个进程的唯一性, 所以有了端口号(port).
端口号有以下特点:
1. port是一个16位的整型(uint_16).
2. port可标识当前主机上唯一的一个网络进程.3. 一个port不能和多个进程关联, 这是和port本身的定义相违背的, 也就是不能一个key(port)映射多个value(进程); 但是一个进程可以关联多个port, 也就是多个key可以映射同一个vlaue.
有一个疑问, 我们知道系统中有一个pid可以唯一标识一个进程, 为什么网络通信不直接用pid?
1. PID 是操作系统内核用来管理进程的一个标识符, 是系统层面的, 所以将进程管理和网络管理进行解耦合, 进程管理无论如何变化(PID的变化), 都不会影响端口号.
2. port是专门用来网络通信的, 系统中并不是所有进程都有网络通信的需求.
3. 网络连接的生命周期通常独立于进程的生命周期, 即使一个进程结束, 网络连接可能会保持一段时间, 或者由其他进程接管.
由于IP地址可以标识计算机在网络中唯一性, 端口号port又能用来标识进程在计算机中唯一性, 所以如果我们需要寻找全网某一个进程, 先通过IP地址查找全网唯一的主机, 然后通过端口号port找到该主机唯一的进程.
所以我们使用socket套接字实现网络通信就需要: 双方的IP地址 + 双方的端口号port.
认识TCP和UDP协议
TCP和UDP是传输层两个使用较多的协议:
TCP协议(Transmission Control Protocol)中文名为传输控制协议, Internet 面向连接的服务:
- 有连接(请求响应)
- 可靠地、按顺序地传送数据(确认和重传)
- 流量控制, 发送方不会淹没接收方
-
拥塞控制, 当网络拥塞时,发送方降低发送速率
应用在HTTP (Web), FTP (文件传送), Telnet (远程登录), SMTP (email)等
UDP协议(User Datagram Protocol)中文名为用户数据报协议, 无连接的服务:
-
无连接
-
不可靠数据传输
-
无流量控制
-
无拥塞控制
应用在流媒体、远程会议、 DNS、Internet电话等
网络字节序
大小端
我们知道计算机分为大端机和小端机, 如果两台计算机的字节序不同, 那么接收到的数据解释出来意义也完全不同. 所以网络为了适配所有类型的主机, 规定: 网络中的字节序一律采用大端. 具体来说:
1. 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出; 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
2. 因此,网络数据流的地址应这样规定: 先发出的数据是低地址, 后发出的数据是高地址.
3. TCP/IP协议规定,网络数据流应采用大端字节序, 即低地址高字节.
4. 所以不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据;如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可;
既然网络有这样的规定, 那我们在写代码时是否需要先判断一下字节序呢?
不需要, 正因为机器大小端的判断很繁琐, 所以操作系统早就提供了支持主机字节序和网络的字节序相互转换的接口.
- 如果主机是小端字节序, 这些函数将参数做相应的大小端转换然后返回
- 如果主机是大端字节序, 这些函数不做转换, 将参数原封不动返回
字节序转换接口
一共有四个接口, 在头文件arpa/inet.h中, 函数名称中的 h代表host, n代表net, ,l表示32位长整数,s表示16位短整数
uint32_t htonl(uint32_t hostlong);
头文件: arpa/inet.h
功能: 将主机上unsigned int类型的数据转换成对应网络字节序
参数: uint32_t hostlong是需要转换的unsigned int类型数据
返回值: 返回转换后的数据
uint16_t htons(uint16_t hostshort);
头文件: arpa/inet.h
功能: 将主机上unsigned short类型的数据转换成对应网络字节序
参数: uint16_t是需要转换的unsigned short类型数据
返回值: 返回转换后的数据
uint32_t ntohl(uint32_t netlong);
头文件: arpa/inet.h
功能: 将从网络上读取的unsigned int类型的数据转换成主机的字节序
参数: uint32_t是需要转换的unsigned int类型数据
返回值: 返回转换后的数据
uint16_t ntohs(uint16_t netshort);
头文件: arpa/inet.h
功能: 将从网络上读取的unsigned short类型的数据转换成主机的字节序。
参数: uint16_t是需要转换的unsigned short类型数据。
返回值: 返回转换后的数据
socket套接字
socket 的原意是“插座”, 在计算机通信领域, socket 被翻译为“套接字”, 它是计算机之间进行通信的一种约定或一种方式. 其命名寓意着Socket协议可以像插座一样即插即用, 快速联通网络上的两台电脑.
套接字作为TCP的上层协议, 可以使用户十分轻易地在计算机网络中互相传递消息, 而无需过多关注复杂的TCP以及IP协议. 其中, 使用文件描述 fd 来标记套接字对象.
我们上网的所有行为无非就两种: 发数据和读数据. 所以, 网络通信实质是数据的IO. Linux下一切皆文件, 所以网络在系统看来也是一个"文件", 也有维护它的结构体, 也有自己的文件描述符.
socket 常见API
//创建 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);
API的设计
先对其中的两个API的参数进行介绍, 从这两个参数可以看出网络编程API的设计思路:
1. domain参数体现了网络编程的不同场景:
网络编程的时候, socket是有很多类别的. 我们进程间通信用到的SYSTEM V标准, 只限于本主机间进程的通信; 而我们用到的socket编程, 有很多种通信方式, 可以网络通信, 也可以本主机通信, 也可以绕过TCP/UDP底层开发等. 常见的有三种通信方式:
a. unix socket: 域间socket -> 不用ip, 用文件路径通信, 和命名管道很类似, 主要用于本主机内部通信.
b. 网络socket: ip+port, 进行网络通信, 也可以本地通信
c. 原始socket: 当我们进行不以数据传输为目的的通信, 主要用来编写一些网络工具, 它会直接
- 绕过传输层去访问网络层
- 或绕过传输层和网络层去访问数据链路层.
所有的domain_family类型, 最常用的有AF_INET(ipv4):
The domain argument specifies a communication domain; this selects the protocol family which will be used for communication. These families are defined in
<sys/socket.h>. The formats currently understood by the Linux kernel include:Name Purpose Man page
AF_UNIX Local communication unix(7)
AF_LOCAL Synonym for AF_UNIX
AF_INET IPv4 Internet protocols ip(7)
AF_AX25 Amateur radio AX.25 protocol ax25(4)
AF_IPX IPX - Novell protocols
AF_APPLETALK AppleTalk ddp(7)
AF_X25 ITU-T X.25 / ISO-8208 protocol x25(7)
AF_INET6 IPv6 Internet protocols ipv6(7)
AF_DECnet DECet protocol sockets
AF_KEY Key management protocol, originally de‐veloped for usage with IPsec
AF_NETLINK Kernel user interface device netlink(7)
AF_PACKET Low-level packet interface packet(7)
AF_RDS Reliable Datagram Sockets (RDS) protocol rds(7)rds-rdma(7)
AF_PPPOX Generic PPP transport layer, for settingup L2 tunnels (L2TP and PPPoE)
AF_LLC Logical link control (IEEE 802.2 LLC)protocol
AF_IB InfiniBand native addressing
AF_MPLS Multiprotocol Label Switching
AF_CAN Controller Area Network automotive busprotocol
AF_TIPC TIPC, "cluster domain sockets" protocol
AF_BLUETOOTH Bluetooth low-level socket protocol
AF_ALG Interface to kernel cr
2. struct sockaddr 参数
我们已经知道, 网络编程时有不同的应用场景, 理论上而言, 我们要给每一种场景设计一套独立的接口, 类似之前的进程间通信. 但是这里出现了一种通用地址类型struct sockaddr, 说明网络API设计者希望所有通信场景使用一套接口:
下图可以看出, 不同的通信场景都为其设计了不同的结构体, 当函数中struct sockaddr拿到一个地址, 取出其前16位标识, 然后根据标识类型的不同区把参数强转为不同的类型, 这是C语言多态思想的一种应用:
简单提一句, 为什么此处不使用 void* 呢?
原因很简单, 设计接口的时候C语言还不支持void*
sockaddr结构的一种实现:
sockaddr_in结构的实现:
我们真正在基于IPv4编程时, 使用的数据结构是 sockaddr_in; 这个结构里主要有三部分信息:
- 地址类型: sin_family
- 端口号: sin_port
- IP地址: sin_addr.s_addr
接口介绍:
1. int socket(int domain, int type, int protocol);
头文件:sys/types.h、sys/socket.h
功能:创建套接字, 而且在创建的时候需要指定使用的通信协议.
参数:
- int domain是地址族, 上面说过是套接字编程的通信类型. 最常用的是AF_INET, 表示使用IPv4的网络套接字进行网络通信.
- int type可指定通信语义, 比如是面向字节流还是面向用户数据报. SOCK_STREAM是面向字节, SOCK_DGRAM是面向用户数据报, 对应了TCP和UDP协议.
- int protocol是用来指定具体协议名的, 比如TCP或者UDP. 如果设置为0, 默认使用由前两个参数所确定的推荐使用的协议.
返回值: 成功创建返回一个文件描述符sockfd; 失败则返回-1, 并且设置错误码errno
2. int bind(int socket, const struct sockaddr *address, socklen_t address_len);
头文件: sys/types.h、sys/socket.h
功能: 将socket套接字 和 address结构体绑定, 以网络通信为例, 因为结构体中有IP地址和端口号port, 所以就是实现了IP地址和端口号的绑定.
参数:
- int sockfd表示之前使用socket()返回的文件描述符
- sockfd, const struct sockaddr * addr 介绍过了;
- socklen_t addrlen表示sockaddr结构体的大小, 单位是字节.
返回值: 成功返回0, 失败返回-1并设置错误码errno
接下来三个接口暂时用不到, 之后再介绍.
UDP单向网络编程
我们先设计一个简单的通信场景, 客户端向服务器发送消息, 服务器打印出客户端发送的数据内容, 并给客户端回复一个回复.
udp_echo_server
服务端
udp_echo_server.hpp
1.设计类
由于当前服务器只有一个, 所以设置成一个不能被拷贝的类, 并为当前服务器存储其socket_id, IP 和Port:
#pragma onceclass NotCopable
{
public:NotCopable(){}
private:NotCopable(const NotCopable&) = delete;NotCopable& operator=(const NotCopable&) = delete;
};
#include "NotCopable.hpp"#include <string>
#include <iostream>const std::string default_ip = "0.0.0.0";
const uint16_t default_port = 8888;
const int default_num = 1024;class UdpEchoServer : NotCopable
{
public:UdpEchoServer(std::string ip = default_ip, uint16_t port = default_port):_port(port)_ip(ip){}void Init(){}void Start(){}~UdpEchoServer(){}private:std::string _ip; uint16_t _port;int _socket_id;
};
2. 初始化(Init)
然后完成初始化的工作:
#include "NotCopable.hpp"
#include <sys/types.h>
#include <sys/socket.h>
#include <string>
#include <iostream>const std::string default_ip = "0.0.0.0";
const uint16_t default_port = 8888;
const int default_num = 1024;class UdpEchoServer : NotCopable
{
public:UdpEchoServer(std::string ip = default_ip, uint16_t port = default_port):_port(port)_ip(ip){}void Init(){//1. 创建socket_socket_id = socket(AF_INET, SOCK_DGRAM, 0);if(_socket_id < 0){lg.LogMessage(Fatal, "socket errr, ", errno, ": ", strerror(errno));exit(Socket_Err);}lg.LogMessage(Info, "socket create success");//2. 绑定ip+端口号struct sockaddr_in local;bzero(&local, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);//绑定端口inet_pton(AF_INET, _ip.c_str(), &local.sin_addr);//绑定ipint bind_ret = bind(_socket_id, (sockaddr*)&local, sizeof(sockaddr_in));if(bind_ret < 0){lg.LogMessage(Fatal, "bind errr, ", errno, ": ", strerror(errno));exit(Bind_Err);}lg.LogMessage(Info, "socket bind success");}void Start(){}~UdpEchoServer(){}private:std::string _ip; uint16_t _port;int _socket_id;
};
a. 先用socket接口创建一个socket, 并输出对应的日志信息
b. 完成绑定工作, 我们需要自己定义一个socketaddr_in结构体:
- 可以利用bzero对其内容初始化为0, 其实和memset差不多
- 注意绑定端口时我们要用htons转换为网络序列, 因为这也是网络的数据.
- 绑定ip需要介绍新的接口了, 我们之前提到过, 我们用户更喜欢看点分十进制的ip地址, 而数据传输中是32位的整型传输的, 所以这之间的转换是不可避免的:
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>//将"x.x.x.x"的ip转换为sockaddr_in里的in_addr
int inet_aton(const char *cp, struct in_addr *inp);//将点分十进制的 IPv4 地址转为in_addr内部的整型
in_addr_t inet_addr(const char *cp);//提取点分十进制的 IPv4 地址字符串的网络号
in_addr_t inet_network(const char *cp);//将 struct in_addr 结构中的网络字节序地址转换为点分十进制字符串
char *inet_ntoa(struct in_addr in);//通过网络号和主机号生成一个 IPv4 地址
struct in_addr inet_makeaddr(in_addr_t net, in_addr_t host);//提取 struct in_addr 中的主机号部分
in_addr_t inet_lnaof(struct in_addr in);//提取 struct in_addr 中的网络号部分
in_addr_t inet_netof(struct in_addr in);
还有一种可以转换IPV6地址的接口:
#include <arpa/inet.h>// convert IPv4 and IPv6 addresses from text to binary form
int inet_pton(int af, const char *src, void *dst);
c. 最后调用bind接口绑定即可, 也可以打印日志信息
其中exit用到了一些自定义的枚举常量, 保存在commond.h 用于保存错误码:
#pragma once
enum UdpError
{Usage_Err = 0,Socket_Err,Bind_Err,Recv_Err,Sendto_Err
};
3. 运行(Start)
运行阶段我们需要创建一个缓冲区去接收客户端发来的信息, 又需要新的接口 recvfrom 去进行数据的接收, 对应的也要求服务器向客户端发送一个回复信息, 需要接口 sendto.
recvfrom:
头文件: sys/types.h 和 sys/socket.h
功能: 从一个外部套接字中接收数据存在buf中, 并同时获取发送方的地址信息存储在src_addr中.
参数:
sockfd:
指定接收数据的套接字描述符(文件描述符). 必须是一个已绑定的 UDP 套接字
buf:
一个指向接收缓冲区的指针, 用于存储接收到的数据
len:
指定缓冲区buf
的长度, 即可接收的最大字节数
flags
用于设置接收选项, 常见值包括:
0
:默认行为(阻塞模式接收)MSG_PEEK
:查看数据但不将其从队列中移除。MSG_WAITALL
:等待所有指定字节被接收。
src_addr:
一个指向sockaddr
结构的指针, 用于存储发送方的地址信息. 如果不需要发送方地址信息, 可以设置为NULL.
addrlen:
一个指向socklen_t
类型的变量的指针, 用于存储src_addr
的大小. 调用前需设置为结构体大小, 调用后返回实际地址长度.
返回值: 成功返回接收到的字节数, 失败返回 -1,
同时设置 errno
sendto:
原型: ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
头文件: sys/types.h 和 sys/socket.h
功能: 函数用于向一个指定地址的进程发送数据.
参数:
sockfd:
指定发送数据的套接字描述符, 通常是一个 UDP 套接字
buf:
一个指向要发送的数据的指针
len:
要发送的数据的长度
flags
用于设置接收选项, 常见值包括:
0
:默认发送行为。MSG_DONTWAIT
:非阻塞模式发送。dest_addr
:
一个指向sockaddr
结构的指针, 用于指定目标地址和端口
addrlen:
dest_addr
的大小,ipv4为sizeof(struct sockaddr_in)
返回值: 成功返回发送到的字节数, 失败返回 -1,
同时设置 errno
提前准备好缓冲区, 然后recvfrom接收消息即可, 注意要给\0留一个位置; 接收完之后这里我把接收到的消息再用sendto返回给客户端:
#include "NotCopable.hpp"
#include <sys/types.h>
#include <sys/socket.h>
#include <string>
#include <iostream>const std::string default_ip = "0.0.0.0";
const uint16_t default_port = 8888;
const int default_num = 1024;class UdpEchoServer : NotCopable
{
public:UdpEchoServer(std::string ip = default_ip, uint16_t port = default_port):_port(port)_ip(ip){}void Init(){}void Start(){char buffer[default_num];while(true){struct sockaddr client;socklen_t len = sizeof(client);ssize_t recvfrom_ret = recvfrom(_socket_id, buffer, sizeof(buffer)-1, 0, &client, &len);if(recvfrom_ret < 0){lg.LogMessage(Debug, "recvfrom errr, ", errno, ": ", strerror(errno), "\n");exit(Recv_Err);}//lg.LogMessage(Info, "recvfrom success");buffer[recvfrom_ret] = '\0';InetAddr ia(&client);std::cout << ia.Debug() << ":" << buffer << std::endl;//打印发送方地址信息//回复发送方ssize_t sendto_ret = sendto(_socket_id, buffer, sizeof(buffer), 0, &client, len);if(sendto_ret < 0){lg.LogMessage(Debug, "sendto errr, ", errno, ": ", strerror(errno), "\n");exit(Sendto_Err);}}~UdpEchoServer(){}private:std::string _ip; uint16_t _port;int _socket_id;
};
上面的 InetAddr ia(&client); 还没解释, 它主要是封装了一个套接字的 ip 和 port 用于输入打印:
#pragma once#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string>class InetAddr
{
public:InetAddr(sockaddr* sock):_sock(sock){if(sock->sa_family == AF_INET){sockaddr_in* temp = (sockaddr_in*) sock;_port = ntohs(temp->sin_port);_ip = inet_ntoa(temp->sin_addr);}else{_port = 0;_ip = "0.0.0.0";}}uint16_t Port() const{return _port;}std::string Ip() const{return _ip;}std::string Debug(){std::string temp = "[";temp += (_ip + ":" + std::to_string(_port) + "]");return temp;}
private:std::string _ip;uint16_t _port;sockaddr* _sock;
};
4. 测试
main.cc:
#include "udp_echo_server.hpp"
#include "commond.h"
#include <iostream>
#include <memory>void Usage(char* proc)
{std::cout << "Usage: \n\t" << proc << "proc_name port" << std::endl;
}int main(int argc, char* argv[])
{if(argc != 3){Usage(argv[0]);return Usage_Err;}std::string ip = argv[1];uint16_t port = std::stoi(argv[2]);std::unique_ptr<UdpEchoServer> server = std::make_unique<UdpEchoServer>(ip, port);server->Init();server->Start();return 0;
}
输入要绑定的ip和端口号即可, 可以看到服务端正在等待客户端给它发消息:
输入指令 : netstat -aunp
-a: 显示所有连接和监听的端口
-p: 显示网络连接的进程信息
-u: 仅显示 UDP 连接
-n: 以数字形式显示地址和端口, 而不是将地址解析为主机名或端口解析为服务名称
可以看到本地环回ip:8888 , 由进程./udpserver建立:
ss指令也可以达到类似的效果, 使用和netstat类似.
客户端
最后来实现客户端即可, 步骤和服务端类似, 依然是:
1. 需要创建一个套接字socket
2. 然后bind绑定, 但是我们这里不需要显式手写bind, 客户端会在首次发送数据的时候OS会自动的随机进行bind, 而不是手写与固定的ip和port绑定死.
所以我们可以明确:
a. server端的端口号一定是众所周知的, 且不可被改变的.
比如一些服务: HTTP、FTP、SMTP 等, 都对应一个固定端口号(例如 HTTP 为 80, HTTPS 为 443). 客户端通过固定的端口号, 客户端可以通过预定义的方式找到对应的服务, 避免混乱.
b. 而client也需要port, 但是是随机的端口号. 为什么?
因为客户端可能会非常多, 为了解决多客户端并发通信的问题, 随机分配保证了每个客户端连接的 <客户端IP, 客户端端口> 都是唯一的, 避免一个key映射多个value.
3. 然后向服务端发送数据即可, 随后接收服务端发来的回复信息:
#include <string>
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "commond.h"void Usage(const std::string& proc)
{std::cout << "Usage " << proc << ": server_ip server_port\n";
}int main(int argc, char* argv[])
{if(argc != 3){Usage(argv[0]);return Usage_Err;}std::string server_ip = argv[1];std::uint16_t server_port = std::stoi(argv[2]);//1. 建立服务int socket_fd = socket(AF_INET, SOCK_DGRAM, 0);if(socket_fd < 0){std::cerr << "socket fail: %d" << errno << strerror(errno) << std::endl;}std::cout << "socket create success: " << socket_fd << std::endl;//2. 自动bind//do nothing//3. 封装server的socketsockaddr_in server_socket;server_socket.sin_family = AF_INET;server_socket.sin_port = htons(server_port);inet_pton(AF_INET, server_ip.c_str(), &server_socket.sin_addr);while(true){std::string content;std::cout << "Please enter content: ";std::getline(std::cin, content);ssize_t sendto_ret = sendto(socket_fd, content.c_str(), content.size(), 0, (sockaddr*)&server_socket, sizeof(server_socket));if(sendto_ret > 0){char buffer[1024];sockaddr_in socket;socklen_t len = sizeof(socket);ssize_t recvfrom_ret = recvfrom(socket_fd, buffer, sizeof(buffer)-1, 0, (sockaddr*)&socket, &len);if(recvfrom_ret > 0){buffer[recvfrom_ret] = '\0';std::cout << "server respond: " << buffer << std::endl; }else{std::cerr << "recvfrom fail: %d" << errno << strerror(errno) << std::endl;}}else{std::cerr << "sendto fail: %d" << errno << strerror(errno) << std::endl;}}close(socket_fd);return 0;
}
综合测试
先来进行本地的测试, 客户端向本地127.0.0.1发送数据:
如图, 左侧是服务端, 右侧是客户端. 客户端向服务端发送的数据:
- 服务端能接收 客户端的[ip:port]和消息的内容,
- 客户端也能收到服务端的回复内容
用netstat查看一下连接状态, 发现客户端确实自动被分配了一个端口号54066 :
补充问题:
我们把服务器的ip地址绑定为127.0.0.1, 只能进行本地的通信, 进行代码的测试. 那我们如果绑定到一个固定的公网ip上, 想让其他主机也访问服务器呢?
当我想绑定到我云服务器固定的IP时, 它会报错. 因为云服务器的公网 IP 通常是通过 NAT 或负载均衡器(LB)映射到云服务器的内网 IP, 而不是直接分配到服务器的网卡上. 也就是说, 云服务器的网卡通常只有内网 IP, 而公网 IP 并不是直接绑定到该网卡的物理接口上. 因此, Cannot assign requested address 是
因为该公网 IP 不直接存在于服务器的网络接口上. 但是如果我们使用的是一个真实的Linux环境, 就可以bind其ip.
所以结论是: 其实并不推荐给服务器bind固定的ip, 这样做的一个弊端是: 未来此服务器只能接受到向固定ip发送的消息, 假如当前机器有多个网卡 iA ipB ipC.., 但是只bind了ipA, 我们只能收到发送给ipA的报文, 对于发送到此机器的其它ip的报文都无法收到.
所以更推荐bind任意ip的方式, 把ip绑定到 INADDR_ANY (0.0.0.0), 因此我们服务器只需要指定特定port, 往后发送给服务端的数据, 只要发送的目的端口是8888, 且ip是此机器上的ip, 都能被接收到, 实现了ip的动态绑定. 这样, 服务器就会监听所有可用的网络接口上的指定端口, 无论是内网 IP、公网 IP 还是其他虚拟接口上的 IP.
所以代码中server里的_ip成员其实根本不用维护, 直接绑定任意ip即可.
测试一下, 此时启动了一个客户端1(本地), 一个客户端2(linux虚拟机). 服务器都可以接收到客户端的信息.
注意: 云服务器需要开放对应的UDP端口, 否则无法外部通信.
windows客户端测试
windows端的socket接口和linux端的差别很小, 基本相同, 明白了linux的socket API, 基本就可以在windows写一个类似的客户端, 但还是有一点差别:
1. windows下需要包含 winsock2.h
和 ws2tcpip.h
头文件, 且Windows 使用 ws2_32.lib
库来链接 Winsock 相关的函数, 需要添加 #pragma comment(lib, "ws2_32.lib")
语句帮助自动链接该库
2. 需要先使用 WSAStartup()
初始化 Winsock 库, 在程序结束时需要调用 WSACleanup()
来清理资源。
//初始化winsocket库
WSADATA wsaData;
WSAStartup(MAKEWORD(2, 2), &wsaData);
//清理
WSACleanup();
3. SOCKET
是 Windows 中的特定类型, 是一个无符号整数
SOCKET fd = socket(AF_INET, SOCK_DGRAM, 0);
4. Windows 上, 使用 closesocket()
来关闭套接字.
#define _CRT_SECURE_NO_WARNINGS
#include <winsock2.h>
#include <ws2tcpip.h>
#include <iostream>
#include <string>#pragma warning(disable: 4996)
#pragma comment(lib, "ws2_32.lib")void Usage(char* s)
{std::cout << s << ": server_ip server_port" << std::endl;
}int main(int argc, char* argv[])
{if (argc != 3){Usage(argv[0]);exit(-1);}//初始化winsocket库WSADATA wsaData;WSAStartup(MAKEWORD(2, 2), &wsaData);//1.创建socketSOCKET fd = socket(AF_INET, SOCK_DGRAM, 0);if (fd < 0){std::cerr << "sock fail: " << WSAGetLastError() << std::endl;}//2.bind//自动bindsockaddr_in serverAddr;memset(&serverAddr, 0, sizeof(serverAddr));serverAddr.sin_family = AF_INET;serverAddr.sin_addr.s_addr = inet_addr(argv[1]);serverAddr.sin_port = htons(std::stoi(argv[2]));std::string msg;char buffer[1024];while (true){std::cout << "Please enter: ";std::getline(std::cin, msg);int sendto_ret = sendto(fd, msg.c_str(), msg.size(), 0, (sockaddr*)&serverAddr, sizeof(serverAddr));if (sendto_ret < 0){std::cerr << "send fail: " << WSAGetLastError() << std::endl;}sockaddr_in originAddr;int len = sizeof(originAddr);int recvfrom_ret = recvfrom(fd, buffer, sizeof(buffer), 0, (sockaddr*)&originAddr, &len);if (recvfrom_ret < 0){std::cerr << "recv fail: " << WSAGetLastError() << std::endl;}buffer[recvfrom_ret] = '\0';std::cout << "[" << inet_ntoa(originAddr.sin_addr) << ":" << ntohs(originAddr.sin_port) <<"]: " << buffer << std::endl;}closesocket(fd);WSACleanup();
}
Windows客户端:
Linux服务端:
由于windows下是GBK编码不是UTF-8, 所以数据传递过去会显示乱码.
udp_command_server
我们已经实现了最基本的通信, 现在想要在此基础上添加一些功能, 比如: 客户端端向服务端发送一条指令, 服务端在本地执行这条指令并把结果返回给客户端.
1. 所以我们需要在客户端添加一个函数对象成员, 其类型自定义为string(*)(string)类型, 用于接受一个给定字符串, 然后结果以字符串形式返回:
using command_func_t = std::function<std::string(const std::string&)>;
#pragma once
#include <string>
#include <iostream>
#include <cerrno>
#include <cstring>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>#include "Log.hpp"
#include "NotCopable.hpp"
#include "commond.h"
#include "InetAddr.hpp"const uint16_t default_port = 8888;
const int default_num = 1024;class UdpEchoServer : NotCopable
{using command_func_t = std::function<std::string(const std::string&)>;public:UdpEchoServer(command_func_t fun, uint16_t port = default_port):_port(port),_func(fun){}void Init(){}void Start(){}~UdpEchoServer(){}
private:uint16_t _port;int _socket_id;command_func_t _func;
};
2. 主要需要修改一下Start的部分, 接收到客户端的消息之后, 不是无脑把消息打印出来, 而是执行一次函数功能后再发送结果:
void Start(){char buffer[default_num];while(true){//从客户端接受信息struct sockaddr_in client;socklen_t len = sizeof(client);ssize_t recvfrom_ret = recvfrom(_socket_id, buffer, sizeof(buffer)-1, 0, (sockaddr*)&client, &len);if(recvfrom_ret < 0){lg.LogMessage(Debug, "recvfrom errr, ", errno, ": ", strerror(errno), "\n");exit(Recv_Err);}buffer[recvfrom_ret] = '\0';//客户端提示信息InetAddr ia(client);std::cout << ia.Debug() << ":" << buffer << std::endl;//执行服务端功能std::string msg = _func(buffer);ssize_t sendto_ret = sendto(_socket_id, msg.c_str(), msg.size(), 0, (sockaddr*)&client, len);if(sendto_ret < 0){lg.LogMessage(Debug, "sendto errr, ", errno, ": ", strerror(errno), "\n");exit(Sendto_Err);}}
3. main.cc部分也要修改, 需要自定义"指令结果"->"字符串"的函数, 并作为参数传递给udpserver的构造函数.
其中用到了popen函数:
popen 函数首先创建一个管道, 然后父进程fork创建一个子进程, 子进程通过进程替换执行给定的外部命令(如 ls
、grep
、cat
等).
- 如果父进程指定
mode
为"r",
popen
将子进程的标准输出(stdout)重定向到管道的写端. 这样, 父进程可以从管道的读取端读取(fgets, fread等)子进程的输出. - 如果使用
"w"
模式, 父进程可以通过管道向子进程传送数据. 它可以使用fputs,
fprintf
等函数将数据写入管道.
参数:
command: 是一个字符串, 表示要执行的命令. 通常是一个 shell 命令, 或者任何可以在命令行中执行的程序. 如果你希望执行一个 shell 命令, 可以直接传递给它.
type: 是一个字符串, 指定文件流的打开方式, 取值可以是:
- "r": 表示以只读方式打开管道 (即从子进程读取输出)
- "w": 表示以写入方式打开管道 (即将数据传递给子进程的标准输入)
返回值:
- 成功时,
popen
返回一个FILE *
类型的文件指针, 指向打开的管道. 你可以通过该指针使用标准的文件操作函数 (如fgets
,fputc
,fputs
,fprintf
,fread
,fwrite
等) 来与子进程进行通信。- 如果失败, 返回
NULL.
可以通过errno
来查看失败的具体原因.
#include "udp_command_server.hpp"
#include "commond.h"
#include <iostream>
#include <memory>void Usage(char* proc)
{std::cout << "Usage: \n\t" << proc << "proc_name port" << std::endl;
}std::string CommandToResult(const std::string& command)
{FILE* fp = popen(command.c_str(), "r");if(fp == nullptr){return "Execute error, create file fail\n";}std::string response;char buffer[1024];while(true){char *s = fgets(buffer, sizeof(buffer), fp);if(!s) break;else response += buffer;}pclose(fp);return response.empty() ? "erro command" : response;
} int main(int argc, char* argv[])
{if(argc != 2){Usage(argv[0]);return Usage_Err;}uint16_t port = std::stoi(argv[1]);std::unique_ptr<UdpEchoServer> server = std::make_unique<UdpEchoServer>(CommandToResult, port);server->Init();server->Start();return 0;
}
linux服务端:
windows客户端:
UDP双向网络编程
现在来实现一个简单的聊天室, 建立一个类似群聊的功能, 多个客户端可以互相接受和发送消息.
简单的聊天室
思路: 肯定会有很多使用聊天室的客户端, 服务端一旦接受到消息, 就可以保存用户的地址信息, 然后服务器把数据通过保存的地址转发给每个用户.
之前我们的服务器都是单线程的, 而udp/tcp都是支持全双工通信的, 所以可以支持读写并发执行, 所以可以考虑使用多线程. 因此让主线程专门从客户端读取数据(recvfrom), 由其它的线程去负责发数据(sendto).
服务端
server的初始化工作没有太大改动, 只需要启动线程池即可:
服务器执行任务部分做了很大的改动, 之前我们是主线程接受到数据之后还负责转发数据给客户端, 现在不需要了, 主线程专门负责接受数据, 此外还有一些额外的工作:
1. 我们服务端需要维护在线用户的地址, 设计一个AddUser函数把用户添加到一个容器里, 这里先选择vector, 注意:
- a. 不要重复添加
- b. 访问全局变量记得加锁
AddUser:
2. 我们需要把发送数据的任务(称之为路由任务Route), 封装为一个函数对象传递给线程池由线程负责执行发数据的任务
Route 负责把数据转发给所有用户.
注意: 访问临界资源_online_users要加锁,
3. 其它工作: 声明任务的类型, 初始化和销毁锁.
using command_func_t = std::function<void()>;
public:
UdpChatServer(uint16_t port = default_port)
:_port(port)
{pthread_mutex_init(&_user_mutex, nullptr);
}
~UdpChatServer()
{pthread_mutex_destroy(&_user_mutex);
}
客户端
客户端的改动就比较大了, 因为我们不再是单纯的一个人和服务器进行对话, 我们在发送消息的同时也要能接收信息, 而之前单线程的客户端无法并发地执行收发两个任务, 是先完成send再recv的, 如果我不send, 我将无法收到服务器的消息.
比如这个场景: 当我不想发言, 一直阻塞在输入框时, 我也需要能看到其它客户端发送来的消息, 而不是串行地读写: 只有我发送了一条消息后, 才能接收到服务器发来的消息.
我们把收消息和发消息封装为两个函数, 启动两个线程分别去执行:
class ThreadData
{
public:ThreadData(int sockfd, sockaddr_in server): _serveraddr(server), _sockfd(sockfd){}public:int _sockfd;InetAddr _serveraddr;
};int main(int argc, char *argv[])
{if (argc != 3){Usage(argv[0]);return Usage_Err;}std::string server_ip = argv[1];std::uint16_t server_port = std::stoi(argv[2]);// 1. 建立服务int socket_fd = socket(AF_INET, SOCK_DGRAM, 0);if (socket_fd < 0){std::cerr << "socket fail: %d" << errno << strerror(errno) << std::endl;}std::cout << "socket create success: " << socket_fd << std::endl;// 2. 自动bind// do nothing// 3. 封装server的socketsockaddr_in server_socket;server_socket.sin_family = AF_INET;server_socket.sin_port = htons(server_port);inet_pton(AF_INET, server_ip.c_str(), &server_socket.sin_addr);ThreadData td(socket_fd, server_socket);Thread<ThreadData> send_thread("thread_1", SendTo, td);Thread<ThreadData> recv_thread("thread_2", RecvFrom, td);send_thread.Start();recv_thread.Start();send_thread.Join();recv_thread.Join();return 0;
}
注意看这里特意向错误流中输出, 是为了等会能更好的显示内容:
测试一下:
主要观察右侧客户端的内容, 左侧是服务器会打印一些日志信息.
1. 本地客户端先发送了一条消息, 此时虚拟机还没输入消息, 所以服务器没有添加该用户地址, 所以右侧显示框没有内容:
2. 虚拟机发送消息后, 两端正式可以开始互相交流了 , 一方发送消息, 另一方可以同步的显示内容: