socket基本概念
socket又称为套接字,是一组接口,本身可以理解为一个函数,且对于linux系统而言,它可以被看作是一个文件描述符。那么这个接口具体是哪里的接口呢?又有什么作用呢?
socket与TCP/IP协议
典型的网络应用往往是由以对程序组成的,它们位于两个不同的端系统中,当运行这两个程序时会创建一个客户进程和一个服务器进程,且这两个进程往往运行在不同的主机上。
但是由于每个主机系统都有各自命名进程的方法,且互相往往是不兼容的,这时两个不同系统的主机上的进程就相当于由于语言不同无法沟通的两个不同国家的人一样,所以为了能够进行使得不同系统上的进程可以顺利进行通信,需要引入一套统一的标准,这就相当于秦始皇统一度量衡和语言一样。这就引出了我们的网络协议标准模型——OSI七层模型,或者说时我们的TCP/IP网络协议栈(传输控制协议),具体如下图所示:
以上的每一层都有其对应的协议,在上图中也作标记,而socket也就位于应用层和传输层之间,它可以看作是用户进程与传输层的中间人,如下图所示:
这样的标准使得用户只需要针对socket进行编程即可,不需要研究传递的消息在网络层在光缆上具体如何传输,这也是分层带来的好处之一。
这里需要注意的是由于传输层协议主要是TCP和UDP协议,所以作为应用层和传输层的中间人,socket编程也分为了面向TCP和面向UDP两类。关于TCP和UDP的具体知识非常繁杂,本文就不特别详解这部分的知识了,后续关于这两大协议的知识将会直接作为补充向大家介绍。
socket五要素
socket在本质上是为了不同主机上的进程能够进行通信,就像前文中我们说到往往分为两个进程,即服务器进程和客户端进程,那么在发送相关数据时,往往需要确定是发往哪台主机中的哪个端口,传输过程中服从哪种协议。
我们可以使用发快递来类比理解,卖家可以理解为服务器进程,买家即是客户端进程,发送一批货物首先需要备注好卖家信息和买家信息,其中的本地地址即是卖家店铺所在地址,端口号就相当于卖家名字,同样的远程地址即是买家地址,端口号即是买家名字。而协议大家可以理解为指定快递运送过程中的快递公司。这样就可以将快递从卖家手上发送到买家手上。
宗接下来,socket具有了以下的五要素:
- 协议
- 本地地址
- 本地端口
- 远程地址
- 远程端口
接口流程图
关于TCP的客户端和服务端接口流程图如下图所示:
编写客户端
如我们给出的图,按顺序运行相关函数,首先创建一个socket,然后客户端和服务器端进行连接,连接后就可以读写数据,使用结束后关闭该socket即可。
具体代码如下,其中备注代码相关细节解析:
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
using namespace std;
int main(){
// 创建socket
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(-1 == fd){
perror("socket error");
return 1;
}
struct sockaddr_in addr;
addr.sin_family = AF_INET; // 协议族
// IP地址对应的长整数
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
// 端口号是服务器端的端口号
addr.sin_port = htons(8088); // 将主机序转为网络序
// 做与服务器的连接
// 需要知道服务器的IP地址
int res = connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));;
if(-1 == res){ // 创建链接返回-1为失败
perror("socket error");
return 1;
}
// 写数据
string msg;
cin >> msg;
// 发送数据用write
write(fd, msg.c_str(), msg.size()+1);
// 读数据
char buff[200];
read(fd, buff, 200);
cout << buff << endl;
// 关闭socket
close(fd);
}
编写服务器
如我们给出的图,按顺序运行相关函数,具体代码如下,其中备注代码相关细节解析:
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
using namespace std;
int main(){
// 为服务端创建端口号
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if(-1 == listenfd){
perror("socket error");
return 1;
}
// 初始化地址结构体
struct sockaddr_in addr;
addr.sin_family = AF_INET; // 协议族
addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // IP地址
addr.sin_port = htons(8088); // 端口号
// 绑定
// 将服务器的IP地址和端口号进行绑定
// 客户端连接的IP地址其实就是服务端的主机的IP地址
int res = bind(listenfd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
if(-1 == res){
perror("bind error");
return 1;
}
// 监听
// 10指的是服务器端可以监听的连接个数
// 超过10个以上的连接到大将会被拒绝
res = listen(listenfd,10);
if(-1 == res){
perror("listen error");
return 1;
}
// 阻塞等待连接
// accept后面的参数其实也是个IP地址结构体
// 如果不想要,就可以设置为NULL
// 这个返回值connfd和客户端的fd是一一对应的。
int connfd = accept(listenfd, NULL, NULL);
// 读客户端传来的数据
char buff[200];
read(connfd, buff, 200);
cout << buff << endl;
// 向客户端写数据
string msg;
cin >> msg;
write(connfd,msg.c_str(), msg.size()+1);
close(connfd);
close(listenfd);
}
优化为双工的通信
以上实现的客户端和服务端都有两个阻塞,为了能够让客户端和服务端可以“聊天”,不需要等待对方发送一个信息才能进行下一步读取。
实现思路就是:使用线程处理其中的某一块阻塞,更改为异步操作。
client.cpp
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <thread>
using namespace std;
int main(int argc,char* argv[]) {
// 创建套接字
int fd = socket(AF_INET,SOCK_STREAM,0);
if(-1 == fd) {
perror("socket error");
return 1;
}
// 连接服务器
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr(argv[1]);
addr.sin_port = htons(atoi(argv[2]));
int res = connect(fd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr));
if(-1 == res) {
perror("connect error");
return 1;
}
thread t([=]() {
for(;;) {
char buff[200];
int n = read(fd,buff,200); // 阻塞
if(0 == n) break; // 对方close()
cout << buff <<endl;
}
});
for(;;) {
string msg;
if(cin >> msg) { // 阻塞
write(fd,msg.c_str(),msg.size()+1);
} else {
break;
}
}
close(fd);
}
server.cpp
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <thread>
using namespace std;
int main(int argc,char* argv[]) {
int listenfd = socket(AF_INET,SOCK_STREAM,0);
if(-1 == listenfd) {
perror("socket error");
return 1;
}
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr(argv[1]);
addr.sin_port = htons(atoi(argv[2]));
int res = bind(listenfd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr));
if(-1 == res) {
perror("bind error");
return 1;
}
res = listen(listenfd,3);
if(-1 == res) {
perror("listen error");
return 1;
}
int connfd = accept(listenfd,NULL,NULL);// 阻塞等待连接
thread t([=]() {
for(;;) {
char buff[200];
int n = read(connfd,buff,200); // 阻塞
if(0 == n) break;
cout << buff <<endl;
}
});
for(;;) {
string msg;
cin >> msg; // 阻塞
write(connfd,msg.c_str(),msg.size()+1);
}
close(connfd);
close(listenfd);
}
注意:
- 三次握手发生在客户端的connect中以及服务端的accept中
- 如果客户端close提前退出,那么服务端的read将会得到返回值为0的数据
优化服务器
服务器可接受多次连接
以上的服务器只能接受一个客户端的连接,所以接下来我们实现能够和多个客户端进行连接。
首先关于服务器端如何接受多个客户端的连接,我们需要一直能够accpet
客户端的连接,所以我们给该函数分配一个线程专门来等待处理客户端的连接。
那么在和多个客户端建立连接之后,创建对应客户端的文件描述符connfd就会有多个,所以此时需要一个vector存储这些connfd。
除此以外,在server文件中,我们在accpet连接之后,还创建了一个线程来读从客户端来的数据,此时这个读线程的生存周期只有连接线程中的一轮for循环,所以我们需要管理这些线程,在代码中选择创建一个线程的vector,从而扩大线程的生存周期,使得该线程的生存周期和main函数保持一致。
结束一个连接的时候,需要关闭相应的fd,并在存储fd的容器中移除该fd,在存储线程的相应容器中去掉该fd对应的线程。
具体代码如下:
server.cpp
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <thread>
#include <vector>
#include <map>
#include <list>
using namespace std;
int main(int argc,char* argv[]) {
// 创建套接字
int listenfd = socket(AF_INET,SOCK_STREAM,0);
if(-1 == listenfd) {
perror("socket error");
return 1;
}
// 绑定IP和端口
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr(argv[1]);
addr.sin_port = htons(atoi(argv[2]));
int res = bind(listenfd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr));
if(-1 == res) {
perror("bind error");
return 1;
}
// 设定连接个数
res = listen(listenfd,3);
if(-1 == res) {
perror("listen error");
return 1;
}
list<int> connfds;
map<int,thread> connect; // 保存线程
thread th([&]() {
for(;;) {
// 等待接收连接
int connfd = accept(listenfd,NULL,NULL);// 阻塞等待连接
connfds.push_back(connfd);
thread t([&,connfd]() {
for(;;) {
char buff[200];
int n = read(connfd,buff,200); // 阻塞
if(0 == n){ // 客户端断开连接
close(connfd); // 关闭对应连接
connfds.remove(connfd); // 移除连接
connect.erase(connfd); // 移除连接处理线程ZZ
break;
}
cout << buff <<endl;
}
});
t.detach(); // 系统负责回收线程退出后的资源
connect.insert(make_pair(connfd,move(t)));
// 线程对象不可拷贝,但可以移动
// connect.push_back();
}
});
for(;;) {
string msg;
cin >> msg; // 阻塞
for(auto connfd:connfds)
write(connfd,msg.c_str(),msg.size()+1);
}
// 关闭
for(auto connfd:connfds) close(connfd);
close(listenfd);
}
服务器转发功能
主要是在进行读数据后修改的,修改内容为:
if(0 == n){ // 客户端断开连接
close(connfd); // 关闭对应连接
connfds.remove(connfd); // 移除连接
connect.erase(connfd); // 移除连接处理线程ZZ
break;
} else {
for(auto fd:connfds){ // 向其他的客户端转发数据
if(fd == connfd) continue;
write(fd,buff,n);
}
}
若客户端未退出,服务器就将获取的信息转发给其他客户端。以上我们就实现了多连接和转发功能,这样的一对服务器-客户端其实就是一个小型的聊天室了。