
本篇主要是写一个基于 UDP ( User Datagram Protocol ⽤户数据报协议)的简单的回显服务器和客户端代码,Echo server。

1.Server端
1.1 初始化
1.1.1 创建套接字
首先我们要创建套接字,调用函数socket,成功返回一个文件描述符,失败返回-1.

- 第一个参数就是要做什么通信,有很多选项,进行网络通信就传AF_INET;

- 第二个参数要创建的套接字类型,也有很多,传SOCK_DGRAM

- 第三个参数是要设定的协议类型,其实这里AF_INET和SOCK_DGRAM就已经能说明是UDP协议了,这个参数设为0。
cpp
//UdpServer.hpp文件
#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include "MyLog.hpp"
using namespace MyLog;
class UdpServer
{
public:
UdpServer()
: _sockfd(-1)
{
}
void Init()
{
// 创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd == -1)
{
LOG(LogLevel::FATAL) << "创建套接字失败";
exit(1);
}
LOG(LogLevel::INFO) << "creat socket success, sockfd: " << _sockfd;
}
~UdpServer() {}
private:
int _sockfd;
};
创建套接字只是相当于打开了网络文件,将来要收发消息时,别人会知道我的ip和端口号吗?所以我们还要对socket进行绑定。
1.1.2 绑定socket信息
主要就是绑定ip和端口,需要用到的函数是bind。成功返回0,失败返回-1.

第一个参数就是前面创建的sockfd,第二个参数是一个结构体,第三个参数是这个结构体的大小
sockaddr结构:
system V标准用于本地进程间通信,详细介绍在: 【Linux】system V共享内存
posix标准用于网络通信,网络通信的本质其实就是进程间通信,这个标准也可以用于进行本地通信。
未来在通信的时候我们会有网络socket、本地socket、原始socket(此处不做介绍),网络socket就用于网络通信,本地socket用于本地通。
因为socket有很多种类,来满足不同的场景,所以socket未来的接口也会不同的规范。
在通信时我们要把数据发给别人,别人也要发给我,所以发出去的数据里一定会有自己的端口号和IP地址,放在一个结构体里,这种 结构 一般用于网络通信, 叫 sockaddr_in 。
本地套接字通信叫做unix域间通信,原理就是使用套接字接口,A进程和B进程把指定路径下的文件打开,不就是管道嘛,它的结构就叫做 sockaddr_un 。

要进行网络通信就传sockaddr_in , 要进行本地通信就传sockaddr_un 。
但是socket的设计者只想提供一种通信接口,所以又有了一个结构叫 sockaddr。这是一个通用的接口,规定不管是 sockaddr_in还是sockaddr_un,前面都要有16位地址类型,AF_INET就是进行网络通信,AF_UNIX就是进行本地通信

而 sockaddr也有16位地址类型,以后我们进行本地通信就创建 sockaddr_in结构,网络通信就创建sockaddr_un结构,但是传参的时候只能传sockaddr 结构,直接强转就行了。在函数内部会自行区分是网络通信还是本地通信。
所以在绑定socket之前,我们要先填写sockaddr_in结构体的信息。如下是sockaddr_in结构体的具体内容。

sin_port是端口号,sin_addr是IP地址,下面的sin_zero就是填充字段,为了保证结构体大小是固定的,地址类型就是__SOCKADDR_COMMON,就是协议家族,具体如下。

cpp
typedef unsigned short int sa_family_t; //就是一个无符号短整数
cpp
//in_port_t其实就是uint16_t
/* Type to represent a port. */
typedef uint16_t in_port_t;
//in_addr里面就是一个in_addr_t的变量,in_addr_t就是uint32_t
/* Internet address. */
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};
所以其实这个sockaddr_in里就是有三个整数而已。
回到代码,在填写sockaddr_in结构体的信息之前要清0,这里用到的函数是bzero。
cpp
void Init()
{
// 1.创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd == -1)
{
LOG(LogLevel::FATAL) << "创建套接字失败";
exit(1);
}
LOG(LogLevel::INFO) << "creat socket success, sockfd: " << _sockfd;
//绑定socket信息,IP和端口
struct sockaddr_in local;
bzero(&local, sizeof(local)); //先清0
local.sin_family = AF_INET;
local.sin_port = // ?
local.sin_addr.s_addr = //?
}
将来我们发数据的时候,还要把端口号也要发给别人,这就说明ip和端口信息也要发送到网络,所以我们需要将本地格式转网络序列。
TCP/IP协议规定,⽹络数据流应 采⽤⼤端字节序 ,即低地址⾼字节。不管这台主机是⼤端机还是⼩端机, 都会按照这个TCP/IP规定的⽹络字节序来发送/接收数据; 如果当前发送主机是⼩端, 就需要先将数据转成⼤端; 否则就忽略, 直接发送即可。

为使⽹络程序具有可移植性,使同样的C代码在⼤端和⼩端计算机上编译后都能正常运⾏,可以调⽤以下库函数做 ⽹络字节序和主机字节序的转换 。
下面是相关函数的接口。

- h表示host,n表示network,l表示32位长整数,s表示16位短整数 ,例如 htonl 表⽰将 32 位的⻓整数从主机字节序转换为⽹络字节序
cpp
local.sin_port = htons(_port);
private:
uint16_t _port; // 端口号
ip地址也是如此,前面说过IP是uint32_t 类型的,是4字节风格的IP地址,而服务器里保存的IP地址是字符串风格的地址,点分十进制,所以我们需要将ip转为4字节,还要将4字节转为网络序列,这两个步骤不需要我们自己完成,有一个函数叫inet_addr,可以完成这两个步骤。

而且这个函数的返回值类型就是in_addr_t,也就是前面说过的ip地址结构体里的变量的类型。
cpp
//UdpServer.hpp文件
#pragma once
#include <iostream>
#include <string>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "MyLog.hpp"
using namespace MyLog;
class UdpServer
{
public:
UdpServer(std::string &ip, uint16_t &port)
: _sockfd(-1),
_port(port),
_ip(ip)
{
}
void Init()
{
// 1.创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd == -1)
{
LOG(LogLevel::FATAL) << "创建套接字失败";
exit(1);
}
LOG(LogLevel::INFO) << "creat socket success, sockfd: " << _sockfd;
// 2.绑定socket信息,IP和端口
struct sockaddr_in local;
bzero(&local, sizeof(local)); // 先清0
local.sin_family = AF_INET;
local.sin_port = htons(_port); // 主机序列转网络序列
// ip转4字节,4字节转网络序列
local.sin_addr.s_addr = inet_addr(_ip.c_str());
}
~UdpServer() {}
private:
int _sockfd;
uint16_t _port; // 端口号
std::string _ip;
};
sockaddr结构填写完成后,就可以绑定了,绑定时要对sockaddr_in结构进行强转。
cpp
void Init()
{
// 1.创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd == -1)
{
LOG(LogLevel::FATAL) << "创建套接字失败";
exit(1);
}
LOG(LogLevel::INFO) << "creat socket success, sockfd: " << _sockfd;
// 2.绑定socket信息,IP和端口
// 2.1 填写sockaddr_in结构体
struct sockaddr_in local;
bzero(&local, sizeof(local)); // 先清0
local.sin_family = AF_INET;
local.sin_port = htons(_port); // 主机序列转网络序列
// ip转4字节,4字节转网络序列
local.sin_addr.s_addr = inet_addr(_ip.c_str());
// 2.2 绑定
int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
if (n < 0)
{
LOG(LogLevel::FATAL) << "bind失败";
exit(2);
}
LOG(LogLevel::INFO) << "bind success";
}
1.2 启动
1.2.1 收消息
一般软件运行起来后就是死循环。
cpp
public:
void Start()
{
if (_isrunning)
return; // 不要重复启动
_isrunning = true;
while(true)
{
}
}
private:
int _sockfd;
uint16_t _port; // 端口号
std::string _ip;
bool _isrunning;
服务器要先接受数据,再发送消息。
接收消息要用到函数recvfrom,成功返回收到的字节数,失败返回-1。
第一个参数就是套接字;第二个参数是接收数据的缓冲区;第三个参数是这个缓冲区的大小;第四个参数是阻塞IO或者非阻塞IO,阻塞式IO就是没有收到数据的时候进程会一直等,就等同于scanf,这里设置成0,表示默认非阻塞;第四个参数用于存储发送数据的对端的ip和端口号(即记录寄件人地址);第四个参数是src_addr的实际大小。
cpp
void Start()
{
if (_isrunning)
return; // 不要重复启动
_isrunning = true;
while (true)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer); // 大小要是socklen_t类型
// 1.收消息
size_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
buffer[n] = 0; // 手动添加字符串结束标志
LOG(LogLevel::DEBUG) << "buffer:" << buffer;
}
}
}
1.2.2 发消息
发消息用到的接口是sendto。

参数和收消息接口类似:第一个参数就是套接字;第二个参数是发消息的缓冲区;第三个参数是这个缓冲区的大小;第四个参数是阻塞IO或者非阻塞IO,这里设置成0,表示默认非阻塞;第四个参数用于存储发送数据的对端的ip和端口号(即给谁发);第四个参数是src_addr的实际大小。
cpp
void Start()
{
if (_isrunning)
return; // 不要重复启动
_isrunning = true;
while (true)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer); // 大小要是socklen_t类型
// 1.收消息
size_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
buffer[n] = 0; // 手动添加字符串结束标志
LOG(LogLevel::DEBUG) << "buffer:" << buffer;
// 2.发消息
std::string echo_string = "server echo# ";
echo_string += buffer;
sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr *)&peer, len);
}
}
}
1.3 源文件
cpp
//UdpServer.cc文件
#include "UdpServer.hpp"
#include <memory>
// 格式为:udpserver ip port
int main(int argc, char *argv[])
{
if (argc != 3)
{
std::cout << "Usage:" << argv[0] << " ip port" << std::endl;
return 1;
}
std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
Refresh_Log_To_Console();
std::unique_ptr<UdpServer> server = std::make_unique<UdpServer>(ip, port);
server->Init();
server->Start();
return 0;
}
2.Client端
Client端就不做封装了,直接在.cc文件里写。
客户端要访问服务器,在日常生活中网络请求都是由客户端发起,服务器处理。客户端必须知道目标服务器的套接字信息(ip+port),所以客户端启动的时候要传服务器的ip和port,格式应该为udpclient server_ip server_port。
但是客户端怎么知道服务器的ip和端口号呢?客户端和服务器是一家公司写的,客户端内部以一定形式内置了服务器的ip和端口号。
cpp
// UdpClient.cc文件
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
// 格式为:udpclient server_ip server_port
int main(int argc, char *argv[])
{
if (argc != 3)
{
std::cout << "Usage:" << argv[0] << " server_ip server_port" << std::endl;
return 1;
}
std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
return 0;
}
客户端也要创建套接字,调用函数socket,成功返回一个文件描述符,失败返回-1。
cpp
// 格式为:udpclient server_ip server_port
int main(int argc, char *argv[])
{
if (argc != 3)
{
std::cout << "Usage:" << argv[0] << " server_ip server_port" << std::endl;
return 1;
}
std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
// 创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
std::cout << "client创建套接字失败" << std::endl;
return 2;
}
return 0;
}
客户端要不要绑定socket信息呢?要绑定。要不要显示的绑定呢?不要。
因为客户端首次发送消息,OS会自动给client进行bind,OS知道ip,而端口号采用随机端口号的方式,因为一个端口号只能被一个进程bind,采用随机端口号是为了避免client端口号冲突。
所以,客户端的端口号是多少并不重要,只要是唯一的就行;服务器端需要显示的bind,因为服务端会被很多个客户端访问,服务端的IP和端口必须是众所周知并且不能轻易改变的,一旦容易改变,客户端可能就访问不到了,就像生活中110或120这样的特殊电话号码是不能轻易改变的,但我们自己使用的手机号码可以任意换。
**客户端要先发送消息,再接收消息。**我们往服务器发消息,需要填写服务器信息。
cpp
// UdpClient.cc文件
#include <iostream>
#include <string>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
// 格式为:udpclient server_ip server_port
int main(int argc, char *argv[])
{
if (argc != 3)
{
std::cout << "Usage:" << argv[0] << " server_ip server_port" << std::endl;
return 1;
}
std::string server_ip = argv[1];
uint16_t server_port = std::stoi(argv[2]);
// 1.创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
std::cout << "client创建套接字失败" << std::endl;
return 2;
}
// 2.bind,但不需要显示的bind
// 填写服务器信息
struct sockaddr_in server;
memset(&server, 0, sizeof(server)); // 先请0
server.sin_family = AF_INET;
server.sin_port = htons(server_port); // 主机转网络
server.sin_addr.s_addr = inet_addr(server_ip.c_str()); // ip转4字节,4字节转网络序列
while (true)
{
// 3.发消息
std::string input;
std::cout << "Please Enter# ";
std::getline(std::cin, input); // 从输入流获取到input里
sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr *)&server, sizeof(server));
}
return 0;
}
客户端收消息的时候也要知道是哪个服务器发的,因为一个客户端可能访问多个服务器。
cpp
while (true)
{
// 3.发消息
std::string input;
std::cout << "Please Enter# ";
std::getline(std::cin, input); // 从输入流获取到input里
sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr *)&server, sizeof(server));
// 4.收消息
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int m = recvfrom(sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
if(m > 0)
{
buffer[m] = 0;
std::cout << buffer << std::endl;
}
}
3.绑定IP问题
我们用ifconfig命令查询,会有如下显示。

172.17.55.42是这台机器的内网ip,暂时不管,而这个127.0.0.1,是本地环回。
本地环回:要求客户端和服务器必须在一台机器上,表明我们是本地通信,客户端发送数据不会推送到网络,而是在OS内部转一圈直接交给对应的服务器端。
这种ip地址经常用来进行网络代码的测试,因为如果把代码直接扔到网络里,有可能是因为网不好导致结果不对,所以我们先用这个ip先对代码进行测试。
bash
#Makefile文件
.PHONY:all
all:udpclient udpserver
udpclient:UdpClient.cc
g++ -o $@ $^ -std=c++17
udpserver:UdpServer.cc
g++ -o $@ $^ -std=c++17
.PHONY:clean
clean:
rm -f udpclient udpserver
编译之后运行两份代码。

然后输入一些内容。

绑定内网IP也可以。

但是不可以直接绑定公网ip,因为公网IP并没有配置到我们的IP上。

并且如果服务器绑定内网IP,而客户端拿着本地环回ip访问,数据就会发不出去。

反过来也不行。
- netstat -anup:查网络连接状态,a表示所有,n表示数字化,u表示查看udp协议,p表示把进程信息也显示出来

客户端想访问ip为127.0.0.1 端口号为8080的地址信息,根本就不存在。
所以如果我们显式的bind,client未来访问的时候,就必须使用server端bind的地址信息。
公网IP不让邦,显式bind的话client又不好访问,所以服务器端不建议显式的bind,那怎么办呢?解决方法如下,把地址改为一个宏。
cpp
//UdpServer.hpp文件UdpServer类里的Init函数内部
//local.sin_addr.s_addr = inet_addr(_ip.c_str());
local.sin_addr.s_addr = INADDR_ANY;
这个宏如下,其实就是0,设置成0后就可以ip任意bind,允许接收任何的信息。

所以,server端就不需要传ip,只需要传个端口号就行。
cpp
// UdpServer.hpp文件
class UdpServer
{
public:
UdpServer(uint16_t &port) // 不需要传ip
: _sockfd(-1),
_port(port),
_isrunning(false)
{
}
//...
private:
int _sockfd;
uint16_t _port; // 端口号
//std::string _ip; // 不需要ip
bool _isrunning;
};
cpp
// UdpServer.cc文件
#include "UdpServer.hpp"
#include <memory>
// 格式为:udpserver port
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cout << "Usage:" << argv[0] << " port" << std::endl;
return 1;
}
uint16_t port = std::stoi(argv[2]);
Refresh_Log_To_Console();
std::unique_ptr<UdpServer> server = std::make_unique<UdpServer>(port);
server->Init();
server->Start();
return 0;
}
然后server端只需要绑定端口号,我们再用netstat查询一下网络连接状态。

现在就是显示的0.0.0.0:8080,就意味着用这台机器上的任意ip来访问server,他都可以向客户端发消息了。

前面是绑的本地环回,下面是绑的内网IP,都能给服务器发消息了。

但是公网IP不可以。

server获取client的IP和port
通过上面的实验我们会发现在server端我们并不知道消息是谁发送的,所以需要获取一下client的IP和port。client的信息就存储在recvfrom函数的第五个参数里,就是peer。
- 对于端口号,因为此是端口号是从网络里拿的,所以是网络序列,我们需要将网络序列转主机序列。
- 对于ip,我们需要的是点分十进制的字符串风格的IP,而从网络里拿到的是4字节网络风格的IP,所以需要用函数inet_ntoa进行转换。
cpp
// UdpServer.hpp文件
void Start()
{
if (_isrunning)
return; // 不要重复启动
_isrunning = true;
while (true)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer); // 大小要是socklen_t类型
// 1.收消息
size_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
buffer[n] = 0; // 手动添加字符串结束标志
uint16_t peer_port = ntohs(peer.sin_port); // 网络序列转主机序列
std::string peer_ip = inet_ntoa(peer.sin_addr); // 4字节网络序列
LOG(LogLevel::DEBUG) << "[" << peer_ip << ":" << peer_port << "]# " << buffer;
// 2.发消息
std::string echo_string = "server echo# ";
echo_string += buffer;
sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr *)&peer, len);
}
}
}


4.服务器回调函数
收到消息后,之前我们是把这个buffer消息打印一下,现在我们需要设计一个服务器的回调函数,让这个回调函数来对数据进行处理,处理完成后把结果返回来就行。有了回调函数,在初始化的时候就要要求传对应的回调方法。
cpp
// UdpServer.hpp文件
#include <functional>
using namespace MyLog;
using func_t = std::function<std::string(const std::string&)>; // 参数和返回值都为string类型
class UdpServer
{
public:
UdpServer(uint16_t &port, func_t func) // 初始化时要传回调方法
: _sockfd(-1),
_port(port),
_isrunning(false),
_func(func)
{
}
void Start()
{
if (_isrunning)
return; // 不要重复启动
_isrunning = true;
while (true)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer); // 大小要是socklen_t类型
// 1.收消息
size_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
buffer[n] = 0; // 手动添加字符串结束标志
uint16_t peer_port = ntohs(peer.sin_port); // 网络序列转主机序列
std::string peer_ip = inet_ntoa(peer.sin_addr); // 4字节网络序列
std::string result = _func(buffer); // 回调函数处理,并返回结果
// 2.把结果发出去
sendto(_sockfd, result.c_str(), result.size(), 0, (struct sockaddr *)&peer, len);
}
}
}
private:
int _sockfd;
uint16_t _port; // 端口号
bool _isrunning;
func_t _func; // 服务器的回调函数
};
下面有个回调方法的例子。
cpp
// UdpServer.cc文件
#include "UdpServer.hpp"
#include <memory>
std::string DefaultHandler(const std::string &messages)
{
std::string s = "***";
s = s + messages + "***";
return s;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cout << "Usage:" << argv[0] << " port" << std::endl;
return 1;
}
uint16_t port = std::stoi(argv[1]);
Refresh_Log_To_Console();
// 初始化时传回调方法
std::unique_ptr<UdpServer> server = std::make_unique<UdpServer>(port, DefaultHandler);
server->Init();
server->Start();
return 0;
}

现在UdpServer只用来进行网络通信的,而怎么处理由上层决定。这就是简单的代码的层状结构。
本篇分享就到这里,我们下篇见~
