前言:
上文我们讲解了Socket编程的预备【Linux网络】套接字Socket编程预备-CSDN博客
本文我们来讲解一下使用Socket编程基于UDP协议的网络通信:Echo Server,回显服务。
服务器端实现
大致分为两大步:
初始化阶段:1.创建套接字 2.填充并绑定Socket信息
启动服务器:1.接收客户端的信息 2.回显消息
初始化阶段
创建套接字
cpp
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
domain(地址族 / 协议族):指定网络层协议,决定套接字使用的地址格式。常见取值:AF_INET:IPv4 协议(最常用)
type(套接字类型):指定传输层协议的特性。常见取值:SOCK_DGRAM:数据报套接字
protocol(具体协议):指定使用的传输层协议。通常设为 0,表示根据 domain 和 type 自动选择默认协议
返回值:
成功:返回一个非负整数(套接字描述符,类似文件描述符,用于后续操作)
失败:返回 -1,并设置全局变量 errno 表示错误原因(如 EAFNOSUPPORT 表示地址族不支持)
绑定信息
cpp
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd(套接字描述符)
addr(地址结构指针)
addrlen(地址结构长度)
返回值
成功:返回 0,表示套接字已成功绑定到指定地址
失败:返回 -1,表示失败
在上一篇文章我们讲过。对于struct sockaddr* 参数,要看我们想要进行什么类型的通信来决定。如果想要进行网络通信就要传入 struct sockaddr_in的结构体指针;而如果是进行本地通信就要传入 struct sockaddr_un结构体指针。
这里我们是进行的网络通信的,所以下面我们就来看看 struct sockaddr_in结构体:

地址家族:
第一个成员变量表示地址家族。
##:表示将左右两个字符串合并为一个,既 sin_family。
sin_family的类型为 sa_family_t,其本质是短整型变量。


端口号:
其本质就是短整型变量。

IP地址:
其本质是一个in_addr的结构体对象。
结构体in_addr中只有一个成员变量:表示IP地址。其本质是整型变量。

具体实现
cpp
class udpserver
{
public:
udpserver(string& addr, uint16_t port)
: _addr(inet_addr(addr.c_str())), //注:直接将其转换为合法的ip地址
_port(port)
{
_running = false;
}
// 初始化:1.创建套接字 2.填充并绑定地址信息
void Init()
{
// 1.创建套接字
// 返回套接字描述符 地址族 数据类型 传输协议
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(LogLevel::FATAL) << "创建套接字失败!";
exit(1);
}
LOG(LogLevel::INFO) << "创建套接字";
// 2.绑定信息
// 2.1填充信息
struct sockaddr_in local;
// 将指定内存块的所有字节清零
bzero(&local, sizeof(local));
local.sin_family = AF_INET; // IPv4地址族
local.sin_addr.s_addr = _addr; // IP地址(主机序列转化为网络序列)
local.sin_port = htons(_port); // 端口号
// 2.2绑定信息
int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if (n < 0)
{
LOG(LogLevel::FATAL) << "绑定失败";
exit(1);
}
LOG(LogLevel::INFO) << "绑定成功";
}
private:
int _sockfd;
uint32_t _addr;
uint16_t _port;
bool _running;
}
启动服务器阶段
接收客户端信息
cpp
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);
sockfd:套接字描述符
buf:指向接收数据的缓冲区
len:缓冲区 buf 能接收的字节数
flags:控制接收行为的标志位,通常为 0
src_addr:指向一个 sockaddr 结构体,用于存储发送方的地址信息
addrlen:指向 socklen_t 类型的指针,用于指定 src_addr 缓冲区的大小(传入时),并返回实际存储的地址长度(传出时)
返回值
成功时:返回接收到的字节数
失败时:返回 -1
向客户端发送信息
cpp
#include <sys/socket.h>
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:指向目标地址结构体的指针,用于指定数据发送的目标主机和端口
addrlen:结构体的大小
返回值
成功时:返回实际发送的字节数(ssize_t 类型,非负整数),通常等于 len(除非发生截断或错误)
失败时:返回 -1
具体实现
cpp
class udpserver
{
public:
// 启动运行:一直运行不停止;1.接收客户端的信息 2.对客户端发送来的信息进行回显
void Start()
{
// 一定是死循环
_running = true;
while (_running)
{
// 接收客户端的信息
char buff[1024];
struct sockaddr_in peer;
unsigned int len = sizeof(peer);
// 套接字描述符,数据存放的缓冲区,接收方式:默认,保存发送方的ip与端口,输入输出参数:输入peer的大小,输出实际读取的数据大小
ssize_t s = recvfrom(_sockfd, buff, sizeof(buff) - 1, 0, (struct sockaddr*)&peer, &len);
printf("%s\n", buff);
// 回显消息
if (s > 0)
{
// 将数据发送给客户端
buff[s] = 0;
// 套接字描述符,要发送的信息,发送方式:默认,接收方的ip与端口信息
// 不应发送整个缓冲区(sizeof(buff)),而应发送实际接收的字节数 s
ssize_t t = sendto(_sockfd, buff, s, 0, (struct sockaddr*)&peer, len);
if (t < 0)
{
LOG(LogLevel::WARNING) << "信息发送给客户端失败";
}
}
memset(&buff, 0, sizeof(buff)); // 清理缓存
}
}
private:
int _sockfd;
uint32_t _addr;
uint16_t _port;
bool _running;
}
服务器实现
cpp
//UdpServer.hpp
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <arpa/inet.h>
#include "Log.hpp"
using namespace LogModule;
class udpserver
{
public:
udpserver(string &addr, uint16_t port)
: _addr(inet_addr(addr.c_str())), // 注:直接将其转换为合法的ip地址
_port(port)
{
_running = false;
}
// 初始化:1.创建套接字 2.填充并绑定地址信息
void Init()
{
// 1.创建套接字
// 返回套接字描述符 地址族 数据类型 传输协议
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(LogLevel::FATAL) << "创建套接字失败!";
exit(1);
}
LOG(LogLevel::INFO) << "创建套接字";
// 2.绑定信息
// 2.1填充信息
struct sockaddr_in local;
// 将指定内存块的所有字节清零
bzero(&local, sizeof(local));
local.sin_family = AF_INET; // IPv4地址族
local.sin_addr.s_addr = _addr; // IP地址(主机序列转化为网络序列)
local.sin_port = htons(_port); // 端口号
// 2.2绑定信息
int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
if (n < 0)
{
LOG(LogLevel::FATAL) << "绑定失败";
exit(1);
}
LOG(LogLevel::INFO) << "绑定成功";
}
// 启动运行:一直运行不停止;1.接收客户端的信息 2.对客户端发送来的信息进行回显
void Start()
{
// 一定是死循环
_running = true;
while (_running)
{
// 接收客户端的信息
char buff[1024];
struct sockaddr_in peer;
unsigned int len = sizeof(peer);
// 套接字描述符,数据存放的缓冲区,接收方式:默认,保存发送方的ip与端口,输入输出参数:输入peer的大小,输出实际读取的数据大小
ssize_t s = recvfrom(_sockfd, buff, sizeof(buff) - 1, 0, (struct sockaddr *)&peer, &len);
printf("%s\n", buff);
// 回显消息
if (s > 0)
{
// 将数据发送给客户端
buff[s] = 0;
// 套接字描述符,要发送的信息,发送方式:默认,接收方的ip与端口信息
// 不应发送整个缓冲区(sizeof(buff)),而应发送实际接收的字节数 s
ssize_t t = sendto(_sockfd, buff, s, 0, (struct sockaddr *)&peer, len);
if (t < 0)
{
LOG(LogLevel::WARNING) << "信息发送给客户端失败";
}
}
memset(&buff, 0, sizeof(buff)); // 清理缓存
}
}
private:
int _sockfd;
uint32_t _addr;
uint16_t _port;
bool _running;
};
cpp
//UdpServer.cc
#include "UdpServer.hpp"
#include <cstdlib>
// 给出 ip地址 端口号
int main(int argc, char *argv[])
{
if (argc != 3)
{
std::cout << "Please use: " << argv[0] << " IP " << "PORT " << endl;
}
else
{
string ip = argv[1];
uint16_t port = stoi(argv[2]); // 注:字符串转整数
udpserver us(ip, port);
us.Init();
us.Start();
}
}
cpp
//Log.hpp
// 实现日志模块
#pragma once
#include <iostream>
#include <sstream> // 包含stringstream类
#include <filesystem> //C++17文件操作接口库
#include <fstream>
#include <sys/types.h>
#include <unistd.h>
#include "Mutex.hpp"
using namespace std;
using namespace MutexModule;
// 补充:外部类只能通过内部类的实例化对象,来访问内部类中的方法与成员,且受修饰符限制
// 内部类可以直接访问外部类的方法以及成员,没有限制
namespace LogModule
{
const string end = "\r\n";
// 实现刷新策略:a.向显示器刷新 b.向指定文件刷新
// 利用多态机制实现
// 包含至少一个纯虚函数的类称为抽象类,不能实例化,只能被继承
class LogStrategy // 基类
{
public:
//"=0"声明为纯虚函数。纯虚函数强制派生类必须重写该函数
virtual void SyncLog(const string &message) = 0;
};
// 向显示器刷新:子类
class ConsoleLogStrategy : public LogStrategy
{
public:
void SyncLog(const string &message) override
{
// 加锁,访问显示器,显示器也是临界资源
LockGuard lockguard(_mutex);
cout << message << end;
}
private:
Mutex _mutex;
};
// 向指定文件刷新:子类
const string defaultpath = "./log";
const string defaultfile = "my.log";
class FileLogStrategy : public LogStrategy
{
public:
FileLogStrategy(const string &path = defaultpath, const string &file = defaultfile)
: _path(path), _file(file)
{
LockGuard lockguard(_mutex);
// 判断路径是否存在,如果不存在就创建对应的路径
if (!(filesystem::exists(_path)))
filesystem::create_directories(_path);
}
void SyncLog(const string &message) override
{
// 合成最后路径
string Path = _path + (_path.back() == '/' ? "" : "/") + _file;
// 打开文件
ofstream out(Path, ios::app);
out << message << end;
}
private:
string _path;
string _file;
Mutex _mutex;
};
//
// 日志等级
// enum class:强类型枚举。1.必须通过域名访问枚举值 2.枚举值不能隐式类型转化为整型
enum class LogLevel
{
DEBUG, // 调试级
INFO, // 信息级
WARNING, // 警告级
ERROR, // 错误级
FATAL // 致命级
};
//
// 将等级转化为字符串
string LevelToStr(LogLevel level)
{
switch (level)
{
case LogLevel::DEBUG:
return "DEBUG";
case LogLevel::INFO:
return "DEBUG";
case LogLevel::WARNING:
return "WARNING";
case LogLevel::ERROR:
return "ERROR";
case LogLevel::FATAL:
return "FATAL";
default:
return "UNKOWN";
}
}
// 获取时间
string GetTime()
{
// time函数:获取当前系统的时间戳
// localtime_r函数:将时间戳转化为本地时间(可重入函数,localtime则是不可重入函数)
// struct tm结构体,会将转化之后的本地时间存储在结构体中
time_t curr = time(nullptr);
struct tm curr_time;
localtime_r(&curr, &curr_time);
char buffer[128];
snprintf(buffer, sizeof(buffer), "%04d-%02d-%02d %02d:%02d:%02d",
curr_time.tm_year + 1900, // 年份是从1900开始计算的,需要加上1900才能得到正确的年份
curr_time.tm_mon + 1, // 月份了0~11,需要加上1才能得到正确的月份
curr_time.tm_mday, // 日
curr_time.tm_hour, // 时
curr_time.tm_min, // 分
curr_time.tm_sec); // 秒
return buffer;
}
//
// 实现日志信息,并选择刷新策略
class Logger
{
public:
Logger()
{
// 默认选择显示器刷新
Strategy = make_unique<ConsoleLogStrategy>();
}
void EnableConsoleLogStrategy()
{
Strategy = make_unique<ConsoleLogStrategy>();
}
void EnableFileLogStrategy()
{
Strategy = make_unique<FileLogStrategy>();
}
// 日志信息
class LogMessage
{
public:
LogMessage(const LogLevel &level, const string &name, const int &line, Logger &logger)
: _level(level),
_name(name),
_logger(logger),
_line_member(line)
{
_pid = getpid();
_time = GetTime();
// 合并:日志信息的左半部分
stringstream ss; // 创建输出流对象,stringstream可以将输入的所有数据全部转为为字符串
ss << "[" << _time << "] "
<< "[" << LevelToStr(_level) << "] "
<< "[" << _pid << "] "
<< "[" << _name << "] "
<< "[" << _line_member << "] "
<< " - ";
// 返回ss中的字符串
_loginfo = ss.str();
}
// 日志文件的右半部分:可变参数,重载运算符<<
// e.g. <<"huang"<<123<<"dasd"<<24
template <class T>
LogMessage &operator<<(const T &message) // 引用返回可以让后续内容不断追加
{
stringstream ss;
ss << message;
_loginfo += ss.str();
// 返回对象!
return *this;
}
// 销毁时,将信息刷新
~LogMessage()
{
// 日志文件
_logger.Strategy->SyncLog(_loginfo);
}
private:
string _time;
LogLevel _level;
pid_t _pid;
string _name;
int _line_member;
string _loginfo; // 合并之后的一条完整信息
// 日志对象
Logger &_logger;
};
// 重载运算符(),便于创建LogMessage对象
// 这里返回临时对象:当临时对象销毁时,调用对应的析构函数,自动对象中创建好的日志信息进行刷新!
// 其次局部对象也不能传引用返回!
LogMessage operator()(const LogLevel &level, const string &name, const int &line)
{
return LogMessage(level, name, line, *this);
}
private:
unique_ptr<LogStrategy> Strategy;
};
// 为了用户使用更方便,我们使用宏封装一下
Logger logger;
// 切换刷新策略
#define Enable_Console_LogStrategy() logger.EnableConsoleLogStrategy();
#define Enable_File_LogStrategy() logger.EnableFileLogStrategy();
// 创建日志,并刷新
//__FILE__ 和 __LINE__ 是编译器预定义的宏,作用是获取当前代码所在的文件名、行号
#define LOG(level) logger(level, __FILE__, __LINE__) // 细节:不加;
};
cpp
//Mutex.hpp
// 封装锁接口
#pragma once
#include <pthread.h>
namespace MutexModule
{
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&mutex, nullptr);
}
~Mutex()
{
pthread_mutex_destroy(&mutex);
}
void Lock()
{
pthread_mutex_lock(&mutex);
}
void Unlock()
{
pthread_mutex_unlock(&mutex);
}
pthread_mutex_t *Get()
{
return &mutex;
}
private:
pthread_mutex_t mutex;
};
class LockGuard
{
public:
LockGuard(Mutex &mutex)
: _Mutex(mutex)
{
_Mutex.Lock();
}
~LockGuard()
{
_Mutex.Unlock();
}
private:
// 为了保证锁的底层逻辑,锁是不能够拷贝的,并且也是没有拷贝构造函数的
// 避免拷贝,应该引用
Mutex &_Mutex;
};
}
客户端实现
1.启动时要给出服务器的ip与端口号
2.创建套接字
3.客户端的端口不需要我们手动绑定,也不能手动的绑定。填写服务器的信息
4.向服务器发送信息
5.接收服务器返回的信息
注意:
client要不要显式的bind? 不要!!首次发送消息,OS会自动给client进行bind,OS知道IP,端口号采用随机端口号的方式
为什么?一个端口号,只能被一个进程bind,为了避免client端口冲突
client端的端口号是几,不重要,只要是唯一的就行!
思考:
那为什么server的实现需要显示的绑定端口号?
因为服务器是要被大量的客户端访问的,这也就意味这个服务器的IP与端口必须是众所周知的,并且不能轻易改变的!
客户端最终实现
cpp
#include "Log.hpp"
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <cstdlib>
#include <iostream>
using namespace LogModule;
// 给出 ip地址 端口号
int main(int argc, char *argv[])
{
if (argc != 3)
{
std::cout << "Please use: " << argv[0] << " IP " << "PORT " << endl;
exit(1);
}
uint32_t ip = inet_addr(argv[1]); // 注:字符串转合法ip地址
uint16_t port = stoi(argv[2]); // 注:字符串转整数
// 创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
LOG(LogLevel::FATAL) << "创建套接字失败";
exit(1);
}
LOG(LogLevel::INFO) << "创建套接字";
// 绑定?不用显示绑定,OS会自动的绑定
// 填写服务器信息
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = ip;
local.sin_port = htons(port);
// 一定是死循环
while (true)
{
// 向服务器发送信息
cout << "Please Cin # ";
std::string buff;
cin >> buff;
// std::getline(std::cin, buff);
// buff.size()-1 会丢失最后一个字符,应改为 buff.size()
ssize_t s = sendto(sockfd, buff.c_str(), buff.size(), 0, (struct sockaddr *)&local, sizeof(local));
if (s < 0)
{
LOG(LogLevel::WARNING) << "向服务器发送信息失败";
exit(1);
}
// 接收服务器返回的信息
char buffer[1024];
memset(&buffer, 0, sizeof(buffer));
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t ss = recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&peer, &len);
if (ss < 0)
{
LOG(LogLevel::WARNING) << "接收服务器信息失败";
exit(1);
}
printf("%s\n", buffer);
memset(&buff, 0, sizeof(buffer)); // 清理缓存
}
}
优化服务器
绑定问题
通过实际运行结果,我们发现
bind公网IP,无法绑定(因为云服务器并没有配置公网IP)。
服务器与客户端都绑定内网IP or 本地环回,可以绑定,可以通信。
服务器与客户端绑定不一致,可以绑定,但无法通信。

通过上面的情况,我们会发现:当我们显式的配置服务器的IP地址后,客户端在访问时必须使用服务器bind的IP才能访问,这极大限制了访问的灵活性。
所以我们并不建议,让服务器手动的bind特定的IP地址,而是可以让其bind任意的IP地址。
cpp
local.sin_addr.s_addr = INADDR_ANY;
赋值为INADDR_ANY,表示任意地址
这样,服务器在启动时就不用传递IP地址了,只需要传递端口号即可。
处理问题
如果服务器想要对客户端发送来的信息进行一定的处理后,再返回应该怎么做?
可以让main函数处,传入自定义处理方法。在类中使用包装器:function来获取到main函数处传入的方法。并在接收客户端消息后,调用对应方法进行处理,最后将处理结果再发送给客户端。
服务器最终实现
cpp
//UdpServer.cc
#include "UdpServer.hpp"
#include <cstdlib>
// 自定义处理方法
std::string func(std::string s)
{
string h = "hello: ";
return h + s;
}
// 给出 端口号
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cout << "Please use: " << argv[0] << " PORT" << endl;
}
else
{
// string ip = argv[1];
uint16_t port = stoi(argv[1]); // 注:字符串转整数
udpserver us(port, func);
us.Init();
us.Start();
}
}
cpp
//UdpServer.hpp
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <arpa/inet.h>
#include <functional>
#include "Log.hpp"
using namespace LogModule;
class udpserver
{
using func_t = function<string(string)>;
public:
udpserver(uint16_t port, func_t func)
// : _addr(inet_addr(addr.c_str())), // 注:直接将其转换为合法的ip地址
: _port(port),
_func(func)
{
_running = false;
}
// 初始化:1.创建套接字 2.填充并绑定地址信息
void Init()
{
// 1.创建套接字
// 返回套接字描述符 地址族 数据类型 传输协议
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(LogLevel::FATAL) << "创建套接字失败!";
exit(1);
}
LOG(LogLevel::INFO) << "创建套接字";
// 2.绑定信息
// 2.1填充信息
struct sockaddr_in local;
// 将指定内存块的所有字节清零
bzero(&local, sizeof(local));
local.sin_family = AF_INET; // IPv4地址族
// local.sin_addr.s_addr = _addr; //IP地址(主机序列转化为网络序列)
local.sin_addr.s_addr = INADDR_ANY; // 赋值为INADDR_ANY,表示任意地址
local.sin_port = htons(_port); // 端口号
// 2.2绑定信息
int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
if (n < 0)
{
LOG(LogLevel::FATAL) << "绑定失败";
exit(1);
}
LOG(LogLevel::INFO) << "绑定成功";
}
// 启动运行:一直运行不停止;1.接收客户端的信息 2.对客户端发送来的信息进行回显
void Start()
{
// 一定是死循环
_running = true;
while (_running)
{
// 接收客户端的信息
char buff[1024];
struct sockaddr_in peer;
unsigned int len = sizeof(peer);
// 套接字描述符,数据存放的缓冲区,接收方式:默认,保存发送方的ip与端口,输入输出参数:输入peer的大小,输出实际读取的数据大小
ssize_t s = recvfrom(_sockfd, buff, sizeof(buff) - 1, 0, (struct sockaddr *)&peer, &len);
buff[s] = 0;
printf("%s\n", buff);
// 回显消息
if (s > 0)
{
// 调用自定义方法
std::string ss = _func(string(buff));
// 将数据发送给客户端
// 套接字描述符,要发送的信息,发送方式:默认,接收方的ip与端口信息
ssize_t t = sendto(_sockfd, ss.c_str(), ss.size(), 0, (struct sockaddr *)&peer, len);
if (t < 0)
{
LOG(LogLevel::WARNING) << "信息发送给客户端失败";
}
}
memset(&buff, 0, sizeof(buff)); // 清理缓存
}
}
private:
int _sockfd;
uint32_t _addr;
uint16_t _port;
bool _running;
// 回调方法
func_t _func;
};