目录
[一. 端口号的概念](#一. 端口号的概念)
[二. 对于UDP和TCP协议的认识](#二. 对于UDP和TCP协议的认识)
[三. 网络字节序](#三. 网络字节序)
[3.1 字节序的概念](#3.1 字节序的概念)
[3.2 网络通信中的字节序](#3.2 网络通信中的字节序)
[3.3 本地地址格式和网络地址格式](#3.3 本地地址格式和网络地址格式)
[四. socket编程的常用函数](#四. socket编程的常用函数)
[4.1 sockaddr结构体](#4.1 sockaddr结构体)
[4.2 socket编程常见函数的功能和使用方法](#4.2 socket编程常见函数的功能和使用方法)
[五. UDP协议实现网络通信](#五. UDP协议实现网络通信)
[5.1 UDP协议服务端的封装](#5.1 UDP协议服务端的封装)
[5.2 UDP协议客户端的封装](#5.2 UDP协议客户端的封装)
[六. TCP协议实现网络通信](#六. TCP协议实现网络通信)
[6.1 TCP协议服务端的封装](#6.1 TCP协议服务端的封装)
[6.2 TCP协议客户端的封装](#6.2 TCP协议客户端的封装)
[七. 总结](#七. 总结)
一. 端口号的概念
进行网络通信,其根本目的并不是让两个主机之间进行通信,而是让运行在主机上的两个进程之间相互通信。如,我们在日常生活中经常要通过微信发送消息,我发送的消息必须要经过网络传输,才能够被对方接受。我发送的消息,并没有被对方主机的其他应用接受,而只是被对方主机上运行的微信这一进程接受。++由此可见,网络通信本质上是网络中的两主机通过网络实现进程间通信,为此,OS必须通过特定的方式,来标识接受数据的进程++。
++端口号,是在某一主机上,用来标识进程唯一性的编号++ ,与之对应的IP地址,用于表示网络中唯一的一台主机,因此,IP地址 + 端口号,可用于表示全网中唯一的一个进程。
关于端口号,有如下的基本结论:
- 端口号用于表示主机中的唯一一个进程。
- 端口号是一个16位、2字节的整数,其数据类型为uint16_t。
- IP + 端口号,可用于表示全网唯一一个进程。
- 一个端口号只能对应一个进程,而一个进程能够对应多个端口号。
**端口号(port)和进程pid之间的关系:**每个进程都有对应的pid,用于在系统中标识特定进程,但不进行网络通信的进程不需要有端口号,理论上讲id + port,也可以识别网络中唯一一个进程,但是使用端口号和id,能够实现系统进程和网络通信功能之间的解耦。
二. 对于UDP和TCP协议的认识
UDP协议,即用户数据报协议(User Datagram Protocol),其特征有:
- 属于传输层面的协议。
- 不需要连接 -- 一方给另一方发送数据时,不需要另一方处于等待状态。
- 不可靠传输 -- 可能出现失帧、丢包等问题。
- 面向数据报的通信方式。
TCP协议,即传输控制协议(Transmission Control Protocol),其特征有:
- 属于控制层面的协议。
- 需要连接 -- 一方给另一方发送数据时,另一方必须处于等待状态,发送数据前要预先建立连接才能够发送成功。
- 可靠传输 -- 不会出现失帧、丢包等问题。
- 面向字节流的通信。
**对于需要连接和不需要连接的理解:**需要链接,类似于生活中的接打电话,我们给一方打电话的时候,对方需要听到电话铃声,确认接听才能通信,确认接听电话,就类似于网络通信中的建立链接。不需要连接,类似于生活中发送电子邮件的通信方式,我们要给某人发送电子邮件时,可以不用事先通知对方,只要发送,等待对方合适的时候查看即可,对方不需要事先准备接收邮件,即不需要连接。
**对于可靠传输和不可靠传输的理解:**可靠传输和不可靠传输并不是好坏的评判标准,原因是:(a). UDP协议虽然可能存在丢包失帧等问题,但是发生问题的概率极小,有些时候这并不是不可以接受的。 (b). 虽然TCP协议不会出现UDP这样的不可靠传输的问题,但是可靠通信的建立,是需要成本的,在有些可以一定程度接受数据传输出现问题的场景,采用TCP协议综合效益并不高。
三. 网络字节序
3.1 字节序的概念
内存中存储数据的字节序有两种:(1). 小端字节序 -- 低位存储在低地址,高位存储在高地址。(2). 大端字节序 -- 低位存储在高地址,高位存储在低地址。
图3.1以十六进制表示的数据int num = 0XAABBCCDD为例,展示了大端机和小端机存储数据的规则,这个数据第低位为DD,高位为AA。
图3.1 小端机和大端机使用内存的方式
3.2 网络通信中的字节序
假设这样一种场景,一台小端机要通过网络给一台大端机发送数据,假设他们以他们各自的字节序发送向网络中发数据和从网络中读数据,那么就会出现"乱序"问题,因此需要一定的协议,用于规范网络数据的字节序,以避免"乱序问题"。
++规定:网络中的数据,全部采用大端字节序。++
我们有时候无法确定发送的数据,或者从网络中读取来的数据是大端还是小端,为了保证发送和读取数据的可靠性,C标准库提供了下面4个函数,可以实现网络和主机数据之间的相互转换:
- uint32_t htonl(uint32_t ++hostlong++) -- 将32位主机数据转为网络格式。
- uint16_t htons(uint16_t ++hostshort++) -- 将16位主机数据转为网络格式。
- uint32_t ntohl(uint32_t ++netlong++) -- 将32位网络数据转为主机格式。
- uint16_t ntohs(uint16_t ++netshort++) -- 将16位网络数据转为主机格式。
3.3 本地地址格式和网络地址格式
一般我们在主机中标识ip地址,都采用const char*数据类型、点分十进制方法来表示, 如1.23.122.234,但是在网络中,为了节省资源,ip应当采用四字节的方法来表示,下面几个函数的功能,是实现本地const char*点分十进制ip格式和网络四字节ip格式之间的转化:
- in_addr_t inet_addr (const char *++cp++) -- 本地ip格式转网络ip格式。
- const char* inet_ntoa (struct in_addr ++in++) -- 网络ip格式转本地ip格式。
- const char *inet_ntop(int af, const void *src, char *dfs, socklen_t len) -- 网络ip格式转本地ip格式,将转换后的结果存储在dfs所指向的空间中。
注意:一般建议使用inet_ntop函数,而不是采用inet_ntoa函数,因为inet_ntoa为了返回本地格式的ip,会在函数内部开辟一块static空间来记录转换来的结果,这样就带来两个问题:1. 线程不安全 2. 如果多次调用inet_ntoa函数,那么最后一次调用的返回结果会覆盖掉前面的结果。而采用inet_ntop函数,返回结果会被存储在用户指定的buffer空间中,杜绝了inet_ntoa函数的这两个问题。
inet_ntop函数的af参数表示通信方式(AF_INET表示ipv4格式网络地址,AF_INET6表示ipv6格式网络地址),src为指向struct sin_addr类型数据的指针,dfs为接收结果的输出型参数。
四. socket编程的常用函数
4.1 sockaddr结构体
图4.1给出了sockaddr、sockaddr_in和sockaddr_un的结构,其中sockaddr为socket API抽象出来的一种结构体,使用与ipv4、ipv6、udp、tcp、本地通信,等各种形式的socket。
sockaddr_in为网络通信使用的结构体,sockaddr_un为本地通信使用的结构体。
图4.1 sockaddr结构体
网络通信常用的结构为sockaddr_in,其内容包括:16位地址类型AF_INIT,用于确定通信方式为网络通信、32位IP地址用于在网络中定位特定的主机、8字节填充没有实际意义,一般为0。
struct sockaddr_in的定义:
struct sockaddr_in
{
__SOCKADDR_COMMON (sin_);
in_port_t sin_port; /* Port number. */
struct in_addr sin_addr; /* Internet address. */
/* Pad to size of `struct sockaddr'. */
unsigned char sin_zero[sizeof (struct sockaddr) -
__SOCKADDR_COMMON_SIZE -
sizeof (in_port_t) -
sizeof (struct in_addr)];
};
struct in_addr的定义:
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};
4.2 socket编程常见函数的功能和使用方法
对于socket程序,一般包含四个头文件,即可支持所有的socket相关函数:
- #include <sys/types.h>
- #include <sys/socket.h>
- #include <netinet/in.h>
- #include <apra/inet.h>
创建socket文件描述符函数 -- socket:
- ++UDP协议和TCP协议、客户端以及服务端++,都需要创建socket文件描述符。
- socket文件描述符与系统级文件描述符的本质相同,都是数组arrayfd对应的一个下标,表示进程打开了某个特定的文件。
socket函数 -- 创建socket文件描述符
**函数原型:**int socket(int domain, int type, int protocol)
函数参数:
- domain -- 选取通信的范围(网络还是本地),AF_INET为网络通信,AF_LOCAL为本地通信。
- type -- 通信类型,UDP协议传SOCK_DGRAM,TCP协议传SOCK_STREAM。
- protocal -- 指定协议类型,一般传0即可。
**返回值:**如果成功,返回创建的socket文件描述符,如果失败返回-1。
绑定端口号-- bind:
- 将用户给出和端口号,在内核中与当前的进程相关联。
- ++在TCP和UDP协议的服务端++,都需要使用bind绑定。
- 在客户端,不采用bind绑定用户设定的端口号,因为如果客户端由用户设置端口号,如果这个端口号被其他进程占用,那么会出现绑定失败的问题。
- 客户端在第一次给服务端发送消息时,由OS自动分配端口号。
bind函数 -- 绑定端口号
函数原型: int bind(int ++socket++ , const struct sockaddr *++address++ , socklen_t *++len++)
函数参数:
- socket -- socket文件描述符
- address -- 输入型参数,用于指定套接字sockaddr
- len -- 输入型参数,用于给出sockaddr结构体的长度(占用多少字节)
**返回值:**成功返回0,失败返回-1。
设置监听状态 -- listen:
- 一般用于TCP协议中的服务器端。
- 在TCP协议中,只有设置服务器处于监听状态,客户端才可以与服务器建立链接并实现通信。
listen函数 -- 设置监听状态
**函数原型:**int listen(int socket, int backlog)
函数参数:
- socket -- socket文件描述符
- backlog -- 应传一个不太大也不太小的数字。
**返回值:**成功返回0,失败返回-1。
建立通信连接 -- connect:
- ++一般用于TCP协议的客户端。++
- 在TCP协议中,如果客户端希望与服务端通信,那么就必须与服务端建立连接。
- 客户端使用connect函数与服务器建立链接,必须保证服务器处于监听状态。
connect函数 -- 建立通信连接
函数原型: int connect(int ++socket++ , struct sockaddr *++address++ , socklen_t ++len++)
函数参数:
- socket -- socket文件描述符
- address -- 被链接的一端的套接字
- len -- 套接字长度
**返回值:**成功返回0,失败返回-1。
接受通信另一方的连接请求 -- accept:
- 如果另一方尝试与本地进程建立网络通信,那么accept可以用于接收对方的连接。
- accept函数一般用于TCP协议的服务端,只有accept函数成功接收了另一端的连接请求,才算是真正建立起来通信连接。
accept函数 -- 接受通信另一端的连接请求
函数原型: int accept(int ++socket++ , struct sockaddr_in *++address++ , socklen_t *++len++)
函数参数:
- socket -- socket文件描述符。
- address -- 输出型参数,用于获取请求连接的一方的套接字信息。
- len -- 套接字长度。
**返回值:**成功返回用于通信的"网络文件"的文件描述符,失败返回-1。
从网络中读取数据函数 -- recvfrom:
- 从网络中读取指定长度的数据,到指定的缓冲区中。
- ++用于UDP协议的客户端和服务端读取对端消息。++
recvfrom函数 -- UDP协议从网络中读取数据
函数原型: ssize_t recvfrom(int ++socket++ , void *++buffer++ , size_t ++length++ , int ++flag++ , struct sockaddr *++addr++ , socklen_t *++addr_length++)
函数参数:
- socket -- socket文件描述符。
- buffer -- 接受数据的缓冲区。
- length -- 至多读取的字节数。
- flag -- 读取数据的方式,一般设置为0,表示阻塞式等待网络中被写入数据。
- addr -- 输出型参数,用于接受发送数据的主机ip及进程端口号。
- addr_length:套接字长度。
**返回值:**读取成功返回读到的字节数,对端关闭返回0,失败返回-1。
从网络中读取数据 -- recv:
- 从网络中读取数据到指定缓冲区。
- ++用于TCP协议中客户端和服务端读取对端消息。++
recvfrom函数 -- TCP协议从网络中读取数据
函数原型: ssize_t recv(int ++socket++ , void *++buffer++ , size_t ++length++ , int ++flag++)
函数参数:
- socket -- socket文件描述符。
- buffer -- 接受数据的缓冲区。
- length -- 至多读取的字节数。
- flag -- 读取数据的方式,一般设置为0,表示阻塞式等待网络中被写入数据。
**返回值:**读取成功返回读到的字节数,对端关闭返回0,失败返回-1。
向网络中发送数据函数 -- sendto:
- 可以通过指定ip和端口号,将数据发送给指定主机的某个进程。
- 给定端口号和ip时要注意将ip和端口号转为网络格式。
sendto函数 -- 向网络中发送数据
函数原型: ssize_t sendto(int ++socket++ , void *++buffer++ , size_t ++length++ , int ++flag++ , const struct sockaddr *++addr++ , socklen_t ++addr_length++)
函数参数:
- socket -- socket文件描述符。
- buffer -- 存储待发送数据的缓冲区。
- length -- 要发送的数据的字节数。
- flag -- 发生数据的方式,一般直接设置为0即可。
- addr -- 指定接受数据的主机ip和端口号。
- addr_length:套接字长度。
**返回值:**成功返回发送出去的字节数,失败返回-1。
五. UDP协议实现网络通信
5.1 UDP协议服务端的封装
本文实现一个服务端的demo程序,其功能为:服务端从客户端读取数据,记录数据源主机的ip和端口号,如果源主机第一次向服务器发送数据,就将该主机的ip和端口号插入到哈希表中,每次服务器接受到数据,就将数据发回哈希表中记录的主机,这样就模拟实现了简单的群聊功能。
**服务端初始化步骤:**创建socket文件描述符 -> 绑定端口号。
**服务端启动后的工作流程:**通过recvfrom函数从客户端读取数据 -> 判断发送数据的客户端是否已经向服务器发送过数据,如果没有,那么将客户端的ip和端口号插入哈希表 -> 将读取到的数据发回哈希表中记录的客户端。
启动服务端程序时,要指定端口号,以便客户端能够与服务端建立通信,一般来说服务端不用显示设定ip,而是通过INADDR_ANY来设置,这表示无论客户端ip是多少,都可以实现与服务端的通信。
Log.hpp文件(日志打印相关内容):
#pragma once
#include <iostream>
#include <cstdio>
#include <cstdarg>
#include <ctime>
#define DEBUG 0
#define NORMAL 1
#define WARING 2
#define ERROR 3
#define FATAL 4
const char* g_levelMap[5] =
{
"DEBUG",
"NORMAL",
"WARING",
"ERROR",
"FATAL"
};
void logMessage(int level, const char *format, ...)
{
// 1. 输出常规部分
time_t timeStamp = time(nullptr);
struct tm *localTime = localtime(&timeStamp);
printf("[%s] %d-%d-%d, %02d:%02d:%02d\n", g_levelMap[level], localTime->tm_year, localTime->tm_mon, \
localTime->tm_mday, localTime->tm_hour, localTime->tm_min, localTime->tm_sec);
// 2. 输出用户自定义部分
va_list args;
va_start(args, format);
vprintf(format, args);
va_end(args);
}
udp_serve.hpp文件(对服务端封装):
#pragma once
#include "Log.hpp"
#include <iostream>
#include <string>
#include <unordered_map>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
class Server
{
public:
// 服务端构造函数
Server(uint16_t port, const std::string& ip = "")
: _port(port)
, _ip(ip)
, _sock(-1)
{ }
// 初始化服务器
void init()
{
// 1. 创建网络套接字
_sock = socket(AF_INET, SOCK_DGRAM, 0);
if(_sock < 0) // 检查套接字创建成功与否
{
logMessage(FATAL, "%d:%s\n", errno, strerror(errno));
exit(2);
}
logMessage(DEBUG, "套接字创建成功, _sock:%d\n", _sock);
// 2. bind:将用户设置的ip和port在内核中与进程相关联
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
// INADDR_ANY: 表示服务器在工作过程中可以从任意ip获取数据
// inet_addr函数: 将主机ip转为4字节网络ip
local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
local.sin_port = htons(_port); // 将主机端口转换为网络端口格式
if(bind(_sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
logMessage(FATAL, "%d:%s\n", errno, strerror(errno));
exit(3);
}
logMessage(DEBUG, "用户设置的ip和port在内核中与进程相关联成功!\n");
}
// 启动服务器程序
void start()
{
// 服务器进程永不退出
// 从客户端读取数据
char buffer[1024]; // 输出缓冲区
char key[128]; // 存储客户端ip和端口号
struct sockaddr_in sock_cli; // 客户端套接字
memset(&sock_cli, 0, sizeof(sock_cli)); // 初始化0
socklen_t len = sizeof(sock_cli); // 输入型参数 -- 套接字长度
std::string addr_cli; // 数据源客户端的ip
uint16_t port_cli; // 数据源客户端的端口号
while(true)
{
// 输出读取到的数据
memset(buffer, 0, sizeof(buffer));
memset(key, 0, sizeof(key));
ssize_t n = recvfrom(_sock, (void *)buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&sock_cli, &len);
if(n > 0)
{
buffer[n] = 0; // 添加尾部'/0'
addr_cli = inet_ntoa(sock_cli.sin_addr); // inet_ntoa函数负责将网络ip转换为主机ip
port_cli = ntohs(sock_cli.sin_port); // 网络套接字转换为主机套接字
snprintf(key, 128, "[%s-%d]", addr_cli.c_str(), port_cli);
printf("[%s:%d]# %s\n", addr_cli.c_str(), port_cli, buffer); // 输出发送端的ip和port,以及发送的内容
}
else if(n == 0)
{
logMessage(DEBUG, "未读取到数据!\n");
continue;
}
else // 数据读取失败
{
logMessage(ERROR, "读取数据失败!\n");
continue;
}
// 将客户端的ip和port插入到哈希表
if(_mp.find(key) == _mp.end())
{
_mp.insert({key, sock_cli});
logMessage(NORMAL, "成功插入客户端, %s\n", key);
}
// 将读取到的数据全部发送给客户端主机
for(const auto& iter : _mp)
{
std::string msg_cli;
msg_cli += key;
msg_cli += "# ";
msg_cli += buffer;
if(sendto(_sock, msg_cli.c_str(), msg_cli.size(), 0, (struct sockaddr *)&(iter.second), sizeof(iter.second)) < 0)
{
logMessage(ERROR, "服务器发回消息失败!");
continue;
}
logMessage(NORMAL, "向客户端写回数据 -- %s\n", iter.first.c_str());
}
}
}
// 析构函数
~Server()
{
if(_sock >= 0)
{
close(_sock);
}
}
private:
uint16_t _port; // 端口号
std::string _ip; // 服务器ip地址
int _sock; // 套接字对应文件描述符
std::unordered_map<std::string, struct sockaddr_in> _mp; // 哈希表,记录接收到信息的客户端的ip和port
};
udpserve.cc文件(服务端源文件):
#include "udp_serve.hpp"
#include <memory>
void usage(const char *command)
{
std::cout << "\nUsage# " << command << " port\n" << std::endl;
}
int main(int argc, const char **argv)
{
if(argc != 2)
{
usage(argv[0]);
exit(1);
}
uint16_t port = static_cast<uint16_t>(atoi(argv[1]));
std::unique_ptr<Server> psvr(new Server(port));
psvr->init();
psvr->start();
return 0;
}
5.2 UDP协议客户端的封装
本文采用多线程的方式实现UDP客户端demo程序,一个线程负责从服务器读取数据,另一个线程负责向服务器发送数据。
**客户端初始化init:**创建socket即可,不需要bind端口号,当客户端第一次向服务端发送数据的时候,OS会自动为客户端进程分配端口号。
**客户端启动函数执行的工作:**创建两个线程,一个调用recvfrom函数从服务端读数据,另一个调用sendto函数向服务器写数据。
启动客户端程序时,需要告知服务器对应的ip和端口号,才能够成功与服务器建立通信。
udp_client.hpp文件(封装客户端):
#pragma once
#include "Log.hpp"
#include "Thread.hpp"
#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
struct SendMessageData
{
int _sock;
struct sockaddr_in _sock_srv;
socklen_t _len;
SendMessageData(int sock, struct sockaddr_in sock_srv)
: _sock(sock), _sock_srv(sock_srv), _len(sizeof(sock_srv))
{
}
};
class Client
{
public:
// 构造函数
Client(const std::string &ip, uint16_t port)
: _ip(ip), _port(port), _sock(-1)
{
}
// 初始化函数
void init()
{
// 1. 创建网络套接字
_sock = socket(AF_INET, SOCK_DGRAM, 0);
if (_sock < 0)
{
logMessage(FATAL, "%d:%s", errno, strerror(errno));
exit(2);
}
// 2. 绑定 -- 客户端需要绑定,但一般不会由程序员来进行绑定
// 而是在第一次发送消息时,由OS自动分配ip和port
}
// 客户端启动程序
void start()
{
// 创建服务器的struct sockaddr
struct sockaddr_in srv_sock;
memset(&srv_sock, 0, sizeof(srv_sock));
srv_sock.sin_family = AF_INET;
srv_sock.sin_addr.s_addr = inet_addr(_ip.c_str()); // 主机ip转为网络ip
srv_sock.sin_port = htons(_port); // 主机port转为网络port
SendMessageData sendData(_sock, srv_sock);
// 发送消息
thread th_send(send_message, (void *)&sendData);
th_send.start();
// 接受反馈回来的消息
thread th_recieve(recieve_message, (void *)&_sock);
th_recieve.start();
th_send.join();
th_recieve.join();
}
// 析构函数
~Client()
{
if (_sock < 0)
{
close(_sock);
}
}
private:
static void *send_message(void *args)
{
SendMessageData *ptr = (SendMessageData *)args;
while (true)
{
std::string msg;
std::cerr << "请输入你要发送的消息: " << std::flush;
std::getline(std::cin, msg); // 按行读取
sendto(ptr->_sock, msg.c_str(), msg.size(), 0, (const sockaddr *)&ptr->_sock_srv, ptr->_len);
}
return nullptr;
}
static void *recieve_message(void *args)
{
// memset(buffer, 0, sizeof(buffer));
char buffer[1024];
while (true)
{
struct sockaddr tmp;
memset(&tmp, 0, sizeof(tmp));
socklen_t len = sizeof(tmp);
ssize_t n = recvfrom(*(int *)args, (void *)buffer, sizeof(buffer) - 1, 0, (sockaddr *)&tmp, &len);
if (n > 0)
{
// std::cerr << "aaaa" << std::endl;
buffer[n] = '\0';
printf("%s\n", buffer);
}
}
return nullptr;
}
std::string _ip; // 发生数据的主机ip
uint16_t _port; // 端口号
int _sock; // 套接字
};
udp_client.cc文件(客户端源文件):
#include "udp_client.hpp"
#include <memory>
#include <signal.h>
void Usage(const char *command)
{
std::cout << "/nUsage: " << command << " ip port\n" << std::endl;
}
int main(int argc, const char **argv)
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
// signal(SIGCHLD, SIG_IGN);
std::unique_ptr<Client> pcli(new Client(argv[1], atoi(argv[2])));
pcli->init();
pcli->start();
return 0;
}
六. TCP协议实现网络通信
6.1 TCP协议服务端的封装
本文采用多进程的方式来编写服务端demo代码,每次接收到客户端的连接请求,就为这个客户端创建一个进程,用于与该客户端通信。
**服务端初始化init:**创建socket套接字 -> 将本地ip和端口号在内核中与当前进程绑定 -> 设置服务端进程处于listen状态,以便随时接受客户端的连接请求。
**服务端启动start:**接受客户端的连接请求并记录请求连接的客户端的套接字 -> 创建子进程 -> 在子进程中调用读取客户端发送的信息 -> 读取成功后,发回给客户端。
启动服务器时,需要显示给出端口号,以便客户端能够顺利连接到服务器。
tcp_server.hpp文件(服务端封装):
#pragma once
#include "Log.hpp"
#include <iostream>
#include <unistd.h>
#include <string>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/wait.h>
#include <unistd.h>
const int g_size = 1024;
static void server(int serverSock, struct sockaddr_in sock_cli)
{
char buffer[g_size]; // 存储读取数据的缓冲区
while(true)
{
ssize_t n = recv(serverSock, buffer, g_size - 1, 0); // 读取数据
if(n > 0)
{
buffer[n] = '\0';
uint16_t cli_port = ntohs(sock_cli.sin_port);
char cli_addr[20]; // 地址
memset(cli_addr, 0, sizeof(cli_addr));
// socklen_t len = sizeof(sock_cli.sin_addr);
inet_ntop(AF_INET, &sock_cli.sin_addr, cli_addr, sizeof(cli_addr));
printf("[%s-%d] %s\n", cli_addr, cli_port, buffer);
// 发回客户端
send(serverSock, buffer, strlen(buffer), 0);
}
else if(n == 0)
{
logMessage(DEBUG, "对端关闭,读取结束!\n");
break;
}
else // n < 0
{
logMessage(ERROR, "读取失败!\n");
break;
}
}
close(serverSock);
}
class TcpServer
{
public:
TcpServer(uint16_t port, const std::string &ip = "")
: _port(port), _ip(ip), _listenSock(-1)
{ }
// 服务器初始化
void init()
{
// 1. 创建网络套接字
_listenSock = socket(AF_INET, SOCK_STREAM, 0);
if (_listenSock < 0) // 创建socket失败
{
logMessage(FATAL, "socket error, %d:%s\n", errno, strerror(errno));
exit(2);
}
logMessage(NORMAL, "socket success, _listenSock:%d\n", _listenSock);
// 2. 绑定端口号
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET; // 设置网络协议族
local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str()); // 本地格式地址转为网络格式
local.sin_port = htons(_port); // 本地格式端口号转为网络格式
if (bind(_listenSock, (const sockaddr *)&local, sizeof(local)) < 0)
{
logMessage(FATAL, "bind error, %d:%s\n", errno, strerror(errno));
exit(3);
}
logMessage(NORMAL, "bind success, %s:%d\n", _ip.c_str(), _port);
// 3. 设置监听状态
if (listen(_listenSock, _backlog) < 0)
{
logMessage(FATAL, "listen error, %d:%s\n", errno, strerror(errno));
exit(4);
}
logMessage(NORMAL, "listen success\n");
logMessage(NORMAL, "Server Init Success!\n");
}
// 运行服务器
void start()
{
while (true)
{
// 接受客户端的链接请求
struct sockaddr_in sock_cli;
memset(&sock_cli, 0, sizeof(sock_cli));
socklen_t len = sizeof(sock_cli);
int serverSock = accept(_listenSock, (struct sockaddr *)&sock_cli, &len);
if (serverSock < 0) // 接受客户端请求失败
{
logMessage(ERROR, "accept error, %d:%s\n", errno, strerror(errno));
continue;
}
uint16_t cli_port = ntohs(sock_cli.sin_port);
char cli_addr[20]; // 地址
memset(cli_addr, 0, sizeof(cli_addr));
inet_ntop(AF_INET, &sock_cli.sin_addr, cli_addr, len);
cli_addr[strlen(cli_addr)] = '\0';
logMessage(NORMAL, "accept success [%s-%d]\n", cli_addr, cli_port);
// 多进程接受客户端信息
pid_t id = fork();
if (id == 0)
{
if (fork() > 0)
exit(0); // 子进程退出
// 子进程的子进程(孙子进程)此时变为孤儿进程
// 由1号进程领养,OS自动回收进程
server(serverSock, sock_cli);
exit(0);
}
waitpid(id, nullptr, 0);
close(serverSock);
}
}
// 析构函数
~TcpServer()
{
if (_listenSock >= 0)
{
close(_listenSock);
}
}
private:
uint16_t _port; // 端口号
std::string _ip; // 本地ip
int _listenSock; // socket文件描述符
static const int _backlog = 20;
};
tcp_server.cc文件(服务端源文件):
#include "tcp_server.hpp"
#include <memory>
void Usage(const char *proc)
{
std::cout << "\nServer Usage# " << proc << " ServerPort\n" << std::endl;
}
int main(int argc, const char **argv)
{
if(argc != 2)
{
Usage(argv[0]);
exit(1);
}
std::unique_ptr<TcpServer> ptr_srv(new TcpServer(atoi(argv[1])));
ptr_srv->init();
ptr_srv->start();
return 0;
}
6.2 TCP协议客户端的封装
TCP协议客户端,需要先调用connect函数,尝试与服务器建立链接,才能与服务器正常通信。
运行TCP协议客户端的时候,需要显示给的IP地址和服务器对应的端口号。
tcp_client.hpp文件(客户端封装):
#pragma once
#include "Log.hpp"
#include <iostream>
#include <unistd.h>
#include <string>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/wait.h>
#include <unistd.h>
class TcpClient
{
public:
TcpClient(const std::string& ip, uint16_t port)
: _ip(ip), _port(port), _sock(-1)
{ }
// 初始化客户端
void init()
{
// 创建socket文件描述符
_sock = socket(AF_INET, SOCK_STREAM, 0);
if(_sock < 0)
{
logMessage(FATAL, "socket error, %d:%s\n", errno, strerror(errno));
exit(2);
}
logMessage(NORMAL, "socket success, _sock:%d\n", _sock);
// 客户端与服务端连接
struct sockaddr_in sock_srv;
memset(&sock_srv, 0, sizeof(sock_srv));
sock_srv.sin_family = AF_INET;
sock_srv.sin_addr.s_addr = inet_addr(_ip.c_str());
sock_srv.sin_port = htons(_port);
socklen_t len = sizeof(sock_srv);
if(connect(_sock, (struct sockaddr *)&sock_srv, len) < 0)
{
logMessage(FATAL, "connect error, %d:%s\n", errno, strerror(errno));
exit(3);
}
logMessage(NORMAL, "connect success\n");
logMessage(NORMAL, "Client Init Success\n");
}
void start()
{
std::string msg; // 发送的消息
char buffer[1024]; // 接受服务器发回的消息
while(true)
{
std::cout << "请输入你要发送的消息: " << std::flush;
std::getline(std::cin, msg);
if(msg == "quit")
break;
ssize_t n = send(_sock, msg.c_str(), msg.size(), 0);
if(n > 0)
{
logMessage(NORMAL, "成功发送数据!\n");
ssize_t s = recv(_sock, buffer, 1023, 0);
if(s > 0)
{
buffer[s] = '\0';
printf("回显# %s\n", buffer);
}
else if(s == 0)
{
logMessage(DEBUG, "服务器退出!\n");
break;
}
else
{
logMessage(DEBUG, "获取服务器发回数据失败!\n");
continue;
}
}
}
}
~TcpClient()
{
if(_sock < 0)
{
close(_sock);
}
}
private:
std::string _ip;
uint16_t _port;
int _sock;
};
tcp_client.cc文件(客户端源文件):
#include "tcp_server.hpp"
#include <memory>
void Usage(const char *proc)
{
std::cout << "\nServer Usage# " << proc << " ServerPort\n" << std::endl;
}
int main(int argc, const char **argv)
{
if(argc != 2)
{
Usage(argv[0]);
exit(1);
}
std::unique_ptr<TcpServer> ptr_srv(new TcpServer(atoi(argv[1])));
ptr_srv->init();
ptr_srv->start();
return 0;
}
七. 总结
- 在网络中,每一台主机都有一个独立的ip地址,每台主机上的进程都可以对应与一个或多个端口号,但是一个端口号不能够对应多个进程。
- 网络通信,本质上是网络中两台主机上的相应进程之间的进程间通信,通过 IP + Port,可以标识全网中唯一的一个进程。
- 主机字节序有大端和小端之分,网络数据统一采用大端字节序,进行网络通信时,必须调用相应的接口函数,来实现网络格式和主机格式之间的转换。
- sockaddr结构体可用于网络(本地)通信,表示套接字,struct sockaddr_in专门用于网络通信,其成员包括网络协议族(AF_INET、AF_INET6、AF_LOCAL等)、主机ip以及端口号,用于socket编程的函数,接受的参数均为struct sockaddr类型。
- UDP协议全称用户数据报协议,使用UDP协议通信,不需要通信双方建立连接,通信不可靠,可能出现丢包、失帧等问题,但是UDP协议通信的成本较低,因此依旧存在广泛的应用。
- TCP协议全称传输控制协议,通信双方需要再建立好连接之后才可以通信,通信过程可靠,不会出现丢包等问题,但是可靠通信的成本也相对较高。