一、预备知识
1.源IP地址、目的IP地址
源IP地址指的就是发送数据包的那个主机的IP地址。
目的IP地址就是想要发送到的那个主机的IP地址。
IP地址可以标识全网内唯一的一台主机。
2.端口号(port)
端口号(port)是传输层协议的内容。
- 端口号是一个2字节、16比特位的整数。
- 端口号用来标识一个进程,告诉操作系统当前数据要交给哪一个进程来处理。
- IP地址 + 端口号能够唯一标识网络上的某一台主机的某一个进程。
- 一个端口号只能被一个进程占用。
任何的网络服务或网络客户端,如果要进行正常的数据通信,必须要使用端口号来唯一标识自己。一个进程可以与一个端口号绑定,再加上主机IP地址该端口号就在网络层面上唯一标识一台主机上的唯一一个进程。
这种IP+port标识的方案叫做socket通信。
PID vs PORT
一台机器上会存在大量的进程,为了区分所有的进程,设计了PID来加以区分(系统的概念);但是只有部分进程需要进行网络数据请求,所以用port来标识这些需要进行网络数据请求的进程(网络的概念)。
这类似于身份证号可以唯一标识每一个人,但是在学校里又用学号来唯一标识每一个人。身份证号可以看做PID,学号可以看做port,它们之间并不冲突,都是在各自场景下最合适的管理方案。
3.TCP、UDP
这里仅很简略地介绍两个协议,之后会详细讲解。
(1)TCP
- 传输层协议
- 有连接
- 可靠传输
- 面向字节流
(2)UDP
- 传输层协议
- 有连接
- 可靠传输
- 面向字节流
4.网络字节序
内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏
移地址也有大端小端之分,网络数据流同样有大端小端之分。那么如何定义网络数据流的地址呢?
TCP/IP协议规定:网络数据流应采用大端字节序,即低地址高字节。
所以不管主机是大端机还是小端机,都会按照TCP/IP规定的网络字节序来发送/接收数据;如果当前发送主机是小端,就需要先将数据转成大端;否则就忽略,直接发送即可。
函数
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。
这些函数名很容易理解,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
例如htonl表示将32位的长整数从主机字节序转换为网络字节序后,从主机向网络发送。如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
5.IP地址的表示
一般IP地址会表示为点分十进制字符串的风格,即"192.168.230.125"这样,且每一部分数据的范围是0~255。
但IPV4通常是32比特位的数据,而注意到上面每一部分数据的范围,所以将每个数字都用8比特位来表示,整体刚好用一个32比特位的无符号整数来表示。
二、socket编程函数
下面的这些函数在这里先从理论上解释一下,后面代码中基本都会实际用到,也会附有相应的注释,所以最好先向下看代码,结合代码来看函数。
1.socket
该函数打开一个网络通讯端口,如果成功,就像open()一样返回一个文件描述符。
(1)domain
这个选项是域,也就是各种协议,有如下选项。对于IPv4,该参数指定为AF_INET。
(2)type
即服务类型,有如下选项,其中SOCK_STREAM对应TCP,SOCK_DGRAM对应UDP,下面的代码主要用这两种来编写。
(3)protocol
即协议类别,一般设置为0即可,因为该函数会通过前两个参数自动推导出第三个参数的协议类别。
(4)返回值
注意它的返回值是一个文件描述符。成功返回对应的文件描述符,失败返回-1。
2.bind
服务器程序所的网络地址(IP地址)和端口号(port)通常是固定不变的,而客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接;服务器需要调用bind绑定一个固定的网络地址和端口号。
(1)sockfd
这个参数传入上面socket的返回值即可。
(2)addr
这是一个sockaddr类型的结构体。
socket相关接口是一层抽象的网络编程接口,适用于各种底层网络协议,包括IPv4、IPv6、以及后面要讲的UNIX Domain Socket等等。然而, 各种网络协议的地址格式并不相同。
如下面三种地址格式。
这里查看先查找sockaddr_in的定义所在的位置,然后查看其内容:
虽然接口的参数是sockaddr,但是下面基于IPv4编程时,使用的数据结构是sockaddr_in。
(3)addrlen
传入第二个参数addr的大小即可。
3.recvfrom
从sockfd中读数据,读入的内容放入buf内,期望读到的个数是len,实际读到的个数作为返回值返回,flags是读取的方式,可设为0;剩下的src_addr和addrlen是表示发送内容的对端addr的相关信息(类似于上面函数sockaddr_in的内容,但这里是sockaddr,还略有区别)。
4.不同类型IP地址之间的转换函数
5.sendto
从参数来看和recvfrom基本相同,所以用法也很像。
向sockfd中写如buf内的数据,期望写入的个数是len,实际写入的个数作为返回值返回,flags是读取的方式,可设为0;剩下的src_addr和addrlen是表示发送内容的对端addr的相关信息(类似于上面函数sockaddr_in的内容,但这里是sockaddr,还略有区别)。
三、简单的UDP网络程序
下面的四段代码重点在两段实现类的hpp文件中,新用到的函数更是重中之重;两段主函数逻辑比较简单,主要用来启动服务器和客户端。
1.服务器实现
主要就是实现一个UdpServer类,重要的内容(主要是函数调用)都附在注释中。
//udp_server.hpp
#pragma once
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <unistd.h>
#define DEFAULT 8081
using namespace std;
class UdpServer
{
public:
UdpServer(string _ip, int _port = DEFAULT)
{
ip = _ip;
port = _port;
sockfd = -1;
}
//初始化服务器
bool InitUdpServer()//返回值判断是否有错误
{
//创建套接字
//Ipv4用AF_INET
//tcp协议用SOCK_DGRAM
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)//创建失败就打印并返回false
{
cerr << "socket error" << endl;
return false;
}
//创建套接字成功
cout << "socket create success, sockfd : " << sockfd << endl;
//用sockaddr族中的sockaddr_in来绑定(bind)
struct sockaddr_in local;
memset(&local, '\0', sizeof(local));
//该结构体共有三个成员变量
//1.sin_family
//以AF_INET为例
local.sin_family = AF_INET;
//2.sin_port
//port需要发送到网络上,以保证别的主机也能找到
//所以在传入时需要保证网络字节序
//又由于port只需要用到低16位,所以函数名的最后一个字母是s,而不是l
local.sin_port = htons(port);
//3.sin_addr
//sin_addr结构体内只含一个参数s_addr
//这是ip地址的整数ip,但传入的ip一般是点分十进制表示的字符串
//所以需要用inet函数来将其转化,但这个函数需要的参数是char*,所以用传入c_str()
local.sin_addr.s_addr = inet_addr(ip.c_str());
//绑定端口号
if(bind(sockfd, (const struct sockaddr*)&local, sizeof(local)) < 0)//返回值小于0说明有错误,返回false
{
cerr << "bind error" << endl;
return false;
}
cout << "bind success" << endl;
return true;
}
#define SIZE 128
void Start()//启动服务器
{
char buffer[SIZE];//读取收到的内容
for(;;)//服务器要死循环地执行功能
{
struct sockaddr_in addr;//创建一个结构体,可以从recvfrom获取到对端主机的信息
socklen_t len = sizeof(addr);//该结构体的大小
//调用recvfrom函数
ssize_t sz = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&addr, &len);
if(sz > 0)//读取成功
{
buffer[sz] = '\0';
//将port转化形式后得到
int _port = ntohs(addr.sin_port);
//将整数形式的ip转化为点分十进制的字符串得到
string _ip = inet_ntoa(addr.sin_addr);
cout << _ip << " : " << _port << "# " << buffer << endl;
}
else
{
cerr << "recvfrom error" << endl;
}
}
}
~UdpServer()
{
if(sockfd >= 0)
close(fd);
}
private:
string ip;
int port;
int sockfd;
};
2.服务器主函数
主函数主要是调用udp_server.hpp中的定义,使服务器运行起来。
#include "udp_server.hpp"
int main(int argc, char* argv[])
{
//希望传入的命令行参数是两个
//第一个是运行可执行程序
//第二个传入端口号port
if(argc != 2)
{
cerr << "usage : " << argv[0] << " port" << endl;
return 1;
}
string ip = "127.0.0.1";//表示本主机
int port = atoi(argv[1]);//从命令行中拿到端口号
//new一个指针来执行功能
UdpServer* svr = new UdpServer(ip, port);
svr->InitUdpServer();
svr->Start();
return 0;
}
从main函数中的ip号也可看出,先在本地测试上面关于服务器的两段代码。
运行服务器,并用netstat查看当前网络的状态,相关命令行参数的含义已在下图中指明。
通过命令可以查看到,创建的服务器已经正常运行。
3.客户端实现
实现如下,与服务器的实现相近,但更简单些。
#pragma once
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <cstring>
#include <netinet/in.h>
#include <unistd.h>
using namespace std;
class UdpClient
{
public:
UdpClient(string _ip, int _port)
{
server_ip = _ip;
server_port = _port;
}
bool InitUdpClient()
{
//Ipv4用AF_INET
//tcp协议用SOCK_DGRAM
sockfd = socket(AF_INET, SOCK_DGRAM, 0);//创建套接字
if(sockfd < 0)//发生错误
{
cerr << "socket error!" << endl;
return false;
}
//客户端可以不绑定,如果不绑定则自动生成一个端口号
return true;
}
void Start()
{
//设置一个addr变量传入sendto函数
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(server_port);//将port转化形式后得到
addr.sin_addr.s_addr = inet_addr(server_ip.c_str());//将整数形式的ip转化为点分十进制的字符串得到
string msg;//输入消息
for(;;)
{
cout << "Please Enter# ";
cin >> msg;
sendto(sockfd, msg.c_str(), msg.size(), 0, (const struct sockaddr*)&addr, sizeof(addr));//向服务器发送msg的内容
}
}
~UdpClient()
{
if(sockfd >= 0)
close(fd);
}
private:
int sockfd;
string server_ip;//ip
int server_port;//端口号
};
4.客户端主函数
主函数如下,逻辑简单。
#include "udp_client.hpp"
int main(int argc, char* argv[])
{
//要求传入的命令行参数是:可执行程序,IP地址,端口号共3个参数
if(argc != 3)
{
cerr << "usage : " << argv[0] << " server_ip server_port" << endl;
return 1;
}
string ip = argv[1];//第二个命令行参数是IP地址
int port = atoi(argv[2]);//第三个命令行参数是服务器的端口号
UdpClient* ucl = new UdpClient(ip, port);
ucl->InitUdpClient();//初始化
ucl->Start();//启动客户端
return 0;
}
5.运行程序
运行服务器和客户端,从客户端向服务器发送消息,服务器成功收到了消息。
再次运行客户端和服务器,并用netstat查看网络状态如下,从端口号及进程名称可以看到都是完全匹配的。
四、简单的TCP网络程序
1.函数
tcp是面向链接的,也就是说在发送数据前,需要先建立链接。
(1)listen
既然需要建立链接,服务器就必须不断花时间检测是否有新的链接需要建立,这里就用到函数listen。
listen()声明sockfd处于监听状态,并且最多允许有backlog个客户端处于连接等待状态,如果接收到更多的连接请求就忽略,它的值不会太大(一般是5),backlog是全链接队列的最大长度,之后会具体讲到。
调用成功返回0,失败则返回-1。
(2)accept
建立链接后当然就需要接收链接,可以通过accept函数来接收。
sockfd代表从哪里获取链接,从后面两个参数可以拿到想要链接的客户端的信息。
从返回值可以看出,成功时同样返回一个文件描述符。
第一个参数sockfd和返回值都是文件描述符,但不同的是,sockfd的作用是获取新的链接,返回值是用来服务客户端的文件描述符。
(3)connect
服务器创建链接并持续监听后,就需要客户端来连接服务器的链接了,这里要用到connect函数。
sockfd表示通过这个套接字向对端发起链接请求,对端的信息用addr和addrlen来表示。
返回值:链接或绑定成功返回0,否则返回-1。
2.服务器实现
tcp服务器的实现大体上和udp相同,区别在于tcp需要建立链接、接收链接。
而且在用tcp套接字编程时,读、写都是向同一个文件描述符进行。
#pragma once
#include <iostream>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
#include <sys/fcntl.h>
using namespace std;
//下面两个宏的值可以修改为其它
#define BACKLOG 5//默认全链接队列的最大长度
#define DFL_PORT 8081//默认端口号
class TcpServer
{
private:
int port;
int listen_sockfd;
public:
TcpServer(int _port = DFL_PORT)
{
port = _port;
listen_sockfd = -1;
}
bool InitTcpServer()
{
//Ipv4用AF_INET
//tcp协议用SOCK_STREAM
listen_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(listen_sockfd < 0)
{
//创建套接字失败
cerr << "socket error" << endl;
exit(2);
}
//创建结构体
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
//初始化成员变量
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = INADDR_ANY;//只要是发送给服务器的IP地址都要
if(bind(listen_sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0)
{
//绑定失败
cerr << "bind error" << endl;
exit(3);
}
//这里用tcp实现,所以比udp多一步链接
if(listen(listen_sockfd, BACKLOG) < 0)
{
//监听失败(建立链接失败)
cerr << "listen error" << endl;
exit(4);
}
return true;
}
void Start()
{
struct sockaddr_in addr;//获得客户端的信息
for(;;)//死循环地执行
{
socklen_t len = sizeof(addr);
int sock = accept(listen_sockfd, (struct sockaddr*)&addr, &len);//接收链接
if(sock < 0)
{
//接收链接失败,继续持续接受链接
cout << "accept error, continue ..." <<endl;
continue;
}
//inet_ntoa:把网络序列转成主机序列,并接着转化成点分十进制格式
string _ip = inet_ntoa(addr.sin_addr);
//ntohs:将客户端的端口号转化为主机序列
int _port = ntohs(addr.sin_port);
cout << "get a new link [" << _ip << "]:" << _port << endl;
Service(sock, _ip, _port);
}
}
//服务器的代码就是从sock中读入客户端发送的内容并打印
void Service(int sock, string& _ip, int _port)
{
while(true)
{
char buffer[1024];
//read的返回值
//1.大于0表示实际读到了多少字节
//2.等于0表示读取到文件末尾,或写端关闭
//3.为-1表示读取出错
ssize_t sz = read(sock, buffer, sizeof(buffer) - 1);
if(sz > 0)
{
buffer[sz] = '\0';
cout << _ip << ":" << _port << "# " << buffer << endl;
write(sock, buffer, sz);
}
else if(sz == 0)
{
cout << _ip << ":" << _port << "close" << endl;
break;
}
else//sz < 0
{
cerr << sock << " read error" << endl;
break;
}
}
close(sock);
cout << "service done" << endl;
}
~TcpServer()
{
if(listen_sockfd >= 0)
{
close(listen_sockfd);
}
}
};
3.客户端实现
#pragma once
#include <iostream>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
#include <string>
using namespace std;
class TcpClient
{
private:
string svr_ip;//服务器的IP地址
int svr_port;//服务器的端口号
int sockfd;
public:
TcpClient(string _ip, int _port)
{
svr_ip = _ip;
svr_port = _port;
sockfd = -1;
}
void InitTcpClient()
{
//Ipv4用AF_INET
//tcp协议用SOCK_STREAM
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(sockfd < 0)
{
cerr << "socket error" << endl;
exit(2);
}
//不需要监听(listen)、不需要绑定(bind)
}
void Start()
{
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
//将端口号从主机序列转化为网络序列
addr.sin_port = htons(svr_port);
//将点分十进制序列转化为整型,该函数为C语言的接口,所以用c_str()从string转化为char*
addr.sin_addr.s_addr = inet_addr(svr_ip.c_str());
if(connect(sockfd, (struct sockaddr*)&addr, sizeof(addr)) == 0)
{
//链接或绑定成功
cout << "connect success" << endl;
Request(sockfd);
}
else
{
//链接或绑定失败
cout << "connect fail" << endl;
}
}
void Request(int sock)
{
string message;
while(true)
{
cout << "Please Enter # ";
cin >> message;
//向sock写入内容(发送给服务器)
write(sock, message.c_str(), message.size());
//把服务器发送回来的消息再打印出来
char buffer[1024];
ssize_t s = read(sock, buffer, sizeof(buffer) - 1);
if(s > 0)
{
buffer[s] = 0;
cout << "server echo # " << buffer << endl;
}
}
}
~TcpClient()
{
if(sockfd >= 0)
close(sockfd);
}
};
4.两个主函数
主函数的逻辑很简单,就是创建对象并调用成员函数。
//tcp_client.cpp
#include "tcp_client.hpp"
int main(int argc, char* argv[])
{
if(argc != 3)
{
return 1;
}
TcpClient tcli(argv[1], atoi(argv[2]));
tcli.InitTcpClient();
tcli.Start();
return 0;
}
//tcp_server.cpp
#include "tcp_server.hpp"
int main()
{
TcpServer tsvr(8081);
tsvr.InitTcpServer();
tsvr.Start();
return 0;
}
5.运行程序
运行上述代码,结果如下,按顺序在每一行命令或运行结果旁都附有说明,完成了客户端和服务器之间的通信。
这个服务器其实存在一个很严重的问题,就是同一时刻可以由许多客户端连接服务器,但只能有一个客户端与服务器交互;也就是如果有一个客户端正在交互,其他所有想要与服务器交互的请求都会被搁置,直到当前正在交互的客户端退出。
6.服务器优化
(1)多进程
多进程优化主要是创建子进程来完成服务,这样父进程就可以一直监听链接,连接后把服务交给子进程完成,整个服务器就有多个执行流,就可以允许多个客户端同时访问。
下面的优化与前面服务器的差别就只有在Start()函数内,所以下面只放修改后的Start()函数。
void Start()
{
//将子进程的信号自定义为忽略,这样父进程就不会阻塞地等待某一个子进程导致父进程卡住不动
signal(SIGCHLD, SIG_IGN);
struct sockaddr_in addr;//获得客户端的信息
for(;;)//死循环地执行
{
socklen_t len = sizeof(addr);
int sock = accept(listen_sockfd, (struct sockaddr*)&addr, &len);//接收链接
if(sock < 0)
{
//接收链接失败,继续持续接受链接
cout << "accept error, continue ..." <<endl;
continue;
}
//inet_ntoa:把网络序列转成主机序列,并接着转化成点分十进制格式
string _ip = inet_ntoa(addr.sin_addr);
//ntohs:将客户端的端口号转化为主机序列
int _port = ntohs(addr.sin_port);
cout << "get a new link [" << _ip << "]:" << _port << endl;
pid_t id = fork();//创建子进程
if(id == 0)
{
//子进程提供服务
close(listen_sockfd);//子进程不需要接受链接,它只需要完成服务即可
//子进程会继承父进程的文件描述符
Service(sock, _ip, _port);
exit(0);//子进程完成服务,直接退出
}
//父进程关闭sock不会影响子进程,而关闭可以防止可用的文件描述符越来越少
close(sock);
}
}
修改后再次运行服务器并同时用两个客户端访问,服务器可以同时处理这两个请求。
(2)多线程
struct info//结构体,包含套接字、IP地址和端口
{
int sock;
string ip;
int port;
info(int _sock, string _ip, int _port)
:sock(_sock), ip(_ip), port(_port)
{
}
};
//由于pthread_create中参数要求只有arg,如果定义为成员函数一定会有this指针干扰,所以定义为static函数
static void* HandlerRequest(void* arg)
{
info* i = (info*)arg;
pthread_detach(pthread_self());//分离新线程,这样主线程就不需要等待
Service(i->sock, i->ip, i->port);//提供服务
close(i->sock);//关闭套接字
delete i;//释放动态内存
return nullptr;
}
void Start()
{
struct sockaddr_in addr;//获得客户端的信息
for(;;)//死循环地执行
{
socklen_t len = sizeof(addr);
int sock = accept(listen_sockfd, (struct sockaddr*)&addr, &len);//接收链接
if(sock < 0)
{
//接收链接失败,继续持续接受链接
cout << "accept error, continue ..." << endl;
continue;
}
//inet_ntoa:把网络序列转成主机序列,并接着转化成点分十进制格式
string _ip = inet_ntoa(addr.sin_addr);
//ntohs:将客户端的端口号转化为主机序列
int _port = ntohs(addr.sin_port);
cout << "get a new link [" << _ip << "]:" << _port << endl;
//创建结构体,作为pthread_create的最后一个参数传入相关信息
info i = {
sock, _ip, _port};
pthread_t tid;
//最后一个参数传入info结构体,这样新线程也可以知道全部需要的信息
pthread_create(&tid, nullptr, HandlerRequest, &i);
}
}
//服务器的代码就是从sock中读入客户端发送的内容并打印
//由于上面HandlerRequest函数时静态函数,它只能访问静态函数,所以这里Service也要修改为静态函数
static void Service(int sock, string& _ip, int _port)
{
while(true)
{
char buffer[1024];
//read的返回值
//1.大于0表示实际读到了多少字节
//2.等于0表示读取到文件末尾,或写端关闭
//3.为-1表示读取出错
ssize_t sz = read(sock, buffer, sizeof(buffer) - 1);
if(sz > 0)
{
buffer[sz] = '\0';
cout << _ip << ":" << _port << "# " << buffer << endl;
write(sock, buffer, sz);//将客户端发来的消息写回去
}
else if(sz == 0)
{
cout << _ip << ":" << _port << " close" << endl;
break;
}
else//sz < 0
{
cerr << sock << " read error" << endl;
break;
}
}
close(sock);
cout << "service done" << endl;
}
两种修改方法最后的效果相同,运行结果同上。
文章评论