一 预备知识
1 端口号和进程id
主机间的数据传输本质是两个进程在通信,就像是我们打开抖音刷视频,视频不是都保存在手机上的,而是服务器发送给你的,这里就是用到了网络。
那如何保证把数据给指定进程呢? 就是用端口号去标识主机中的进程,是个两字节的数据,16个比特位的整数。而ip+端口号表示互联网中唯一的进程,而两个互联网中唯一的进程间通信就是通过套接字通信。
那端口号不能用进程pid吗? 这样会让进程管理和网络管理耦合度提高,所以就重新设计出了端口号。显然一个端口号只能被一个进程绑定,但一个进程可以关联多个端口号,不过如何理解一个进程绑定多个端口号,暂时没想到使用场景。端口号本质就是一个hash表的下标,下标存着进程pcb指针。
2 初识网络通信
网络的数据是如何给进程的呢? 首先a主机给b主机发消息,然后消息经过有限和无线等传输到了b主机,此时数据该给谁呢,就是通过数据中的目标端口号找到指定进程,直接给进程?不是的,后面实现我们就知道进程访问网络来的数据实际上是访问文件,所以os不是直接把数据给进程的,而是找到进程后找到文件描述符表,通过套接字找到文件,然后找到缓冲区,将数据拷贝到缓冲区中。显然套接字就是一个文件描述符。
2 初识UDP和TCP协议
传输层有两个比较重要的协议,就是TCP/UDP。先来认识认识TCP,TCP协议是自带可靠性的,也就是会在数据传输中如果数据丢失了,会采用不同的策略来保证数据能传输给接收者。是面向字节流。
而UDP协议则是面向用户数据报协议,是个不可靠传输协议,也就是不管数据是否传输成功,直接把数据给下一层就不管了。(什么叫面向数据报和面向字节流,后面我们会理解,反正就是收发单位按一个数据报或者按一个字节来计算)
UDP的意义:使用简单,而且对服务器压力小,不需要数据百分百送到用户手上就可以用udp。
3 网络字节序
大小端:大端,将高权值位放低地址,大端,反之小端。首先数据有高地址和低地址,先发送谁,如果不定下来,对方怎么知道先接收的是高地址还是低地址,好吧,那就规定先发出低地址的数据,这样接收主机就可以按接受顺序还原数据,并且由此规定网络中先发的数据是低地址的,后发的数据是高地址的,
这样还有新问题,当a主机发数据到了b主机上,如果两个主机字节序不一样,在解读网络发来的数据的时候就会出错。因为b主机是按接收顺序,按地址还原的数据,所以字节序不会改变。 例如A主机发出0x123456,会先把低地址上的数据发出去,在网络中排列还是小端,B主机是大端机,B主机在解释这个数据的时候会按大端字节序来解释,就解释反了。而且B主机是无法判断来的数据是大端还是小端,所以我们统一规定网络中的数据序列必须是大端。
协议没办法解决,因为协议本身也是数据,接收数据的主机甚至都无法区分哪里是报头,哪里是报文。
转换使用的库函数
二 socket接口
下面有不少函数,先别急着记忆,后面使用再来理解。
上述函数都有个参数,好像是个结构体,接下来看看这个sockaddr结构体是什么?
这是一个通用的结构体,因为pro 6既想提供主机内通信,又想提供跨网络通信,这些用的都是不用的协议,例如网络通信有ipv4和ipv6标准,它们的ip地址就不一样,所以必定是传递不同的参数,可能要实现不同的接口,为了维持接口的一致,就要先保证传入的类型一样,所以就设计出sockaddr这个通用结构体。
为什么不用void*呢?因为设计时还不支持void*,当支持时已经晚了。
网络通信就用中间这个这个结构体,本地就用右边的那个结构体,调用socket接口函数时都要强转成sockaddr传入,内部会根据前十六位看看是什么类型,然后判断是网络通信还是本地通信。内部拿到指针,还要把数据接收过去,所以就让我们传大小,他们内部先把数据拿过去,然后再做对应类型的强转,我还想着为什么不是内部通过指针访问前十六位,然后就判断大小,就不用我们外部传了,现在想想这其实是另一种设计思路,我想都可以,接下来就实现一个简易的客户端和服务端通信程序来理解理解udp通信。
三 实现服务端
1 初始化服务端
目的:客户端给服务端写数据,然后客户端也能收到服务端发回的数据。
要一次性实现两个可执行文件,makefile就要用下面的格式。all是有依赖关系,无依赖方法,clean是有依赖方法,没有依赖关系。
.PHONY:allall:udp_client udp_server
udp_client:udp_client.ccg++ -o $@ $^ -std=c++11
udp_server:udp_server.ccg++ -o $@ $^ -std=c++11
.PHONY:clean
clean:rm -rf udp_client udp_server
server.cc提供接口如下。
#include<functional>
int main()
{udp->start();return 0;
}
start内部做初始化动作。
先来创建套接字来通信,本质是打开一个文件。
参数介绍;1 :是表明是ip地址类型,因为我们是要网络通信,所以可以传AF_INET或者AF_INET6,有意思的是我们后面bind的时候还要传一次,这是为什么呢?后面提。
参数2是传套接字类型,有流式套接字和数据报式套接字,参数3为协议,如果是TCP,参数2用下图第一个,udp用第二个,也可以传个零,内部会根据参数2来判断参数3要传什么协议。显然不同的通信方式要用创建不同的套接字。
那要创建不同的套接字,给的参数肯定是不同的,如下图,这些参数是要保存起来的,所以创建文件前要提前知道大小给下面的数据开空间,例如网络套接字需要ip,ipv4和ipv6地址大小不一。所以在创建文件前就要说明ip的地址类型。面向字节流的文件和面向数据报的文件据我目前理解是不同的,后面看完协议或许能理解区别,所以在创建时要说明文件是面向字节流的还是面向数据报的,以上我对socket参数的理解。
那什么时候初始化文件传参初始化套接字属性呢? 绑定的时候。
参数1就是上面调用socket返回的文件描述符,第二个参数就是那个通用结构体,第三个参数就是套接字的大小,为什么要传大小也已经提过啦,比较复杂的就是初始化这个结构体。我们只要初始化前三个成员即可。
第一个成员比较有意思,这个宏实际上是在定义一个变量,被替换成sa_family_t sin_family。
sa_family_t类型恰好就是一个十六位的类型,保存的是就是套接字类型标识。我们知道这个标识是用来分清楚传入的指针是sockadd_in还是sockaddr_un,为什么bind还要再传套接字标识符呢,我们不是已经在创建文件前说好套接字类型了吗,那文件属性应该保存了套接字类型了吧,所以bind的时候就不用再传套接字属性。
我认为设计者可以获取文件属性,知道绑定的文件需要什么类型的参数,这样对传入参数直接强转,也可以外部指定类型,内部做判断强转,都可以,只是当时用了后者的实现方式。
而且套接字类型标识怎么不是SOCK_STEREAM和SOCK_DGRAN呢? 为什么我们在初始化传的是AF_INET?
我的理解就是套接字需要ip+端口,还需要标识是面向数据报还是面向字节流,这些都是套接字的属性,实际上属性就是套接字的类型,所以SOCK_STEREAM和SOCK_DGRAN是套接字类型标识,AF_INET和AF_UNIX也被称为套接字类型标识。
而在bind的时候,传的参数一般是ip和端口,例如网络通信要传32的ip地址,而ipv6下要传128位,主机通信又不用传ip地址,所以就用AF_INET和AF_UNIX,AF_INET6来区分,而不是用SOCK_STEREAM和SOCK_DGRAN这两个宏。
sin_port保存的是端口号,有个细节就是我们对这个端口号做了主机转网络序列,显然端口号是要被发送到网络上的。
sin_addr是sock结构体内的结构体,成员就是一个四字节的ip地址,但是我们没有传一个具体的ip地址,首先我这代码就是在云服务器下跑的,云服务器一般不允许用户bind公网ip地址,貌似是和安全有关系,而且直接绑定也不好,首先云服务器有多个ip地址,如果绑定了具体的ip地址,我们写的程序就只能接收固定目标ip地址的数据,如果我们像上图传个INADDR_ANY(全0没必要转网络字节序),此时这个服务器上收到的消息只要是给我们这个进程对应的端口号,那我们就都能接收。我们服务端代码指定端口号这也是个细节,下面会再谈。
所以服务器的大致实现如下。
const static uint16_t default_port = 8080;class UdpServer{public:UdpServer(u_int16_t port = default_port)//缺省参数要从左往右给:_port(port){;}~UdpServer(){}void start(){ //打开网络文件socket_ = socket(AF_INET,SOCK_DGRAM,0);if(socket_ < 0){std::cout<<"create socket error"<<strerror(errno)<<std::endl;exit(SOCKET_ERR);}std::cout<<"create socket successs"<<std::endl;//绑定端口号和ip地址struct sockaddr_in sock;//头文件<netinet/in.h>bzero(&sock,sizeof(sock));sock.sin_addr.s_addr = INADDR_ANY;//设置ip地址 表示所有的ip的地址sock.sin_port = htons(_port);sock.sin_family = AF_INET; if(bind(socket_,(sockaddr*)(&sock),sizeof(sock)) < 0){std::cout<<"bind error "<<strerror(errno)<<std::endl;exit(BIND_ERR);}std::cout<<"bind successs"<<std::endl;}private:int socket_;uint16_t _port;};
前面说了我们写的服务端ip地址由云服务器上的os来绑定,那端口号为什么也是自己指定呢?为什么不能让os随机应变,看到哪个端口号空了就把那个端口号给我的服务端,因为我们服务端这个进程的端口号就像是报警电话,是不能经常变的,所以需要公司对服务器的端口号做统一划分,这样客户端通信的时候就一直都知道服务端的端口号是什么。
注意:exit返回的错误宏,统一封装在err.hpp中。
2 服务端工作函数
前面已经说了服务端如何打开文件,显然网络中来的数据要被放到这个文件内,所以我们接下来就要让服务端从这个文件中读取数据,就是用到下面的recvfrom函数。
从sockfd读数据到buf中,buf长度为len,flag表示读取方式,有阻塞和非阻塞。返回值表示读取的字节。还有两个输入输出型参数意义:因为要记录是谁发过来的数据,所以需要保存对方的ip+端口号,下面会有用的。
当我们可以收数据了,就可以再把数据发给客户端,sendto函数就是发消息的函数,后面两个参数就是表示要给谁发数据,这就是为什么前面recvfrom函数中要传入一个sock结构体的原因,此时就会把我们bind的ip和端口发给客户端。
有意思的是,我们这个sock内部保存的客户端的ip和端口,是我们先前从网络中读取的,此时这个数据是什么序列呢?有兴趣的可以尝试一下,就会发现recvfrom函数没帮我们转成主机序列,所以我们在将sock内的数据要发送到网络时也就不用再转成网络序列了。
所以,按照当前设计就是服务端创建好文件后就死循环地从文件读和写。
四 客户端实现
也要创建套接字来通信
客户端要不要绑定呢? 不用,要os自己指定ip和端口号。为什么不要自己绑定呢?因为我们手机上会多个客户端,如果每个软件设计者都自己指定端口号,那冲突了怎么办?
那服务端的端口号为什么要自己指定呢?免得服务端挂了后端口号就变了,那我们的软件难道都要更新服务端的端口号吗? 所以软件服务端的端口号是固定的,那服务器上不会有很多个进程,多个服务端吗,那会不会别的进程在抢端口号导致某个进程起不来,不会的,这些端口号都会被公司统一管理分配,你有见过哪家微信的服务端部署在阿里上吗,所以不会出现竞争。
可是要发消息用sendto函数的时候,就有点犯难了,如何知道服务器的IP和端口,显然服务器的ip和端口也是设计者一开始传入的。
客户端如果不给服务端发消息,服务端就无法获取客户端的ip和端口,也无法给客户端发消息,客户端也无法获取服务端ip和端口,所以在编写客户端时会传入ip和端口。
./client 服务端ip + port
int main(int argc,char*argv[])
{if(argc != 3){usage(argv[0]);std::cout<<"stage error"<<strerror(errno)<<std::endl;exit(USAGE_ERR);}const std::string ip_ = argv[1];u_int16_t port = atoi(argv[2]);int socket_ = socket(AF_INET,SOCK_DGRAM,0);if(socket_ < 0){std::cout<<"create socket error"<<strerror(errno)<<std::endl;exit(SOCKET_ERR);}std::cout<<"create socket successs"<<std::endl; //打开网络文件//发消息struct sockaddr_in sock;sock.sin_addr.s_addr = inet_addr(ip_.c_str());将字符串类型的地址转为四字节地址,而且是网络字节序了sock.sin_port = htons(port);sock.sin_family = AF_INET;pthread_t id;pthread_create(&id,nullptr,recv,&socket_);while(true){std::string msg;std::cout<<"客户端# "<<std::endl;getline(std::cin,msg);sendto(socket_,msg.c_str(),msg.size(),0,(sockaddr*)&sock,sizeof(sock));struct sockaddr_in tmp;char buffer[1024] = {0};socklen_t len = sizeof(tmp);int ret = recvfrom(socket_,buffer,sizeof(buffer)-1,0,(sockaddr*)&tmp,&len);buffer[ret] = '\0';if(ret)std::cout<<buffer<<std::endl;} return 0;
}
收消息,并且保存服务端的ip和端口,虽然我们已经知道了服务端的ip和端口,但是总不能让内部做判断,不用接收服务端的ip和端口,就为了省几个字节,没必要,本代码比较简单,我们只是先搭个架子,能让服务端收到消息,客户端也能发消息并且收到回信就好了,tmp内容会和msg变量内容一样。
那客户端什么时候绑定的呢,显然是在sendto函数时,使用样例如下。
127.0.0.1是本地环回ip,表示当前主机,填这个ip就是告诉os,数据不用发到网络,你直接按照往网络发的步骤做,在发给网络前停住发回给自己,因为网络通信中一般是不会出错的,所以只要我们的数据能走到往网络发的哪一步,我们的代码就没问题。毕竟我们也不好弄出两台主机。
五 应用1
如何将服务器改成群聊模式,也就是任意一个人发送的消息,结果要给每一个人。这里我们就用到了多线程模式,一个线程收消息,一个线程发消息给所有的用户,这其实就是一个生产消费者模型,而缓冲区应该放什么呢?收到的消息。而我们先前就实现过了一个环形队列CirQueue,现在可以直接拿来用了。所以我们在服务端增加了两个线程,这两个线程也是我先前封装的小组件,我们后面讲怎么用,并且把代码贴出来,大家看看内部实现即可。
online_用来保存用户的套接字信息,方便发消息给所有用户。为什么要保存构建用户名,后面方便输出提示显示是谁发的。
添加用户
void adduser(std::string name, struct sockaddr_in sock){{LockGuard lock(&mutex_);if(!online_.count(name))//返回1:存在,返回0:不存在online_[name] = sock;}}
线程接收数据。
//开始接受数据void Recv(){while(true){struct sockaddr_in sock;char buffer[1024] = {0};socklen_t len = sizeof(sock);int n = recvfrom(socket_,buffer,sizeof(buffer)-1,0,(sockaddr*)&sock,&len);buffer[n] = '\0';std::cout<<"port_ "<<sock.sin_port<<" ip "<<sock.sin_addr.s_addr;std::string clientip = inet_ntoa(sock.sin_addr);u_int16_t clientport = ntohs(sock.sin_port);std::cout<<"port_ "<<clientport<<" ip "<<clientip;std::string name;name += clientip;name += "-";name += to_string(clientport);name += "#";adduser(name,sock);name += buffer;推送到环形队列rp_.push(name);std::cout<<clientip<<"-"<<clientport<<"# "<<buffer<<std::endl;//输出处理前的字符//上面是在接收客户端的ip和端口号// sendto(socket_, name.c_str(),name.size(),0,(sockaddr*)&sock,len);//不用转网络序列,是因为我们接收的时候就没转成主机序列}}
开始广播,负责调用sendto函数发消息。
void broadcast()//负责给所有的客户端发消息{while(true){std::string msg;rp_.pop(msg);//获取一条消息std::vector<sockaddr_in> tmp;{LockGuard lock(&mutex_);for(auto e : online_){tmp.push_back(e.second);}}for(auto e : tmp){sendto(socket_, msg.c_str(),msg.size(),0,(sockaddr*)&e,sizeof(e));
//不用转网络序列,是因为我们接收的时候就没转成主机序列}}}
不用给队列加锁,环形队列内部会用信号量和锁做保护,访问online_要加锁保护,所以我在类内又增加一个锁成员mutex_。
在加锁时又用到了之前封装的加锁接口,此时直接把锁传入,内部会自行加锁,这对{}一点都不多余,是为了给锁划分作用域。
可是为什么要拷贝一份再sendto呢,这里就非常巧妙了,因为我们如果是直接访问online_,那就要先加锁,那sendto也在加锁内,sendto又不会有线程安全的问题,为了符合加锁区域越小越好,所以就设计了这样的代码,在获取用户ip时加锁,免得另一个线程来添加,获取完后就可以直接发消息了。
环形队列实现。
template<class T>
class CirQueue
{
public:CirQueue(int n = SIZE):vt_(n),head_(0),tail_(0){sem_init(&Consumer_,0,0);sem_init(&Productor_,0,n);pthread_mutex_init(&Con_mutex,nullptr);pthread_mutex_init(&Pro_mutex,nullptr);}~CirQueue(){sem_destroy(&Consumer_);sem_destroy(&Productor_);pthread_mutex_destroy(&Con_mutex);pthread_mutex_destroy(&Pro_mutex);}void Lock(pthread_mutex_t& mutex){pthread_mutex_lock(&mutex);}void Unlock(pthread_mutex_t& mutex){pthread_mutex_unlock(&mutex);}void push(const T& data){sem_wait(&Productor_);//申请信号量tail_ = tail_ % vt_.size();Lock(Con_mutex);//加锁vt_[tail_++] = data;Unlock(Con_mutex);//解锁sem_post(&Consumer_);//释放信号量}void pop(T& data){sem_wait(&Consumer_);head_ = head_ % vt_.size();Lock(Pro_mutex);data = vt_[head_++];//多线程访问会出问题,这里还要加锁,因为信号量只是起到限制执行流数量Unlock(Pro_mutex);sem_post(&Productor_);}
public:std::vector<T> vt_;int head_;int tail_;sem_t Consumer_;sem_t Productor_;pthread_mutex_t Con_mutex;pthread_mutex_t Pro_mutex;
};
线程创建封装
class Thread
{
public:typedef enum{NEW = 1,RUNING,EXIT}status;// typedef void* (*fun_t)(void*);using fun_t = std::function<void()>;Thread(){;}Thread(int num,fun_t fun):id_(0),status_(NEW),fun_(fun){name_ = "thread->" + std::to_string(num);}~Thread(){;}static void * threadRun(void*arg){Thread* th = (Thread*) arg;// th->fun_(th->arg_);th->fun_();return nullptr;}void Run(){int n = pthread_create(&id_,nullptr,threadRun,(void*)this);if(n != 0)//成功返回0,不成功返回错误码exit(4);status_ = RUNING; }void join(){pthread_join(id_,nullptr);status_ = EXIT;}std::string getname(){return name_;}int getstatus(){return status_;}pthread_t getid(){if(status_ == RUNING)return id_;else{std::cout<<name_<<" not create ";return 1;} }pthread_t id_;std::string name_;//线程名status status_;//线程状态fun_t fun_;//线程执行函数void* arg_;//线程参数
};
我们前面添加了不少成员,构造和析构函数也有不少变化,构造函数,Thread的构造函数很简单,一个数字用来给内部做线程名,还有个可调用对象。
释放锁和回收线程。
start函数内让两个线程跑起来。