目录
1、实现服务器的逻辑
1.1 socket
1.2 bind
1.3 listen
1.4 accept
1.5 read
1.6 write
1.7 服务器代码
2、实现客户端的逻辑
2.1 connect
2.3 客户端代码
3、实现服务器与客户端的通信
结语
前言:
在Linux下,实现传输层协议为TCP的套接字进行网络通信,网络层协议为IPv4,需要用到的接口如下:实现服务器的接口有(socket、bind、listen、accept、read、write),实现客户端的接口有(socket、connect、write、read)。
实现方式:因为网络通信的本质是进程间通信在云服务器上创建一个服务器进程和一个客户端进程,服务器进程先启动,然后让客户端链接到服务器上,至此客户端可以向服务器发送消息,并且服务器收到消息后可以给对方反馈信息。
TCP通信示意图如下:
1、实现服务器的逻辑
将服务器封装成一个类,服务器的端口号、IP地址以及网络描述符(一种类似文件描述符的字段)作为类的成员变量,这样做的好处是对软件实现分层,方便维护代码。按照下文的接口顺序调用实现服务器类。
1.1 socket
实现网络通信的第一步基本都是调用socket接口,目前已经明确传输层协议为TCP,网络层协议为IPv4,因此可以直接调用socket,该函数介绍如下:
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
//domain表示网络层协议,填AF_INET表示IPv4,填AF_INET6表示IPv6
//type表示传输层协议,SOCK_STREAM为TCP,SOCK_DGRAM为UDP
//protocol表示指定特定的协议,默认填0即可
//调用成功返回一个类型文件描述符的网络描述符,失败返回-1
1.2 bind
在网络通信中,bind函数与socket函数密不可分,他是用来绑定网络描述符和地址信息的,目的是让后续的通信可以直接通过网络描述符进行。在使用bind函数前,需要先创建一个包含地址信息的结构体,IPv4对应的结构体类型是struct sockaddr_in,该结构体里需要程序员手动填写3个信息,分别是:1、TCP/UDP,2、自定义的端口号,3、该主机的IP地址。
体现地址信息的伪代码如下:
struct sockaddr_in local;//定义变量
memset(&local, 0, sizeof(local));//对内部内容清零
local.sin_family = AF_INET;//填写ip协议
local.sin_port = htons(port_);//填写端口号
inet_aton(ip_.c_str(), &(local.sin_addr));//填写ip地址
其中由于网络传输数据规定以大端字节序传输,因此填写端口号和ip时需要对其进行大端字节序的转换,这里可以调用htons函数和inet_aton函数实现转换,这两个函数的介绍如下:
#include <arpa/inet.h>
uint16_t htons(uint16_t hostshort);
//将主机字节序转换成网络大端字节序,以返回值的形式转换
#include <arpa/inet.h>
int inet_aton(const char *string, struct in_addr*addr);
//将字符串形式的ip转换成网络字节序,并直接赋值给addr指向的空间
在地址信息完善后,紧接着就是调用bind函数进行绑定,bind函数介绍如下:
int bind(int socket, const struct sockaddr *address,
socklen_t address_len);
//socket表示要绑定的网络描述符
//address表示指向的地址信息变量
//address_len表示地址信息变量的大小
//调用成功返回0,失败返回-1并设置错误码
1.3 listen
与UDP不同的是,UDP绑定之后就可以用网络描述符进行接收和发送消息的操作了,但是TCP不一样,TCP的绑定完成后需要对网络描述符再进行监听操作,监听实际上是一种抽象的连接概念(因为TCP是有连接的,而UDP是无连接的,所以需要监听),即调用listen函数。listen函数的介绍如下:
#include <sys/socket.h>
int listen(int sockfd, int backlog);
//sockfd表示要监听的网络描述符
//backlog表示设置套接字的链接队列的最大数量
//调用成功返回0,失败返回-1并设置错误码
1.4 accept
待上述3个函数完成基础工作后,接下来就是最重要的连接环节了,accept函数的作用是和客户端进行连接,但是注意链接不意味着直接接收对方发来的数据,连接完成之后才能开始正常通信。accept函数介绍如下:
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
//sockfd表示服务器的网络描述符,即通过观察该描述符得知是否有客户端链接
//addr是指向地址信息的指针,用来保存客户端的地址信息
//addrlen表示存储客户端地址信息的结构体大小
//重要的是调用成功时返回一个新的网络描述符,失败返回-1
accept的重要之处在于若连接成功,则返回一个新的网络描述符,而该网络描述符才是后续通信的”端口“,意味着现在服务器有两个网络描述符了,一个是socket返回的,一个是accept返回的。可以这样理解他们:socket返回的描述符表示该服务器的总描述符,类似二叉树的根结点,而accept返回的描述符就是用于服务多个客户端的次描述符,即给每个来连接的客户端都分配一个次描述符,类似二叉树的叶子结点。(但是当前服务器的实现只能服务单客户端,因为他不是多线程的)
题外话:UDP通信只有一个网络描述符,所有的客户端都是通过这一个描述符来进行和服务器的通信。
1.5 read
链接完毕之后,说明此时客户端和服务器双方建立了联系,服务器的下一步就是读取客户端发送的信息,因为TCP是面向字节流的,因此可以使用文件操作的方式进行流读取,即使用read函数读取客户端发送的数据,read函数介绍如下:
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
//fd表示要读取的文件描述符
//buf表示将读取数据存放至缓冲区的缓冲区指针
//count表示读取的字节数
1.6 write
服务器不仅仅可以接收数据,还可以向客户端反馈数据,因为accept让服务器和客户端之间有了唯一的网络描述符,因此服务器可以通过该描述符对客户端反馈信息,而无需像UDP需要记录对方的地址信息才可以反馈信息。write函数介绍如下:
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
//fd表示要写入的文件描述符
//buf表示指向写入的内容
//count表示要写入的字节
1.7 服务器代码
实现服务器类的代码如下:
#pragma once
#include <iostream>
#include <string>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
const int defaultfd = -1;
const std::string defaultip = "0.0.0.0";//云服务器默认通信ip
const int backlog = 10; // 但是一般不要设置的太大
class TcpServer
{
public:
TcpServer(const uint16_t &port, const std::string &ip = defaultip)
: listensock_(defaultfd), port_(port), ip_(ip)
{
}
void InitServer()
{
listensock_ = socket(AF_INET, SOCK_STREAM, 0);
printf("创建套接字成功, listensock_: %d\n", listensock_);
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port_);
inet_aton(ip_.c_str(), &(local.sin_addr));
bind(listensock_, (struct sockaddr *)&local, sizeof(local));
printf("绑定套接字成功, listensock_: %d\n", listensock_);
// Tcp是面向连接的,服务器一般是比较“被动的”,
//服务器一直处于一种,一直在等待连接到来的状态
listen(listensock_, backlog);
printf("监听套接字成功, listensock_: %d\n", listensock_);
}
void Start()
{
printf("TCP服务器正在运行....");
for (;;)
{
// 1. 获取新连接
struct sockaddr_in client;//保存客户端的地址信息
socklen_t len = sizeof(client);
//类似recvfrom
int sockfd = accept(listensock_, (struct sockaddr *)&client, &len);
uint16_t clientport = ntohs(client.sin_port);//将客户端端口号拿出来
char clientip[32];
//将ip从网络字节序转换成主机序列并写进缓冲区clientip内
inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));
printf("链接到一个客户端, sockfd: %d, client port: %d\n",
sockfd, clientport);
// 2. 根据新连接来进行通信
Service(sockfd);
close(sockfd);//关闭文件描述符
}
}
void Service(int sockfd)
{
// 测试代码
char buffer[4096];
while (true)
{
ssize_t n = read(sockfd, buffer, sizeof(buffer));
if (n > 0)
{
buffer[n] = 0;
std::cout << "客户端说# " << buffer << std::endl;
std::string echo_string = "服务器回答# ";
echo_string += buffer;
write(sockfd, echo_string.c_str(), echo_string.size());
}
else if(n==0)
{
std::cout<<"读到文件末尾"<<std::endl;
sleep(1);
break;
}
else
{
std::cout<<"读到出错"<<std::endl;
sleep(1);
break;
}
}
}
~TcpServer()
{
if(listensock_>0) close(listensock_);
}
private:
int listensock_;
uint16_t port_;
std::string ip_;
};
2、实现客户端的逻辑
与服务器相比,客户端的实现逻辑要简单许多,因为服务器要将底层的基础设施建设好,而客户端只需要完成连接这一个动作即可,然后就是发送数据和接收反馈,当然,客户端的第一步也是调用socket拿到属于客户端的网络描述符,因为后续的通信工作要通过该描述符进行。
2.1 connect
客户端只需要做连接动作即可,而连接动作是依靠connect函数实现的,connect函数介绍如下:
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
//sockfd表示要与对方建立连接的客户端网络描述符
//addr指向地址信息的地址,该地址信息即服务器的地址信息
//addrlen表示addr指向的地址信息的大小
所以调用connect前必须知道服务器的地址信息:即ip地址和端口号,可以通过命令行参数的形式传给客户端代码,并且拿到地址信息后也是要构造struct sockaddr_in变量将信息填写进去。
2.3 客户端代码
实现客户端代码如下(注意客户端直接在main函数中实现,无需在对其进行封装,因为客户端的代码简单):
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
void Usage(const std::string &proc)
{
std::cout << "\n\rUsage: " << proc << " serverip serverport\n"
<< std::endl;
}
// ./tcpclient serverip serverport
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
//从命令行参数获取服务器的端口和ip
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
// 初始化地址信息
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
// 将ip转换成网络字节序
inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr));
int sockfd = 0;
sockfd = socket(AF_INET, SOCK_STREAM, 0);//创建套接字
// 客户端发起connect的时候,进行自动随机bind
//链接服务器
int n = connect(sockfd, (struct sockaddr *)&server, sizeof(server));
if (n < 0)
{
std::cout << "链接失败, reconnect: " << std::endl;
sleep(2);
}
while (true)
{
std::string message;
std::cout << "Please Enter# ";
std::getline(std::cin, message);
int n = write(sockfd, message.c_str(), message.size());//发送数据
if (n < 0)
{
std::cout << "write出错" << std::endl;
// break;
}
char inbuffer[4096];
n = read(sockfd, inbuffer, sizeof(inbuffer));//接收反馈
if (n > 0)
{
inbuffer[n] = 0;
std::cout << inbuffer << std::endl;
}
}
close(sockfd);
return 0;
}
3、实现服务器与客户端的通信
实现双方通信前,必须满足双方都为进程,但是服务器目前还只是一个类,因此要把服务器类实例化在main函数中,让服务器也变成进程。实现服务器进程的代码如下:
#include "TCPser.hpp"
#include <iostream>
#include <memory>
void Usage(std::string proc)
{
std::cout << "\n\rUsage: " << proc << " port[1024+]\n" << std::endl;
}
int main(int argc, char *argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(1);
}
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<TcpServer> tcp_svr(new TcpServer(port));
tcp_svr->InitServer();
tcp_svr->Start();
return 0;
}
运行结果:
特别注意:通信时,必须保证服务器先运行起来,若客户端先运行起来,则会在connect处连接失败,因为找不到对应的服务器端口号。
结语
以上就是关于在Linux下实现TCP网络通信的讲解,实现TCP的主要思路是了解套接字的作用,以及理清对套接字相关函数的调用顺序,并且其次TCP通信的原理。
最后如果本文有遗漏或者有误的地方欢迎大家在评论区补充,谢谢大家!!