一、预备知识
1.1 理解网络通信的本质
问题1:在进行网络通信的时候,是不是我们的两台机器在进行通信呢??
——>思考一下我们打开qq软件,他属于应用层,完成了数据的发送和接受……
1、用户使用应用层软件,完成数据的发送和接受
2、网络协议的中下三层,主要解决的是将数据安全可靠的送到远端机器
而要使用软件进行通信,就得先把这个软件启动起来,也就是进程,所以网络通信的本质就是进程间通信!!只不过是不同主机下的进程!!
1.2 理解IP地址和端口号
既然我们要进行两个主机之间的进程间通信,那么我们就要知道如何找到对方吧! 所以我们需要有IP地址来作为寻找主机的唯一标识。
可是光有IP地址就能完成通信了呢??想象一下发qq消息的例子,有了IP地址能够把消息发送到对方的机器上,可是对方机器有那么多进程,我怎么知道我要发给哪个进程呢??因此我们还需要有一个标识来区分出信息要交给哪个进程,所以我们需要有端口号!!
端口号(port)是传输层协议的内容.
端口号是一个2字节16位的整数;
端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理;
一个端口号只能被一个进程占用.
所以IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;
传输层协议(TCP和UDP)的数据段中有两个端口号, 分别叫做源端口号和目的端口号. 就是在描述 "数据是谁发的, 要 发给谁"
1.3 端口号VS进程ID
我们之前在学习系统编程的时候, 学习了pid表示唯一一个进程; 此处我们的端口号也是唯一表示一个进程. 那么这 两者之间是怎样的关系?
问题1:pid已经能够标识一台主机上进程的唯一性了,为什么要需要搞一个端口号
-——>(1)首先从技术角度绝对是可以的,但是如果我们把网络和系统都用这个pid,那么一旦系统改了,网络也要跟着改(牵一发而动全身),所以不如单独设计一套专属于网络的数据来让系统和网络功能解耦(有点像生活中,我们有唯一的身份证,但是在学校要有学号,在公司要有工号) ,也就是说,我们存在的意义相似,但这并不代表我就得和你一样!!(2)不是所有的进程都需要网络通信,但是所有的进程都要有pid
问题2:可是我们的客户端怎么知道服务端的端口号是多少呢?
-——>所以端口号必须是众所周知、精心设计、被用户所知晓的!!app和服务端都是一个公司开发的,所以端口号肯定提前已经被内置进去了,我们用户并不需要关心,我们只需要知道打开了这个app然后发送请求,对方就一定可以收到 所以客户端默认必须得知道服务端的端口号,所以无法通信!!
问题3:一个进程可以绑定多个端口号么?一个端口号可以绑定多个进程么?
--——> 一个进程可以绑定多个端口号!但是一个端口号不能绑定多个进程!因为我们只是想在网络通信的时候能通过端口号找到进程就行了!!
1.4 TCP vs UDP
TCP(Transmission Control Protocol 传输控制协议)
传输层协议
有连接
可靠传输
面向字节流
UDP(User Datagram Protocol 用户数据报协议)
传输层协议
无连接
不可靠传输
面向数据报
问题1:有连接和无连接怎么理解?
——>连接就好比我们打电话的时候,会先“喂”,其实就是确保连接了之后我们的沟通才是有效的,而无连接就好比我们发送邮件,要么不发要么就发一整块,反正我发了就行,至于你收到没有我并不关心!
问题2:为什么tcp看起来比udp好这么多,那为啥udp还得存在呢??
——>计算机中很多词语都是中性的,并无褒贬之意(比如可靠和不可靠) ,就好比我们物理学的惰性气体,只不过是在描述他的特征而已!
可靠是有成本的,而不可靠会更简单,比如tcp为了可靠所以要能够制定一个策略能够知道数据包是否丢失然后进行重传,而你在数据包丢出之后在还没确保传输成功之前你必然会把报文信息在传输层一直维护着,否则你拿什么重传呢??又或者需要重传几次呢??万一数据乱序了你是不是还得排序、编序号?? 而udp就是一拿到数据包就转手往下扔,他也懒得维护报文信息,因为他压根不关心数据包的发送情况。
注意,可靠的前提是网络必须连通!!
1.5 网络字节序
我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?
发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.
不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据;
如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可;
问题:为什么要有网络字节序呢??
——>在网络还没出现之前,就已经有大端和小端之说了,但是他们谁也说服不了谁,因为都没有一个很成熟的方案能够证明大端好还是小端好,而且当时这个标准即使制定了也没啥收益,所以大家并没有这个动力,因此在市面上大端和小端的机器都是有的!!在以往单机的情况下,其实都不会有很大的影响
但是后来网络产生后,通信双方并不清楚对方是小端还是大端,所以在解析对方的信息的时候就会出现问题(因为大端和小端的解析方法不一样),从而导致发送方和接收方数据不一致的问题。
所以网络说:既然我无法改变你们,那么我就做个规定,我发送报文的时候必须包含当前机器是大端还是小端的信息,这样对方在收到这个数据包之后就可以根据这个字段来采取不同的解析方法。
可是这样也是不行的!!因为大端还是小端决定了解析的方法,所以即使你在报文里提示了当前数据是大端还是小端,我的解析方式如果是错的我也压根提取不到!!!
所以我们网络又说了:既然这样,那我规定不管你机器是大端还是小端的,只要你把这个数据发到网络上,那就必须得是大端的!!所以这就要求小端机器如要想要进行网络通信,就必须得先把自己的数据变成大端的才能往网络里发!!
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。
这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
例如:htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回 ;
如果主机是大端字节序,这些 函数不做转换,将参数原封不动地返回。
1.6 socket接口
socket 常见API
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address, socklen_t address_len);
// 开始监听socket (TCP, 服务器)
int listen(int socket, int backlog);
// 接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address, socklen_t* address_len);
// 建立连接 (TCP, 客户端)
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
1.7 套接字的种类
所谓套接字 (Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。
套接字的种类:
1、域间套接字(同一个机器内) struct sockaddr_un
2、原始套接字(网络工具)
原始套接字一般不关心传输层的东西,他一般是绕过传输层去考虑网络层和链路层,所以他一般被用来封装一些网络工具:比如网络装包、网络诊断……
3、网络套接字(用户间通信)struct sockaddr_in
但是我们想将网络接口统一抽象化,所以参数的类型必须是统一的!!
所以我们统一是用sockaddr这个类型,然后根据他的前16位来分辨他是哪一种类型的套接字,所以在使用接口的时候要做一个强转
问题:为什么要用sockaddr这个结构,用void*不好吗?C语言不是经常用他来做任意类型转换吗?然后我们再用short强转一下不就拿到前面的数据了吗??
——>因为网络接口出来的时候C语言的标准还没有void* 之后再想改也很难改回来了!!
二、UDP
2.1 服务端
所需要的类型:
int _sockfd;//创建套接字对应的文件描述符
uint16_t _port;//端口号
string _ip;//服务端的ip地址 bind 0 表示绑定任意地址
bool _isrunning;//服务端是否正在运行
2.1.1 Init-创建服务端
注意:其实可以在构造函数里做 但是我们平时尽量不要在构造函数里做一些有风险的事情!!
1、首先要创建套接字
第一个参数 是套接字的域,AF_LOCAL是本地的,AF_INET是网络ipv4的
第二个参数 是套接字的类型 SOCK_STREAM是面向字节流的(TCP),SOCK_DGRAM是面向数据报的(UDP)
第三个参数 是协议类型 目前默认为0
返回值是如果创建成功返回文件描述符 (相当于是一个可以写入网络的一个文件),如果创建失败返回-1
2、绑定套接字
第一个参数是 文件描述符
第二个参数是 套接字的类型 类型是sockaddr*
第三个参数是 套接字类型的长度 类型是socklen_t
返回值:如果成功了就返回0,如果绑定失败就返回-1
问题:输入型参数的sockaddr类型
(1)我们先创建出来之后,然后可以用bzero(有点像C语言的memset)将指针内容先清空然后再填充
(2)local.sin_family 表明这个通用类型是属于网络套接字还是域间套接字
(3)local.sin_port 端口号
因为端口号必须在两个主机之间流通,所以必须传输到网络中!!因此要转成字节序!!
(4)local.sin_addr.s_addr ip地址 但是我们用户一般习惯输出的是 点分十进制,所以我们必须把他转化成 4字节类型的整数
问题:如何快速将整数IP和字符串IP相转化??
但是我们的库里面提供了这样的方法!!
(5)最后再bind绑定一下!
全部代码:
void Init()//创建服务器
{
//1、首先第一步是创建套接字 第一个是套接字的域 第二个是面向数据段 第三个是协议类型
_sockfd=socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd<0)//创建失败
{
lg(Fatal,"socket creat error,sockfd:%d",_sockfd);
exit(SOCKET_ERR);
}
lg(Info,"socket creat success,sockfd:%d",_sockfd);
//2、 绑定套接字 (要先把里面的字段给初始化了)
//先初始化一下字段
struct sockaddr_in local; //创建套接字类型
bzero(&local, sizeof(local)); //将类型都清空 然后我们再填
local.sin_family=AF_INET;//family是用来表明这个类型是网络套接字还是域间套接字
local.sin_port=htons(_port);//端口号必须要先变成网络字节序
local.sin_addr.s_addr=inet_addr(_ip.c_str());// inet_addr () 函数的作用是将点分十进制 的IPv4地址转换成网络字节序列的长整型。
//bind绑定一下
if(bind(_sockfd,(const struct sockaddr *)&local, sizeof(local)) < 0)// socelen_t 类型
{
lg(Fatal, "bind error, errno: %d, err string: %s", errno, strerror(errno));
exit(BIND_ERR);
}
lg(Info, "bind success, errno: %d, err string: %s", errno, strerror(errno));
}
2.1.2 Run-服务器启动
1、先将客户端的套接字信息收回来,拿到客户端的信息
因为udp不是面向字节流的,所以只能用recvfrom接口
第一个参数是服务器文件描述符fd
第二个是接受客户端发送给我们的数据
第三个是数据的大小
第四个是接收的方式,0表示阻塞接收
第五个和第六个是输出型参数,将客户端发来的套接字信息拿到(可以获取客户端的ip和端口号)
2、将接受到的数据加工一下然后再发回给客户端
第五个和第六个是输入型数,通过客户端套接字信息将处理后的数据发送过去
当然,其实这里我们其实可以将这些数据交给一个回调函数去处理
全部代码:
void Run(func_t func) // 启动服务器
{
_isrunning = true;
char inbuffer[size];
while (_isrunning) // 肯定服务器要一直跑
{
// 1、第一步是要将客户端的套接字信息(输出型参数)收回来
struct sockaddr_in client;
socklen_t len = sizeof(client);
ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&client, &len);
if (n < 0)
{
lg(Warning, "recvfrom error, errno: %d, err string: %s", errno, strerror(errno));
continue;
}
// 2、我们将收回来的信息当成字符串加工一下 然后返回给客户端
inbuffer[n] = 0;
std::string info = inbuffer;
std::string echo_string = func(info);
sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (const sockaddr *)&client, len);
}
}
2.1.3 关于port和ip
port:[0-1023]一般是系统内定的端口号,有固定的应用协议使用
ip:禁止直接bind公网ip (在虚拟机上可以)(因为服务端的机器可能会有多个网卡、多个ip,所以我们不能只绑定一个!!)
2.1.4 查看当前的网络状态
2.1.5 环回地址
2.1.6 地址转换函数
只介绍基于IPv4的socket网络编程,sockaddr_in中的成员struct in_addr sin_addr表示32位 的IP地址
但是我们通常用点分十进制的字符串表示IP 地址,以下函数可以在字符串表示 和in_addr表示之间转换;
字符串转in_addr的函数:
in_addr转字符串的函数:
其中inet_pton和inet_ntop不仅可以转换IPv4的in_addr,还可以转换IPv6的in6_addr,因此函数接口是void *addrptr。
实例:
2.1.7 关于inet_ntoa
inet_ntoa这个函数返回了一个char*, 很显然是这个函数自己在内部为我们申请了一块内存来保存ip的结果. 那么是 否需要调用者手动释放呢?
man手册上说, inet_ntoa函数, 是把这个返回结果放到了静态存储区. 这个时候不需要我们手动进行释放.
那么问题来了, 如果我们调用多次这个函数, 会有什么样的效果呢? 参见如下代码:
因为inet_ntoa把结果放到自己内部的一个静态存储区, 这样第二次调用时的结果会覆盖掉上一次的结果.
在APUE中, 明确提出inet_ntoa不是线程安全的函数;(多线程可能会出问题)
但是在centos7上测试, 并没有出现问题, 可能内部的实现加了互斥锁;
在多线程环境下, 推荐使用inet_ntop, 这个函数由调用者提供一个缓冲区保存结果, 可以规避线程安全问 题;
2.2 UDP客户端
1、创建客户端的套接字
问题:client要bind吗??
——> 要!只不过不需要用户显示的bind!一般有OS自由随机选择!(因为我们多个app的客户端都会在同一个手机上,如果需要自己绑定的话,那么还需要各大开发商互相协商,因此我们都同意由OS来给我们随机分配)
一个端口号只能被一个进程bind,对server是如此,对于client,也是如此!
其实client的port是多少,其实不重要,只要能保证主机上的唯一性就可以!
系统什么时候给我bind呢?首次发送数据的时候
2、用户输入数据,然后将数据和套接字类型发给服务端
3、从服务端将信息接收回来
代码:
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using namespace std;
void Usage(std::string proc)
{
std::cout << "\n\rUsage: " << proc << " serverip serverport\n"
<< std::endl;
}
// ./udpclient serverip serverport
int main(int argc,char* argv[]) //必须知道服务器的ip和端口号
{
if(argc!=3)
{
Usage(argv[0]);
exit(0);
}
string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
//1、第一步 创建套接字
int sockfd=socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd<0)
{
cout << "socker error" << endl;
return 1;
}
//传服务器套接字类型
struct sockaddr_in server;//输出型参数
bzero(&server, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport); //?
server.sin_addr.s_addr = inet_addr(serverip.c_str());
socklen_t len = sizeof(server);
//将数据发给服务端 然后再接受回来
string message;
char buffer[1024];
while(true)
{
cout<<"please enter@";
getline(cin,message);
// 1. 数据 2. 给谁发 目的机
sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&server, len);
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t s = recvfrom(sockfd, buffer, 1023, 0, (struct sockaddr*)&temp, &len);//输出型参数
//会将服务端的套接字带回来
if(s > 0)
{
buffer[s] = 0;
cout << buffer << endl;
}
}
}
2.3 主函数
将外部的方法传进去
#include"UdpServer.hpp"
#include <memory>
void Usage(std::string proc)
{
std::cout << "\n\rUsage: " << proc << " port[1024+]\n" << std::endl;
}
string Handler(const string &str)
{
std::string res = "Server get a message: ";
res += str;
std::cout << res << std::endl;
return res;
}
int main(int argc,char*argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(0);
}
uint16_t port = std::stoi(argv[1]);
unique_ptr<UdpServer> svr(new UdpServer(port));
svr->Init();
svr->Run(Handler);
return 0;
}
要先在云服务器那里开放一下端口
这样就可以进行通信了!!
2.4 模拟云服务器命令输入
我们从客户端那里获得的命令肯定不仅仅只是简单字符串接受,我们还可以根据传过来的字符串当成是命令!!
有一个函数是popen
#include"UdpServer.hpp"
#include <memory>
#include <vector>
void Usage(std::string proc)
{
std::cout << "\n\rUsage: " << proc << " port[1024+]\n" << std::endl;
}
string Handler(const string &str)
{
std::string res = "Server get a message: ";
res += str;
std::cout << res << std::endl;
return res;
}
bool SafeCheck(const std::string &cmd)
{
int safe = false;
vector<string> key_word = {
"rm",
"mv",
"cp",
"kill",
"sudo",
"unlink",
"uninstall",
"yum",
"top",
"while"
};
for(auto &word : key_word)
{
auto pos = cmd.find(word);
if(pos != std::string::npos) return false;
}
return true;
}
std::string ExcuteCommand(const std::string &cmd)
{
// SafeCheck(cmd);
FILE *fp = popen(cmd.c_str(), "r");
if(nullptr == fp)
{
perror("popen");
return "error";
}
std::string result;
char buffer[4096];
while(true)
{
char *ok = fgets(buffer, sizeof(buffer), fp);
if(ok == nullptr) break;
result += buffer;
}
pclose(fp);
return result;
}
int main(int argc,char*argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(0);
}
uint16_t port = std::stoi(argv[1]);
unique_ptr<UdpServer> svr(new UdpServer(port));
svr->Init();
svr->Run(ExcuteCommand);
return 0;
}
2.5 实现聊天室+多线程
1、我们需要有一个ip来标识消息是谁发的(其实我们服务端是可以拿到客户端的ip地址的!)
2、群聊的话,我们客户端发的消息必须所有人都得看到,所以我们需要在服务端维护一张用户列表(有人发言的似时候先检查一下是不是在用户列表里,如果不在的话就新增一下),然后当服务端接收到消息的时候要把所有的消息再广播给其他客户端。
(1)检查并添加新用户
void CheckUser(const struct sockaddr_in &client, const string clientip, uint16_t clientport)
{
auto iter = online_user_.find(clientip);
if (iter == online_user_.end())
{
online_user_.insert({clientip, client});
std::cout << "[" << clientip << ":" << clientport << "] add to online user." << endl;
}
}
(2)将消息广播给所有客户端
void Broadcast(const string &info, const string clientip, uint16_t clientport)
{
for (const auto &user : online_user_)
{
std::string message = "[";
message += clientip;
message += ":";
message += std::to_string(clientport);
message += "]# ";
message += info;
socklen_t len = sizeof(user.second);
sendto(_sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)(&user.second), len);
}
}
但是这样是有问题的,因为我们的客户端必须先进行getline之后才会收到其他的群聊信息,可是如果我们长时间不说话的话我就会一直看不到其他的群聊信息,就会被阻塞到getline中,因此我们这里必须要用多线程,然后一个线程发, 一个线程收,这样发一旦阻塞住了才不会影响到收!!
所以我们的客户端要改成多线程版的
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <strings.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h>
#include "Terminal.hpp"
using namespace std;
void Usage(std::string proc)
{
std::cout << "\n\rUsage: " << proc << " serverip serverport\n"
<< std::endl;
}
struct ThreadData
{
struct sockaddr_in server;
int sockfd;
std::string serverip;
};
void *recv_message(void *args)
{
// OpenTerminal();
ThreadData *td = static_cast<ThreadData *>(args);
char buffer[1024];
while (true)
{
memset(buffer, 0, sizeof(buffer));
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t s = recvfrom(td->sockfd, buffer, 1023, 0, (struct sockaddr *)&temp, &len);
if (s > 0)
{
buffer[s] = 0;
cerr << buffer << endl;
}
}
}
void *send_message(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
string message;
socklen_t len = sizeof(td->server);
std::string welcome = td->serverip;
welcome += " comming...";
sendto(td->sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&(td->server), len);
while (true)
{
cout << "Please Enter@ ";
getline(cin, message);
// std::cout << message << std::endl;
// 1. 数据 2. 给谁发
sendto(td->sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&(td->server), len);
}
}
// 多线程
// ./udpclient serverip serverport
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(0);
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
struct ThreadData td;
bzero(&td.server, sizeof(td.server));
td.server.sin_family = AF_INET;
td.server.sin_port = htons(serverport); //?
td.server.sin_addr.s_addr = inet_addr(serverip.c_str());
td.sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (td.sockfd < 0)
{
cout << "socker error" << endl;
return 1;
}
td.serverip = serverip;
pthread_t recvr, sender;
pthread_create(&recvr, nullptr, recv_message, &td);
pthread_create(&sender, nullptr, send_message, &td);
pthread_join(recvr, nullptr);
pthread_join(sender, nullptr);
close(td.sockfd);
return 0;
}
2.6 聊天窗口
我们希望能够实现聊天窗口 在下面发消息,在上面看到全部的消息
通过这个可以知道当前的终端是哪个文件
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
std::string terminal = "/dev/pts/1";
int OpenTerminal()
{
int fd = open(terminal.c_str(), O_WRONLY);
if(fd < 0)
{
std::cerr << "open terminal error" << std::endl;
return 1;
}
dup2(fd, 2);
// printf("hello world\n");
// close(fd);
return 0;
}
然后该线程的接受到的消息都会往另一个终端上面写,这样就实现输出和输出分离了!!
还有一种更为简单的做法