一. UDP协议
何为UDP协议的含义,上篇粗略提及了一下TCP与UDP的区别:
TCP: • 传输层协议 • 有连接 • 可靠传输 • 面向字节流
UDP: • 传输层协议 • 无连接 • 不可靠传输 • 面向数据报
那何为可靠,何为不可靠呢?
TCP协议是有连接的。如果两台主机想要建立通信,就必须先建立连接,通过三次握手(后续博客会讲到)建立连接,只有当连接成功后,才能进行通信。 TCP可靠性体现在:如果数据在传输过程中出现了丢包等等情况,会有相应的解决方法。 TCP可靠性实现方法:
- 确认和重传: TCP 使用确认和重传机制来确保数据的完整性和可靠性。接收方会发送确认(ACK)给发送方,告知已成功接收到数据,如果发送方未收到确认,会重新发送数据。
- 序号和顺序控制: TCP 会为每个数据段分配一个序号,并且在接收端按序重组数据,以确保数据包按正确的顺序交付。
- 流量控制: TCP 使用滑动窗口协议进行流量控制,确保发送方和接收方之间的数据传输速率合理,避免了数据包的过载和丢失。
- 拥塞控制: TCP 还实现了拥塞控制机制,通过动态调整发送速率来避免网络拥塞,以提高整体网络性能和稳定性。
UDP协议是无连接的,也就是会说,UDP通信时,无需等待建立连接,只需拿到对应通信主机的端口号 IP地址,就能唯一确定一个进程,实现通信。 UDP不可靠机制: UDP 不提供数据传输的确认、重传、排序等机制。如果发送端发送了数据包,不会收到接收端的确认。因此,如果一个数据包在传输中丢失或损坏,UDP 不会重传数据,接收方也无法得知数据包的丢失。
但是,并不是说,TCP就是百利而无一害的。前面说了,TCP还有一个特性---面向字节流,这就导致了,目标主机读取到的内容可能并不是完整的源主机发送的内容。后续讲TCP实现的时候会体现出来。
二. Socket编程
2.1 sockaddr家族
此处我们再次引用一张图:
sockaddr_in
结构体是用于跨网络通信的sockaddr_un
结构体是用于本地通信的
为了让套接字的网络通信和本地通信能够使用同一套函数接口,于是就出现了sockaddr
结构体,该结构体与sockaddr_in
和sockaddr_un
的结构都不相同,但这三个结构体头部的16个比特位都是一样的,这个字段叫做协议簇。
这样好处就是:
- socket编程涉及到的接口函数传参时,只需统一传sockaddr类型的参数即可。
- 此时通过sockaddr结构体,将套接字网络通信和本地通信在参数方面统一
注意事项:
在进行网络通信编程时,统一定义的还是sockaddr_in结构体,只不过在调用接口时需要将sockaddr_in结构强转位sockaddr类型。
2.2 接口介绍
- socket创建套接字
int socket(int domain, int type, int protocol);
参数说明: 1.domin:创建套接字的域或者叫做协议家族,也就是创建套接字的类型(指定通信所用到的协议家族) 相当于sockaddr结构的前16个位的内容。
如果是本地通信就选择AF_UNIX,如果是网络通信就选择AF_INET(IPV4)或者AF_INET6(IPV6),此处我们UDP通信由于是通过网络通信,所以选择AF_INET即可。后面TCP通信也是选择AF_INET。 2.type:创建套接字时所需的服务类型(指定具体的通信协议)
- 如果是UDP通信,选择SOCK_DGRAM(用户数据报服务),UDP是面向数据报的。
- 如果是TCP通信,选择SOCK_STREAM(流式套接字),TCP是面向字节流的。
3.protocol:创建套接字的协议类别 此处可以指明是UDP通信还是TCP通信,但是一般设置为0,表示默认。系统会自动根据前两个参数推导出是UDP通信还是TCP通信。
返回值:
成功的话会返回打开的文件描述符。
注意事项:
- socket函数属于系统调用接口。
- socket函数是被进程调用的。
- bind绑定
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明: 1.sockfd:绑定的文件的文件描述符 我们调用socket函数后,会返回一个文件描述符,这里就需要绑定这个文件描述符。通信就依赖于这个文件描述符。sockfd是全双工的,既可以收数据,又可以发数据。 2.addr:一个包含自身网络信息的结构体 我们需要确定绑定的IP和端口号,才能通信。 3.addrlen:传入的addr结构体的长度 用sizeof求得即可。
返回值说明: 成功绑定0会被返回,失败-1会被返回,错误码会被设置。
- 本机端口序列与网络端口序列互相转换函数
//htonl:表示将长整型的本机端口序列转换为长整型的网络端口序列。
uint32_t htonl(uint32_t hostlong);
//htons:表示将短整型的本机端口序列转换为短整型的网络端口序列。
uint16_t htons(uint16_t hostshort);
//ntohl:表示将长整型的网络端口序列转换为长整型的本机端口序列。
uint32_t ntohl(uint32_t netlong);
//ntohs:表示将短整型的网络端口序列转换为短整型的本机端口序列。
uint16_t ntohs(uint16_t netshort);
参数说明: hostlong代表本机端口序列 netlong代表网络端口序列 uint32_t即长整型 uint16_t即短整型
- recvfrom读取数据
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
参数说明: 1.sockfd:对应操作的文件描述符 表示从该文件描述符索引的文件当中读取数据。 2.buf: 读取数据的存放位置 3.len: 期望读取数据的字节数 4.flags:读取的方式 一般设置为0,表示阻塞读取。 5.src_addr:对端网络相关的属性信息 包括协议簇、IP地址、端口号等。 6.addrlen:期望读取的src_addr结构体的长度 代表实际读取到的src_addr结构体的长度,这是一个输入输出型参数。
返回值说明: 成功实际读到的字节数会被返回,失败-1会被返回,错误码会被设置。
- sendto发送数据
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
参数说明: 1.sockfd:对应操作的文件描述符 表示将数据写入到文件描述符索引的文件当中。 2.buf: 待写入数据的存放位置 3.len: 期望写入数据的字节数 4.flags:写入的方式 一般设置为0,表示阻塞写入。 5.dest_addr:对端网络相关的属性信息 包括协议簇、IP地址、端口号等。 6.addrlen:期望读取的dest_addr结构体的长度 代表实际读取到的dest_addr结构体的长度,这是一个输入输出型参数。
返回值说明: 成功实际写入的字节数会被返回,失败-1会被返回,错误码会被设置。
三. 服务端实现
我们将服务端封装成一个类,并封装对应步骤在类函数中。
代码语言:javascript复制class UdpServer
{
public:
UdpServer(uint16_t port)
: _sockfd(defaultfd), _port(port)
,_isrunning(false)
{}
~UdpServer()
{}
private:
int _sockfd;
uint16_t _port; // 服务器所用的端口号
bool _isrunning;
};
此处为什么服务端不需要IP地址呢? 这是因为服务器不好绑定一个具体的IP地址,不利于服务的广度。我们此处不设置具体的IP地址,就是为了任何IP都能连接服务器。
服务端初始成员函数:
代码语言:javascript复制void InitServer()
{
// 1.创建udp socket 套接字...必须要做的
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(FATAL, "socket error,%s,%dn", strerror(errno), errno);
exit(SOCKET_ERROR);
}
LOG(INFO, "socket create success,sockfd: %dn", _sockfd);
// 2.1 填充sockaddr_in结构
struct sockaddr_in local; // struct sockaddr_in 系统提供的数据类型,local是变量,用户栈上开辟空间
bzero(&local, sizeof(local)); // 清空
local.sin_family = AF_INET;
local.sin_port = htons(_port); // port要经过网络传输给对面,即port先到网络,所以要将_port,从主机序列转化为网络序列
//a.字符串风格的点分十进制的IP地址转成4字节IP
//b.将主机序列转成网络序列
//in_addr_t inet_addr(const char* cp)->同时完成a&b
//local.sin_addr.s_addr =inet_addr(_ip.c_str());
local.sin_addr.s_addr=INADDR_ANY;//htonl(INADDR_ANY)
// 2.2 bind sockfd和网络信息(IP(?) Port)
int n = bind(_sockfd,(struct sockaddr*)&local,sizeof(local));
if(n<0)
{
LOG(FATAL, "bind error,%s,%dn", strerror(errno), errno);
exit(BIND_ERROR);
}
LOG(INFO, "socket bind successn");
}
大概步骤就是通过socket函数创建出套接字,然后填充一个sockaddr_in结构体,最后用这个结构体绑定创建的套接字。
此处LOG函数是仿照日常生活中网络服务的思路,一些操作都有着对应的日志记录,所以创建一个日志文件,实现日志功能。
代码语言:javascript复制#pragma once
//日志
#include<iostream>
#include<fstream>
#include<cstdio>
#include<string>
#include<ctime>
#include<unistd.h>
#include<sys/types.h>
#include<stdarg.h>
#include<pthread.h>
#include"LockGuard.hpp"
using namespace std;
bool gIsSave=false;
const string logname="log.txt";
void SaveFile(const string& filename,const string& message)
{
ofstream out(filename,ios::app);
if(!out.is_open())
{
return;
}
out<<message;
out.close();
}
//1.日志是有等级的
enum Level
{
DEBUG=0,
INFO,
WARNING,
ERROR,
FATAL
};
string LevelToString(int level)
{
switch(level)
{
case DEBUG: return "Debug";break;
case INFO: return "Info";break;
case WARNING: return "Warning";break;
case ERROR: return "Error";break;
case FATAL: return "Fatal";break;
default: return "Unknown";break;
}
}
string GetTimeString()
{
time_t curr_time=time(nullptr);
struct tm* format_time=localtime(&curr_time);
if(format_time==nullptr) return "None";
char time_buffer[64];
snprintf(time_buffer,sizeof(time_buffer),"%d-%d-%d %d:%d:%d",
format_time->tm_year 1900,format_time->tm_mon 1,format_time->tm_mday,
format_time->tm_hour,format_time->tm_min,format_time->tm_sec);
return time_buffer;
}
pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER;
//2.日志是由格式的
// 日志等级 时间 代码所在的文件名/行数 日志的内容...
void LogMessage(string filename,int line,bool issave,int level,const char* format,...)
{
string levelstr=LevelToString(level);
string timestr=GetTimeString();
pid_t selfid=getpid();
//可变参数部分处理
char buffer[1024];
va_list arg;
va_start(arg,format);
vsnprintf(buffer,sizeof(buffer),format,arg);
va_end(arg);
LockGuard lockguard(&lock);
string message;
message="[" timestr "]" "[" levelstr "]" "[pid: "
to_string(selfid) "]" "[" filename "]"
"[" to_string(line) "]" buffer "n";
if(!issave)
{
cout<<message;
}
else
{
SaveFile(logname,message);
}
}
void Test(int num,...)
{
va_list arg;
va_start(arg,num);
while(true)
{
int data=va_arg(arg,int);
cout<<"data: "<<data<<endl;
num--;
}
va_end(arg);//arg==NULL
}
//C99新特性 __VA_ARGS__
#define LOG(level,format,...) do {LogMessage(__FILE__,__LINE__,gIsSave,level,format,##__VA_ARGS__);} while(0)
#define EnableFile() do {gIsSave=true;} while(0)
#define EnableScreen() do {gIsSave=false;} while(0)
此处我们引进了锁来保护临界资源,也是仿照C 中RAII的思路,创建了一个出了作用域自动销毁的锁。内容如下:
代码语言:javascript复制#ifndef __lock_GUARD_HPP__
#define __lock_GUARD_HPP__
#include<iostream>
#include<pthread.h>
class LockGuard
{
public:
LockGuard(pthread_mutex_t* mutex)
:_mutex(mutex)
{
pthread_mutex_lock(_mutex);//构造加锁
}
~LockGuard()
{
pthread_mutex_unlock(_mutex);
}
private:
pthread_mutex_t* _mutex;
};
#endif
服务端启动成员函数:
我们可以发现现实生活中,一些软件好像啥时候都能用。比如今天晚上我失眠了。诶!打开抖音刷刷。好像不管我们几点失眠,抖音都能刷视频。所以我们见微知著,服务器应该是一直运行的,这样不论客户端什么时候去访问都能得到回应。
代码语言:javascript复制void Start()
{
//一直运行,直到管理者不想运行了,服务器都是死循环
_isrunning=true;
while(true)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
//1.我们要让server先收数据
ssize_t n=recvfrom(_sockfd,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&peer,&len);
if(n>0)
{
buffer[n]=0;
InetAddr addr(peer);
LOG(DEBUG,"get message from [%s:%d]: %sn",addr.Ip().c_str(),addr.Port(),buffer);
//2.我们要将server收到的数据,发回给对方
sendto(_sockfd,buffer,strlen(buffer),0,(struct sockaddr*)&peer,len);
}
}
_isrunning=false;
}
大概思路就是,我们需要先通过recvfrom函数接收客户端发来的消息,然后再通过sendto函数发回给客户端。
我们发现这个函数里面出现了一个新的东西,即InetAddr,这其实是一个结构体,我们来看看吧!
代码语言:javascript复制#include<iostream>
#include<string>
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<netinet/in.h>
using namespace std;
class InetAddr
{
private:
void GetAddress(string* ip,uint16_t* port)
{
*port=ntohs(_addr.sin_port);//网络字节序转为主机字节序
*ip=inet_ntoa(_addr.sin_addr);//将网络字节序IP转为点分式十进制IP
}
public:
InetAddr(const struct sockaddr_in &addr)
:_addr(addr)
{
GetAddress(&_ip,&_port);
}
string Ip()
{
return _ip;
}
uint16_t Port()
{
return _port;
}
~InetAddr()
{}
private:
struct sockaddr_in _addr;
string _ip;
uint16_t _port;
};
这个结构体重包含了sockaddr_in结构对象,以及IP地址和端口号。
那么服务端代码合起来就是:
代码语言:javascript复制#pragma once
#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <strings.h>
#include <stdlib.h>
#include "Log.hpp"
#include"InetAddr.hpp"
using namespace std;
enum
{
SOCKET_ERROR = 1,
BIND_ERROR,
USAGE_ERROR
};
const static int defaultfd = -1;
class UdpServer
{
public:
UdpServer(uint16_t port)
: _sockfd(defaultfd), _port(port)
,_isrunning(false)
{
}
void InitServer()
{
// 1.创建udp socket 套接字...必须要做的
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(FATAL, "socket error,%s,%dn", strerror(errno), errno);
exit(SOCKET_ERROR);
}
LOG(INFO, "socket create success,sockfd: %dn", _sockfd);
// 2.1 填充sockaddr_in结构
struct sockaddr_in local; // struct sockaddr_in 系统提供的数据类型,local是变量,用户栈上开辟空间
bzero(&local, sizeof(local)); // 清空
local.sin_family = AF_INET;
local.sin_port = htons(_port); // port要经过网络传输给对面,即port先到网络,所以要将_port,从主机序列转化为网络序列
//a.字符串风格的点分十进制的IP地址转成4字节IP
//b.将主机序列转成网络序列
//in_addr_t inet_addr(const char* cp)->同时完成a&b
//local.sin_addr.s_addr =inet_addr(_ip.c_str());
local.sin_addr.s_addr=INADDR_ANY;//htonl(INADDR_ANY)
// 2.2 bind sockfd和网络信息(IP(?) Port)
int n = bind(_sockfd,(struct sockaddr*)&local,sizeof(local));
if(n<0)
{
LOG(FATAL, "bind error,%s,%dn", strerror(errno), errno);
exit(BIND_ERROR);
}
LOG(INFO, "socket bind successn");
}
void Start()
{
//一直运行,直到管理者不想运行了,服务器都是死循环
_isrunning=true;
while(true)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
//1.我们要让server先收数据
ssize_t n=recvfrom(_sockfd,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&peer,&len);
if(n>0)
{
buffer[n]=0;
InetAddr addr(peer);
LOG(DEBUG,"get message from [%s:%d]: %sn",addr.Ip().c_str(),addr.Port(),buffer);
//2.我们要将server收到的数据,发回给对方
sendto(_sockfd,buffer,strlen(buffer),0,(struct sockaddr*)&peer,len);
}
}
_isrunning=false;
}
~UdpServer()
{
}
private:
int _sockfd;
uint16_t _port; // 服务器所用的端口号
bool _isrunning;
};
四. 服务端调用实现
由于我们将服务端封装成了一个类,所以需要创建出对象,然后调用初识函数以及开始函数。
代码语言:javascript复制#include<iostream>
#include<memory>
#include"UdpServer.hpp"
#include"Log.hpp"
using namespace std;
void Usage(string proc)
{
cout<<"Usage:nt"<<proc<<" local_portn"<<endl;
}
// ./udpserver port
int main(int argc,char *argv[])
{
if(argc!=2)
{
Usage(argv[0]);
exit(USAGE_ERROR);
}
EnableScreen();
//string ip=argv[1];
uint16_t port=stoi(argv[1]);
unique_ptr<UdpServer> usvr=make_unique<UdpServer>(port);
usvr->InitServer();
usvr->Start();
return 0;
}
思路就是,创建一个对象,依次调用两个成员函数,即可创建出服务端。
五. 客户端实现
代码语言:javascript复制客户端的创建前面一部分还是跟服务端一样,即创建套接字,构建目标主机结构体信息,即构建sockaddr_in结构体信息。唯一不同的是,客户端是不需要显示bind的。 为什么呢? 为了防止客户端的端口冲突。如果我们直接将客户端绑定了一个确定的端口号,其他人是不知道的,每个人都绑定一个确定的端口号,势必会有冲突。 那什么时候绑定呢? 在客户端首次发送消息数据的时候,操作系统会给客户端随机分配端口号,以防端口冲突。
void Usage(string proc)
{
cout<<"Usage:nt"<<proc<<" serverip serverportn"<<endl;
}
// ./udpclient serverip serverport
int main(int argc,char *argv[])
{
if(argc!=3)
{
Usage(argv[0]);
exit(1);
}
string serverip=argv[1];
uint16_t serverport=stoi(argv[2]);
//1.创建socket
int sockfd=socket(AF_INET,SOCK_DGRAM,0);
if(sockfd<0)
{
cerr<<"socket error"<<endl;
}
//2.client一定要bind,client也有自己的ip和port,但是不建议显示(和server一样用bind函数)bind
//a.那如何bind呢?当udp client首次发送数据的时候,os会自动随机的给client进行bind--为什么?要bind,必然要和port关联!防止client port冲突
//b.什么时候bind?首次发送数据的时候
//构建目标主机的socket信息
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());
}
下面就可以直接与服务端通信了。
代码语言:javascript复制string message;
//3.直接通信即可
while(true)
{
cout<<"Please Enter# ";
getline(cin,message);
sendto(sockfd,message.c_str(),message.size(),0,(struct sockaddr*)&server,sizeof(server));
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
char buffer[1024];
ssize_t n=recvfrom(sockfd,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&peer,&len);
if(n>0)
{
buffer[n]=0;
cout<<"server echo# "<<buffer<<endl;
}
}
通信时,首先通过sendto将要发送的信息发送到服务端,服务端收到后会将信息发送回来,然后再用recvfrom函数收到信息即可。
客户端整体代码如下:
代码语言:javascript复制#include<iostream>
#include<string>
#include<cstdio>
#include<cstring>
#include<unistd.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
using namespace std;
void Usage(string proc)
{
cout<<"Usage:nt"<<proc<<" serverip serverportn"<<endl;
}
// ./udpclient serverip serverport
int main(int argc,char *argv[])
{
if(argc!=3)
{
Usage(argv[0]);
exit(1);
}
string serverip=argv[1];
uint16_t serverport=stoi(argv[2]);
//1.创建socket
int sockfd=socket(AF_INET,SOCK_DGRAM,0);
if(sockfd<0)
{
cerr<<"socket error"<<endl;
}
//2.client一定要bind,client也有自己的ip和port,但是不建议显示(和server一样用bind函数)bind
//a.那如何bind呢?当udp client首次发送数据的时候,os会自动随机的给client进行bind--为什么?要bind,必然要和port关联!防止client port冲突
//b.什么时候bind?首次发送数据的时候
//构建目标主机的socket信息
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());
string message;
//3.直接通信即可
while(true)
{
cout<<"Please Enter# ";
getline(cin,message);
sendto(sockfd,message.c_str(),message.size(),0,(struct sockaddr*)&server,sizeof(server));
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
char buffer[1024];
ssize_t n=recvfrom(sockfd,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&peer,&len);
if(n>0)
{
buffer[n]=0;
cout<<"server echo# "<<buffer<<endl;
}
}
return 0;
}
六. 效果展示
最后服务端和客户端通信的效果如下:
可以看到服务端在创建套接字成功,bind成功之后开始工作。
客户端发送消息,服务端收到消息,并返还给客户端。
总结:
好了,到这里今天的知识就讲完了,大家有错误一点要在评论指出,我怕我一人搁这瞎bb,没人告诉我错误就寄了。
祝大家越来越好,不用关注我(疯狂暗示)