server.cpp
客户端与服务器建立连接之后,如何实现数据的读写操作?
- read 和 write 函数:这是标准的文件 I/O 函数,在网络编程中也可以用来进行数据的读写操作。read 函数从文件描述符中读取指定字节数的数据,而 write 函数向文件描述符中写入指定字节数的数据。这些函数适用于面向流(Stream)的协议,如 TCP,但不适用于面向消息(Message)的协议,如 UDP。
- 通过下图可以非常清晰的看到,服务器与客户端通过 read 函数读对方发送的数据,通过 write 函数向对方发送数据。
为什么要创建套接字 clnt_sockfd,而不是继续使用 sockfd?
- 服务器端接受到一个客户端的连接请求时,会创建一个新的套接字(clnt_sockfd)来处理这个连接,而原先的监听套接字(sockfd)仍然在阻塞模式下监听其他客户端的连接请求。新的套接字可以独立地进行数据传输,不影响服务器端对其他客户端的连接请求的处理,从而实现了并发处理多个客户端的连接请求和数据传输。
read 函数和 write 函数是用于在 socket 通信中进行数据读写的函数,具体的作用和用法如下:
- read函数的作用是从已连接的套接字中读取数据,其函数原型为:
ssize_t read(int fd, void *buf, size_t count);
其中,fd表示需要读取数据的文件描述符,buf表示接收数据的缓冲区,count表示需要读取的数据长度。
read 函数的返回值表示实际读取到的数据长度,如果返回值为 0,则表示连接已关闭;如果返回值为 -1,则表示出现错误,错误码 存 储在 errno 变量中。
- write函数的作用是向已连接的套接字中写入数据,其函数原型为:
ssize_t write(int fd, const void *buf, size_t count);
其中,fd 表示写入数据的文件描述符,buf 表示待写入数据的缓冲区,count 表示需要写入的数据长度。
write 函数的返回值表示实际成功写入的数据长度,如果返回值为 -1,则表示出现错误,错误码存储在 errno 变量中。
read 函数和 write 函数是否会阻塞?
-
在默认情况下,read 函数和 write 函数都是阻塞函数,当调用它们时,如果没有数据可读或无法写入数据,系统将自动阻塞等待,直到有数据可读或者可以写入数据为止。例如,当接收缓冲区没有数据可供读取时,read 函数会一直阻塞等待,直到有数据可读或者对端关闭连接为止。
-
如果需要使用非阻塞 IO 模式,可以使用 fcntl 函数的 F_SETFL 命令将文件描述符设置为非阻塞模式。此时,read 函数和 write 函数将不再阻塞,如果没有数据可读或无法写入数据,read 函数和 write 函数将立即返回,并设置 errno 变量为 EAGAIN(操作继续进行的条件不满足)。
-
另外,也可以使用 多路复用技术 (如select、poll、epoll等)来避免 read 函数和 write 函数的阻塞问题,这样可以 同时监视多个文件描述符 ,以便在任何一个文件描述符就绪时进行操作。
代码:
注释版
#include "util.h" // 自定义的错误处理函数 util
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h> // read 和 write 函数
int main() {
// fd 是 file descriptor 文件描述符。更多关于 socket fd :https://zhuanlan.zhihu.com/p/399651675。
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// socket返回的值是一个文件描述符,0、1、2 分别表示标准输入、标准输出、标准错误,所以其他打开的文件描述符都会大于 2, 错误时就返回 -1。
errif(sockfd == -1, "socket create error");
// 除 sin_family 参数外,sockaddr_in 内容以网络字节顺序表示。
struct sockaddr_in serv_addr;
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
serv_addr.sin_port = htons(8888);
// bind 函数返回 0 表示绑定成功,-1 表示绑定失败。
int rebid = bind(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr));
errif(rebid == -1, "socket bind error");
// listen 函数返回 0 表示调用成功,-1 表示调用失败。
int reltn = listen(sockfd, SOMAXCONN);
errif(reltn == -1, "sockfd listen error");
struct sockaddr_in clnt_addr;
bzero(&clnt_addr, sizeof(clnt_addr));
// accept 函数要求第三个参数是 socklen_t 类型。
socklen_t clnt_addr_len = sizeof(clnt_addr);
// accept 函数接受一个客户端请求后,若连接成功会返回一个新的非负 SOCKFD 值,若连接失败返回 -1。
int clnt_sockfd = accept(sockfd, (sockaddr*)&clnt_addr, &clnt_addr_len);
errif(clnt_sockfd == -1, "socket accept error");
printf("new client fd %d! IP: %s Port: %d\n", clnt_sockfd, inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port));
// 建立一个socket连接后,使用 <unistd.h> 头文件中 read 和 write 函数来进行网络接口的数据读写操作。
// 这两个函数用于 TCP 连接。如果是 UDP,需要使用 sendto 和 recvfrom。
while (true) {
// 定义缓冲区,存放从客户端读到的数据。
char buf[1024];
// 清空缓冲区。
bzero(&buf, sizeof(buf));
// 使用 write() 可以向套接字中写入数据,使用 read() 可以从套接字中读取数据。
// size_t 是通过 typedef 声明的 unsigned int 类型;ssize_t 在 "size_t" 前面加了一个"s",代表 signed,即 ssize_t 是通过 typedef 声明的 signed int 类型。
// read() 的原型:ssize_t read(int fd, void *buf, size_t nbytes);
// fd 为要读取的文件的描述符,buf 为要接收数据的缓冲区地址,nbytes 为要读取的数据的字节数。
ssize_t read_bytes = read(clnt_sockfd, buf, sizeof(buf));
// read() 函数会从 fd 文件中读取 nbytes 个字节并保存到缓冲区 buf,成功则返回读取到的字节数。
if(read_bytes > 0){
printf("message from client fd %d: %s\n", clnt_sockfd, buf);
// 将相同数据写回客户端。
// write() 的原型:ssize_t write(int fd, const void *buf, size_t nbytes);
// fd:要写入的文件的描述符。buf:要写入的数据的缓冲区地址。nbytes:要写入的数据的字节数。
write(clnt_sockfd, buf, sizeof(buf));
} else if(read_bytes == 0){ // read() 返回 0 表示 EOF ,规定如此。通常是对端关闭了连接。
printf("client fd %d disconnect\n", clnt_sockfd);
// 小细节:Linux 系统的文件描述符理论上是有限的,在使用完一个 fd 之后,需要使用头文件 <unistd.h> 中的 close 函数关闭。
close(clnt_sockfd);
break;
} else if(read_bytes == -1){ // read() 返回 -1,表示发生错误。
close(clnt_sockfd);
errif(true, "socket read error");
}
}
// 使用完一个 fd 后应该及时关闭。
close(sockfd);
return 0;
}
无注释版
#include "util.h"
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
errif(sockfd == -1, "socket create error");
struct sockaddr_in serv_addr;
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
serv_addr.sin_port = htons(8888);
int rebid = bind(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr));
errif(rebid == -1, "socket bind error");
int reltn = listen(sockfd, SOMAXCONN);
errif(reltn == -1, "sockfd listen error");
struct sockaddr_in clnt_addr;
bzero(&clnt_addr, sizeof(clnt_addr));
socklen_t clnt_addr_len = sizeof(clnt_addr);
int clnt_sockfd = accept(sockfd, (sockaddr*)&clnt_addr, &clnt_addr_len);
errif(clnt_sockfd == -1, "socket accept error");
printf("new client fd %d! IP: %s Port: %d\n", clnt_sockfd, inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port));
while (true) {
char buf[1024];
bzero(&buf, sizeof(buf));
ssize_t read_bytes = read(clnt_sockfd, buf, sizeof(buf));
if(read_bytes > 0){
printf("message from client fd %d: %s\n", clnt_sockfd, buf);
write(clnt_sockfd, buf, sizeof(buf));
} else if(read_bytes == 0){
printf("client fd %d disconnect\n", clnt_sockfd);
close(clnt_sockfd);
break;
} else if(read_bytes == -1){
close(clnt_sockfd);
errif(true, "socket read error");
}
}
close(sockfd);
return 0;
}
client.cpp
客户端的逻辑与服务器端相同,想要读取对方发来的数据时,用 read 函数,想要向对方发送数据时,用 write 函数。这里的发送与读取都是使用 系统调用 在套接字上完成的。简单说套接字就是 IP 地址和端口号;准确说套接字是一种用于网络通信的编程接口和抽象概念。
套接字是一种 特殊类型的文件描述符 。在 Linux/Unix 系统中,所有的 I/O(包括文件、套接字、管道等)都用文件描述符来进行表示和操作,因此套接字实际上也属于文件描述符的一种。但是套接字并不是一个真正的文件,而是一个通信端点,并提供了专门的系统调用用于在程序和远程主机之间进行数据传输。
代码:
注释版
#include "util.h"
#include <stdio.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
#include <unistd.h>
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
errif(sockfd == -1, "socket create error");
struct sockaddr_in serv_addr;
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
serv_addr.sin_port = htons(8888);
// connect 函数参数包括一个套接字描述符,一个指向目标主机地址和端口号的结构体指针,和该结构体的大小。
// connect函数要进行TCP三次握手,如果成功则返回0,如果失败则返回-1。connect函数的失败是通过超时来控制的,它会在规定的时间内发起多次连接。
int recnt = connect(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr));
errif(recnt == -1, "socket connect error");
while(true) {
// 定义缓冲区,存放从键盘输入到服务器的数据。
char buf[1024];
// 清空缓冲区。
bzero(&buf, sizeof(buf));
printf("请输入数据:\n");
scanf("%s", buf);
// 发送缓冲区中的数据到服务器socket,返回已发送数据大小。
ssize_t write_bytes = write(sockfd, buf, sizeof(buf));
// write() 函数会将缓冲区 buf 中的 sizeof(buf) 个字节写入文件 sockfd,成功则返回写入的字节数,失败则返回 -1。
if(write_bytes == -1){
printf("socket already disconncted, can't write any more!\n");
break;
}
// 清空缓冲区。
bzero(&buf, sizeof(buf));
// 从服务器 socket 读到缓冲区,返回已读数据大小。
ssize_t read_bytes = read(sockfd, buf, sizeof(buf));
// 在 read() 中,<0 代表出错(实际上只用到了 -1),==0 代表 EOF,>0 才是读取成功,此时代表字节数。关于 read() 返回 0:https://www.zhihu.com/question/355020251
if(read_bytes > 0) {
printf("message from server: %s\n", buf);
} else if(read_bytes == 0) { // read 返回 0,表示 EOF,通常是服务器断开连接,等会儿可以通过人为终止服务器连接进行测试
printf("server socket disconnected!\n");
break;
} else if(read_bytes == -1) { // read 返回 -1,表示发生错误,按照上文方法进行错误处理。
// 发生错误,等下调用错误处理函数后会自动退出程序,所以需要提前关闭 sockfd。
close(sockfd);
errif(true, "socket read error");
}
}
close(sockfd);
return 0;
}
// 两台计算机之间的通信相当于两个套接字之间的通信.
// 在服务器端用 write() 向套接字写入数据,客户端就能收到,然后再使用 read() 从套接字中读取出来,就完成了一次通信。
无注释版
#include "util.h"
#include <stdio.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
#include <unistd.h>
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
errif(sockfd == -1, "socket create error");
struct sockaddr_in serv_addr;
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
serv_addr.sin_port = htons(8888);
int recnt = connect(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr));
errif(recnt == -1, "socket connect error");
while(true) {
char buf[1024];
bzero(&buf, sizeof(buf));
printf("请输入数据:\n");
scanf("%s", buf);
ssize_t write_bytes = write(sockfd, buf, sizeof(buf));
if(write_bytes == -1){
printf("socket already disconncted, can't write any more!\n");
break;
}
bzero(&buf, sizeof(buf));
ssize_t read_bytes = read(sockfd, buf, sizeof(buf));
if(read_bytes > 0) {
printf("message from server: %s\n", buf);
} else if(read_bytes == 0) {
printf("server socket disconnected!\n");
break;
} else if(read_bytes == -1) {
close(sockfd);
errif(true, "socket read error");
}
}
close(sockfd);
return 0;
}
util.cpp
网络编程中的错误处理非常重要,可以帮助我们快速诊断和解决网络问题,保证程序的高可靠性和稳定性。
常见的错误处理方法:
- 检查函数返回值:在网络编程中,绝大多数的函数都会返回一个整数值,通常为 -1 表示出错,而其他值则表示成功执行。
- 程序退出处理:当程序遇到严重的错误时,可能无法通过修复进行恢复,此时应该尽早退出程序,以避免程序导致更严重的后果。在退出程序之前,应尽量释放程序占用的资源, 关闭已经打开的文件 、 套接字 等资源。
- 异常处理:网络编程中可能会遇到各种意外情况,如断网、超时等异常。在遇到这些异常时,应该采取相应的措施,如重试、重新连接或优雅退出等。
代码
注释版
#include "util.h"
#include <stdio.h> // perror 函数
#include <stdlib.h> // exit 函数和 宏 EXIT_FAILURE
void errif(bool condition, const char *errmsg){
if(condition){
// void preeor(const char*) 把一个描述性错误消息输出到标准错误 stderr。
perror(errmsg);
// void exit(int) 使程序在 main 以外的函数中终止。实参是程序返回到计算机操作系统的退出代码。
// 参数为 0 或 EXIT_SUCCESS 表示程序正常终止,否则表示因异常程序终止。EXIT_FAILURE 是 <stdilb.h> 中定义的宏,值为 1。表示程序不成功执行。
exit(EXIT_FAILURE);
}
}
无注释版
#include "util.h"
#include <stdio.h>
#include <stdlib.h>
void errif(bool condition, const char *errmsg){
if(condition){
perror(errmsg);
exit(EXIT_FAILURE);
}
}
util.h
代码
#ifndef UTIL_H
#define UTIL_H
void errif(bool, const char*);
#endif
Makefile
Makefile 是一个文本文件,用于描述一个或多个源代码文件之间的依赖关系以及如何编译和链接这些源代码文件。Makefile 的主要作用是用于自动化地构建和管理源代码文件的编译和部署,可以极大地提高程序员的工作效率。
代码
注释版
server:
# 反斜线表示换行,便于阅读
g++ util.cpp client.cpp -o client && \
g++ util.cpp server.cpp -o server
# clean 清除上次的 make 命令产生的目标文件。为了避免称为 make 的默认目标,不成文的规矩是 clean 放在文件最后。
clean:
rm server && rm client
无注释版
server:
g++ util.cpp client.cpp -o client && \
g++ util.cpp server.cpp -o server
clean:
rm server && rm client