目录
背景知识
主机间通信本质
socket
端口号特点:
为什么不用进程pid?
网络字节序
socket编程接口API
sockaddr结构
编辑 简单UDP网络程序
了解UDP协议
简易多人聊天室实现
服务端代码:
客户端代码:
背景知识
主机间通信本质
各自主机上的进程之间相互交互数据
IP地址完成主机与主机之间的通信
主机上各自的通信进程分别是发送数据和接收数据的一方
socket
IP地址:标识主机唯一性(4字节32位)
端口号port:标识了主机上的进程唯一性(2字节16位)
那么 IP地址 + 端口号 就能够标识网络上的某一台主机的某一个进程,将IP地址+端口号称为socket对,之间用冒号分隔,如 源IP:源端口号 目的IP:目的端口号。
端口号特点:
端口号 (port) 是传输层协议的内容 .端口号是一个2字节16位的整数;端口号用来标识一个进程一个进程可以绑定多个端口号
一个端口号只能被一个进程占用
OS内部用哈希表存储端口号,通过哈希表映射,使用端口号可以快速找到进程
为什么不用进程pid?
避免进程管理和网络通信的强耦合 ,同时端口号标识的进程是要进行网络通信的网络进程,没有端口号则说明是本地进程,不进行网络通信,就好比身份证号与学号,独立分配,便于管理。
网络字节序
在网络中发送主机将发送缓冲区的数据按内存从低到高地址顺序发出,接收主机保存数据在接收缓冲区从低到高。
TCP/IP协议规定:网络数据流应采用大端字节序,即低地址高字节。
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库中的四个函数做网络字节序和主机字节序的转换,当主机是小端字节序,函数内部才会将参数做转换,否则原封不动返回。
h表示host,n表示network,l表示32位长整数,s表示16位短整数。
socket编程接口API
TCP或UDP,客户端/服务器
创建socket文件描述符:
int socket(int domain, int type, int protocol);
TCP或UDP,服务器
绑定端口号:
int bind(int socket, const struct sockaddr *address,
socklen_t address_len);
TCP,服务器
监听socket:
int listen(int socket, int backlog);
接收请求:
int accept(int socket, struct sockaddr* address,
socklen_t* address_len);
TCP,客户端
建立连接:
int connect(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
sockaddr结构
socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6(网络通信)以及UNIX Domain Socket(域间通信)。在网络中有描述网络地址的结构体sockaddr。
struct sockaddr_in结构体是描述网络通信
sockaddr_un结构体是描述域间通信
两个类型都可以用struct sockaddr 类型表示,在使用接口时进行强制类型转换就行了,因为他们前面16位字段表示地址类型,底层根据16位地址类型进行类型转换就可以适用不同类型的地址了。
在内核代码中可以看到sockaddr_in描述网络通信地址的结构体中有字段表示IP地址和端口号port:
简单UDP网络程序
了解UDP协议
UDP协议是传输层协议
特点:
无连接:不用提前建立连接,类似邮箱,谁都能往邮箱发消息
不可靠传输:与TCP相比没有超时重传等机制保证丢包重传,数据传输是不可靠的
面向数据报:意味着应用层交给UDP多长的报文, UDP原样发送, 既不会拆分, 也不会合并,比如用UDP传输100个字节的数据如果发送端调用一次sendto, 发送100个字节, 那么接收端也必须调用对应的一次recvfrom, 接收100个字节; 而不能循环调用10次recvfrom,每次接收10个字节
支持全双工:读写使用同一个套接字,UDP调用sendto会将数据直接交给内核,内核直接将数据交给网络层,UDP有接收缓冲区,但是不能保证发送报文的顺序和接收的一致,缓冲区满了之后再到达的报文会被丢弃。
简易多人聊天室实现
服务端代码:
1.创建套接字int socket(int domain,int type,int protocol):
参数说明:
domain:域,本地(AF_UNIX)或网络(AF_INET)
type:报文类型,流式(SOCK_STREAM),用户数据报(SOCK_DGRAM)
protocol:协议类型,在网络应用中填充0
返回值:返回一个文件描述符
2.绑定网络信息,先填充协议家族,指明ip和port
先填充基本信息到sockaddr_in网络地址结构体
2.1填充协议家族,域:.sin_family
2.2填充端口号:.sin_port,需用htons转化
2.3填充IP地址sin_addr.s_addr
使用inet_addr函数指定填充确定的IP,内部自动调用hton
IP地址填充INADDR_ANY表示绑定服务器上的所有IP,因为云服务器禁止绑定确定的IP,为了安全性云服务器上的IP是模拟出来的。
IP互相转化函数
inet_ntoa:将四字节ip转化为点分十进制字符串
返回的地址存储在静态存储区,下一次调用的时候会覆盖上一次的结果,这个函数可能是非线程安全函数,取决不同平台的实现
inet_addr:将点分十进制字符串转化成四字节IP,内部自动hton,这个函数未必是线程安全函数
其他IP地址转化函数也类似:
在多线程环境下, 推荐使用inet_ntop, 这个函数由调用者提供一个缓冲区保存结果, 可以规避线程安全问题:
2.4bind网络信息
参数说明:
sockfd:绑定的文件描述符
addr:网络相关的属性信息,包括协议家族、IP地址、端口号等。
addrlen:传入的addr结构体的长度。
3.收发消息
往套接字发送消息:
使用sendto函数往套接字发送消息:
表示往sockfd套接字发送buf中的len长度的内容,flag填0表示像write一样阻塞读取,后两个参数是输入型参数,表示要发送的对端主机信息(IP,端口号)。
从套接字收消息:
这个函数与上面类似,将从sockfd套接字中读取len长度字节数据到buf中,后两个参数主要是做输出型参数,可以从中提取消息来源的主机信息(IP,端口号)。
注意:recvfrom和sendto是专门用于udp收发用户数据报的
实现多人聊天室的功能:让服务器作为数据的中间收发者,客户端发送数据给服务端,服务端再将数据广播给所有(除了发送方)客户端,为了维护多个客户端信息,可以在服务端收取数据的时候提取发送方的主机信息,将其存储在unordered_map中方便广播。
实现日志功能:引入日志,使用可变参数函数实现日志函数,方便格式化输出,顺便包含各式头文件方便服务器代码和客户端代码引用。
在log.hpp中:
#pragma once
#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
#include <stdarg.h>
#include <time.h>
#include <string>
#include <unordered_map>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <pthread.h>
#include <iostream>
#include <unistd.h>
#include <string>
#define DEBUG 0
#define NOTICE 1
#define WARINING 2
#define FATAL 3
using namespace std;
const char *log_level[] = {"DEBUG", "NOTICE", "WARINING", "FATAL"};
void logMessage(int level, const char *format, ...)
{
assert(level >= DEBUG);
assert(level <= FATAL);
char *name = getenv("USER");
char logInfo[1024];
va_list ap;
va_start(ap, format); // 第一个知道类型的参数format
vsnprintf(logInfo, sizeof(logInfo), format, ap); // 以format格式写入logInfo,自动传递参数
va_end(ap); // ap=NULL
FILE *out = level == FATAL ? stderr : stdout;
fprintf(out, "%s | %u | %s | %s\n",
log_level[level],
(unsigned int)time(nullptr),
name == nullptr ? "unknown" : name,
logInfo);
}
服务器代码实现:
#include "log.hpp"
class udpServer
{
private:
uint16_t port_;
string ip_;
int sockfd_;
unordered_map<string, struct sockaddr_in> users_; // ip:port peer
public:
udpServer(int port, string ip = "")
: port_((uint16_t)port), ip_(ip), sockfd_(-1)
{
}
~udpServer() {}
void init()
{
// 1.创建socket,打开文件
sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd_ < 0)
{
logMessage(FATAL, "socket : %s:%d", strerror(errno), sockfd_);
exit(1);
}
// 2.绑定网络信息,指明ip和port
struct sockaddr_in local;
bzero(&local, sizeof(local));
// 2.1填充协议家族,域
local.sin_family = AF_INET;
// 2.2填充服务器对应的端口号
local.sin_port = htons(port_);
// 2.3填充IP地址 //将点分十进制转化成四字节ip
local.sin_addr.s_addr = ip_.empty() ? htonl(INADDR_ANY) : inet_addr(ip_.c_str());
// 2.4绑定网络信息
if (bind(sockfd_, (struct sockaddr *)&local, sizeof(local)) == -1)
{
logMessage(FATAL, "bind : %s:%d", strerror(errno), sockfd_);
}
cout << "create server success" << endl;
}
void start()
{
char inbuffer[1024];
char outbuffer[1024];
while (1)
{
// 往peer写入客户端信息
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t s = recvfrom(sockfd_, inbuffer, sizeof(inbuffer) - 1,
0, (struct sockaddr *)&peer, &len);
if (s > 0)
{
inbuffer[s] = '\0';
}
else
{
logMessage(WARINING, "recvfrom : %s:%d", strerror(errno), sockfd_);
continue;
}
// 取出客户端的ip和port
string peerIp = inet_ntoa(peer.sin_addr);
uint32_t peerPort = ntohs(peer.sin_port);
logMessage(NOTICE, "[%s:%d]# %s", peerIp.c_str(), peerPort, inbuffer);
broadcastMessage(peerIp, peerPort, peer, inbuffer); // 如果是新用户 就先添加用户
}
}
void broadcastMessage(string peerIp, uint32_t peerPort, struct sockaddr_in peer, char *send)
{
string socket = peerIp + ":";
socket += to_string(peerPort);
auto it = users_.find(peerIp);
if (it == users_.end())
users_.insert({socket, peer});
string message="FROM";
message += "[";
message += peerIp;
message += ":";
message += to_string(peerPort);
message += "]";
message += " echo# ";
message += send;
for (auto &user : users_)
{
if (user.first != socket)
{
sendto(sockfd_, message.c_str(), message.size(),
0, (struct sockaddr *)&user.second, sizeof(user.second));
}
}
}
};
int main(int argc, char *argv[])
{
using namespace std;
if (argc != 2 && argc != 3)
{
cout << "Usage:\n\t " << argv[0] << " port [ip]" << endl;
exit(3);
}
uint16_t port = atoi(argv[1]);
string ip;
if (argc == 3)
ip = argv[2];
udpServer svr(port, ip);
svr.init();
svr.start();
return 0;
}
客户端代码:
客户端工作较为简单,直接创建套接字,然后就可以往服务器收发消息了,
客户端不需要bind,指的是不需要用户自己bind端口信息,os会自动bind,而且客户端不能绑定指定端口,因为端口可能被别的客户端使用,导致客户端无法启动,而服务端提供的服务需要被所有人知道,所以不能随便改变端口号,需要显式bind指定端口。
当客户端首次调用sendto函数时,函数内部会自动绑定(bind)。
为了避免多个客户端收发的线程一样导致输入输出的卡顿,创建一个新线程用于收取服务器发来的消息,原来的主线程用于发消息给服务器。
#include "log.hpp"
void *recvAndPrint(void *args)
{
int sockfd = *(int *)args;
char buffer[1024];
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
while (1)
{
ssize_t s = recvfrom(sockfd, buffer, sizeof(buffer) - 1,
0, (struct sockaddr *)&temp, &len);
if (s > 0)
{
buffer[s] = 0;
cout << buffer<< endl;
}
}
return nullptr;
}
struct sockaddr_in server;
int main()
{
string ip = "127.0.0.1";//表示本地环回,给本主机发消息
uint16_t port = 8080;
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
assert(sockfd > 0);
bzero(&server, sizeof server);
server.sin_family = AF_INET;
server.sin_port = htons(port);
server.sin_addr.s_addr = inet_addr(ip.c_str());
// 接收服务器信息的线程
pthread_t t;
pthread_create(&t, nullptr, recvAndPrint, (void *)&sockfd);
// 往服务器发送信息
string buffer;
while (1)
{
cout << "Please Enter# ";
std::getline(std::cin, buffer);
sendto(sockfd, buffer.c_str(), buffer.size(),//首次调用时自动bind
0, (struct sockaddr *)&server, sizeof(server));
}
close(sockfd);
return 0;
}
效果演示(3人通信):因为客户端发消息才会bind,所以后发消息的客户端看不到最先发消息的客户端的前面的消息。