网络编程套接字
- 0. 前言
- 1. 认识端口号
- 2. 认识TCP和UDP协议
- 3. 网络字节序
- 4. socket编程接口
- 5. 实现一个简单的UDP网络程序
- 5.1 需求分析
- 5.2 头文件准备
- 5.3 服务器端设计
- 5.4 客户端设计
- 5.5 本地测试
- 5.6 跨网络测试
- 5.7 UDP小应用——客户端输入命令,服务器端执行
- 6. 地址转换函数
- 7. 实现一个简单的TCP网络程序
- 7.1 V1版本——bug版
- 7.1.1 服务器端
- 7.1.2 客户端
- 7.2 V2版——多进程,wait版
- 7.3 V3版——多进程信号版
- 7.4 V4——多线程版
- 8. 如何理解:面向数据报&&面向字节流
0. 前言
学习本章,需要先了解日志:学习日志。并且要对Linux系统有一定的了解,遇到知识盲区可以去看我的Linux专栏。
1. 认识端口号
1. 思考
- 网络通讯的最终目的,是主机和主机之间通信吗?答案是:不仅仅是。最终目的是两台主机中的两个进程间通信。
- 我们所有的网络通信行为,本质都是进程间通信!!!
- 第一步首先要将数据送达目标机器,第二步则是找到指定进程;
- IP地址用于标识互联网中唯一一台主机,端口号(port)则用来标识主机中唯一一个进程;
{ip:port}
组合,可以标识互联网中唯一一个进程,这个组合也叫套接字,socket(这个英文单词有插座、插孔的意思)。
2. 端口号(port)
- 端口号是传输层协议的内容;
- 端口号是一个2字节16比特的整数;
- 端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理;
- IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;
- 一个端口号只能被一个进程占用;但一个进程可以有多个端口号。
3. 端口号 VS pid
pid
就可以标识一台机器中的唯一一个进程,为什么还要设计端口号?pid
是系统中设计的一个标识,如果有一天,系统的设计规则改变了,那么将会直接影响网络的设计。为了将系统和网络进行解耦,又设计了一个端口号的概念,独属于网络范畴。
2. 认识TCP和UDP协议
这里我们先对两个协议有一个直观认识,后面再讨论细节。
1. TCP
- 传输层协议;
- 有连接;
- 可靠传输;
- 面向字节流。
2. UDP
- 传输层协议;
- 无连接;
- 不可靠传输;
- 面向数据报。
3. 可靠传输 VS 不可靠传输
- “可靠”在这里是一个中性词,没有好坏之分;
- 对于可靠传输,可能会存在丢包检测的操作,一旦发现丢包就重发数据,这也必然会消耗更多的资源和时间;
- 不可靠传输则不会做这些检测,对于处于良好网络环境的两台机器,丢包的检测倒显得次要了。并且不可靠传输的实现也较为简单,在一些场景下会有奇效;
- 例如在直播领域,画面偶尔卡顿一下,声音炸一下,影响其实不是很大,所以UDP协议也广泛应用在直播领域。
对于其他的概念,有连接和无连接,面向字节流和面向数据报,我们之后再详谈。
3. 网络字节序
1. 大端机和小端机
- 大端机:高权值位存在低地址处;
- 小端机:高权值位存在高地址处。
2. 问题引入
- 发送主机经常将发送缓冲区中的数据按照内存地址从低到高的顺序发出;
- 接收主机把从网络中接收的字节依次保存在接收缓冲区中,也是按照内存地址从低到高的顺序保存的;
- 此时如果一个大端机要和小端机进行网络通信,由于彼此的存储方案不同,小端机收到的数据很可能是相反的,如何解决?
3. 网络字节序
- 为了解决上述问题,统一规定,发送进网络中的数据,必须是大端的;这也就意味着,所有从网络中收到数据的机器,都会知道数据是大端的;
- 规定网络中的数据是大端,而不是小端,没有什么特别的理由。如果硬要找一个理由,就是按照大端方式存储的数据,可读性较强。
4. 一批和网络字节序有关的接口
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong); // 4字节主机ip序列转网络序列
uint16_t htons(uint16_t hostshort); // 2字节主机端口序列转网络序列
uint32_t ntohl(uint32_t netlong); // 4字节网络ip序列转主机序列
uint16_t ntohs(uint16_t netshort); // 2字节网络端口序列转主机序列
- 其中,h就是host主机的意思,n是net网络的意思,l就是long,s就是short。
4. socket编程接口
1. 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);
- 网络编程时,socket有很多类别:
- unix socket:域间socket,使用同一台机器上的文件路径,类似于命名管道,用来进行本主机内部的通信;
- 网络socket:使用ip+端口号,进行网络通信;
- 原始socket:用来编写一些网络工具。
- 理论上而言,我们应该给上述每种应用场景都设计一套编程接口,但事实是,他们共用一套编程接口。这是如何实现的?
2. sockaddr结构
- socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及后面要讲的UNIX Domain Socket。然而,各种网络协议的地址格式并不相同。
- IPv4和IPv6的地址格式定义在
netinet/in.h
中,IPv4地址用sockaddr_in
结构体表示,包括16位地址类型,16位端口号和32位IP地址; - IPv4、IPv6地址类型分别定义为常数
AF_INET
、AF_INET6
。这样,只要取得某种sockaddr
结构体的首地址,不需要知道具体是哪种类型的sockaddr
结构体,就可以根据地址类型字段确定结构体中的内容,自动强制转化; struct sockaddr
是一个通用地址类型,在使用时需要强制转化;这样的好处是提高了程序的通用性,可以接收IPv4,IPv6,以及UNIX Domain Socket各种类型的sockaddr
结构体指针做为参数。这是C风格的多态;- 之所以没有使用
void
作为通用地址类型,是因为这批接口被设计出来时,C语言还不支持void*
。
3. sockaddr
/* Structure describing a generic socket address. */
struct sockaddr
{
__SOCKADDR_COMMON (sa_); /* Common data: address family and length. */
char sa_data[14]; /* Address data. */
};
4. 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)];
};
- 虽然socket api的接口是
sockaddr
,但是我们真正在基于IPv4编程时,使用的数据结构是sockaddr_in
; - 这个结构里主要有三部分信息:
- 地址类型
sin_family
,一个宏; - 端口号
sin_port
,uint16_t
类型,typedef uint16_t in_port_t;
; - IP地址
sin_addr
,struct in_addr
类型(一会儿还要看这个类型里有什么)。
- 地址类型
有的同学可能有疑问,哪里有
sin_family
?我们再转到宏定义,看一下它是怎么实现的:#define __SOCKADDR_COMMON(sa_prefix) \ sa_family_t sa_prefix##family
这里就要考验大家的C语言功底了,已知有
typedef unsigned short int sa_family_t;
,sa_prefix
是一个占位符,传的是sin_
,##
表示预编译时,和后面的内容拼接,所以就有了sin_family
。
5. in_addr
/* Internet address. */
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};
in_addr.s_addr
用来表示一个IPv4的IP地址,其实就是一个32位的整数。
5. 实现一个简单的UDP网络程序
5.1 需求分析
1. 服务器端
- 服务器端接收客户端发来的消息,并将消息再发回给客户端;
- 服务器接收消息时,也想知道客户端的端口号和ip地址。
2. 客户端
- 客户端向服务器端发消息,并接收服务器端发来的消息。
5.2 头文件准备
1. nocpoy类——nocopy.hpp
- 继承该类的子类,统统不能拷贝。
#pragma once
class nocopy
{
public:
nocopy()
{}
~nocopy()
{}
nocopy(const nocopy&) = delete;
const nocopy& operator=(const nocopy&) = delete;
};
2. 错误信息设计——Comm.hpp
#pragma once
enum{
Usage_Err = 1, // 使用方式错误
Socket_Err, // Socket错误
Bind_Err // Bind错误
};
3. 网络地址类设计——InetAddr.hpp
- 网络编程中,经常会遇到打印或保存网络地址的需求,所以提前把网络地址类给封装好。
#pragma once
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
class InetAddr
{
public:
InetAddr(struct sockaddr_in &addr)
{
_port = ntohs(addr.sin_port);
_ip = inet_ntoa(addr.sin_addr);
}
std::string Ip() { return _ip; }
uint16_t Port() { return _port;}
std::string PrintDebug()
{
std::string info = _ip;
info += ":";
info += std::to_string(_port);
return info;
}
~InetAddr()
{}
private:
std::string _ip; // ip
uint16_t _port; // 端口号
};
下面两个类设计在我们Linux专栏中都有讲到,感兴趣的小伙伴可以学习一下。
4. 日志类——Log.hpp
#pragma once
#include<iostream>
#include<string>
#include<cstdarg>
#include<ctime>
#include<fstream>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include"LockGuard.hpp"
enum
{
Debug = 0,
Info, // 正常信息
Warning, // 告警
Error, // 错误
Fatal // 致命错误
};
enum
{
Screen = 0, // 向显示器打印
OneFile, // 向一个文件打印
ClassFile // 向多个文件打印
};
// 将日志等级转换为string
std::string LevelToString(int level)
{
switch(level)
{
case Debug:
return "Debug";
case Info:
return "Info";
case Warning:
return "Warning";
case Error:
return "Error";
case Fatal:
return "Fatal";
default:
return "Unknown";
}
}
const int defaultstyle = Screen; // 默认风格是向显示器打印
const std::string default_filename = "log."; // 默认文件名
const std::string logdir = "log"; // 默认日志文件夹
class Log
{
public:
Log()
:_style(defaultstyle)
,_filename(default_filename)
{
mkdir(logdir.c_str(), 0775);
pthread_mutex_init(&_mutex, nullptr);
}
void Enable(int style)
{
_style = style;
}
std::string TimeStampExLocalTime()
{
time_t currtime = time(nullptr);
struct tm *curr = localtime(&currtime);
char time_buffer[128];
snprintf(time_buffer, sizeof(time_buffer), "%d-%d-%d %d:%d:%d", \
curr->tm_year+1900, curr->tm_mon+1, curr->tm_mday,\
curr->tm_hour, curr->tm_min, curr->tm_sec);
return time_buffer;
}
void WriteLogToOneFile(const std::string &logname, const std::string &message)
{
umask(0);
int fd = open(logname.c_str(), O_CREAT | O_WRONLY | O_APPEND, 0666);
if(fd < 0) return;
write(fd, message.c_str(), message.size());
close(fd);
}
void WriteLogToClassFile(const std::string &levelstr, const std::string &message)
{
std::string logname = logdir;
logname += "/";
logname += _filename;
logname += levelstr;
WriteLogToOneFile(logname, message);
}
void WriteLog(const std::string &levelstr, const std::string &message)
{
switch(_style)
{
case Screen:
std::cout << message;
break;
case OneFile:
WriteLogToClassFile("all", message);
break;
case ClassFile:
WriteLogToClassFile(levelstr, message);
break;
default:
break;
}
}
void LogMessage(int level, const char* format, ...) // 类C的一个日志接口
{
char rightbuffer[1024];
va_list args; // 这是一个char *类型(或者void *)的指针
va_start(args, format); // 让arg指向可变部分
vsnprintf(rightbuffer, sizeof(rightbuffer), format, args); // 将可变部分按照指定格式写入到content中
va_end(args); // 释放args, args = nullptr
char leftbuffer[1024];
std::string levelstr = LevelToString(level);
std::string currtime = TimeStampExLocalTime(); // 获取当前时间
std::string idstr = std::to_string(getpid());
snprintf(leftbuffer, sizeof(leftbuffer), "[%s][%s][%s] ", \
levelstr.c_str(), currtime.c_str(), idstr.c_str());
std::string loginfo = leftbuffer;
loginfo += rightbuffer;
{
LockGuard lockguard(&_mutex);
WriteLog(levelstr, loginfo);
}
}
~Log()
{}
private:
int _style;
std::string _filename;
pthread_mutex_t _mutex;
};
// 配置log
Log lg;
class Config
{
public:
Config()
{
// 在此配置
lg.Enable(Screen);
}
~Config()
{}
};
Config config;
5. LockGurad.hpp
#pragma once
#include <pthread.h>
// 不定义锁,默认认为外部会给我们传入锁对象
class Mutex
{
public:
Mutex(pthread_mutex_t *lock):_lock(lock)
{}
void Lock()
{
pthread_mutex_lock(_lock);
}
void Unlock()
{
pthread_mutex_unlock(_lock);
}
~Mutex()
{}
private:
pthread_mutex_t *_lock;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t *lock):_mutex(lock)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex _mutex;
};
5.3 服务器端设计
1. 服务器主程序——Main.cc
- 我们希望通过
./udp_server ip port
的方式启动服务器,ip和端口号需要我们手动传入。 UdpServer
就是一个服务器类,接下来就要逐步实现这个类。
#include "UdpServer.hpp"
#include "Comm.hpp"
#include <memory>
// 使用手册
void Usage(std::string proc)
{
std::cout << "Usage : \n\t" << proc << " local_ip local_port\n" << std::endl;
}
// ./udp_server 127.0.0.1 8888
int main(int argc, char *argv[])
{
if(argc != 3)
{
Usage(argv[0]);
return Usage_Err;
}
std::string ip = argv[1]; // 拿到字符串风格的ip地址
uint16_t port = std::stoi(argv[2]); // 拿到2字节整数风格的端口号
std::unique_ptr<UdpServer> usvr = std::unique_ptr<UdpServer>(new UdpServer(ip, port));
usvr->Init(); // 初始化服务器
usvr->Start(); // 启动服务器
return 0;
}
- 接下来设计
UdpServer.hpp
。
2. 设计成员变量+搭建基本框架
- 理论来讲,需要三个成员变量:
- sockfd(这个大家可能会有疑问,不着急,在代码中学习);
- ip地址;
- port端口号。
- 设计一个
Init
接口,初始化服务器;再设置一个Start
接口,启动服务器。
const static std::string defaultip = "0.0.0.0"; // 默认ip地址
const static int defaultfd = -1;
const static uint16_t defaultport = 8888; // 默认端口
class UdpServer : public nocopy
{
public:
UdpServer(const std::string &ip = defaultip, uint16_t port = defaultport)
:_ip(ip), _port(port), _sockfd(defaultfd)
{}
void Init()
{
...
}
void Start()
{
...
}
~UdpServer()
{
close(_sockfd);
}
private:
std::string _ip; // 服务器ip地址
uint16_t _port; // 端口号
int _sockfd; // 文件细节
};
3. 设计Init接口
- 初始化服务器分为两步:
- a. 创建socket;
- b. 绑定,指定网络信息。
class UdpServer : public nocopy
{
public:
...
void Init()
{
// 1. 创建socket(本质就是创建了文件细节)
_sockfd = socket(AF_INET, SOCK_DGRAM, 0); // udp协议
if (_sockfd < 0)
{
// 创建socket失败
lg.LogMessage(Fatal, "socket error, %d : %s\n", errno, strerror(errno));
exit(Socket_Err);
}
lg.LogMessage(Info, "socket success, sockfd: %d\n", _sockfd); // 创建socket成功
// 2. 绑定(指定网络信息)
struct sockaddr_in local;
bzero(&local, sizeof(local)); // 初始化local结构体
local.sin_family = AF_INET; // 表明地址格式是IPV4
local.sin_port = htons(_port); // 端口号转网络序列
local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 这个函数干两件事情 a. 将字符串风格ip转为4字节ip b. 将4字节ip变为网络序列
// 将网络信息设置进内核(网络信息和文件信息关联)
int n = ::bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if(n != 0)
{
lg.LogMessage(Fatal, "bind error, %d : %s\n", errno, strerror(errno));
exit(Bind_Err);
}
}
...
private:
std::string _ip; // 服务器ip地址
uint16_t _port; // 端口号
int _sockfd; // 文件细节
};
- 使用socket接口创建UDP套接字:
_sockfd = socket(AF_INET, SOCK_DGRAM, 0)
。domain
:指定协议族(Address Family),AF_INET
表示使用 IPv4 地址族。type
:指定套接字类型,SOCK_DGRAM
表示创建一个数据报套接字,适用于 UDP 协议。protocol
:指定使用的协议。对于 UDP,通常传入 0,表示使用默认协议(UDP)。
- 返回值是
int
类型,这个返回值的使用方式就像文件描述符一样,之后收发消息都要用到它。使得跟网络的交互,就像是在对某个文件进行写入和读取一样。
bind
绑定网络信息,需要我们提前填充好struct sockaddr
结构体,注意网络字节序和主机字节序之间的转化。注意和C++标准库中的std::bind
函数区分,他们是夫妻肺片和夫妻的区别,完全不是一个东西。
4. 设计Start接口
const static int defaultsize = 1024; // 默认收发消息时的缓冲区大小
class UdpServer : public nocopy
{
public:
...
void Start()
{
char buffer[defaultsize];
// 服务器永远不退出
for(;;)
{
struct sockaddr_in peer; // 远端
socklen_t len = sizeof(peer); // 指明peer的长度,不能乱写
// 收消息
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
InetAddr addr(peer);
buffer[n] = 0; // 最后一个字符串设置为'\0'
std::cout << "[" << addr.PrintDebug() << "]# "<< buffer << std::endl;
// 把消息返回
sendto(_sockfd, buffer, strlen(buffer), 0, (struct sockaddr*)&peer, len);
}
}
}
...
private:
std::string _ip; // 服务器ip地址
uint16_t _port; // 端口号
int _sockfd; // 文件细节
};
- 收消息:
recvfrom
是一个用于接收来自套接字的数据的系统调用,通常与基于数据报(UDP)的套接字一起使用。它可以从指定的套接字接收数据,并且可以获取发送方的地址信息。- sockfd:套接字描述符,由
socket()
系统调用返回。 - buf:指向接收数据的缓冲区。数据将被存储到这个缓冲区中。
- len:缓冲区的最大长度,即可以接收的最大字节数。
- flags:用于控制接收行为的标志。常见的标志包括:
- 0:默认行为,阻塞直到数据到达。
MSG_DONTWAIT
:非阻塞模式,如果无数据立即返回。
- src_addr:指向
sockaddr
结构的指针,用于存储发送方的地址信息(如 IP 地址和端口号)。如果不需要获取发送方地址,可以传入 NULL。 - addrlen:指向
socklen_t
类型的指针,用于指定src_addr
的大小。调用前应设置为src_addr
的大小,调用后将被设置为实际存储的地址长度。(不能乱填) - 返回值:
- 成功返回实际接收的数据长度;
- 失败返回-1。
- sockfd:套接字描述符,由
- 发消息:
sendto
是一个用于向指定地址发送数据的系统调用,通常与基于数据报(UDP)的套接字一起使用。- len:缓冲区数据长度;
- flag:用于控制发送行为的标志。常见的标志包括:
- 0:默认行为。
MSG_DONTWAIT
:非阻塞模式,如果无法立即发送数据,则返回 -1。
- dest_addr:指向目标地址的指针,通常是一个
struct sockaddr_in
(IPv4)或struct sockaddr_in6
(IPv6)结构。该结构包含目标的 IP 地址和端口号。 - addrlen:目标地址结构的大小(以字节为单位)。
- 返回值:
- 成功返回实际发送的字节数;
- 失败返回-1。
5. 完整的UdpServer.hpp头文件
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cerrno>
#include "nocopy.hpp"
#include "Log.hpp"
#include "Comm.hpp"
#include "InetAddr.hpp"
const static std::string defaultip = "0.0.0.0"; // 默认ip地址
const static int defaultfd = -1;
const static uint16_t defaultport = 8888; // 默认端口
const static int defaultsize = 1024; // 默认收发消息时的缓冲区大小
class UdpServer : public nocopy
{
public:
UdpServer(const std::string &ip = defaultip, uint16_t port = defaultport)
:_ip(ip), _port(port), _sockfd(defaultfd)
{}
void Init()
{
// 1. 创建socket(本质就是创建了文件细节)
_sockfd = socket(AF_INET, SOCK_DGRAM, 0); // udp协议
if (_sockfd < 0)
{
// 创建socket失败
lg.LogMessage(Fatal, "socket error, %d : %s\n", errno, strerror(errno));
exit(Socket_Err);
}
lg.LogMessage(Info, "socket success, sockfd: %d\n", _sockfd); // 创建socket成功
// 2. 绑定(指定网络信息)
struct sockaddr_in local;
bzero(&local, sizeof(local)); // 初始化local结构体
local.sin_family = AF_INET;
local.sin_port = htons(_port); // 端口号转网络序列
local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 这个函数干两件事情 a. 将字符串风格ip转为4字节ip b. 将4字节ip变为网络序列
// 将网络信息设置进内核(网络信息和文件信息关联)
int n = ::bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if(n != 0)
{
lg.LogMessage(Fatal, "bind error, %d : %s\n", errno, strerror(errno));
exit(Bind_Err);
}
}
void Start()
{
char buffer[defaultsize];
// 服务器永远不退出
for(;;)
{
struct sockaddr_in peer; // 远端
socklen_t len = sizeof(peer); // 指明peer的长度,不能乱写
// 收消息
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
InetAddr addr(peer);
buffer[n] = 0; // 最后一个字符串设置为'\0'
std::cout << "[" << addr.PrintDebug() << "]# "<< buffer << std::endl;
// 把消息返回
sendto(_sockfd, buffer, strlen(buffer), 0, (struct sockaddr*)&peer, len);
}
}
}
~UdpServer()
{
close(_sockfd);
}
private:
std::string _ip; // 服务器ip地址
uint16_t _port; // 端口号
int _sockfd; // 文件细节
};
5.4 客户端设计
1. 客户端与服务器端的不同
- 无论是客户端还是服务器端,想要进行网络通信,
socket
是必须创建的,也是一定要bind
的。区别是,客户端不需要显示的bind
,而是在第一次发送消息时,随机绑定端口; - 这是为什么,客户端为什么不需要显示绑定,端口又为什么是随机的?
- 因为服务器端的
ip
和port
一定是众所周知,是服务方提供给大家的; - 而客户端上会有非常多的进程,也一定会有非常多的端口号。如果给客户端绑定一个固定端口,很容易发生端口冲突,所以干脆直接交给系统随机分配。
- 因为服务器端的
2. 完整代码
#include <iostream>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
// 使用手册
void Usage(std::string proc)
{
std::cout << "Usage : \n\t" << proc << " server_ip server_port\n" << std::endl;
}
// ./udp_client server_ip server_port
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
return 1;
}
std::string serverip = argv[1]; // 服务端ip
uint16_t serverport = std::stoi(argv[2]); // 服务端port
// 1. 创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
std::cerr << "socket error: " << strerror(errno) << std::endl;
return 1;
}
std::cout << "create socket success: " << sockfd << std::endl;
// 2. client要不要进行bind?一定要。但是不需要显示bind,client会在首次发送数据时自动进行bind
// 为什么?server端的ip和端口号,一定是众所周知的,不可改变的,client需要bind随机端口
// 为什么?因为client会非常多,不随机绑定端口容易出现端口冲突
// 2.1 填充server信息
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());
while(true)
{
// 我们要发的数据
std::string inbuffer;
std::cout << "Please Enter# ";
std::getline(std::cin, inbuffer);
// 给server端发消息
ssize_t n = sendto(sockfd, inbuffer.c_str(), inbuffer.size(), 0, (struct sockaddr*)&server, sizeof(server));
if(n > 0)
{
char buffer[1024];
// 收消息
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t m = recvfrom(sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&temp, &len); // 最后两个参数一般建议都是要填的(传空指针会有坑)
if(m > 0)
{
buffer[m] = 0;
std::cout << "server echo# " << buffer << std::endl;
}
}
}
close(sockfd);
return 0;
}
5.5 本地测试
1. 认识netstat命令
netstat
命令的-uanp
选项组合的含义如下:- -u:显示 UDP 相关的网络连接。
- -a:显示所有网络连接(包括监听和非监听的)。
- -n:以数字形式显示地址和端口号,而不是解析为域名或服务名称。
- -p:显示每个连接的进程信息(需要管理员权限)。
netstat -uanp
的输出通常包含以下字段:- Proto:协议类型(如 udp 或 udp6)。
- RefCnt:引用计数(通常为 1)。
- Local Address:本地地址和端口号(格式为
ip:port
)。 - Foreign Address:远端地址和端口号(格式为
ip:port
)。对于监听状态 的套接字,通常显示为*:port
。 - State:连接状态(对于 UDP,通常为空)。
- PID/Program name:占用该端口的进程 ID 和进程名称。
2. 运行服务器端
- 可以发现,本地的8888端口跑起了一个网络服务
./udp_server
。
3. 接下来我们启动客户端,并向服务器端发送数据
- 上图中,我们先启动了客户端,之后查看
netstat
,发现并没有属于客户端的网络服务启动(因为没有绑定);之后客户端向服务器端发送了一个数据,再次查看netstat
,发现有一个./udp_client
的客户端网络服务启动了,这也侧面验证了,客户端在第一次发送消息时bind
随机端口;之后我们又向服务端发送了一些消息,服务端也都收到了,并且把消息返回给了客户端,实验结束。 127.0.0.1
是回环地址,表示本主机ip,bind
该ip地址常用于,也只能用于做本地的网络cs测试。
5.6 跨网络测试
1. 问题引入
bind
环回地址,只能用于本地通信,我们想进行网络通信,怎么办?云服务器不是有公网ip吗,bind
试试:
- 发现绑定失败了。这是因为,我们所看到的云服务器的ip,是提供商虚拟出来的,无法绑定。但是如果你有一台真的Linux系统,比如说本机上安装的虚拟机,是可以绑定的。
- 但是,我们强烈不建议给服务器
bind
固定ip,因为一台服务器可能有多个网卡,也就有多个ip地址,如果给服务器bind
了固定ip,它将只能收到由那个ip对应的网卡传来的信息。所以在实际应用时,更推荐任意ip绑定的方式,只绑定固定端口。即无论客户端通过哪个ip地址访问到了服务器,只要端口号找对了,就可以和服务器进行网络通信。 - 基于上述内容,我们需要修改一下代码。
2. 修改服务器端代码
Main.cc
主程序:
#include "UdpServer.hpp"
#include "Comm.hpp"
#include <memory>
// 使用手册
void Usage(std::string proc)
{
std::cout << "Usage : \n\t" << proc << " local_port\n" << std::endl;
}
// ./udp_server 8888
int main(int argc, char *argv[])
{
if(argc != 2)
{
Usage(argv[0]);
return Usage_Err;
}
// std::string ip = argv[1]; // 拿到字符串风格的ip地址
uint16_t port = std::stoi(argv[1]); // 拿到2字节整数风格的端口号
std::unique_ptr<UdpServer> usvr = std::unique_ptr<UdpServer>(new UdpServer(port));
usvr->Init(); // 初始化服务器
usvr->Start(); // 启动服务器
return 0;
}
UdpServer.hpp
头文件:bind
时ip绑定INADDR_ANY
。
// const static std::string defaultip = "0.0.0.0"; // 默认ip地址
const static int defaultfd = -1;
const static uint16_t defaultport = 8888; // 默认端口
const static int defaultsize = 1024; // 默认收发消息时的缓冲区大小
class UdpServer : public nocopy
{
public:
UdpServer(uint16_t port = defaultport)
:_port(port), _sockfd(defaultfd)
{}
...
void Init()
{
// 1. 创建socket(本质就是创建了文件细节)
...
// 2. 绑定(指定网络信息)
struct sockaddr_in local;
bzero(&local, sizeof(local)); // 初始化local结构体
local.sin_family = AF_INET;
local.sin_port = htons(_port); // 端口号转网络序列
// local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 这个函数干两件事情 a. 将字符串风格ip转为4字节ip b. 将4字节ip变为网络序列
local.sin_addr.s_addr = INADDR_ANY; // 绑定任意ip(这个宏实际上就是0)
// 将网络信息设置进内核(网络信息和文件信息关联)
int n = ::bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
...
}
...
private:
// std::string _ip; // 服务器ip地址
uint16_t _port; // 端口号
int _sockfd; // 文件细节
};
3. 给出一份Windows下的客户端代码,代码逻辑和之前写的Linux客户端一样,并且代码部分也大同小异
- 这里我选择的编译器是MinGW下的g++,编译的时候记得带
-lws2_32
选项。
#include <iostream>
#include <cstdio>
#include <string>
#include <cstdlib>
#include <winsock2.h>
#include <Windows.h>
#pragma comment(lib, "ws2_32.lib") // 类似于Linux中的-l选项,指明要链接的库
// 下面写你的服务器公网ip和端口号
uint16_t serverport = 8888;
std::string serverip = "101.42.38.249";
int main()
{
WSADATA wsd;
WSAStartup(MAKEWORD(2, 2), &wsd); // 固定写法,初始化ws2库,版本是2.2
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
server.sin_addr.s_addr = inet_addr(serverip.c_str());
SOCKET sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd == SOCKET_ERROR)
{
std::cout << "socket error" << std::endl;
return 1;
}
std::string message;
char buffer[1024];
while(true)
{
std::cout << "Please Enter# ";
std::getline(std::cin, message);
sendto(sockfd, message.c_str(), (int)message.size(), 0, (struct sockaddr*)&server, sizeof(server));
struct sockaddr_in temp;
int len = sizeof(temp);
int s = recvfrom(sockfd, buffer, 1023, 0, (struct sockaddr*)&temp, &len);
if(s > 0)
{
buffer[s] = 0;
std::cout << "server echo# " << buffer << std::endl;
}
}
// 区别2
closesocket(sockfd);
WSACleanup(); // 固定写法,清理ws2库
return 0;
}
4. 开始测试
- 注意:云服务器上,大部分的端口都是不开放的,需要我们找到安全管理的相关部分,手动打开端口。
- 上图中,先启动服务器,然后查看服务器上的
netstat
,可以看到绑定的ip地址是0.0.0.0
,这个ip表示任意ip;右侧新出现的窗口是Windows上的cmd窗口,随后运行的a.exe
程序是Windows版的客户端,可以看到客户端发送的消息,服务器全部收到了,并且发回给了客服端,实验很成功。
5.7 UDP小应用——客户端输入命令,服务器端执行
只是让服务器把消息返回的话,未免太单调了,我们来写一个好玩的应用!
- 认识
popen
函数:- 该函数会创建一个子进程执行
command
对应的命令,并创建一个管道返回其文件描述符; type
指明管道方向,“r”:表示父进程可以从子进程的输出中读取数据(子进程的标准输出被重定向到管道);“w”:表示父进程可以向子进程的输入中写入数据(子进程的标准输入被重定向到管道)。
- 该函数会创建一个子进程执行
#include <stdio.h>
FILE *popen(const char *command, const char *type);
- 服务器端主程序
Main.cc
:
#include "UdpServer.hpp"
#include "Comm.hpp"
#include <memory>
#include <string>
#include <vector>
// 使用手册
void Usage(std::string proc)
{
std::cout << "Usage : \n\t" << proc << " local_port\n" << std::endl;
}
// 黑名单
std::vector<std::string> black_words = {
"rm",
"unlink",
"cp",
"mv",
"chmod",
"exit",
"reboot",
"halt",
"shutdown",
"kill"
};
// Debug
std::string OnMessadeDefault(std::string request)
{
return request += "[got you!!!]";
}
bool SafeCheck(std::string command)
{
for(auto &k : black_words)
{
std::size_t pos = command.find(k);
if(pos != std::string::npos)
return false;
}
return true;
}
std::string ExecuteCommand(std::string command)
{
if(!SafeCheck(command)) return "unsafe!!!";
std::cout << "get a message: " << command << std::endl;
FILE *fp = popen(command.c_str(), "r");
if(fp == nullptr)
{
return "execute error, reason is unknow";
}
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() ? "success" : response;
}
// ./udp_server 8888
int main(int argc, char *argv[])
{
if(argc != 2)
{
Usage(argv[0]);
return Usage_Err;
}
// std::string ip = argv[1]; // 拿到字符串风格的ip地址
uint16_t port = std::stoi(argv[1]); // 拿到2字节整数风格的端口号
// std::unique_ptr<UdpServer> usvr = std::unique_ptr<UdpServer>(new UdpServer(OnMessadeDefault, port));
std::unique_ptr<UdpServer> usvr = std::unique_ptr<UdpServer>(new UdpServer(ExecuteCommand, port));
usvr->Init(); // 初始化服务器
usvr->Start(); // 启动服务器
return 0;
}
- 改写
UdpSerever.hpp
:
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cerrno>
#include <functional>
#include "nocopy.hpp"
#include "Log.hpp"
#include "Comm.hpp"
#include "InetAddr.hpp"
// const static std::string defaultip = "0.0.0.0"; // 默认ip地址
const static int defaultfd = -1;
const static uint16_t defaultport = 8888; // 默认端口
const static int defaultsize = 1024; // 默认收发消息时的缓冲区大小
using func_t = std::function<std::string(std::string)>; // 定义一个函数类型
class UdpServer : public nocopy
{
public:
UdpServer(func_t OnMesssade = nullptr, uint16_t port = defaultport)
:_port(port), _sockfd(defaultfd), _OnMessage(OnMesssade)
{}
void Init()
{
// 1. 创建socket(本质就是创建了文件细节)
_sockfd = socket(AF_INET, SOCK_DGRAM, 0); // udp协议
if (_sockfd < 0)
{
// 创建socket失败
lg.LogMessage(Fatal, "socket error, %d : %s\n", errno, strerror(errno));
exit(Socket_Err);
}
lg.LogMessage(Info, "socket success, sockfd: %d\n", _sockfd); // 创建socket成功
// 2. 绑定(指定网络信息)
struct sockaddr_in local;
bzero(&local, sizeof(local)); // 初始化local结构体
local.sin_family = AF_INET;
local.sin_port = htons(_port); // 端口号转网络序列
// local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 这个函数干两件事情 a. 将字符串风格ip转为4字节ip b. 将4字节ip变为网络序列
local.sin_addr.s_addr = INADDR_ANY; // 绑定任意ip(这个宏实际上就是0)
// 将网络信息设置进内核(网络信息和文件信息关联)
int n = ::bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if(n != 0)
{
lg.LogMessage(Fatal, "bind error, %d : %s\n", errno, strerror(errno));
exit(Bind_Err);
}
}
void Start()
{
char buffer[defaultsize];
// 服务器永远不退出
for(;;)
{
struct sockaddr_in peer; // 远端
socklen_t len = sizeof(peer); // 指明peer的长度,不能乱写
// 收消息
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
InetAddr addr(peer);
buffer[n] = 0; // 最后一个字符串设置为'\0'
// std::cout << "[" << addr.PrintDebug() << "]# "<< buffer << std::endl;
// 处理消息
std::string response = _OnMessage(buffer);
// 把消息返回
sendto(_sockfd, response.c_str(), response.size(), 0, (struct sockaddr*)&peer, len);
}
}
}
~UdpServer()
{
close(_sockfd);
}
private:
// std::string _ip; // 服务器ip地址
uint16_t _port; // 端口号
int _sockfd; // 文件细节
func_t _OnMessage; // 回调方法
};
- 其他包括客户端在内的文件统统不用改,我们来做一下这个实验:
- 可以看到,输入的命令在远端执行了,并且过滤掉了我们设置好的,不安全的命令,实验成功。
6. 地址转换函数
1. 一些地址转换函数
-
本节只介绍基于IPv4的socket网络编程,
sockaddr_in
中的成员变量struct in_addr sin_addr
表示32位的IP地址,但是我们通常用点分十进制的字符串表示IP地址。以下函数可以在字符串表示和in_addr
表示之间转换: -
字符串转
in_addr
函数:
-
in_addr
转字符串函数:inet_ntop
中的len参数是指缓冲区strptr
的大小。
-
其中
inet_pton
和inet_ntop
不仅可以转换IPv4的in_addr
,还可以转换IPv6的in6_addr
,因此函数接口是void *addrptr
。
2. 不安全的inet_ntoa
inet_ntoa
这个函数返回了一个char*
,很显然是这个函数自己在内部为我们申请了一块内存来保存ip的结果,那么是否需要调用者手动释放呢?
man
手册上说,inet_ntoa
函数,是把这个返回结果放到了静态存储区,这个时候不需要我们手动进行释放。即使是这样,这个函数依然存在问题。- 对于返回值是
char*
类型的函数,我们就要尤其注意,因为它返回的是一个地址。如果有多个线程调用inet_ntoa
,很可能出现一种情况,接收值之间相互覆盖,混淆在一起。 - 在APUE中,明确提出
inet_ntoa
不是线程安全的函数。但是在Centos7上测试,并没有出现问题,可能内部的实现加了互斥锁。同学们课后自己写程序验证一下在自己的机器上inet_ntoa
是否会出现多线程的问题。 - 在多线程环境下,推荐使用
inet_ntop
,这个函数由调用者提供一个缓冲区保存结果,可以规避线程安全问题。
7. 实现一个简单的TCP网络程序
服务器端实现TCP通信一共分四步:
- 创建socket套接字;
bind
绑定;- 设置监听状态;
- 获取连接。
客户端分三步:
- 创建socket套接字;
bind
绑定;- 获取连接。
接下来,我会实现V1到V4,4个版本的通信程序,带领大家实现TCP通信!程序的功能还是,先从客户端收到信息,再返回给客户端。
7.1 V1版本——bug版
7.1.1 服务器端
1. 搭建服务器基本架构
- 下面注释中可以看到,需要我们实现的TCP建立连接的4个步骤;其中
Server
就是服务器要提供的服务,我们单独拎出来写。
class TcpServer : public nocopy
{
public:
TcpServer(uint16_t port)
:_port(port), _isrunning(false)
{}
void Init()
{
// 1. 创建套接字
...
// 2. 填充本地网络信息,并绑定
...
// 3. 设置socket为监听状态,TCP特有
...
}
// 服务器要提供的服务
void Service(int sockfd)
{
...
}
void Start()
{
_isrunning = true;
while(_isrunning)
{
// 4. 获取链接
...
// 5. 提供服务,v1~v4版本
// v1
Service(sockfd);
}
}
~TcpServer()
{}
private:
uint16_t _port;
bool _isrunning; // 是否启动
};
2. 创建socket+bind
- 到这里为止,TCP建立连接的方式和UDP还一模一样;关于
setsockopt
的问题我们先不讲,只需要照着写即可。
const static int default_backlog = 5;
class TcpServer : public nocopy
{
public:
...
void Init()
{
// 1. 创建套接字
_listensockfd = socket(AF_INET, SOCK_STREAM, 0); // 创建TCP套接字
if(_listensockfd < 0)
{
lg.LogMessage(Fatal, "create socket errror, errno code: %d, error string: %s\n", errno, strerror(errno));
exit(Socket_Err);
}
lg.LogMessage(Debug, "create socket success, sockfd: %d\n", _listensockfd);
// 固定写法:解决一些少量绑定失败的问题 -- 后面讲到底层原理时再详细解释(这里要解决一个问题:服务端主动断开连接,再启动时bind失败的问题)
int opt = 1;
setsockopt(_listensockfd, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, &opt, sizeof(opt));
// 2. 填充本地网络信息,并绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
if(bind(_listensockfd, (struct sockaddr*)&local, sizeof(local)))
{
lg.LogMessage(Fatal, "bind error, errno code: %d, error string: %s\n", errno, strerror(errno));
exit(Bind_Err);
}
lg.LogMessage(Debug, "bing socket success, sockfd: %d\n", _listensockfd);
// 3. 设置socket为监听状态,TCP特有
...
}
...
private:
uint16_t _port;
int _listensockfd;
bool _isrunning; // 是否启动
};
3. 设置监听状态
- 这一步是TCP特有的。
const static int default_backlog = 5;
class TcpServer : public nocopy
{
public:
...
void Init()
{
...
// 3. 设置socket为监听状态,TCP特有
if(listen(_listensockfd, default_backlog)) // 第二个参数先不解释
{
lg.LogMessage(Fatal, "Listen socket error, errno code: %d, errror string: %s\n", errno, strerror(errno));
exit(Listen_Err);
}
lg.LogMessage(Debug, "listen socket success, sockfd: %d\n", _listensockfd);
}
...
~TcpServer()
{
close(_listensockfd);
}
private:
uint16_t _port;
int _listensockfd;
bool _isrunning; // 是否启动
};
4. 获取连接,提供服务
accept
的第一个参数是刚刚socket
的返回值,后两个参数跟远端机器有关,接收远端机器的相关信息。accept
的返回值也是一个文件描述符,后面Service
中在进行网络通信时,使用的是accept
返回的fd
,而不是socket
返回的,这一点要尤其注意!!!
class TcpServer : public nocopy
{
public:
...
// TCP 连接可以进行全双工通信
void Service(int sockfd)
{
char buffer[1024];
while(true)
{
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = 0;
std::cout << "client say# " << buffer << std::endl;
std::string echo_string = "server echo# ";
echo_string += buffer;
write(sockfd, echo_string.c_str(), echo_string.size());
}
else if(n == 0) // 网络中,read如果返回值是0,表示读到了文件结尾(对端关闭了链接!)
{
lg.LogMessage(Info, "client quit...\n");
break;
}
else
{
lg.LogMessage(Error, "read socket error, errno code: %d, error string: %s\n", errno, strerror(errno));
break;
}
}
}
void Start()
{
_isrunning = true;
while(_isrunning)
{
// 4. 获取链接
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);
if(sockfd < 0)
{
lg.LogMessage(Warning, "listen socket error, errno code: %d, error string: %s\n", errno, strerror(errno));
continue;
}
lg.LogMessage(Debug, "accept success, get a new sockfd: %d\n", sockfd);
// 5. 提供服务,v1~v4版本
// v1
Service(sockfd);
close(sockfd);
}
}
...
private:
uint16_t _port;
int _listensockfd;
bool _isrunning; // 是否启动
};
- 该怎么理解呢?小剧场:
- 第一个
socket
返回的fd
,就像饭店门口招揽客人的人员(张三,这里指一类人),不断的把客人引到饭店里来;第二个accept
返回的fd
,就像饭店的后厨人员(李四),客人到饭店里以后,就不归张三管了,而是归李四管; - 张三招揽客人也可能失败,但是没关系,张三转头就去招揽别的客人了。对标到服务器中,设置
listen
状态就像饭店开始营业,accept
就像派张三出去揽客。招揽到客人后,就该给客人提供对应的服务啦。
- 第一个
5. TcpServer.hpp完整代码
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h>
#include <wait.h>
#include <signal.h>
#include <unistd.h>
#include "Log.hpp"
#include "nocopy.hpp"
#include "Comm.hpp"
const static int default_backlog = 5;
class TcpServer : public nocopy
{
public:
TcpServer(uint16_t port)
:_port(port), _isrunning(false)
{}
void Init()
{
// 1. 创建套接字
_listensockfd = socket(AF_INET, SOCK_STREAM, 0); // 创建TCP套接字
if(_listensockfd < 0)
{
lg.LogMessage(Fatal, "create socket errror, errno code: %d, error string: %s\n", errno, strerror(errno));
exit(Socket_Err);
}
lg.LogMessage(Debug, "create socket success, sockfd: %d\n", _listensockfd);
// 固定写法:解决一些少量绑定失败的问题 -- 后面讲到底层原理时再详细解释(这里要解决一个问题:服务端主动断开连接,再启动时bind失败的问题)
int opt = 1;
setsockopt(_listensockfd, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, &opt, sizeof(opt));
// 2. 填充本地网络信息,并绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
if(bind(_listensockfd, (struct sockaddr*)&local, sizeof(local)))
{
lg.LogMessage(Fatal, "bind error, errno code: %d, error string: %s\n", errno, strerror(errno));
exit(Bind_Err);
}
lg.LogMessage(Debug, "bing socket success, sockfd: %d\n", _listensockfd);
// 3. 设置socket为监听状态,TCP特有
if(listen(_listensockfd, default_backlog)) // 第二个参数先不解释
{
lg.LogMessage(Fatal, "Listen socket error, errno code: %d, errror string: %s\n", errno, strerror(errno));
exit(Listen_Err);
}
lg.LogMessage(Debug, "listen socket success, sockfd: %d\n", _listensockfd);
}
// TCP 连接可以进行全双工通信
void Service(int sockfd)
{
char buffer[1024];
while(true)
{
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = 0;
std::cout << "client say# " << buffer << std::endl;
std::string echo_string = "server echo# ";
echo_string += buffer;
write(sockfd, echo_string.c_str(), echo_string.size());
}
else if(n == 0) // 网络中,read如果返回值是0,表示读到了文件结尾(对端关闭了链接!)
{
lg.LogMessage(Info, "client quit...\n");
break;
}
else
{
lg.LogMessage(Error, "read socket error, errno code: %d, error string: %s\n", errno, strerror(errno));
break;
}
}
}
void Start()
{
_isrunning = true;
while(_isrunning)
{
// 4. 获取链接
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);
if(sockfd < 0)
{
lg.LogMessage(Warning, "listen socket error, errno code: %d, error string: %s\n", errno, strerror(errno));
continue;
}
lg.LogMessage(Debug, "accept success, get a new sockfd: %d\n", sockfd);
// 5. 提供服务,v1~v4版本
// v1
Service(sockfd);
close(sockfd);
}
}
~TcpServer()
{
close(_listensockfd);
}
private:
uint16_t _port;
int _listensockfd;
bool _isrunning; // 是否启动
};
7.1.2 客户端
1. TcpClient.cc客户端
- 和UDP客户端相比,新增了断线重连功能。有些时候,可能因为一些网络原因导致连接建立失败,我们需要给客户端再一次重连的机会。
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <string>
#include <memory>
#include <cstdlib>
// 默认尝试重连次数
#define Retry_Count 5
using namespace std;
void Usage(const std::string& proc)
{
std::cout << "Usage : \n\t" << proc << " server_ip server_port\n" << std::endl;
}
bool VisitServer(std::string &serverip, uint16_t serverport, int *pcnt)
{
bool ret = true; // 返回值
// 1. 创建socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(sockfd < 0)
{
std::cerr << "socket error" << std::endl;
return false;
}
// 2. connect
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
inet_pton(AF_INET, serverip.c_str(), &server.sin_addr); // 更安全
int n = connect(sockfd, (struct sockaddr*)&server, sizeof(server)); // 自动进行bind
if(n < 0)
{
std::cerr << "connect error" << std::endl;
ret = false;
goto END;
}
*pcnt = 1; //重置cnt,注意重置的位置要在connect之后
// 并没有像server一样,产生新的sockfd,未来我们就用connect成功的sockfd进行通信即可
while(true)
{
std::string inbuffer;
std::cout << "Please Enter# ";
getline(std::cin, inbuffer);
if(inbuffer == "quit")
{
ret = true;
goto END;
}
ssize_t n = write(sockfd, inbuffer.c_str(), inbuffer.size());
if(n > 0)
{
char buffer[1024];
ssize_t m = read(sockfd, buffer, sizeof(buffer) - 1);
if(m > 0)
{
buffer[m] = 0;
std::cout << "get a echo message -> " << buffer << std::endl;
}
else
{
ret = false;
goto END;
}
}
else if(n == 0)
{
// donothing
}
else
{
ret = false;
goto END;
}
}
END:
close(sockfd);
return ret;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
return 1;
}
std::string serverip = argv[1];
uint16_t serverport = stoi(argv[2]);
// 连接服务器失败时,进行Retry_Count次重连
int cnt = 1;
while(cnt <= Retry_Count)
{
bool result = VisitServer(serverip, serverport, &cnt); // 将cnt传入,方便重置
if(result)
{
break;
}
else
{
sleep(1);
std::cout << "server offline, retrying..., count : " << cnt++ << std::endl;
}
}
if(cnt >= Retry_Count)
{
std::cout << "server offline, client quit..." << std::endl;
}
return 0;
}
2. 测试能否正常通信
- 先启动服务器端,然后查看
netstat
状态(-l
选项表示只查看监听状态的端口,-t
选项表示显示TCP连接),可以看到tcp_server
已经跑起来了;然后启动客户端,再查看网络状态(记得去掉-l
选项),可以发现客户端和服务器端都起来了;最后客户端发送消息,服务器端也收到并返回给了客户端,实验很成功。
3. 测试断线重连功能
- 结果符合预期:服务端先退出再启动,客户端先进入重连状态,服务器启动后客户端重连成功;第二次,服务器关闭,客户端尝试五次重连后仍未成功,超时未响应,客户端也关闭。
4. 有bug!
- 可以看到,只有一个客户端时,我们可以正常通信;当我们再启动一个客户端之后,发现后来的客户端发送的消息,无法到达服务器,直到第一个客户端关闭了,第二个客户端的信息才徐徐道来,这显然不是我们想看到的。
- 这是因为,目前我们的服务是一个单线程的,服务器再和一个客户端建立连接后,进入
Service
函数提供服务,无法再accept
新线程;直到旧的客户端主动关闭连接后,服务器才能accept
到新的客户。
7.2 V2版——多进程,wait版
1. 修改服务器端
class TcpServer : public nocopy
{
public:
...
// TCP 连接可以进行全双工通信
void Service(int sockfd)
{
...
}
void Start()
{
_isrunning = true;
while(_isrunning)
{
// 4. 获取链接
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);
if(sockfd < 0)
{
lg.LogMessage(Warning, "listen socket error, errno code: %d, error string: %s\n", errno, strerror(errno));
continue;
}
lg.LogMessage(Debug, "accept success, get a new sockfd: %d\n", sockfd);
// 5. 提供服务,v1~v4版本
// v2 多进程
pid_t id = fork();
if(id < 0)
{
close(sockfd);
continue;
}
else if(id == 0)
{
// child
close(_listensockfd);
if(fork() > 0) exit(0); // 子进程直接退出
// 孙子进程正常处理(孙子进程被系统领养,资源交给系统回收)
Service(sockfd);
close(sockfd);
exit(0);
}
else
{
close(sockfd);
pid_t rid = waitpid(id, nullptr, 0); // 阻塞等待(不建议改成非阻塞等待)
if(rid == id)
{
// do nothing
}
}
}
}
...
private:
uint16_t _port;
int _listensockfd;
bool _isrunning; // 是否启动
};
- 首先思考,如果去掉
if(fork() > 0) exit(0)
这段代码行不行?肯定是不行的,因为这样就和V1没区别了,父进程还是会在waitpid
处阻塞等待,无法accept
。有同学就灵机一动,把waitpid
设置成非阻塞等待不就好了? - 我们不建议这么做,因为如果
waitpid
非阻塞等待,且没有子进程退出的情况下,每一次的执行waitpid
都是没有意义的,都是对资源的浪费。所以我们巧妙的写了if(fork() > 0) exit(0)
这段代码,子进程直接退出,父进程直接回收子进程,孙子进程提供服务。由于孙子进程的父进程死了,所以孙子进程被OS领养,变成孤儿进程,由系统释放资源。 - 还需要尤其注意文件描述符
fd
的释放:子进程会拷贝父进程的文件描述符表,这个表是浅拷贝,如果不即使释放不属于自己的文件描述符,文件描述符会越积越多,最终将表沾满,程序崩溃——这种现象也叫文件描述符泄漏。
2. 测试一波
- 完美!
- 有同学可能有疑问,为什么服务器端拿到了两个4号
sockfd
?要注意,这两个sockfd
指向的不是同一个文件,因为上一个子进程已经将不属于自己的fd
全close
了,所以在新的子进程的文件描述符表中,4号位置仍然是空的,申请的新fd
仍然指向自己文件描述符表的4号位置。
7.3 V3版——多进程信号版
1. 修改后的代码片
- 直接忽略
SIGCHLD
信号,Linux环境中,如果忽略该信号,子进程会自己释放资源。
class TcpServer : public nocopy
{
public:
...
void Start()
{
_isrunning = true;
signal(SIGCHLD, SIG_IGN); // v3 在Linux环境中,如果对SIGCHLD进行忽略,子进程退出时,自动释放自己的资源
while(_isrunning)
{
// 4. 获取链接
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);
if(sockfd < 0)
{
lg.LogMessage(Warning, "listen socket error, errno code: %d, error string: %s\n", errno, strerror(errno));
continue;
}
lg.LogMessage(Debug, "accept success, get a new sockfd: %d\n", sockfd);
// 5. 提供服务
// v3 多进程 —— 信号版
pid_t id = fork();
if(id < 0)
{
close(sockfd);
continue;
}
else if(id == 0)
{
// child
close(_listensockfd);
Service(sockfd);
close(sockfd);
exit(0);
}
else
{
close(sockfd);
}
}
}
...
private:
uint16_t _port;
int _listensockfd;
bool _isrunning; // 是否启动
};
2. 测试:
7.4 V4——多线程版
1. 直接看代码
class TcpServer;
class ThreadData
{
public:
ThreadData(int sock, TcpServer *ptr)
:_sockfd(sock)
,_svr_ptr(ptr)
{}
int Sockfd() { return _sockfd; }
TcpServer *GetServer() { return _svr_ptr; }
~ThreadData()
{
close(_sockfd);
}
private:
int _sockfd;
TcpServer *_svr_ptr;
};
class TcpServer : public nocopy
{
public:
...
// TCP 连接可以进行全双工通信
void Service(int sockfd)
{
char buffer[1024];
while(true)
{
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = 0;
std::cout << "client say# " << buffer << std::endl;
std::string echo_string = "server echo# ";
echo_string += buffer;
write(sockfd, echo_string.c_str(), echo_string.size());
}
else if(n == 0) // 网络中,read如果返回值是0,表示读到了文件结尾(对端关闭了链接!)
{
lg.LogMessage(Info, "client quit...\n");
break;
}
else
{
lg.LogMessage(Error, "read socket error, errno code: %d, error string: %s\n", errno, strerror(errno));
break;
}
}
}
static void *HandlerRequest(void *args)
{
pthread_detach(pthread_self()); // 分离线程
ThreadData *td = static_cast<ThreadData*>(args);
td->GetServer()->Service(td->Sockfd());
delete td;
return nullptr;
}
void Start()
{
_isrunning = true;
signal(SIGCHLD, SIG_IGN); // v3 在Linux环境中,如果对SIGCHLD进行忽略,子进程退出时,自动释放自己的资源
while(_isrunning)
{
// 4. 获取链接
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);
if(sockfd < 0)
{
lg.LogMessage(Warning, "listen socket error, errno code: %d, error string: %s\n", errno, strerror(errno));
continue;
}
lg.LogMessage(Debug, "accept success, get a new sockfd: %d\n", sockfd);
// 5. 提供服务,v1~v4版本
// v4 多线程
ThreadData *td = new ThreadData(sockfd, this);
pthread_t tid;
// 主线程和新线程,不需要关闭所谓文件描述符,只需要将线程分离
pthread_create(&tid, nullptr, HandlerRequest, td);
}
}
~TcpServer()
{
close(_listensockfd);
}
private:
uint16_t _port;
int _listensockfd;
bool _isrunning; // 是否启动
};
2. 测试
- 可以看到,文件描述符出现5了,因为大家都属于一个进程。
8. 如何理解:面向数据报&&面向字节流
我们在发送消息和接收消息时,似乎不用对消息做网络序列的转化。这是因为,IO类函数,
write/read
,recvfrom/sendto
,会自动帮我们做网络序列的转化。
由于我们还不了解协议的底层原理,所以今天理解起来仍然是很困难的,只能感性的认识一下。
数据和数据之间,是有边界的。对于UDP协议,我们不用关心如何确定数据之间的边界,因为UDP协议帮我们处理好了,用户直接拿到的,就是有边界的数据。
对于TCP协议,数据就像水流一样,没有边界,需要用户来确定数据的边界。