计算机网络编程———手写 TCP 服务器(一)搞懂网络编程核心 API
目录一、TCP vs UDP —— 一句话讲清区别二、单进程 TCP 服务器完整拆解2.1 socket bind —— 跟 UDP 一样2.2 setsockopt2.3 listen —— 把电话线插上2.4 两个文件描述符 —— 整篇文章最重要的概念2.5 accept —— 前台叫号2.6 单进程版的完整流程2.7 ⭐ read 返回值 —— 最要命的坑三、客户端实现 —— 拨号打电话四、源码 · 单进程 TCP 服务器完整版makefileLog.hppTcpServer.hppMain.ccTcpClient.cc五、本篇总结最近在啃 Linux 网络编程这篇文章是这个系列的第一篇从最基础的单进程 TCP 服务器讲起。这篇主要讲三件事三个核心 APIlisten、accept、read/write一个核心概念两个文件描述符 —— listensock_ 和 sockfd一个要命的坑read 返回 0 为什么不处理会炸一、TCP vs UDP —— 一句话讲清区别搞网络编程第一步是搞清楚 TCP 和 UDP 到底有什么不一样。用生活类比是最容易理解的。UDP 像寄信。你写好一封信扔进邮筒对方能不能收到、什么时候收到你不知道也不关心。你可以同时给张三、李四、王五各寄一封完全不冲突。TCP 像打电话。你必须先拨号对方接了你们之间建立了一条专线。你在这头说他在那头听。说完了挂电话线路就断了。落实到代码上差别就在流程步骤UDP 服务器TCP 服务器1socket()socket()2bind()bind()3—listen()4—accept()5recvfrom() / sendto()read() / write()UDP 两步半就完事了TCP 多了 listen 和 accept。这多出来的两个 API就是 TCP 整个复杂度的源头。二、单进程 TCP 服务器完整拆解下面我拆开讲每个环节配代码配说明争取你看完就能自己敲出来。2.1 socket bind —— 跟 UDP 一样listensock_ socket(AF_INET, SOCK_STREAM, 0);注意这里的SOCK_STREAM。UDP 用的是SOCK_DGRAM数据报一个个独立小包裹TCP 用的是SOCK_STREAM流式像水管里的水没有边界。bind 的部分和 UDP 完全一样初始化一个struct sockaddr_in填上 IP 和端口传进去绑。2.2 setsockoptint opt 1; setsockopt(listensock_, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, opt, sizeof(opt));这行代码是防坑专用。场景你跑着服务器发现有个 bugctrlc 停了马上改完代码重新跑。结果报错bind error: Address already in use。原因操作系统在端口释放后有一个TIME_WAIT状态几十秒到几分钟不等。这段时间内端口被认为还在使用中不让绑定。解法SO_REUSEADDR|SO_REUSEPORT告诉内核哥们我知道端口还没完全释放但我赶时间让我先用。建议开发阶段必须加。不加的话每次重启都要等几十秒2.3 listen —— 把电话线插上listen(listensock_, backlog); // backlog 10listen 做了什么它把 socket 创建的 listensock_ 变成了监听状态告诉操作系统这个 socket 可以接收客户端的连接请求了。backlog 是内核中连接等待队列的长度。想像一下前台接待只能同时记住 10 个在门口排队的客人。第 11 个人来了就得等前面有人被叫进去了才能登记。backlog 一般设 10 左右不用设太大。2.4 两个文件描述符 —— 整篇文章最重要的概念先看一段初始化代码class TcpServer { int listensock_; // 由 socket() 创建 uint16_t port_; std::string ip_; };这里有个命名上的细节。在 UDP 服务器里我们管 socket 的返回值叫sockfd。但在 TCP 服务器里作者把它命名为listensock_。为什么因为 TCP 服务器有两个文件描述符各司其职。变量谁创建的作用数量listensock_socket()只负责监听连接请求1 个sockfdaccept() 的返回值负责与客户端读写通信多个每个连接一个用饭店类比listensock_ 前台接待。看见客人来了喊一声服务员3 号桌有客人。sockfd 专属服务员。走过来您好想吃点什么然后一对一服务。listensock_ 不负责跟任何客人聊天它就坐在前台监工。只有 accept 返回的 sockfd 才负责实际的读写通信。常见错误新手拿到 listensock_ 直接去 read/write发现读不到数据。那是肯定的——listensock_ 是前台接待不是服务员。2.5 accept —— 前台叫号struct sockaddr_in client; socklen_t len sizeof(client); int sockfd accept(listensock_, (struct sockaddr*)client, len);accept 干了三件事从内核的连接等待队列中取出一个已经完成三次握手的连接创建一个新的文件描述符sockfd专门用于和这个客户端通信把客户端的 IP、端口等信息填到 client 结构体里如果连接成功accept 返回一个大于 0 的 sockfd。如果连接失败极少见返回 -1。拿到 sockfd 后可以用inet_ntop把 IP 地址转成字符串用ntohs把端口号转成主机字节序uint16_t clientport ntohs(client.sin_port); char clientip[32]; inet_ntop(AF_INET, (client.sin_addr), clientip, sizeof(clientip)); lg(Info, get a new link, sockfd: %d, client ip: %s, client port: %d, sockfd, clientip, clientport);2.6 单进程版的完整流程void StartServer() { lg(Info, tcpserver is running...); for (;;) { // 1. 等一个客户来 int sockfd accept(listensock_, ...); if (sockfd 0) { continue; // 连接失败就继续等 } // 2. 解析客户端信息IP 端口 // 3. 一对一服务 Service(sockfd, clientip, clientport); // 4. 服务完关闭 close(sockfd); } }这就是单进程/单线程的全部秘密。来了一个客户 → accept 领到桌 → Service 开始服务 → 服务完 close → 回循环顶部继续等下一个。在服务 A 的整个过程中B、C、D 来了就在门口排队等着。2.7 ⭐ read 返回值 —— 最要命的坑void Service(int sockfd, const std::string clientip, uint16_t clientport) { while (true) { char inbuffer[4096]; ssize_t n read(sockfd, inbuffer, sizeof(inbuffer) - 1); if (n 0) { // 正常收到数据 inbuffer[n] 0; // 手动补 \0 // 处理并回复 write(sockfd, echo_string.c_str(), echo_string.size()); } else if (n 0) { // 客户端退出了 lg(Info, %s:%d quit, clientip.c_str(), clientport); break; } else { // 读取出错 lg(Warning, read error); break; } } }为什么 n 0 必须处理我用管道类比来解释。你和朋友之间用一根水管通话。你在水管这头服务器他在那头客户端。正常情况朋友往水管里倒水write你用杯子接read朋友走了朋友把水管那头关了关闭写端你还举着杯子等水管那头堵死了你等一整天也等不到水这时候操作系统会怎么做它发现你在做一个永远读不到数据的 read认为你在浪费 CPU。它会直接把你的服务器进程 kill 掉。你试想一下一个客户端正常退出结果整个服务器被操作系统杀了。所有其他正在通信的客户端全部掉线。如果你的服务器是微信服务器那就是几亿人同时掉线。所以要主动处理 n 0break 退出close(sockfd)告诉操作系统我知道了。操作系统看你主动关了就不杀你了。三、客户端实现 —— 拨号打电话客户端的逻辑比服务器简单得多int sockfd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in server; server.sin_family AF_INET; server.sin_port htons(serverport); inet_pton(AF_INET, serverip.c_str(), (server.sin_addr)); int n connect(sockfd, (struct sockaddr*)server, len);connect —— 拨号。就像你拿起电话sockfd输入对方的号码IP端口按拨号键。客户端要不要 bind答案是要但你不用手动写 bind。操作系统在 connect 的时候自动给你随机分配了一个端口号。你打开微信的时候不需要关心微信用哪个端口连的服务器对吧那是操作系统的事。同理我们自己写的客户端也不用手动 bind。四、源码 · 单进程 TCP 服务器完整版makefileall:tcpserver tcpclient tcpserver:Main.cc g -o $ $^ -stdc11 tcpclient:TcpClient.cc g -o $ $^ -stdc11 .PHONY:clean clean: rm -f tcpserver tcpclientLog.hpp#pragma once #include iostream #include string #include ctime #include cstdio #include cstdarg #include sys/types.h #include sys/stat.h #include fcntl.h #include unistd.h #define SIZE 1024 #define Info 0 #define Debug 1 #define Warning 2 #define Error 3 #define Fatal 4 #define Screen 1 #define Onefile 2 #define Classfile 3 #define LogFile log.txt class Log { public: Log() { printMethod Screen; path ./log/; } void Enable(int method) { printMethod method; } ~Log() {} std::string levelToString(int level) { switch(level) { case Info: return Info; case Debug: return Debug; case Warning: return Warning; case Error: return Error; case Fatal: return Fatal; default: return ; } } void operator()(int level, const char* format, ...) { time_t t time(nullptr); struct tm* ctime localtime(t); char leftbuffer[SIZE]; snprintf(leftbuffer, sizeof(leftbuffer), [%s][%d-%d-%d %d:%d:%d], levelToString(level).c_str(), ctime-tm_year 1900, ctime-tm_mon 1, ctime-tm_mday, ctime-tm_hour, ctime-tm_min, ctime-tm_sec); va_list s; va_start(s, format); char rightbuffer[SIZE]; vsnprintf(rightbuffer, sizeof(rightbuffer), format, s); va_end(s); char logtxt[2 * SIZE]; snprintf(logtxt, sizeof(logtxt), %s %s, leftbuffer, rightbuffer); printLog(level, logtxt); } void printLog(int level, const std::string logtxt) { switch(printMethod) { case Screen: std::cout logtxt std::endl; break; case Onefile: printOneFile(LogFile, logtxt); break; case Classfile: printClassFile(level, logtxt); break; default: break; } } void printOneFile(const std::string logname, const std::string logtxt) { std::string _logname path logname; int fd open(_logname.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666); if(fd 0) return; write(fd, logtxt.c_str(), logtxt.size()); close(fd); } void printClassFile(int level, const std::string logtxt) { std::string filename LogFile; filename .; filename levelToString(level); printOneFile(filename, logtxt); } private: int printMethod; std::string path; }; Log lg;TcpServer.hpp#include iostream #include string #include cstring #include unistd.h #include sys/types.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include Log.hpp const int defaultfd -1; const uint16_t defaultport 8080; const std::string defaultip 0.0.0.0; const int backlog 10; extern Log lg; enum { UsageError 1, SocketError, BindError, ListenError }; class TcpServer { public: TcpServer(const uint16_t port defaultport, const std::string ip defaultip) : listensock_(defaultfd), port_(port), ip_(ip) {} void InitServer() { listensock_ socket(AF_INET, SOCK_STREAM, 0); if (listensock_ 0) { lg(Fatal, create socket error); exit(SocketError); } lg(Info, create socket success, listensock_: %d, listensock_); int opt 1; setsockopt(listensock_, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, opt, sizeof(opt)); struct sockaddr_in server; memset(server, 0, sizeof(server)); server.sin_family AF_INET; server.sin_port htons(port_); inet_aton(ip_.c_str(), (server.sin_addr)); socklen_t len sizeof(server); if (bind(listensock_, (struct sockaddr *)server, len) 0) { lg(Fatal, bind error); exit(BindError); } lg(Info, bind socket success, listensock_: %d, listensock_); if (listen(listensock_, backlog) 0) { lg(Fatal, listen error); exit(ListenError); } lg(Info, listen socket success, listensock_: %d, listensock_); } void StartServer() { lg(Info, tcpserver is running...); for (;;) { struct sockaddr_in client; socklen_t len sizeof(client); int sockfd accept(listensock_, (struct sockaddr*)client, len); if (sockfd 0) { lg(Warning, accept error); continue; } uint16_t clientport ntohs(client.sin_port); char clientip[32]; inet_ntop(AF_INET, (client.sin_addr), clientip, sizeof(clientip)); lg(Info, get a new link, sockfd: %d, client ip: %s, client port: %d, sockfd, clientip, clientport); Service(sockfd, clientip, clientport); close(sockfd); } } void Service(int sockfd, const std::string clientip, uint16_t clientport) { while (true) { char inbuffer[4096]; ssize_t n read(sockfd, inbuffer, sizeof(inbuffer) - 1); if (n 0) { inbuffer[n] 0; std::cout client say# inbuffer std::endl; std::string echo_string tcpserver echo# ; echo_string inbuffer; write(sockfd, echo_string.c_str(), echo_string.size()); } else if (n 0) { lg(Info, %s:%d quit, server close sockfd: %d, clientip.c_str(), clientport, sockfd); break; } else { lg(Warning, read error, sockfd: %d, sockfd); break; } } } ~TcpServer() { if (listensock_ 0) close(listensock_); } private: int listensock_; uint16_t port_; std::string ip_; };Main.cc#include iostream #include memory #include TcpServer.hpp void Usage(const std::string str) { std::cout \n\tUsage: str port[1024]\n std::endl; } int main(int argc, char* argv[]) { if (argc ! 2) { Usage(argv[0]); exit(UsageError); } uint16_t port std::stoi(argv[1]); std::unique_ptrTcpServer server(new TcpServer(port)); server-InitServer(); server-StartServer(); return 0; }TcpClient.cc#include iostream #include string #include unistd.h #include cstring #include sys/types.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h void Usage(const std::string str) { std::cout \n\tUsage: str serverip serverport\n std::endl; } int main(int argc, char* argv[]) { if (argc ! 3) { Usage(argv[0]); return 0; } std::string serverip argv[1]; uint16_t serverport std::stoi(argv[2]); int sockfd socket(AF_INET, SOCK_STREAM, 0); if (sockfd 0) { std::cerr socket create err std::endl; return 1; } struct sockaddr_in server; memset(server, 0, sizeof(server)); server.sin_family AF_INET; server.sin_port htons(serverport); inet_pton(AF_INET, serverip.c_str(), (server.sin_addr)); socklen_t len sizeof(server); int n connect(sockfd, (struct sockaddr*)server, len); if (n 0) { std::cerr connect err... std::endl; return 2; } std::string message; char inbuffer[4096]; while (true) { std::cout Please Enter# ; std::getline(std::cin, message); n write(sockfd, message.c_str(), message.size()); if (n 0) { std::cerr write err std::endl; break; } n read(sockfd, inbuffer, sizeof(inbuffer) - 1); if (n 0) { inbuffer[n] 0; std::cout inbuffer std::endl; } else { break; } } close(sockfd); return 0; }五、本篇总结这篇我们做了三件事理解了 TCP 的三个核心 APIlisten插电话线、accept叫号、read/write对话分清了两个文件描述符listensock_前台接待只负责监听sockfd专属服务员负责通信搞懂了一个最要命的坑read 返回 0 必须处理否则操作系统会杀进程但也暴露了一个大问题单进程版一次只能服务一个客户端后面的排队等到死。下一篇我将尝试多进程、多线程、线程池等并发方案让服务器真正能同时服务多个客户端。