引言
在前几篇博客中,我们学习了Linux网络编程中的一些概念。从本篇博客开始,我们就正式开始写代码。本篇博客我们将写udp服务器和客户端代码,并实现服务器和客户端通信。这些代码学习成本较高,建议大家多敲几遍。如任何问题,欢迎与我沟通。
Udp和Tcp的异同
UDP协议(User Datagram Protocol,用户数据报协议)和TCP协议(Transmission Control Protocol,传输控制协议)是计算机网络中两种常用的传输层协议,它们在多个方面存在显著的异同。以下是对两者异同点的详细比较:
相同点
- 层次位置:两者都位于OSI模型的第四层——传输层,为上层应用提供数据传输服务。
- 作用:都在网络通信中扮演着重要的角色,用于在网络中的不同设备之间传输数据。
不同点
UDP协议 | TCP协议 | |
---|---|---|
可靠性 | 不提供可靠性保证,不保证数据包的顺序、完整性和不重复。 | 提供可靠的数据传输,通过序列号、确认机制和重传机制确保数据的完整性和有序性。 |
连接性 | 无连接协议,发送数据前不需要建立连接,直接发送数据。 | 面向连接的协议,数据传输前需要建立连接,通过“三次握手”机制确认连接状态。 |
传输效率 | 传输效率高,因为不需要建立连接和维持连接状态,开销小。 | 传输效率相对较低,因为需要建立和维护连接,增加了额外的开销。 |
实时性 | 实时性较好,适用于对实时性要求较高的应用,如在线游戏、视频通话等。 | 实时性较差,因为需要等待连接建立和确认,以及处理重传等机制。 |
数据包大小 | 数据包大小没有限制,但通常受限于网络MTU(最大传输单元)。 | 将数据分割成较小的数据块进行传输,以适应不同的网络环境。 |
拥塞控制 | 不使用拥塞控制,网络拥塞时不会降低发送速率。 | 使用拥塞控制机制,根据网络状况调整发送速率,避免网络拥塞。 |
应用场景 | 适用于对可靠性要求不高,但对实时性要求较高的场景,如流媒体传输、DNS查询等。 | 适用于对可靠性要求较高的场景,如文件传输、网页浏览等。 |
总结
UDP协议和TCP协议在可靠性、连接性、传输效率、实时性、数据包大小和拥塞控制等方面存在显著的差异。选择哪种协议取决于具体的应用场景和需求。如果对数据传输的可靠性要求较高,应选择TCP协议;如果对实时性要求较高,且可以容忍一定的数据丢失,则可以选择UDP协议。在实际应用中,两种协议经常结合使用,以满足不同的网络需求。
不难发现,Udp代码较简单,写起来相对的简单一些,上手较容易。所以我们写使用Udp协议进行通信。
为了使大家更加容易理解。我们按照创建udp服务端的整个过程的先后顺序来进行讲解。最后写出完整的代码。
1.1、socket
网络通信必须要申请套接字。申请套接字对应的函数为socket。
代码语言:javascript复制 #include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数:
①domain
:
domain(协议域/协议族):决定了socket的地址类型。常用的协议族有AF_INET(IPv4)、AF_INET6(IPv6)、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等。在通信中,必须采用与协议族对应的地址。例如,AF_INET决定了要使用IPv4地址(32位)与端口号(16位)的组合。
②type
type(socket类型):指定了socket的类型。常用的socket类型有SOCK_STREAM(流式套接字,用于TCP)、SOCK_DGRAM(数据报套接字,用于UDP)、SOCK_RAW(原始套接字,允许对底层协议如IP或ICMP进行直接访问)等。
③protocol
protocol(协议):通常情况下,可以将其设置为0,让系统自动选择type类型对应的默认协议。
返回值
- 当socket函数成功创建了一个套接字时,它返回一个有效的套接字描述符(socket descriptor)。这个描述符是一个非负整数,用于后续的网络操作,如绑定、监听、连接、发送和接收数据等。
- 如果在创建套接字时发生错误,socket函数返回-1,并设置全局变量errno以指示错误原因。此时,可以调用errno变量或perror()函数来获取具体的错误信息。常见的错误码包括EACCES(权限不足)、EADDRINUSE(地址已经被占用)、EAFNOSUPPORT(地址族不支持)、EINVAL(参数无效)、EMFILE(达到进程允许打开的最大文件数目)、ENFILE(系统打开文件数目过多)、ENOBUFS/ENOMEM(内存不足)、EPROTONOSUPPORT(协议不支持)等。
1.2、bind
bind函数在网络编程中扮演着至关重要的角色,它主要用于将一个本地协议地址(包括IP地址和端口号)赋予一个套接字。以下是关于bind函数的详细解释:
代码语言:javascript复制 #include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
参数
①sockfd
:这是由socket()函数返回的文件描述符,代表已经创建的套接字。
②addr
:这是一个指向特定协议地址结构的指针,如struct sockaddr_in或struct sockaddr_un,它包含了地址、端口和可能的IP地址信息。
③addrlen
:这是地址结构的长度,通常以字节为单位。对于IPv4,通常使用sizeof(struct sockaddr_in);对于IPv6,使用sizeof(struct sockaddr_in6);对于Unix域套接字,使用sizeof(struct sockaddr_un)。
返回值:
- 如果bind函数成功执行,它返回0。
- 如果出现错误,返回-1,并设置全局变量errno以指示错误原因。常见的错误包括EACCES(权限不足)、EADDRINUSE(地址已经被使用)、EADDRNOTAVAIL(地址不可用)、EAFNOSUPPORT(地址族不支持该套接字类型)、EINVAL(套接字未打开)、ENOTSOCK(文件描述符不是套接字)等。
使用场景:
在TCP服务器程序中,bind函数通常用于指定服务器应监听的端口号。服务器在启动时捆绑其众所周知的端口,以便客户端可以连接到它。 对于UDP套接字,bind函数同样用于指定接收数据的端口号。 在Unix域套接字中,bind函数可以用来指定套接字在文件系统中的路径名。
注意事项:
- 在调用bind函数之前,套接字必须处于未连接状态(对于面向连接的套接字如TCP)。
- 如果addr参数中的地址或端口号为0,系统将为套接字自动选择一个可用的地址或端口号。
- 在多线程环境中,应确保对bind函数的调用是线程安全的,避免竞态条件。
- 绑定的本质:将用户态的sockaddr_in设置进内核变为系统态。
- 对于端口号而言,如果用户没有调用bind函数进行显式绑定,那么系统在第一次发送消息时,会随机给套接字绑定一个端口号。
1.3、recvfrom
recvfrom函数是一个在POSIX兼容操作系统(如Linux)中用于接收数据的系统调用。它主要用于从指定的套接字接收数据,并适用于面向无连接的协议,如UDP(用户数据报协议)。
代码语言:javascript复制 #include <sys/types.h>
#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
:该块缓冲区的大小。
④flags
:读取数据的方式。默认设为0——阻塞式读取。
⑤src_addr
:输出型参数,该结构体里面包含着数据发送方的信息,如port、ip等等。如果不需要这些信息,可以设为null。
⑥‘’addrlen
:该结构体的大小。
返回值
- 成功时,返回接收到的字符数(字节数)。
- 如果没有可用数据或者连接已经关闭,返回0。
- 如果出现错误,返回-1,并设置errno错误号。此时可以通过perror()函数来打印出错误信息。
注意事项
- 在调用recvfrom函数之前,需要先使用bind函数将socket绑定到一个地址上。
- 如果套接字是非阻塞的,recvfrom函数可能会在没有接收到任何数据时返回-1,并设置errno为EAGAIN或EWOULDBLOCK。
- 如果接收到的数据比缓冲区还大,那么只会取缓冲区大小的数据,并将剩余的数据丢弃。
1.4、sendto
sendto函数是一个系统调用,用于将数据从指定的套接字发送到目标地址。它通常用于UDP(用户数据报协议)通信,因为UDP是无连接的,所以sendto函数允许你向一个特定的地址发送数据报,而不需要事先建立连接。
代码语言:javascript复制#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:已经创建好的socket文件描述符。
- buf:指向要发送的数据的缓冲区。
- len:要发送的数据长度。
- flags:发送选项标志,可以是0或者像MSG_DONTWAIT这样的选项。MSG_DONTWAIT表示非阻塞发送,如果发送缓冲区满,则不等待直接返回。
- dest_addr:目标地址的sockaddr结构体指针。对于IPv4,这通常是一个指向struct sockaddr_in的指针;对于IPv6,则是一个指向struct sockaddr_in6的指针。
- addrlen:目标地址结构体的长度,例如sizeof(struct sockaddr_in)或sizeof(struct sockaddr_in6)。
返回值:
sendto函数的返回值是一个long类型的整数,表示发送的字节数。具体返回值有以下几种可能:
- 如果返回值大于0,则表示数据已经成功发送到了目标地址。返回值代表实际发送的字节数。
- 如果返回值等于0,表示发送的数据长度为0。这可能是因为buf指向的空间长度为0,或者在使用UDP协议时,sendto函数成功地发送了0字节的数据。
- 如果返回值等于-1,表示发送过程中出现了错误。此时,可以通过检查errno的值来确定具体的错误原因。例如,如果errno为EINTR,表示sendto函数被一个信号中断了;如果errno为EAGAIN或EWOULDBLOCK,表示发送缓冲区已满,无法立即发送数据(这通常发生在使用了MSG_DONTWAIT标志的情况下)。
需要注意的是,sendto函数不保证数据的可靠传输。也就是说,发送的数据可能会丢失,或者接收方可能无法按照发送的顺序接收数据。如果需要可靠的数据传输,应该使用TCP协议而不是UDP。 此外,在使用sendto函数之前,需要确保已经通过socket函数创建了一个套接字,并且(对于面向连接的套接字类型)已经通过connect函数与目标地址建立了连接(尽管对于UDP,连接通常不是必需的,但也可以通过connect建立默认的目标地址)。同时,也需要确保目标地址是有效的,并且发送的数据缓冲区是正确设置的。
udp服务端
2.1、代码
代码语言:javascript复制#pragma once
#include <iostream>
#include <string>
#include <strings.h>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <functional>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
namespace Server
{
using namespace std;
static const string defaultIp = "0.0.0.0"; //TODO
static const int gnum = 1024;
enum {USAGE_ERR = 1, SOCKET_ERR, BIND_ERR};
typedef function<void (string,uint16_t,string)> func_t;
class udpServer
{
public:
udpServer(const func_t &cb, const uint16_t &port, const string &ip = defaultIp)
:_callback(cb), _port(port), _ip(ip), _sockfd(-1)
{}
void initServer()
{
// 1. 创建socket
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd == -1)
{
cerr << "socket error: " << errno << " : " << strerror(errno) << endl;
exit(SOCKET_ERR);
}
cout << "socket success: " << " : " << _sockfd << endl;
// 2. 绑定port,ip(TODO)
// 未来服务器要明确的port,不能随意改变
struct sockaddr_in local; // 定义了一个变量,栈,用户
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port); // 你如果要给别人发消息,你的port和ip要不要发送给对方
local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 1. string->uint32_t 2. htonl(); -> inet_addr
//local.sin_addr.s_addr = htonl(INADDR_ANY); // 任意地址bind,服务器的真实写法
int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if(n == -1)
{
cerr << "bind error: " << errno << " : " << strerror(errno) << endl;
exit(BIND_ERR);
}
// UDP Server 的预备工作完成
}
void start()
{
// 服务器的本质其实就是一个死循环
char buffer[gnum];
for(;;)
{
// 读取数据
struct sockaddr_in peer;
socklen_t len = sizeof(peer); //必填
ssize_t s = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &len);
// 1. 数据是什么 2. 谁发的?
if(s > 0)
{
buffer[s] = 0;
string clientip = inet_ntoa(peer.sin_addr); //1. 网络序列 2. int->点分十进制IP
uint16_t clientport = ntohs(peer.sin_port);
string message = buffer;
cout << clientip <<"[" << clientport << "]# " << message << endl;
// 我们只把数据读上来就完了吗?对数据做处理
_callback(clientip, clientport, message);
}
}
}
~udpServer()
{
}
private:
uint16_t _port;
string _ip; // 实际上,一款网络服务器,不建议指明一个IP
int _sockfd;
func_t _callback; //回调
};
}
2.1、说明
- 服务器一旦开始运行,就不会停止。所以服务器本质就是一个死循环。这种一直运行的进程叫做常驻进程。
- 一般来说,服务器不会显式的绑定某一个ip。因为一个主机可能会有不同的ip。但是这台主机内的端口号是唯一的,客户端都是发送信息到特定的端口号上。所以服务器为了可以接收到所有发到这台主机上的信息(不会存在数据丢弃的情况),选择绑定0.0.0.0作为自己的ip。这样就可以接受到任何发送到这台主机指定端口的所有信息。
udp客户端
3.1、代码
代码语言:javascript复制#include <iostream>
#include <string>
#include <strings.h>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <pthread.h>
namespace Client
{
using namespace std;
class udpClient
{
public:
udpClient(const string &serverip, const uint16_t &serverport)
: _serverip(serverip), _serverport(serverport), _sockfd(-1), _quit(false)
{
}
void initClient()
{
// 创建socket
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd == -1)
{
cerr << "socket error: " << errno << " : " << strerror(errno) << endl;
exit(2);
}
cout << "socket success: "
<< " : " << _sockfd << endl;
// 2. client要不要bind[必须要的],client要不要显示的bind,需不需程序员自己bind?不需要!!!
// 写服务器的是一家公司,写client是无数家公司 -- 由OS自动形成端口进行bind!-- OS在什么时候,如何bind
}
static void *readMessage(void *args)
{
int sockfd = *(static_cast<int *>(args));
pthread_detach(pthread_self());
while (true)
{
char buffer[1024];
struct sockaddr_in temp;
socklen_t temp_len = sizeof(temp);
size_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&temp, &temp_len);
if (n >= 0)
buffer[n] = 0;
cout << buffer << endl;
}
return nullptr;
}
void run()
{
pthread_create(&_reader, nullptr, readMessage, (void *)&_sockfd);
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_addr.s_addr = inet_addr(_serverip.c_str());
server.sin_port = htons(_serverport);
string message;
char cmdline[1024];
while (!_quit)
{
//cerr << "# "; // ls -a -l
// cin >> message;
fprintf(stderr, "Enter# ");
fflush(stderr);
fgets(cmdline, sizeof(cmdline), stdin);
cmdline[strlen(cmdline)-1] = 0;
message = cmdline;
sendto(_sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&server, sizeof(server));
}
}
~udpClient()
{
}
private:
int _sockfd;
string _serverip;
uint16_t _serverport;
bool _quit;
pthread_t _reader;
};
} // namespace Client
3.2、说明
客户端需要绑定端口号吗?客户端需要显式的绑定端口号吗?
端口号是需要绑定端口号的,但是不需要显式的绑定端口号的。绑定端口号的工作交给操作系统自主完成,这个工作由操作系统在客户端初次发送消息时完成。
相对于服务端来说,客户端必须绑定特定的端口号,但是端口号的数值对于客户端来说就显得不太重要。
服务端必须指定特定的端口号以供客户端根据该端口号来向服务端发送消息。但是客户端而言,如果显式指明端口号,必然会出现两个客户端竞争一个端口号的情况。所以在通信时就由操作系统随机分配一个端口号供客户端进行通信。