尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

手写Socket Web服务器:根治TCP/HTTP原理恐惧症

手写Socket Web服务器:根治TCP/HTTP原理恐惧症 手写Socket Web服务器不是闲着没事是真能治原理恐惧症我最早接触Socket编程是照着博客敲了一个局域网聊天室两个进程互相发消息跑通那刻觉得自己挺牛。直到后来面试被问HTTP协议底层到底怎么工作的你写的那个聊天室改成Web服务器行不行我才发现自己对天天在用的HTTP协议几乎一无所知。那些框架、服务器软件把底层藏得太好了好到让你觉得请求会自己飞到后端去。那次之后我花了两个周末用Socket手写了一个能处理GET和POST请求的Web服务器才算是把TCP、Socket、HTTP之间的关系彻底捋顺了。这篇文章就是那次实践的完整复盘包含了网络编程中所有值得关注的底层细节包括三次握手到底发生在哪一步、HTTP报文长什么样、服务器怎么解析请求、端口被占用和TIME_WAIT这些坑从哪来、单线程模型怎么一步步变成能用的并发服务器。适合想看透Web服务器原理的人、在面试前突击网络编程的人以及那些被各种诡异网络报错折磨过的运维和开发。项目用Python实现代码量不大但每个函数背后都值得说道说道。1. 为什么放着现成的Nginx不用偏要手写一个1.1 一次被框架惯坏之后的反思当时我在排查一个线上接口超时问题排查到最后发现是连接被反复建立和销毁性能被握手开销拖垮了。同事随口问了一句你确定TCP连接真的被复用了我盯着配置里的Keep-Alive却答不上来。因为我从没在自己的代码里观察过一个连接从建立到关闭的全过程我知道的都是别人告诉我的结论。手写Web服务器的第一个价值就是逼你把我以为变成我看到。当你自己控制Socket的生命周期时你自然会看到一条TCP连接什么时候建立、什么时候被复用、什么时候进入TIME_WAIT。这些东西用框架时是隐形的手写时全是可见的。1.2 手写Web服务器能换来什么说实在的手写这个服务器不是要造一个Nginx的替代品。Nginx处理高并发的能力是靠事件驱动、多进程、epoll这一整套机制撑起来的Python里写个玩具服务器在性能上完全没法比。但写一遍之后你得到的不是一个能上生产的软件而是几个原本根本接触不到的理解理解了Socket、TCP、HTTP三者之间的层次关系。Socket是传输层的编程接口TCP是传输层协议HTTP是应用层协议。一个HTTP请求最终要靠TCP连接承载TCP连接则要依赖Socket这套API来创建和管理。理解了端口、IP、进程之间的关系。端口不是随便绑的两个进程绑同一个端口会报错这个报错背后有一条完整的排查链路。理解了HTTP报文为什么是那个格式。空行分隔头和体Content-Length必须准确这些规定不是为了折磨人而是因为TCP是字节流没有一条消息的概念协议方必须自己定义消息边界。理解了并发模型的基本矛盾。单线程串行处理请求和阻塞式IO之间的矛盾是所有服务器性能问题的根源理解了这一点再看Nginx、Redis的模型就会有豁然开朗的感觉。所以这篇文章更适合把搞懂原理当成目标的人。如果你只是想快速搭一个接口服务那直接用框架就好没必要自己造轮子。但如果你已经写了几年业务代码始终觉得网络协议这块有一层窗户纸没捅破手写一个服务器就是那根手指头。2. 先搞懂SocketHTTP要走路的那条路是怎么铺出来的2.1 Socket到底是什么很多初学者把Socket理解成一个API函数集合这没错但太片面了。更准确地说Socket是一套由操作系统提供的网络编程接口它把TCP/IP协议栈的复杂性封装成了几个系统调用。你可以把Socket想象成一根水管两端的接头内核负责把数据从一端搬运到另一端你的代码只需要负责拿着两端接头往里面倒水、接水。其中一端是客户端Socket另一端是服务器端Socket。客户端负责发起连接服务器端负责被动等待连接到来。两端都基于IP和端口来标识自己。IP地址知道你通向哪台机器端口号知道你到了那台机器后要找哪个进程。所以当一个进程绑定了某个端口后另一个进程再想绑同一个端口操作系统就会报只有一个Socket地址能使用一次这类错误这个后面会详细讲。2.2 服务器端的Socket生命周期bind、listen、accept都在忙什么服务器端Socket的生命周期有五个阶段每个阶段对应一个系统调用我把它列成了一张表阶段系统调用作用关键点创建socket()在内核中创建一个Socket对象需要指定地址族(AF_INET)和类型(SOCK_STREAM)绑定bind()把Socket绑定到指定的IP和端口端口冲突就是在这里报出来的监听listen()把主动Socket变为被动监听Socket内核在这里开始维护连接队列接受accept()从连接队列中取出一个已完成握手的连接三次握手在此之前已经完成收发recv()/send()读写数据默认是阻塞的没数据时会一直等关闭close()释放连接和资源主动关闭方会进入TIME_WAIT状态这里最容易被误解的是listen()和accept()的分工。很多人以为三次握手发生在accept()被调用的那一刻这是错的。listen()之后操作系统内核就开始在后台帮你监听端口了。当客户端发起SYN时内核会自动完成三次握手然后把这个已经就绪的连接放在一个全连接队列里。accept()只是从队列里取出一个已经准备好的连接。所以即使你还没调用accept()客户端那边可能已经认为连接建立了。这个细节解释了为什么某些场景下你能在客户端看到连接建立成功服务器端却还没有任何读写动作。2.3 从浏览器到服务器一个请求在路上经历了什么假设你在浏览器地址栏输入http://127.0.0.1:8080/并回车这条路径上发生的事情远比发了一个请求复杂浏览器解析URL得到主机名127.0.0.1和端口8080以及路径/。浏览器调用connect()创建一个客户端Socket向127.0.0.1:8080发起TCP握手请求。服务器端的内核在listen()之后就在监听这个端口了收到SYN包后完成三次握手并把连接放入全连接队列。服务器进程调用accept()取出这个连接得到一个用于通信的客户端Socket描述符。浏览器按HTTP协议格式通过Socket发送一个报文内容是GET / HTTP/1.1\r\nHost: 127.0.0.1:8080\r\n....服务器用recv()读到这些字节按照HTTP协议去解析拿到方法GET、路径/、版本HTTP/1.1。服务器根据路由和处理函数生成响应报文用send()发回去。浏览器收到响应报文解析HTTP头部和HTML正文渲染页面。整个过程你会发现HTTP协议其实完全不知道TCP握手的存在它只负责把数据按照约定格式塞给Socket层TCP协议也完全不知道HTTP报文的内容是什么它只保证字节流按顺序到达。这种分层设计的好处就是各司其职HTTP改版不用动TCP协议底层换协议也不会影响HTTP的语义。3. 把HTTP请求报文摊开来看3.1 请求行、请求头、请求体三段式报文结构一个标准的HTTP请求报文长这样我用一个实际的报文来展示GET /index.html?page2 HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Accept: text/html,application/json Connection: keep-alive我把这个报文拆开逐行解释。第一行叫请求行由三部分组成请求方法(GET)、请求URI(/index.html?page2)、协议版本(HTTP/1.1)中间用空格分隔。从第二行开始到空行之前都是请求头每行一个头字段格式是字段名: 值。请求头和请求体之间由一个空行分隔这个空行是关键的消息边界标识。空行之后如果有内容那就是请求体比如POST请求携带的表单数据或JSON字符串。这个三段式结构是HTTP协议的骨架解析HTTP报文本质上就是按这个格式切分字节流。你要注意头和体用空行分隔但请求体本身没有统一的结束标识它靠请求头里的Content-Length字段来声明自己有多长。3.2 请求头里藏着哪些关键信息请求头里有些字段对服务器逻辑非常关键Content-Length请求体的字节长度。服务器读取请求体时必须循环读到这个长度才能确定请求体已经读完了。如果这个值算错服务器会多读或少读连接就会错乱。Connection告诉服务器客户端希望保持连接还是请求完毕后关闭keep-alive表示复用连接close表示用完之后关闭。HTTP/1.1默认是keep-aliveHTTP/1.0默认是close。Host在HTTP/1.1中Host是必填项。因为一台服务器可能托管多个域名虚拟主机靠Host字段区分请求该路由到哪个站点。很多人在解析HTTP报文的时候会犯一个低级错误用recv()读一次数据就当读完了。这不对。TCP是字节流协议一次recv()拿到的不一定是一个完整的HTTP请求可能是一部分也可能是粘着了好几个请求。正确的做法是循环读取直到读取到的字节满足某个结束条件——比如头部区域出现了\r\n\r\n或者Content-Length指示的字节数已经读够了。3.3 响应报文的格式和状态码语义服务器返回的响应报文结构和请求报文高度对称也分三段HTTP/1.1 200 OK Content-Type: text/html; charsetutf-8 Content-Length: 61 Connection: keep-alive htmlbodyh1Hello from my socket server/h1/body/html第一行是状态行由协议版本、状态码、状态描述组成。状态码是服务器用来告诉客户端这次的请求到底怎么样了的标准化语言2xx表示成功3xx表示重定向4xx表示客户端问题5xx表示服务器问题。我在实现迷你服务器时至少要处理三个200 OK、404 Not Found、405 Method Not Allowed。别看状态码只是个三位数字HTTP请求能不能被正确处理很大程度上靠它对齐双方的认知。响应头和请求头格式一样常用的有Content-Type告诉客户端响应体的类型Content-Length告诉客户端响应体有多长。注意响应体里如果包含中文Content-Type里的charsetutf-8一定要带上否则浏览器可能按默认编码解析出现乱码。我顺手整理了一个GET和POST的对比表细节都在里面维度GETPOST语义获取资源提交数据并触发处理请求参数位置请求行的URI里如?page2请求体里请求体通常为空可能有用Content-Length声明长度幂等性幂等重复执行结果一致不保证幂等重复提交可能重复生效缓存浏览器可能缓存通常不缓存请求报文示例GET /search?qsocket HTTP/1.1POST /login HTTP/1.1\r\nContent-Length: 21\r\n\r\nusernameadminpass1234. 动手写一个能跑起来的迷你Web服务器4.1 先搭最核心的骨架接收请求并返回固定响应我选Python来实现因为它语法直白方便把注意力放在网络原理上。环境就是Python 3不需要任何第三方库从标准库里的socket开始。一个最基础的服务器骨架只需要这些代码import socket HOST 127.0.0.1 PORT 8080 server_socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_socket.bind((HOST, PORT)) server_socket.listen(5) print(f服务器已启动: http://{HOST}:{PORT}) while True: client_socket, client_addr server_socket.accept() print(f收到来自 {client_addr} 的连接) request b while True: chunk client_socket.recv(4096) if not chunk: break request chunk if b\r\n\r\n in request: break print(收到请求:) print(request.decode(utf-8, errorsignore)) response_body htmlbodyh1Hello from my socket server/h1/body/html response_header HTTP/1.1 200 OK\r\nContent-Type: text/html; charsetutf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n.format(len(response_body.encode(utf-8))) client_socket.send(response_header.encode(utf-8) response_body.encode(utf-8)) client_socket.close()这段代码里有两个细节值得说一下。第一个是setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)这行代码解决的是端口复用问题我后文专门讲。第二个是接收请求时的循环我判断头结束的条件是字节流里出现\r\n\r\n。\r\n是HTTP协议里规定的换行符网络报文里不能使用平台相关的\n或\r必须严格按照\r\n来否则解析会出错。4.2 解析请求行与请求头上面这个骨架可以运行了但只会返回固定内容。要成为一个服务器必须能看懂请求。第一步是解析请求行和请求头。我写了一个简单的解析函数import socket import urllib.parse def parse_request(request_bytes): # 先把字节流转成字符串按行切分 text request_bytes.decode(utf-8, errorsignore) lines text.split(\r\n) # 解析请求行 request_line lines[0] method, path_with_query, version request_line.split( ) # 分离路径和查询参数 if ? in path_with_query: path, query_string path_with_query.split(?, 1) else: path path_with_query query_string # 解析查询参数 query_params {} if query_string: for pair in query_string.split(): if in pair: key, value pair.split(, 1) query_params[key] urllib.parse.unquote(value) # 解析请求头 headers {} for line in lines[1:]: if : in line: key, value line.split(:, 1) headers[key.strip().lower()] value.strip() return { method: method, path: path, version: version, query_params: query_params, headers: headers, body: ... }我特意把查询参数单独解析出来因为做路由的时候经常会用到。比如GET /search?qsocket HTTP/1.1解析完成后path是/searchquery_params是{q: socket}这样路由判断就很方便了。注意headers里我把字段名处理成了全小写。HTTP头字段名是不区分大小写的Host和host是同一个意思。统一转小写可以避免路由和处理逻辑里做大小写判断的麻烦。4.3 支持路由分发和POST请求与静态文件有了解析函数接下来做路由分发。我用一个简单的字典来存路由表routes { /: index_handler, /hello: hello_handler, /now: time_handler, }每个handler是一个函数接收请求字典返回响应状态码、响应头、响应体。路由分发就是根据请求里的path查找对应的handler找不到就返回404。这样写的好处是扩展新页面很方便加一个路径对应一个函数就行。POST请求的解析核心在于请求体。之前说过请求体的结束边界靠Content-Length来决定。所以在接收请求时我不能只判断\r\n\r\n就完事还要检查Content-Lengthdef read_http_request(client_socket): buffer b while b\r\n\r\n not in buffer: chunk client_socket.recv(4096) if not chunk: break buffer chunk # 解析头区域获取 Content-Length head, _, body_start buffer.partition(b\r\n\r\n) headers_text head.decode(utf-8, errorsignore) content_length 0 for line in headers_text.split(\r\n): if line.lower().startswith(content-length:): content_length int(line.split(:, 1)[1].strip()) # 继续读直到请求体长度够 while len(buffer) - len(head) - 4 content_length: chunk client_socket.recv(4096) if not chunk: break buffer chunk return buffer这一段是HTTP解析里最容易出错的地方。很多初学者在上一个循环里读到\r\n\r\n就停了没有继续读请求体导致POST数据丢失。当Content-Type是application/x-www-form-urlencoded时请求体的内容形如usernameadminpass123我用和查询参数一样的逻辑去解析它就行。处理静态文件时最常见的一个问题就是路径遍历攻击。比如用户请求/../../etc/passwd时如果你直接把路径拼到本地文件路径上就会把系统文件读出来发给用户。我在实现时做了防护只允许访问指定目录下的文件并且用os.path.realpath把路径里的..解析后再次校验前缀。import os WEB_ROOT ./static def serve_static_file(path): # 拼接路径 file_path os.path.realpath(os.path.join(WEB_ROOT, path.lstrip(/))) # 校验最终路径必须在 WEB_ROOT 内 if not file_path.startswith(os.path.realpath(WEB_ROOT)): return 403, Forbidden, text/plain if os.path.isfile(file_path): with open(file_path, rb) as f: content f.read() return 200, content, guess_type(file_path) return 404, Not Found, text/plainWeb服务器安全这件事表面上很高深但最基本的一道防线就是这种路径校验。生产级的服务器实现里还会处理符号链接、权限检查、特殊字符编码等等但路径规范化后再做前缀校验是无论如何都不能少的一步。5. 实测过程中的血泪坑bind报错、TIME_WAIT与端口占用5.1 bind: only one usage of each socket address是怎么来的我运行这个服务器时遇到的最经典的报错在标题相关的热搜词里也出现了就是这句OSError: [Errno 98] Address already in use或者在某些语言里你会看到bind: only one usage of each socket address (protocol/network address/port)这个报错的意思是你要绑定的IP和端口组合已经被另一个进程占用了。为什么会出现最常见的原因是上次运行的服务器进程没退出还占着这个端口。排查方法很简单# 查看端口被哪个进程占用 lsof -i :8080 # 或者用 ss ss -lntp | grep 8080看到进程号后可以用kill命令结束它再重新启动服务器。但我后来还遇到一种更隐蔽的情况进程明明退出了端口还是报占用。这就牵扯出了TIME_WAIT。5.2 TIME_WAIT为什么存在又为什么令人头疼我前面在Socket生命周期表里写过主动关闭连接的一方会进入TIME_WAIT状态。TCP连接关闭时要经历四次挥手谁先发起close()谁就要在最后进入TIME_WAIT状态并等待2MSL时间后才完全释放连接资源。MSL是报文最大生存时间典型值是30秒到2分钟所以一个连接可能要等上1到4分钟才会从端口表里消失。为什么要设计这个状态核心是为了防止旧连接的迟到报文影响到新连接。比如你关闭了一个连接但网络中还有一个延迟到达的数据包恰好此时另一个连接用了同样的IP和端口旧包就可能被新连接误收。TIME_WAIT让主动关闭方等着确保旧包在网络中彻底消失后端口才可以复用。那处理这个问题的标准姿势是什么就是我在骨架代码里写的那一行server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)SO_REUSEADDR允许你在端口还有TIME_WAIT连接残留时重新绑定同一个端口避免服务器重启时的尴尬。但要注意它不等于允许两个进程同时监听同一个端口那是SO_REUSEPORT的职责。两者区别我放在表格里选项作用典型场景SO_REUSEADDR允许端口在TIME_WAIT状态下被重新绑定服务器重启、关闭后立即重启SO_REUSEPORT允许多个进程/线程绑定同一个端口由内核做负载均衡多进程服务器、nginx的某些工作模式这里特别提醒一句开发阶段在绑端口前加上SO_REUSEADDR能省掉很多重启报错的烦恼。但如果你的程序处理的是高安全场景这个选项也不会带来安全问题它只是让地址重用更友好而已。5.3 用telnet和curl做最原始的调试手写服务器时浏览器不是最好的调试工具因为浏览器会自动加很多头、会先请求/favicon.ico、还会缓存。我最喜欢的两个调试工具是curl和telnet。curl -v展示真正的请求和响应细节它能让你看见自己写的服务器到底返回了什么curl -v http://127.0.0.1:8080/hello输出里会显示请求行和所有请求头以及服务器返回的状态行、响应头、响应体。如果响应体比Content-Length声明得短curl会报错提示你响应不完整这个反馈非常有用。telnet更原始你可以手工敲入HTTP报文完全掌控发送的内容。排查服务器解析逻辑时用这个办法最直观telnet 127.0.0.1 8080连接建立后手动输入下面的内容注意最后的空行一定要敲上GET /hello HTTP/1.1 Host: 127.0.0.1:8080然后看服务器返回什么。如果没反应先检查是不是空行没敲或者Content-Length之类字段不符合要求。这样的调试方式特别适合从零开始理解协议你亲手把一个报文从键盘上敲出去看着服务器一行行解析它所有抽象的概念都会变得非常具体。6. 从单线程玩具进阶到真正能用的并发服务器6.1 单线程模型的致命短板我刚写的那个服务器主循环是单线程的它接受了第一个请求、处理完、返回响应、关闭连接然后才去accept()下一个连接。这意味着同一时间只能服务一个客户端。第二个客户端发起连接后即使TCP三次握手完成了连接也躺在内核队列里要等第一个请求处理完才会被accept()。这个模型有两个问题。第一是慢请求会阻塞后面的所有人比如请求里带了一个超大的文件上传处理过程可能持续好几秒期间所有其他请求全部排队。第二是连接本身可能因为等待超时被客户端掐掉。解决思路是引入并发一个请求来了开启一个新线程去处理主线程继续accept()下一个连接。Python里用threading.Thread就能实现import threading while True: client_socket, client_addr server_socket.accept() t threading.Thread(targethandle_client, args(client_socket, client_addr)) t.start()每个连接一个线程模型简单立刻就能解决一个慢请求卡住所有人的问题。但要注意两个细节一是Python的GIL限制多线程并不能利用多核并行执行CPU密集任务二是线程数量不可能无限增加当并发连接数很高时线程切换和内存占用会成为新的瓶颈。所以生产级服务器不会用这种朴素的线程模型而是用事件驱动加IO多路复用。6.2 支持Keep-Alive连接复用前面我只实现了Connection: close的逻辑每次请求结束就关闭连接。但HTTP/1.1默认要求支持keep-alive——客户端希望用同一个TCP连接连续发送多个HTTP请求省去反复握手的开销。这看起来是个小改动但实现起来有个非常关键的难点在一个连接上你要知道一个请求什么时候结束下一个请求什么时候开始。好消息是请求头里的Content-Length就是分界依据。你只需要循环解析一个又一个请求直到客户端关闭连接def handle_client(client_socket, client_addr): try: while True: # 读取完整请求 request_data read_http_request(client_socket) if not request_data: break # 解析请求生成响应 request parse_request(request_data) status, body, content_type route(request) # 发送响应 response build_response(status, body, content_type, keep_aliveTrue) client_socket.send(response) # 如果客户端不打算复用了跳出循环 if request[headers].get(connection, ).lower() close: break except Exception as e: print(f连接处理出错: {e}) finally: client_socket.close()别小看这个循环结构它的正确性完全依赖于你前面解析请求时是否严格按照Content-Length读取了请求体。如果少读了下一次循环就会把一个请求的后半截当成新请求的开头报文解析就彻底乱了。所以我在实现时特意写了一个能精确计算每个请求完整长度的读取函数这是Keep-Alive能不能稳定工作的前提。6.3 和Nginx这类成熟服务器的差距在哪里写完这个版本后我拿它和Nginx对比了一下很容易看到差距维度我的手写版Nginx并发模型每连接一线程多进程加事件驱动(epoll)静态文件发送Python读文件后拷贝给Socketsendfile系统调用零拷贝超时处理基本没有完整的读超时、写超时管理请求体限制无限制可能吃满内存可配置的client_max_body_sizeHTTP协议支持最基础的GET/POSTKeep-Alive完整支持HTTP/1.1、HTTP/2、WebSocket等这些差距本质上是处理能力和工程完备性的差距。Nginx的性能核心是epoll事件驱动模型它用一个进程就能同时管理成千上万个连接而不是给每个连接开一个线程。sendfile让静态文件直接在内核态从磁盘发送到网卡不经过用户态内存拷贝速度比我read到Python再用send发送快得多。但这些差距不影响手写这个服务器的价值。理解单线程的瓶颈你才知道多线程解决的是什么问题理解了线程模型的资源消耗你才知道事件驱动为什么高效理解了recv()的阻塞特性你才知道IO多路复用为什么是必要的。Nginx的文档不会把这些故事讲给你听但你自己写一遍全都能体会到。我实验下来最推荐的后续扩展方向是先给服务器加上线程池限制最大并发线程数然后给静态文件处理加上基于sendfile的实现最后尝试引入select或epoll来管理多个连接。每走一步你对服务器性能瓶颈的理解就会深一层。最后唠叨一句我的个人体会。写完这个迷你服务器之后再去看真实项目里的网络报错思路完全不一样了。之前遇到Address already in use我只知道重启大法现在会先去lsof看端口占用、检查程序的退出逻辑、考虑TIME_WAIT的残留。之前不理解为什么接口偶发超时现在会去看连接是否被正确复用。网络编程的知识不一定要靠大项目来积累亲手写一个能跑的Web服务器就够了。我建议你也找时间把自己常用的框架放下从bind开始敲一遍那个过程会有点绕但绕完之后很多费解的术语就都变成常识了。
返回列表