我们已经熟悉了Linux中的TCP状态机与网络编程模型,让我们来简单回顾一下:无论是客户端还是服务器,我们都要先使用socket
函数申请一个套接字作为与网络交互的交界面。对于服务器进程而言,为了让别人在熟知端口上找到自己,使用bind
与端口号绑定,然后使用listen
通知操作系统这是一个“被动”的套接字,外界有网络数据包发往这个套接字对应的端口时,请帮忙转发。使用accept
函数可以获取一个与特定客户端通信的套接字,这个套接字是客户端调用connect
与服务器完成三次握手后,由操作系统返回的。建立好连接后,双方用read
和write
读写消息,进行正式通信。
客户端-服务端 网络编程模型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:告诉操作系统,我想监听网络了
为了让操作系统帮我们办理监听的业务,我们要遵守业务规范,走完socket
、bind
和listen
三个流程。
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
socket
2函数可以向系统申请一个网络套接字,用于网络通信,就好比我们为了使用电信网络要先有一部手机。与手机、电话一样,套接字有许多类型,对应于不同的传输协议。
1
2
3
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
domain
指定了通信协议族,type
指定了套接字的类型,protocol
指定套接字使用的协议。domain
和type
确定时,支持的protocol
通常是唯一的,因此传入0
让系统自动选择。
常用的domain
包括:
domain | 含义 |
---|---|
AF_INET | IPv4协议 |
AF_INET6 | IPv6协议 |
AF_LOCAL / AF_UNIX | 本地套接字 |
常用的type
包括:
type | 含义 |
---|---|
SOCK_STREAM | 全双工、有连接、可靠的字节流,对应 TCP |
SOCK_DGRAM | 无连接、不可靠的数据报,对应 UDP |
专门用于监听客户端请求的套接字称为listen_fd
,为了让该套接字服务于此目的,我们还需要进一步加工,将其传入bind
与listen
函数处理。
bind
bind
3函数用于将一个协议地址赋予申请的套接字。协议地址要符合套接字对应协议的地址格式,就像手机号码和电话号码有不同的格式一样。例如,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
绑定。其中,htonl
和htons
函数是netinet/in.h
中提供的字节序转换函数,h
即host
,n
即net
,l
和s
分别代表long
(32位)和short
(16位)。IP协议使用大端序传输数据,即写数据时,起始地址为高字节,与人的书写习惯一致;而不同的主机可能有不同的端序,因此在通信时要使用hton*
和ntoh*
进行字节序格式转换。
小端序与大端序1
向bind
传入地址时,将sockaddr_in
指针转为sockaddr
通用套接字地址的指针类型,并传入地址长度用于标识指针指向的套接字地址类型。
listen
bind
让套接字和地址关联,相当于有了手机号码。为了真正在网络中接到电话,我们要将手机开机并打开铃声,这样有人呼入时我们才能有响应,这就是listen
4函数所做的。
1
2
3
#include <sys/socket.h>
int listen(int sockfd, int backlog);
listen
将主动套接字变为被动套接字,操作系统随即为我们建立连接队列,为接收数据包做准备。
连接队列与三次握手1
backlog
指定了连接队列的大小,当未就绪连接数堆积到该大小后,新来的连接会被忽略,对端会受到ECONNREFUSED
错误。此时客户端可以选择在稍后重传请求。
连接队列1
如果要让别人打通电话,还需要我们把电话设备接入电话线,让服务器真正处于可接听的状态,这个过程需要依赖listen
函数。
至此,我们的TcpListener
完成了监听工作,可以开始接收来自客户端的连接了。
accept:接听电话,开始服务
在经历了socket
、bind
和listen
的流程后,服务器终于可以开始通过accept
5获取与客户端之间的连接了。accept
函数定义如下:
1
2
3
4
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *restrict addr,
socklen_t *restrict addrlen);
该函数通过addr
和addrlen
返回客户端的地址信息,函数的返回值则是与客户端之间的连接套接字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中可以用read
6和write
7来读取和写入数据。我们可以对套接字进行封装以方便通信。
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};
};
接收数据
我们可以用read
和write
读写文件标识符,对于套接字,还可以用recv
8和send
9以支持更多选项。默认情况下,套接字标识符是阻塞的,即对其读写时,若未满足读写的条件,进程会被阻塞。直到读写完成后,返回读写结果并唤醒进程。因此,我们将函数命名为blocking_receive_n
,n
表示读取的字节数。
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;
}
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提供的send
9函数参数与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提供了connect
10函数用于发起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};
}
TcpClient
的connect
函数在连接成功后,同样返回一个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_length
和f2_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服务时所需要的基本类型,包括TcpListener
、TcpConnection
和TcpClient
;熟悉了Linux网络编程中的常用函数,包括socket
、bind
、listen
、accept
和connect
。套接字在创建完成后,以文件描述符形式返回给用户程序,之后进行套接字相关操作时,都使用其文件描述符。
完整代码仓库
「网络编程101」系列全部代码可在我的GitHub代码仓库中查看:Captor: An easy-to-understand Reactor in C++
欢迎提出各类宝贵的修改意见和issues,指出其中的错误和不足!
最后,感谢你读到这里,希望我们都有所收获!