首页 「网络编程101」来封装一个简单的TCP服务吧!
文章
取消

「网络编程101」来封装一个简单的TCP服务吧!

我们已经熟悉了Linux中的TCP状态机与网络编程模型,让我们来简单回顾一下:无论是客户端还是服务器,我们都要先使用socket函数申请一个套接字作为与网络交互的交界面。对于服务器进程而言,为了让别人在熟知端口上找到自己,使用bind与端口号绑定,然后使用listen通知操作系统这是一个“被动”的套接字,外界有网络数据包发往这个套接字对应的端口时,请帮忙转发。使用accept函数可以获取一个与特定客户端通信的套接字,这个套接字是客户端调用connect与服务器完成三次握手后,由操作系统返回的。建立好连接后,双方用readwrite读写消息,进行正式通信。

-16354356966613 客户端-服务端 网络编程模型1

本文将对上述Linux提供的网络编程接口进行封装,以实现简单的TCP服务。

TcpListener

让我们聚焦于服务器运行的初始流程。为方便TCP服务器实现,我们首先封装TcpListener类,用于创建服务器监听用的套接字,以及调用accept获取与客户端连接的套接字。

1
2
3
4
5
6
7
class TcpListener {
public:
    void listen(int port, bool non_blocking = false);
    TcpConnection accept() const;

    int _listen_fd{-1};
};

listen:告诉操作系统,我想监听网络了

为了让操作系统帮我们办理监听的业务,我们要遵守业务规范,走完socketbindlisten三个流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void TcpListener::listen(int port, bool non_blocking) {
    _listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (non_blocking) {
        fcntl(_listen_fd, F_SETFL, O_NONBLOCK);
    }

    struct sockaddr_in server_addr{};
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(port);

    int on = 1;
    setsockopt(_listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

    int err = bind(_listen_fd, (struct sockaddr *) &server_addr, sizeof(server_addr));
    if (err < 0) {
        cerr << "TcpServer bind error: " << strerror(errno) << endl;
        exit(1);
    }

    err = ::listen(_listen_fd, 1024);
    if (err < 0) {
        cerr << "TcpServer listen error: " << strerror(errno) << endl;
        exit(1);
    }

    signal(SIGPIPE, SIG_IGN);
}

socket

socket2函数可以向系统申请一个网络套接字,用于网络通信,就好比我们为了使用电信网络要先有一部手机。与手机、电话一样,套接字有许多类型,对应于不同的传输协议。

1
2
3
#include <sys/socket.h>

int socket(int domain, int type, int protocol);

domain指定了通信协议族,type指定了套接字的类型,protocol指定套接字使用的协议。domaintype确定时,支持的protocol通常是唯一的,因此传入0让系统自动选择。

常用的domain包括:

domain含义
AF_INETIPv4协议
AF_INET6IPv6协议
AF_LOCAL / AF_UNIX本地套接字

常用的type包括:

type含义
SOCK_STREAM全双工、有连接、可靠的字节流,对应 TCP
SOCK_DGRAM无连接、不可靠的数据报,对应 UDP

专门用于监听客户端请求的套接字称为listen_fd,为了让该套接字服务于此目的,我们还需要进一步加工,将其传入bindlisten函数处理。

bind

bind3函数用于将一个协议地址赋予申请的套接字。协议地址要符合套接字对应协议的地址格式,就像手机号码和电话号码有不同的格式一样。例如,Linux本地套接字的地址可能是unix:///path/to/socket/file,而TCP套接字的地址则是<IP地址>:<端口号>。Linux文档中说bind的作用是将套接字绑定到一个“名字”上,其实就是显式地去指定套接字的地址。对于客户端来说,不需要使用bind,是因为客户端在发起请求时,操作系统会自动帮我们找到一个空闲的端口号,赋予套接字地址。

1
2
3
4
#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr,
        socklen_t addrlen);

对于服务器来说,需要我们自己在服务对应的熟知端口号上进行监听,因此使用bind

除了指定端口号外,进程还可以指定IP地址,该地址需要是属于主机网口的地址。一个主机可能有多个IP地址,此时若指定IP地址,则只能通过指定的IP地址访问到进程。在有多个IP地址的情况下,使用通配地址,可以让操作系统自行选择一个IP地址来填充TCP报文。通常,选择的IP地址是客户端发来的数据包中的目的地址。选择IPv4协议时,通配地址为INADDR_ANY

我们也可以让操作系统来自行选择要绑定的端口,此时端口号设置为0,在实践时并不常用。

1
2
3
4
5
6
struct sockaddr_in server_addr{};
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(port);
bind(_listen_fd, (struct sockaddr *) &server_addr, sizeof(server_addr));

上面的代码段演示了IPv4地址sockaddr_in的设置和bind绑定。其中,htonlhtons函数是netinet/in.h中提供的字节序转换函数,hhostnnetls分别代表long(32位)和short(16位)。IP协议使用大端序传输数据,即写数据时,起始地址为高字节,与人的书写习惯一致;而不同的主机可能有不同的端序,因此在通信时要使用hton*ntoh*进行字节序格式转换。

-16355148157611 小端序与大端序1

bind传入地址时,将sockaddr_in指针转为sockaddr通用套接字地址的指针类型,并传入地址长度用于标识指针指向的套接字地址类型。

listen

bind让套接字和地址关联,相当于有了手机号码。为了真正在网络中接到电话,我们要将手机开机并打开铃声,这样有人呼入时我们才能有响应,这就是listen4函数所做的。

1
2
3
#include <sys/socket.h>

int listen(int sockfd, int backlog);

listen将主动套接字变为被动套接字,操作系统随即为我们建立连接队列,为接收数据包做准备。

-16355148497773 连接队列与三次握手1

backlog指定了连接队列的大小,当未就绪连接数堆积到该大小后,新来的连接会被忽略,对端会受到ECONNREFUSED错误。此时客户端可以选择在稍后重传请求。

-16354356966617 连接队列1

如果要让别人打通电话,还需要我们把电话设备接入电话线,让服务器真正处于可接听的状态,这个过程需要依赖listen函数。

至此,我们的TcpListener完成了监听工作,可以开始接收来自客户端的连接了。

accept:接听电话,开始服务

在经历了socketbindlisten的流程后,服务器终于可以开始通过accept5获取与客户端之间的连接了。accept函数定义如下:

1
2
3
4
#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *restrict addr,
          socklen_t *restrict addrlen);

该函数通过addraddrlen返回客户端的地址信息,函数的返回值则是与客户端之间的连接套接字conn_fd。与listen_fd不同,conn_fd对应于具体的客户端与服务端之间的协议地址对(即客户端与服务器的IP+端口号)。不同的客户端与服务器之间的连接对应于不同的conn_fd,彼此互不影响,可以独立进行收发和关闭。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
TcpConnection TcpListener::accept() const {
    if (_listen_fd == -1) {
        cerr << "TcpListener accept error: not listening\n";
        return TcpConnection{-1};
    }
    struct sockaddr_storage ss{};
    socklen_t len = sizeof(ss);
    int conn_fd = ::accept(_listen_fd, (struct sockaddr *) &ss, &len);
    if (conn_fd < 0) {
        cerr << "TcpServer accept error: " << strerror(errno) << endl;
        exit(1);
    }
    return TcpConnection{conn_fd};
}

在我们的TcpListener中,accept执行完毕后会返回一个conn_fd的封装,即TCP连接。

TcpConnection

在正式建立连接后,服务器就可以接收客户端请求并响应了。套接字与文件一样,在Linux中可以用read6write7来读取和写入数据。我们可以对套接字进行封装以方便通信。

1
2
3
4
5
6
7
8
9
10
11
class TcpConnection {
public:
    explicit TcpConnection(int conn_fd) : _conn_fd(conn_fd) {};

    int blocking_send(const std::string &msg) const;
    int blocking_send_n(const char *msg, int n) const;
    std::string blocking_receive_line() const;
    int blocking_receive_n(char *msg, const int n) const;

    int _conn_fd{-1};
};

接收数据

我们可以用readwrite读写文件标识符,对于套接字,还可以用recv8send9以支持更多选项。默认情况下,套接字标识符是阻塞的,即对其读写时,若未满足读写的条件,进程会被阻塞。直到读写完成后,返回读写结果并唤醒进程。因此,我们将函数命名为blocking_receive_nn表示读取的字节数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int TcpConnection::blocking_receive_n(char *msg, const int n) const {
    int remain = n;
    int rc;
    char *ptr = msg;

    while (remain) {
        if ((rc = ::recv(_conn_fd, ptr, remain, 0)) < 0) {
            if (errno == EINTR) {
                rc = 0;
            } else {
                cerr << "TcpConnection blocking_receive_n error: " << strerror(errno) << endl;
                return -1;
            }
        } else if (rc == 0) {
            break;
        }
        remain -= rc;
        ptr += rc;
    }
    return n - remain;
}

其中,recv8的函数定义如下:

1
2
3
#include <sys/socket.h>

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

读取的数据通过buf指针返回,最多读len字节。返回值为实际读取的字节数量。因此,在blocking_receive_n中,不断读取并减少剩余未读的字节数remain,直到减少到0。尽管如此,该函数每次读取的字节数也不一定是n,因为客户端的发来的数据可能并没有这么多。此时,该函数也会提前返回,返回值也为实际读取的字节数。

为了方便测试,我们可以写另外一个工具函数blocking_receive_line,用于读取客户端发来的一行消息(假设客户端的消息格式为字符串)。该函数每次读取1个字节,判断该字符是否是换行符,是则返回读到的字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
string TcpConnection::blocking_receive_line() const {
    if (_conn_fd == -1) {
        cerr << "TcpConnection receive error: not connected\n";
        return "";
    }

    string msg;
    char c;
    int rc;

    while (true) {
        if ((rc = ::recv(_conn_fd, &c, 1, 0)) == 1) {
            msg += c;
            if (c == '\n') {
                break;
            }
        } else if (rc == 0) {
            break;
        } else {
            if (errno == EINTR) continue;
            cerr << "TcpConnection blocking_receive_line error: " << strerror(errno) << endl;
            break;
        }
    }
    return msg;
}

发送数据

与接收数据类似,我们用send函数发送消息。默认情况下,发送也是阻塞式的。在循环中不断调用send,直到我们要发送的数据全部送出去了。Linux提供的send9函数参数与recv类似,发送的数据也是通过指针与长度确定的。为了方便调用,我们可以在原始的blocking_send_n上封装一层,用户只需要向blocking_send函数传入字符串即可发送消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int TcpConnection::blocking_send_n(const char *msg, const int n) const {
    int remain = n;
    int wc;
    const char *ptr = msg;

    while (remain) {
        if ((wc = ::send(_conn_fd, ptr, remain, 0)) <= 0) {
            if (wc < 0 && errno == EINTR) continue;
            cerr << "TcpConnection blocking_send_n error: " << strerror(errno) << endl;
            return -1;
        }
        remain -= wc;
        ptr += wc;
    }
    return n;
}

int TcpConnection::blocking_send(const string &msg) const {
    return blocking_send_n(msg.c_str(), msg.size());
}

TcpClient

与服务器相比,TcpClient的实现则方便许多,只需要考虑connect即可。Linux提供了connect10函数用于发起TCP的三次握手。与之前类似,我们需要先调用socket创建一个套接字conn_fd,并将其传入connect。同时,指定服务器的地址一起传入。inet_pton函数可以将字符串形式的IP地址转为sin_addr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class TcpClient {
public:
    TcpConnection connect(std::string address, int port);
};

TcpConnection TcpClient::connect(std::string address, int port) {
    int conn_fd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in server_addr{};
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(port);
    inet_pton(AF_INET, address.c_str(), &server_addr.sin_addr);

    int err = ::connect(conn_fd, (struct sockaddr *) &server_addr, sizeof(server_addr));
    if (err < 0) {
        cerr << "TcpClient connect error: " << strerror(errno) << endl;
        exit(1);
    }
    return TcpConnection{conn_fd};
}

TcpClientconnect函数在连接成功后,同样返回一个TCP连接。

让我们来验收一下成果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int main() {
    TcpClient client;
    TcpConnection conn = client.connect("127.0.0.1", 8888);
    conn.blocking_send("hello 1");
    conn.blocking_send(" hello 2\n");
    cout << "client received: " << conn.blocking_receive_line();

    struct {
        u_int32_t f1_length;
        u_int32_t f2_length;
        char data[128] = "client field 1client field 2";
    } message;
    message.f1_length = htonl(14);
    message.f2_length = htonl(14);
    conn.blocking_send_n((char *) &message,
                         sizeof(message.f1_length) + sizeof(message.f2_length) + strlen(message.data));

    conn.close();
}

在上述客户端测试程序中,发送的消息有两种格式。一种是字符串类型,一种是自定义的结构体类型message。对于字符串,我们可以用blocking_send发送,而自定义的结构体类型只能序列化为二进制字节流,用blocking_send_n发送。在服务端则要对这段消息进行解码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
int main() {
    TcpListener listener;
    listener.listen(8888);
    while (true) {
        TcpConnection conn = listener.accept();
        string msg;
        if ((msg = conn.blocking_receive_line()).size() > 0) {
            cout << "server received: " << msg;
            conn.blocking_send(msg);
        }

        // decode message struct
        char buf[128];
        uint32_t f1_length, f2_length;
        int rc;

        rc = conn.blocking_receive_n((char *) &f1_length, sizeof(f1_length));
        if (rc != sizeof(f1_length)) {
            cerr << "receive f1_length error\n";
            continue;
        }
        f1_length = ntohl(f1_length);

        rc = conn.blocking_receive_n((char *) &f2_length, sizeof(f2_length));
        if (rc != sizeof(f2_length)) {
            cerr << "receive f2_length error\n";
            continue;
        }
        f2_length = ntohl(f2_length);

        rc = conn.blocking_receive_n(buf, f1_length);
        if (rc != f1_length) {
            cerr << "receive field1 error\n";
            continue;
        }

        string f1(buf);
        cout << "server received: " << f1 << endl;

        rc = conn.blocking_receive_n(buf, f2_length);
        if (rc != f2_length) {
            cerr << "receive field2 error\n";
            continue;
        }

        string f2(buf);
        cout << "server received: " << f2 << endl;
    }
}

服务端对结构体解码时,首先读取结构体两个字段的长度,即读取f1_lengthf2_length的值,这样就可以根据字段长度读取对应的数据。测试程序的输出如下,符合预期。

1
2
3
4
5
6
$ ./test_client.out 
client received: hello 1 hello 2
$ ./test_server.out 
server received: hello 1 hello 2
server received: client field 1
server received: client field 2

总结

本文封装了实现TCP服务时所需要的基本类型,包括TcpListenerTcpConnectionTcpClient;熟悉了Linux网络编程中的常用函数,包括socketbindlistenacceptconnect。套接字在创建完成后,以文件描述符形式返回给用户程序,之后进行套接字相关操作时,都使用其文件描述符。

完整代码仓库

「网络编程101」系列全部代码可在我的GitHub代码仓库中查看:Captor: An easy-to-understand Reactor in C++

欢迎提出各类宝贵的修改意见和issues,指出其中的错误和不足!

最后,感谢你读到这里,希望我们都有所收获!

References

本文由作者按照 CC BY 4.0 进行授权

TCP状态机和Linux网络编程

「网络编程101」提升效率,减少等待!