网络编程套接字

179 阅读25分钟

网络编程套接字

本节重点

  • 认识IP地址, 端口号, 网络字节序等网络编程中的基本概念;
  • 学习socket api的基本用法;
  • 能够实现一个简单的udp客户端/服务器;
  • 能够实现一个简单的tcp客户端/服务器(单连接版本, 多进程版本, 多线程版本);
  • 理解tcp服务器建立连接, 发送数据, 断开连接的流程;

预备知识

理解源IP地址和目的IP地址

在IP数据包头部中, 有两个IP地址, 分别叫做源IP地址, 和目的IP地址.

思考: 我们光有IP地址就可以完成通信了嘛? 想象一下发qq消息的例子, 有了IP地址能够把消息发送到对方的机器上, 但是还需要有一个其他的标识来区分出, 这个数据要给哪个程序进行解析.

认识端口号

端口号(port)是传输层协议的内容.

  • 端口号是一个2字节16位的整数;
  • 端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理;
  • IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;
  • 一个端口号只能被一个进程占用

理解 "端口号" 和 "进程ID"

我们之前在学习系统编程的时候, 学习了 pid 表示唯一一个进程; 此处我们的端口号也是唯一表示一个进程. 那么这 两者之间是怎样的关系?

为什么要用端口号?

进程重启后,进程ID会发生改变

另外, 一个进程可以绑定多个端口号; 但是一个端口号不能被多个进程绑定;

理解源端口号和目的端口号

传输层协议(TCP和UDP)的数据段中有两个端口号, 分别叫做源端口号和目的端口号. 就是在描述 "数据是谁发的, 要 发给谁";

认识TCP协议

此处我们先对TCP(Transmission Control Protocol 传输控制协议)有一个直观的认识; 后面我们再详细讨论TCP的一 些细节问题.

  • 传输层协议
  • 有连接
  • 可靠传输
  • 面向字节流

认识UDP协议

此处我们也是对UDP(User Datagram Protocol 用户数据报协议)有一个直观的认识; 后面再详细讨论.

  • 传输层协议
  • 无连接
  • 不可靠传输
  • 面向数据报

网络字节序

我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏 移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?

  • 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
  • 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
  • 因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
  • TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.
  • 不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据;
  • 如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可;

为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络 字节序和主机字节序的转换

htonl()ntohl()

#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);

uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
  • 这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
  • 例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
  • 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
  • 如果主机是大端字节序,这些 函数不做转换,将参数原封不动地返回。

IP地址转换函数inet_addr(),inet_aton(),inet_ntoa(),int_pton()

1. 字符串转 in_addr 结构体

in_addr_t inet_addr(const char *cp)

该函数将点分十进制的字符串表示的IPv4地址转换为网络字节序的32位整数。返回的是in_addr_t类型,通常用于填充sin_addr.s_addr字段。

const char *ipString = "192.168.1.1";
in_addr_t ipAddress = inet_addr(ipString);

int inet_pton(int af, const char *src, void *dst)

这个函数是更通用的函数,支持IPv4和IPv6地址的转换。第一个参数 af 表示地址族,常用的是 AF_INET(IPv4)和 AF_INET6(IPv6)。第二个参数 src 是输入的字符串表示的IP地址,第三个参数 dst 是输出的二进制表示的IP地址。

#include <arpa/inet.h>
 
struct in_addr ipv4Address;
const char *ipString = "192.168.1.1";
inet_pton(AF_INET, ipString, &(ipv4Address.s_addr));

2. in_addr 结构体转字符串

char *inet_ntoa(struct in_addr in);

该函数将in_addr结构体中的IPv4地址转换为点分十进制的字符串表示。需要注意的是,返回的是指向静态缓冲区的指针,因此不宜多次调用。

#include <arpa/inet.h>
 
char *inet_ntoa(struct in_addr in);

#include <arpa/inet.h>
 
struct in_addr ipv4Address;
ipv4Address.s_addr = inet_addr("192.168.1.1");
char *ipString = inet_ntoa(ipv4Address);

inet_ntop

#include <arpa/inet.h>
 
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

这是一个更通用的函数,支持IPv4和IPv6地址的转换。第一个参数 af 表示地址族,常用的是 AF_INET(IPv4)和 AF_INET6(IPv6)。第二个参数 src 是输入的二进制表示的IP地址。第三个参数 dst 是输出的字符串表示的IP地址的缓冲区。第四个参数 size 是缓冲区的大小。

#include <arpa/inet.h>
 
struct in_addr ipv4Address;
ipv4Address.s_addr = inet_addr("192.168.1.1");
char ipString[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &(ipv4Address.s_addr), ipString, INET_ADDRSTRLEN);

socket编程结构

socket 常见API

// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
// 绑定端口号 (TCP/UDP, 服务器)      
int bind(int socket, const struct sockaddr *address,
         socklen_t address_len);
// 开始监听socket (TCP, 服务器)
int listen(int socket, int backlog);
// 接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address,
         socklen_t* address_len);
// 建立连接 (TCP, 客户端)
int connect(int sockfd, const struct sockaddr *addr,
         socklen_t addrlen);

sockaddr结构

socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及后面要讲的UNIX Domain Socket. 然而, 各种网络协议的地址格式并不相同.

sruct sockaddr:

16位地址类型 | 14字节地址数据

struct sockaddr_in

16位地址类型:AF__INET | 16位端口号 | 32位IP地址 | 8字节填充

struct sockaddr_un

16位地址类型:AF__UNIX | 108字节路径名

  • IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位地址类型, 16 位端口号和32位IP地址.
  • IPv4、IPv6地址类型分别定义为常数AF_INET、AF_INET6. 这样,只要取得某种sockaddr结构体的首地址, 不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容.
  • socket API可以都用struct sockaddr *类型表示, 在使用的时候需要强制转化成sockaddr_in; 这样的好 处是程序的通用性, 可以接收IPv4, IPv6, 以及UNIX Domain Socket各种类型的sockaddr结构体指针做为 参数;

sockaddr 结构

struct sockaddr {
unsigned short sa_family; /* address family, AF_xxx */
char sa_data[14]; /* 14 bytes of protocol address */
};

说明:

sa_family :是2字节的地址家族,一般都是“AF_xxx”的形式,它的值包括三种:AF_INETAF_INET6AF_UNSPEC

如果指定AF_INET,那么函数就不能返回任何IPV6相关的地址信息;如果仅指定了AF_INET6,则就不能返回任何IPV4地址信息。

AF_UNSPEC则意味着函数返回的是适用于指定主机名和服务名且适合任何协议族的地址。如果某个主机既有AAAA记录(IPV6)地址,同时又有A记录(IPV4)地址,那么AAAA记录将作为sockaddr_in6结构返回,而A记录则作为sockaddr_in结构返回

通常用的都是AF_INET。

sockaddr_in

虽然socket api的接口是sockaddr, 但是我们真正在基于IPv4编程时, 使用的数据结构是sockaddr_in; 这个结构里主 要有三部分信息: 地址类型, 端口号, IP地址.

struct sockaddr_in {
short int sin_family; /* Address family */
unsigned short int sin_port; /* Port number */
struct in_addr sin_addr; /* Internet address */
unsigned char sin_zero[8]; /* Same size as struct sockaddr */
};

sin_family:指代协议族,在socket编程中只能是AF_INET

sin_port:存储端口号(使用网络字节顺序

sin_addr,使用in_addr这个数据结构

sin_zero:是为了让sockaddr与sockaddr_in两个数据结构保持大小相同而保留的空字节。

而其中in_addr结构的定义如下:

in_addr用来表示一个IPv4的IP地址. 其实就是一个32位的整数;

typedef struct in_addr {
union {
struct{ unsigned char s_b1,s_b2, s_b3,s_b4;} S_un_b;
struct{ unsigned short s_w1, s_w2;} S_un_w;
unsigned long S_addr;
} S_un;
} IN_ADDR;

阐述下in_addr的含义,很显然它是一个存储ip地址的共用体有三种表达方式

第一种用四个字节来表示IP地址的四个数字;

第二种用两个双字节来表示IP地址;

第三种用一个长整型来表示IP地址。

给in_addr赋值的一种最简单方法是使用inet_addr函数,它可以把一个代表IP地址的字符串赋值转换为in_addr类型,

addrto.sin_addr.s_addr=inet_addr("192.168.0.2");

反函数inet_ntoa,可以把一个in_addr类型转换为一个字符串

udp API函数介绍

1. UDP编程框架

要使用UDP协议进行程序开发,我们必须首先得理解什么是什么是UDP?这里简单概括一下。

  • UDP(user datagram protocol)的中文叫用户数据报协议,属于传输层。UDP是面向非连接的协议,它不与对方建立连接,而是直接把我要发的数据报发给对方。所以UDP适用于一次传输数据量很少、对可靠性要求不高的或对实时性要求高的应用场景。正因为UDP无需建立类如三次握手的连接,而使得通信效率很高。
  • UDP的应用非常广泛,比如一些知名的应用层协议(SNMP、DNS)都是基于UDP的,想一想,如果SNMP使用的是TCP的话,每次查询请求都得进行三次握手,这个花费的时间估计是使用者不能忍受的,因为这会产生明显的卡顿。所以UDP就是SNMP的一个很好的选择了,要是查询过程发生丢包错包也没关系的,我们再发起一个查询就好了,因为丢包的情况不多,这样总比每次查询都卡顿一下更容易让人接受吧。

UDP通信的流程比较简单,因此要搭建这么一个常用的UDP通信框架也是比较简单的。以下是UDP的框架图。

由以上框图可以看出,客户端要发起一次请求,仅仅需要两个步骤(socket和sendto),而服务器端也仅仅需要三个步骤即可接收到来自客户端的消息(socket、bind、recvfrom)。

下面介绍程序中用到的socket API,这些函数都在sys/socket.h中。

socket()

#include <sys/types.h>          
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
  • domain: :AF_UNIX、AF_INET、AF_NS等(域间通信,ipv4通信,ipv6通信)

  • type: 建立的套接字的类型。这里分三种:

    • SOCK_STREAM: 提供了一个面向连接、可靠的数据传输服务,数据无差错、无重复地发送,且按发送顺序接收。内设流量控制,避免数据流超限;数据被看作是字节流,无长度限制。文件传送协议(FTP)即使用流式套接字。

    • SOCK_DGRAM: 提供了一个无连接服务。数据包以独立包形式被发送,不提供无错保证,数据可能丢失或重复,并且接收顺序混乱。网络文件系统(NFS)使用数据报式套接字。

    • SOCK_RAW: 该接口允许对较低层协议,如IP、ICMP直接访问。常用于检验新的协议实现或访问现有服务中配置的新设备.

  • protocol: 说明该套接字使用的特定协议,如果调用者不希望特别指定使用的协议,则置为0,使用默认的连接模式。

    • IPPROTO_TCP: 6
    • IPPROTO_UDP: 17
  • 返回值: 成功返回一个套接字描述符,失败返回-1

指定本地地址──bind()(客户端不推荐绑定,服务端绑定)

当一个套接字用socket()创建后,存在一个名字空间(地址族),但它没有被命名。bind()将套接字地址(包括本地主机地址和本地端口地址)与所创建的套接字号联系起来,即将名字赋予套接字,以指定本地半相关。其调用格式如下:

int bind(int sockfd, const struct sockaddr  * addr, socklen_t len);
  • sockfd: socket返回的套接字描述符
  • addr: 要绑定的地址信息
  • len: socketaddr的长度
  • 返回值:成功返回0,失败返回-1

关于“bind”: 1、client端的socket不需要bind,内核会自动选择一个未被占用的port供client来使用,如果有多个可用的连接(多个IP),内核会根据优先级选择一个IP作为源IP使用。 2、如果socket使用bind绑定到特定的IP和port,则无论是TCP还是UDP,都会从指定的IP和port发送数据。

sendto()

#include <sys/types.h>
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);
  • sockfd:正在监听端口的套接口文件描述符,通过socket获得
  • buf:发送缓冲区,往往是使用者定义的数组,该数组装有要发送的数据
  • len:发送缓冲区的大小,单位是字节
  • flags:默认是0,阻塞发送,发送缓冲区数据满了就等着
  • dest_addr:指向接收数据的主机地址信息的结构体,也就是该参数指定数据要发送到哪个主机哪个进程
  • addrlen:表示第五个参数所指向内容的长度addrlen:表示第五个参数所指向内容的长度
  • 返回值: 成功:返回发送成功的数据长度 ,失败: -1

recvfrom()

#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);

sockfd:正在监听端口的套接口文件描述符,通过socket获得

buf:接收缓冲区,往往是使用者定义的数组,该数组装有接收到的数据

len:接收缓冲区的大小,单位是字节

flags:填0即可

src_addr:指向发送数据的主机地址信息的结构体,也就是我们可以从该参数获取到数据是谁发出的

addrlen:表示第五个参数所指向内容的长度

  • 返回值:成功:返回接收成功的数据长度
  • 失败: -1

close()

int close(int sockfd);

简单的UDP网络程序

实现一个简单的英译汉的功能

备注: 代码中会用到 地址转换函数 . 参考接下来的章节.

封装 UdpSocket

udp_socket.hpp

#pragma once
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <cassert>
#include <string>
#include <unistd.h>
#include <sys/socket.h>    //套接字接口头文件
#include <netinet/in.h>    //地址结构类型以及协议类型宏头文件
#include <arpa/inet.h>  //字节序转换接口头文件
typedef struct sockaddr sockaddr;
typedef struct sockaddr_in sockaddr_in;
class UdpSocket {
public: 
  UdpSocket() : fd_(-1) {
 }
bool Socket() {
    fd_ = socket(AF_INET, SOCK_DGRAM, 0);
    if (fd_ < 0) {
      perror("socket");
      return false;
   }
    return true;
 }
  bool Close() {
    close(fd_);
    return true;
 }
  bool Bind(const std::string& ip, uint16_t port) {
    sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = inet_addr(ip.c_str());
    addr.sin_port = htons(port);
    int ret = bind(fd_, (sockaddr*)&addr, sizeof(addr));
    if (ret < 0) {
      perror("bind");
      return false;
   }
    return true;
 }
  bool RecvFrom(std::string* buf, std::string* ip = NULL, uint16_t* port = NULL) {
    char tmp[1024 * 10] = {0};
    sockaddr_in peer;
    socklen_t len = sizeof(peer);
    ssize_t read_size = recvfrom(fd_, tmp,
                                  sizeof(tmp) - 1, 0, (sockaddr*)&peer, &len);
    if (read_size < 0) {
      perror("recvfrom");
      return false;
   }
    // 将读到的缓冲区内容放到输出参数中
    buf->assign(tmp, read_size);
    if (ip != NULL) {
      *ip = inet_ntoa(peer.sin_addr);
   }
    if (port != NULL) {
      *port = ntohs(peer.sin_port);
   }
    return true;
 }
  bool SendTo(const std::string& buf, const std::string& ip, uint16_t port) {
    sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = inet_addr(ip.c_str());
  addr.sin_port = htons(port);
    ssize_t write_size = sendto(fd_, buf.data(), buf.size(), 0, (sockaddr*)&addr, 
sizeof(addr));
    if (write_size < 0) {
      perror("sendto");
      return false;
   }
    return true;
 }
private:
  int fd_; 
};

简单的TCP网络程序

TCP socket API 详解

下面介绍程序中用到的socket API,这些函数都在sys/socket.h中

listen()

listen (struct proc ∗p, struct listen_args ∗uap, int ∗retval)
struct listen_args
{ int s;
   int backlog;
};

s是套接字描述符。

backlog是套接字上连接数的队列限制。

connect()

int connect(int sockfd, struct sockaddr *serv_addr, int addrlen);

sockfd是目的服务器的sockt描述符;serv_addr是服务器端的IP地址和端口号的地址。遇到错误时返回-1,并且errno中包含相应的错误码。进行客户端程序设计无须调用bind(),因为这种情况下只需知道目的机器的IP地址,而客户通过哪个端口与服务器建立连接并不需要关心,内核会自动选择一个未被占用的端口供客户端来使用。

accept()

sockfd是被监听的服务器socket描述符,addr通常是一个指向sockaddr_in变量的指针,该变量用来存放提出连接请求的客户端地址;addrten通常为一个指向值为sizeof(struct sockaddr_in)的整型指针变量。错误发生时返回一个-1并且设置相应的errno值。accept()函数将返回一个新的socket描述符,来供这个新连接来使用,在新的socket描述符上进行数据send()和recv()操作。

send()

int send(int sockfd, const void *msg, int len, int flags);

sockfd是你想用来传输数据的socket描述符,msg是一个指向要发送数据的指针。 len是以字节为单位的数据的长度。flags一般情况下置为0(关于该参数的用法可参照man手册)

recv()

int recv(int sockfd,void *buf,int len,unsigned int flags);

sockfd是接受数据的socket描述符;buf 是存放接收数据的缓冲区;len是缓冲的长度。flags也被置为0。recv()返回实际上接收的字节数,或当出现错误时,返回-1并置相应的errno值。

封装 TCP socket

#pragma once
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <string>
#include <cassert>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
typedef struct sockaddr sockaddr;
typedef struct sockaddr_in sockaddr_in;
#define CHECK_RET(exp) if (!(exp)) {\
  return false;\
}
class TcpSocket {
public:
  TcpSocket() : fd_(-1) { }
  TcpSocket(int fd) : fd_(fd) { }
  bool Socket() {
    fd_ = socket(AF_INET, SOCK_STREAM, 0);
    if (fd_ < 0) {
perror("socket");
      return false;
   }
    printf("open fd = %d\n", fd_);
    return true;
 }
  bool Close() const {
    close(fd_);
    printf("close fd = %d\n", fd_);
    return true;
 }
  bool Bind(const std::string& ip, uint16_t port) const {
    sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = inet_addr(ip.c_str());
    addr.sin_port = htons(port);
    int ret = bind(fd_, (sockaddr*)&addr, sizeof(addr));
    if (ret < 0) {
      perror("bind");
      return false;
   }
    return true;
 }
  bool Listen(int num) const {
    int ret = listen(fd_, num);
    if (ret < 0) {
      perror("listen");
      return false;
   }
    return true;
 }
  bool Accept(TcpSocket* peer, std::string* ip = NULL, uint16_t* port = NULL) const {
    sockaddr_in peer_addr;
    socklen_t len = sizeof(peer_addr);
    int new_sock = accept(fd_, (sockaddr*)&peer_addr, &len);
    if (new_sock < 0) {
      perror("accept");
      return false;
   }
    printf("accept fd = %d\n", new_sock);
    peer->fd_ = new_sock;
    if (ip != NULL) {
      *ip = inet_ntoa(peer_addr.sin_addr);
   }
    if (port != NULL) {
      *port = ntohs(peer_addr.sin_port);
   }
    return true;
 }
bool Recv(std::string* buf) const {
    buf->clear();
    char tmp[1024 * 10] = {0};
    // [注意!] 这里的读并不算很严谨, 因为一次 recv 并不能保证把所有的数据都全部读完
    // 参考 man 手册 MSG_WAITALL 节. 
    ssize_t read_size = recv(fd_, tmp, sizeof(tmp), 0);
    if (read_size < 0) {
      perror("recv");
      return false;
   }
    if (read_size == 0) {
      return false;
   }
    buf->assign(tmp, read_size);
    return true;
 }
  bool Send(const std::string& buf) const {
    ssize_t write_size = send(fd_, buf.data(), buf.size(), 0);
    if (write_size < 0) {
      perror("send");
      return false;
   }
    return true;
 }
  bool Connect(const std::string& ip, uint16_t port) const {
    sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = inet_addr(ip.c_str());
    addr.sin_port = htons(port);
    int ret = connect(fd_, (sockaddr*)&addr, sizeof(addr));
    if (ret < 0) {
      perror("connect");
      return false;
   }
    return true;
 }
  int GetFd() const {
    return fd_;
 }
private:
  int fd_;
};

TCP通用服务器

tcp_server.hpp

#pragma once
#include <functional>
#include "tcp_socket.hpp"
typedef std::function<void (const std::string& req, std::string* resp)> Handler;
class TcpServer {
public:
  TcpServer(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
 }
  bool Start(Handler handler) {
    // 1. 创建 socket;
    CHECK_RET(listen_sock_.Socket());
    // 2. 绑定端口号
    CHECK_RET(listen_sock_.Bind(ip_, port_));
    // 3. 进行监听
    CHECK_RET(listen_sock_.Listen(5));
    // 4. 进入事件循环
    for (;;) {
      // 5. 进行 accept
      TcpSocket new_sock;
      std::string ip;
      uint16_t port = 0;
      if (!listen_sock_.Accept(&new_sock, &ip, &port)) {
        continue;
     }
      printf("[client %s:%d] connect!\n", ip.c_str(), port);
      // 6. 进行循环读写
      for (;;) {
        std::string req;
        // 7. 读取请求. 读取失败则结束循环
        bool ret = new_sock.Recv(&req);
        if (!ret) {
          printf("[client %s:%d] disconnect!\n", ip.c_str(), port);
          // [注意!] 需要关闭 socket
          new_sock.Close();
          break;
       }
        // 8. 计算响应
        std::string resp;
        handler(req, &resp);
        // 9. 写回响应
        new_sock.Send(resp);
        printf("[%s:%d] req: %s, resp: %s\n", ip.c_str(), port,
                req.c_str(), resp.c_str());
     }
   }
    return true;
 }
private:
  TcpSocket listen_sock_;
  std::string ip_;
  uint64_t port_;
};

TCP通用客户端

tcp_client.hpp

#pragma once
#include "tcp_socket.hpp"
class TcpClient {
public:
  TcpClient(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
    // [注意!!] 需要先创建好 socket
    sock_.Socket();
 }
  ~TcpClient() {
   sock_.Close();
 }
  bool Connect() {
    return sock_.Connect(ip_, port_);
 }
  bool Recv(std::string* buf) {
    return sock_.Recv(buf);
 }
  bool Send(const std::string& buf) {
    return sock_.Send(buf);
 }
private:
  TcpSocket sock_;
  std::string ip_;
  uint16_t port_;
};

由于客户端不需要固定的端口号,因此不必调用bind(),客户端的端口号由内核自动分配.

注意:

  • 客户端不是不允许调用bind(), 只是没有必要调用bind()固定一个端口号. 否则如果在同一台机器上启动 多个客户端, 就会出现端口号被占用导致不能正确建立连接;
  • 服务器也不是必须调用bind(), 但如果服务器不调用bind(), 内核会自动给服务器分配监听端口, 每次启动 服务器时端口号都不一样, 客户端要连接服务器就会遇到麻烦;

测试多个连接的情况

  • 再启动一个客户端, 尝试连接服务器, 发现第二个客户端, 不能正确的和服务器进行通信.
  • 分析原因, 是因为我们accecpt了一个请求之后, 就在一直while循环尝试read, 没有继续调用到accecpt, 导致不能接 受新的请求.
  • 分析原因, 是因为我们accecpt了一个请求之后, 就在一直while循环尝试read, 没有继续调用到accecpt, 导致不能接 受新的请求.

简单的TCP网络程序(多进程版本)

通过每个请求, 创建子进程的方式来支持多连接;

tcp_process_server.hpp

#pragma once
#include <functional>
#include <signal.h>
#include "tcp_socket.hpp"
typedef std::function<void (const std::string& req, std::string* resp)> Handler;
// 多进程版本的 Tcp 服务器
class TcpProcessServer {
public:
  TcpProcessServer(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
    // 需要处理子进程
    signal(SIGCHLD, SIG_IGN);
 }
  void ProcessConnect(const TcpSocket& new_sock, const std::string& ip, uint16_t port,
                      Handler handler) {
    int ret = fork();
    if (ret > 0) {
      // father
      // 父进程不需要做额外的操作, 直接返回即可. 
      // 思考, 这里能否使用 wait 进行进程等待?
      // 如果使用 wait , 会导致父进程不能快速再次调用到 accept, 仍然没法处理多个请求
      // [注意!!] 父进程需要关闭 new_sock
      new_sock.Close();
      return;
   } else if (ret == 0) {
      // child
  // 处理具体的连接过程. 每个连接一个子进程
      for (;;) {
        std::string req;
        bool ret = new_sock.Recv(&req);
        if (!ret) {
          // 当前的请求处理完了, 可以退出子进程了. 注意, socket 的关闭在析构函数中就完成了
          printf("[client %s:%d] disconnected!\n", ip.c_str(), port);
          exit(0);
       }
        std::string resp;
        handler(req, &resp);
        new_sock.Send(resp);
        printf("[client %s:%d] req: %s, resp: %s\n", ip.c_str(), port, 
            req.c_str(), resp.c_str());
     }
   } else {
      perror("fork");
   }
 }
  bool Start(Handler handler) {
    // 1. 创建 socket;
    CHECK_RET(listen_sock_.Socket());
    // 2. 绑定端口号
    CHECK_RET(listen_sock_.Bind(ip_, port_));
    // 3. 进行监听
    CHECK_RET(listen_sock_.Listen(5));
    // 4. 进入事件循环
    for (;;) {
      // 5. 进行 accept
      TcpSocket new_sock;
      std::string ip;
      uint16_t port = 0;
      if (!listen_sock_.Accept(&new_sock, &ip, &port)) {
        continue;
     }
      printf("[client %s:%d] connect!\n", ip.c_str(), port);
      ProcessConnect(new_sock, ip, port, handler);
   }
    return true;
 }
private:
  TcpSocket listen_sock_;
  std::string ip_;
  uint64_t port_;
};

简单的TCP网络程序(多线程版本)

通过每个请求, 创建一个线程的方式来支持多连接;

tcp_thread_server.hpp

#pragma once
#include <functional>
#include <pthread.h>
#include "tcp_socket.hpp"
typedef std::function<void (const std::string&, std::string*)> Handler;
struct ThreadArg {
  TcpSocket new_sock;
  std::string ip;
  uint16_t port;
  Handler handler;
};
class TcpThreadServer {
public:
  TcpThreadServer(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
    
 }
  bool Start(Handler handler) {
    // 1. 创建 socket;
    CHECK_RET(listen_sock_.Socket());
    // 2. 绑定端口号
    CHECK_RET(listen_sock_.Bind(ip_, port_));
    // 3. 进行监听
    CHECK_RET(listen_sock_.Listen(5));
    // 4. 进入循环
    for (;;) {
      // 5. 进行 accept
      ThreadArg* arg = new ThreadArg();
      arg->handler = handler;
      bool ret = listen_sock_.Accept(&arg->new_sock, &arg->ip, &arg->port);
      if (!ret) {
        continue;
     }
      printf("[client %s:%d] connect\n", arg->ip.c_str(), arg->port);
      // 6. 创建新的线程完成具体操作
      pthread_t tid;
      pthread_create(&tid, NULL, ThreadEntry, arg);
      pthread_detach(tid);
   }
    return true;
 }
  // 这里的成员函数为啥非得是 static?
  static void* ThreadEntry(void* arg) {
  // C++ 的四种类型转换都是什么?
    ThreadArg* p = reinterpret_cast<ThreadArg*>(arg);
    ProcessConnect(p);
    // 一定要记得释放内存!!! 也要记得关闭文件描述符
    p->new_sock.Close();
    delete p;
    return NULL;
 }
  // 处理单次连接. 这个函数也得是 static 
  static void ProcessConnect(ThreadArg* arg) {
    // 1. 循环进行读写
    for (;;) {
      std::string req;
      // 2. 读取请求
      bool ret = arg->new_sock.Recv(&req);
      if (!ret) {
        printf("[client %s:%d] disconnected!\n", arg->ip.c_str(), arg->port);
        break;
     }
      std::string resp;
      // 3. 根据请求计算响应
      arg->handler(req, &resp);
      // 4. 发送响应
      arg->new_sock.Send(resp);
      printf("[client %s:%d] req: %s, resp: %s\n", arg->ip.c_str(),
              arg->port, req.c_str(), resp.c_str());
   }
 }
private:
  TcpSocket listen_sock_;
  std::string ip_;
  uint16_t port_;
};

TCP协议通讯流程

服务器初始化:

  • 调用socket, 创建文件描述符;
  • 调用bind, 将当前的文件描述符和ip/port绑定在一起; 如果这个端口已经被其他进程占用了, 就会bind失 败;
  • 调用listen, 声明当前这个文件描述符作为一个服务器的文件描述符, 为后面的accept做好准备;
  • 调用accecpt, 并阻塞, 等待客户端连接过来;

建立连接的过程:

  • 调用socket, 创建文件描述符;
  • 调用connect, 向服务器发起连接请求;
  • connect会发出SYN段并阻塞等待服务器应答; (第一次)
  • 服务器收到客户端的SYN, 会应答一个SYN-ACK段表示"同意建立连接"; (第二次)
  • 客户端收到SYN-ACK后会从connect()返回, 同时应答一个ACK段; (第三次)

这个建立连接的过程, 通常称为 三次握手;

数据传输的过程

  • 建立连接后,TCP协议提供全双工的通信服务; 所谓全双工的意思是, 在同一条连接中, 同一时刻, 通信双方 可以同时写数据; 相对的概念叫做半双工, 同一条连接在同一时刻, 只能由一方来写数据;
  • 服务器从accept()返回后立刻调 用read(), 读socket就像读管道一样, 如果没有数据到达就阻塞等待;
  • 这时客户端调用write()发送请求给服务器, 服务器收到后从read()返回,对客户端的请求进行处理, 在此期 间客户端调用read()阻塞等待服务器的应答;
  • 服务器调用write()将处理结果发回给客户端, 再次调用read()阻塞等待下一条请求;
  • 客户端收到后从read()返回, 发送下一条请求,如此循环下去;

断开连接的过程:

  • 如果客户端没有更多的请求了, 就调用close()关闭连接, 客户端会向服务器发送FIN段(第一次)
  • 此时服务器收到FIN后, 会回应一个ACK, 同时read会返回0 (第二次);
  • read返回之后, 服务器就知道客户端关闭了连接, 也调用close关闭连接, 这个时候服务器会向客户端发送 一个FIN; (第三次)
  • 客户端收到FIN, 再返回一个ACK给服务器; (第四次)

这个断开连接的过程, 通常称为 四次挥手

在学习socket API时要注意应用程序和TCP协议层是如何交互的:

  • 应用程序调用某个socket函数时TCP协议层完成什么动作,比如调用connect()会发出SYN段
  • 应用程序如何知道TCP协议层的状态变化,比如从某个阻塞的socket函数返回就表明TCP协议收到了某些 段,再比如read()返回0就表明收到了FIN段

TCP 和 UDP 对比

  • 可靠传输 vs 不可靠传输
  • 有连接 vs 无连接
  • 字节流 vs 数据报