第十三章 网络编程
1 网络基础
1.1 两台机器说话,得先有规矩
你给朋友寄一封信。写信要用对方看得懂的语言,装进信封要写地址,交给邮局要知道往哪儿运,最后一段路是邮递员骑电动车送到楼下。这四件事各管一段,谁也不管别人的事。
网络是同一套思路。数据从你的程序出去,到对面那个程序收到,中间被切成了四层。
arduino
你的程序手里 "明天下午三点开会"
│
▼
┌─────────────────────────────────────┐
│ 应用层 HTTP FTP SMTP DNS │ 这句话长什么样
├─────────────────────────────────────┤
│ 传输层 TCP UDP │ 怎么送到对面那个"程序"
├─────────────────────────────────────┤
│ 网际层 IP │ 怎么送到对面那台"机器"
├─────────────────────────────────────┤
│ 链路层 以太网 Wi-Fi │ 怎么送到隔壁那个设备
└─────────────────────────────────────┘
│
▼
网线里跑的一串 0 和 1
往下走一层,就把上一层交下来的东西整个当成数据,前面加上自己这层的头。到了对面再一层层剥掉。
教科书上还有个七层的 OSI 模型,实际干活用的是这个四层的 TCP/IP。两层的对应关系大概是这样。
| 四层模型 | OSI 七层 | 这一层搬运的东西叫什么 | 例子 |
|---|---|---|---|
| 应用层 | 应用层、表示层、会话层 | 报文 / 数据 | HTTP、FTP、SMTP、DNS |
| 传输层 | 传输层 | 段(TCP)、数据报(UDP) | TCP、UDP |
| 网际层 | 网络层 | 包 / 数据报 | IP、ICMP |
| 链路层 | 数据链路层、物理层 | 帧 | 以太网、Wi-Fi |
对写程序的人来说,链路层和网际层基本不用你操心,操作系统和路由器替你干了。你要动手的是应用层和传输层。传输层只有两个选择,TCP 和 UDP。这章后面两节各讲一个。
1.2 IP 地址是门牌号
一台机器要收信,先得有个地址。IPv4 地址是 32 位的数字,写成四个 0 到 255 的十进制数,中间点隔开。
Python 自带一个 ipaddress 模块,专门干这类换算。
python
>>> import ipaddress
>>> a = ipaddress.ip_address("192.168.1.10")
>>> a
IPv4Address('192.168.1.10')
>>> a.version
4
>>> a.is_private
True
>>> a.is_loopback
False
>>> ipaddress.ip_address("127.0.0.1").is_loopback
True
>>> ipaddress.ip_address("8.8.8.8").is_private
False
is_private 说的是这是不是内网地址。有三段是专门留给内网的,你家路由器给你手机分的十有八九就在这里头。
| 网段 | 范围 | 你会在哪儿见到 |
|---|---|---|
| 10.0.0.0/8 | 10.0.0.0 到 10.255.255.255 | 公司内网 |
| 172.16.0.0/12 | 172.16.0.0 到 172.31.255.255 | 虚拟机、Docker |
| 192.168.0.0/16 | 192.168.0.0 到 192.168.255.255 | 家里路由器 |
127.0.0.1 是个特殊的地址,它指的就是你自己这台机器。写网络程序的时候全靠它,服务端和客户端都跑在本机,调通了再搬到别的机器上去。它有个名字叫 localhost。
python
>>> import socket
>>> socket.gethostbyname("localhost")
'127.0.0.1'
>>> socket.getaddrinfo("localhost", 80, proto=socket.IPPROTO_TCP)
[(<AddressFamily.AF_INET6: 23>, 0, 6, '', ('::1', 80, 0, 0)), (<AddressFamily.AF_INET: 2>, 0, 6, '', ('127.0.0.1', 80))]
最后那个 getaddrinfo 的返回值看着吓人,拆开就是个列表,每个元素五样东西,分别是地址族、套接字类型、协议、别名、地址。它一次把 IPv6 和 IPv4 的答案都给你了,::1 是 IPv6 里的 localhost。
网段那件事,ipaddress 也能算。
python
>>> net = ipaddress.ip_network("192.168.1.0/24")
>>> net
IPv4Network('192.168.1.0/24')
>>> net.netmask
IPv4Address('255.255.255.0')
>>> net.num_addresses
256
>>> net.broadcast_address
IPv4Address('192.168.1.255')
>>> [str(x) for x in list(net.hosts())[:3]]
['192.168.1.1', '192.168.1.2', '192.168.1.3']
>>> ipaddress.ip_address("192.168.2.1") in net
False
>>> ipaddress.ip_address("192.168.1.200") in net
True
/24 的意思是前 24 位是网络号,后面 8 位归你分配。num_addresses 是 256,减去网络号自己和一个广播地址,能用的主机是 254 台。平时说的子网掩码 255.255.255.0 就是这 24 位。in 可以直接判断一个地址在不在这个网段里,写白名单的时候比字符串比较省事。
1.3 端口是门牌号后面的房间号
IP 只管送到哪台机器。一台机器上同时跑着浏览器、微信、下载工具,数据到了之后给谁,是端口说了算。
端口是一个 16 位的数字,所以范围是 0 到 65535。
| 范围 | 名字 | 谁在用 |
|---|---|---|
| 0 到 1023 | 知名端口 | HTTP 用 80,HTTPS 用 443,SSH 用 22 |
| 1024 到 49151 | 注册端口 | MySQL 用 3306,Redis 用 6379 |
| 49152 到 65535 | 动态端口 | 客户端临时用,用完就还 |
常见的那几个不用背,系统里有一张表。
python
>>> socket.getservbyname("http")
80
>>> socket.getservbyname("https")
443
>>> socket.getservbyname("ssh")
22
>>> socket.getservbyname("ftp")
21
>>> socket.getservbyname("smtp")
25
>>> socket.getservbyname("domain", "udp")
53
>>> socket.getservbyport(22)
'ssh'
>>> socket.getservbyport(53, "udp")
'domain'
getservbyname 是拿名字查端口号,getservbyport 反着来。第二个参数是协议,不写默认按 tcp 查。DNS 走的是 UDP,所以查它得写 "udp"。
这张表不是全的,别指望什么都有。
python
>>> socket.getservbyname("mysql")
OSError: service/proto not found
我这台机器是 Windows,系统服务名表里压根没登记 mysql。Linux 上通常在 /etc/services 里能查到。所以这函数适合拿来玩,真写程序的时候端口号还是写在配置里更稳。
1.4 一条连接靠五个数定位
机器有了 IP,程序有了端口,还差一样东西。你开两个浏览器标签访问同一个网站,两个连接的服务端 IP 和端口完全一样,客户端 IP 也一样,只有客户端端口不同。
所以一条连接要靠五个东西凑齐才算唯一,它们叫五元组。
markdown
┌─────────────────────────────────────────┐
│ 协议 TCP │
│ 本机 IP 192.168.1.20 │
│ 本机端口 54321 ← 随机分的 │
│ 对方 IP 93.184.216.34 │
│ 对方端口 80 │
└─────────────────────────────────────────┘
这五个数全一样,才是同一条连接
客户端那个端口不用你指定,操作系统从动态端口里随手抓一个给你。同一个浏览器开十个标签,就有十个不同的客户端端口,服务端靠这个把十个连接分开。
这也解释了一件事,为什么服务端的端口要固定而客户端不用。你开个网站,得让别人找得到你,所以 80 是定死的。你去访问别人,对方不需要提前知道你是谁,回信照着你来信上的地址退回去就行。
1.5 socket 是操作系统给你开的一个口子
socket 这个词翻成"套接字",我觉得这个翻译劝退了不少人。它的本意是插座、插口。
你在程序里要收发网络数据,得先跟操作系统要一个口子。操作系统给你一个 socket 对象,之后所有的读写都走它。
python
>>> s = socket.socket()
>>> s.family
<AddressFamily.AF_INET: 2>
>>> s.type
<SocketKind.SOCK_STREAM: 1>
>>> s.proto
0
>>> s.gettimeout() is None
True
>>> s.settimeout(1.5)
>>> s.gettimeout()
1.5
>>> s.close()
socket.socket() 什么都不传,默认建的是 IPv4 的 TCP 套接字。三个参数的含义是这样。
| 参数 | 取值 | 含义 |
|---|---|---|
| family | AF_INET / AF_INET6 |
IPv4 还是 IPv6 |
| type | SOCK_STREAM / SOCK_DGRAM |
TCP 还是 UDP |
| proto | IPPROTO_TCP / IPPROTO_UDP / 0 |
具体协议,写 0 让系统自己挑 |
这几个常量就是整数,可以直接打出来看。
python
>>> socket.AF_INET, socket.AF_INET6
(<AddressFamily.AF_INET: 2>, <AddressFamily.AF_INET6: 23>)
>>> socket.SOCK_STREAM, socket.SOCK_DGRAM
(<SocketKind.SOCK_STREAM: 1>, <SocketKind.SOCK_DGRAM: 2>)
>>> socket.IPPROTO_TCP, socket.IPPROTO_UDP
(6, 17)
>>> socket.SOL_SOCKET, socket.SO_REUSEADDR
(65535, 4)
SOL_SOCKET 和 SO_REUSEADDR 后面设置选项的时候要用。这些数字在不同系统上不一样,写代码一律写常量名。
要 UDP 就换一个 type。
python
>>> u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
>>> u.type
<SocketKind.SOCK_DGRAM: 2>
>>> u.close()
gettimeout() 返回 None 表示阻塞模式,读不到数据就一直等在那儿。settimeout(1.5) 之后最多等 1.5 秒,等不到就抛异常。这一条后面会反复用到,写网络程序不加超时等于埋雷。
1.6 网上发的是字节,顺序得说好
一个 16 位整数 80,十六进制是 0x0050,占两个字节,高字节是 0x00,低字节是 0x50。
问题来了,这两个字节谁放前面。
我的机器是小端序,低字节在前,所以它把 80 存成 50 00。网络规定一律高字节在前,叫网络字节序,也就是大端序。
python
>>> import sys
>>> sys.byteorder
'little'
>>> import struct
>>> socket.htons(80)
20480
>>> struct.pack("!H", 80)
b'\x00P'
>>> socket.ntohs(20480)
80
>>> struct.unpack("!H", b'\x00P')
(80,)
htons 是 host to network short,把本机序转成网络序。80 变成 20480,因为 0x0050 在小端机器上被当成 0x5000,也就是 20480。ntohs 是反方向。
自己拼二进制数据用 struct,格式串开头那个 ! 就表示网络序(大端)。它跟 > 是一个意思。
| 格式符 | 类型 | 字节数 |
|---|---|---|
!B |
无符号字节 | 1 |
!H |
无符号短整型 | 2 |
!I |
无符号整型 | 4 |
!Q |
无符号长整型 | 8 |
!4s |
4 字节的字节串 | 4 |
calcsize 能先量一下占几个字节。
python
>>> struct.calcsize("!I")
4
>>> struct.pack("!I", 1024)
b'\x00\x00\x04\x00'
>>> struct.unpack("!I", b'\x00\x00\x04\x00')
(1024,)
>>> struct.pack("!4sI", b"TEXT", 9)
b'TEXT\x00\x00\x00\t'
>>> struct.unpack("!4sI", b'TEXT\x00\x00\x00\t')
(b'TEXT', 9)
>>> struct.calcsize("!4sI")
8
第四节解决粘包的时候,!I 要派上大用场。先记一个结论,网络上发数据,长度字段也要按固定字节数发,不然对面不知道该读几个字节。
2 TCP 编程
2.1 先握手,再说话
TCP 是打电话。拨号,对方接起来,你说一句"喂",对方回一句"喂",两边确认都在线,才开始说正事。
建立连接要三次握手。
markdown
客户端 服务端
│ │
│ SYN 我要连你,我的起始序号是 x │
│ ───────────────────────────────────────────────► │
│ │
│ SYN+ACK 收到了,我也要连你,我的起始序号是 y │
│ ◄─────────────────────────────────────────────── │
│ │
│ ACK 好的,那就开始吧 │
│ ───────────────────────────────────────────────► │
│ │
│══════════════ 连接建立,可以收发数据 ═════════════│
为什么要三次不是两次。因为网络上的包会迟到。一个早就该到的 SYN 突然冒出来,服务端要是两次就认为连上了,会白建一条连接。三次握手让客户端有机会再确认一次,把这个幽灵连接挡掉。
断开要四次。
markdown
客户端 服务端
│ FIN 我说完了 │
│ ───────────────────────────────────────────────► │
│ │
│ ACK 知道你说完了,但我可能还有话要说 │
│ ◄─────────────────────────────────────────────── │
│ │
│ (服务端把剩下的数据发完) │
│ │
│ FIN 我也说完了 │
│ ◄─────────────────────────────────────────────── │
│ │
│ ACK 好,散了 │
│ ───────────────────────────────────────────────► │
断开比建立多一次,是因为"我这边说完了"和"我这边也不听了"是两件事,TCP 允许只关一半,叫半关闭。
TCP 给你的三条保证,弄明白这三条就知道什么时候该选它。
| 保证 | 白话 |
|---|---|
| 不丢 | 发出去的对方一定能收到,收不到会重发 |
| 不重 | 重发导致的重复会被丢掉 |
| 有序 | 先发的先到,乱了的会重排 |
代价是握手要花时间,每个包都要确认,头部也更大。
2.2 服务端那五个动作
写 TCP 服务端有个固定套路,五个动作,顺序不会变。
scss
1. socket() 跟操作系统要一个口子
2. bind() 把这个口子绑到 (IP, 端口) 上
3. listen() 开始听着,有人来敲门先排队
4. accept() 从队伍里领一个人进来 ← 没人的时候会卡在这儿
5. recv/send 跟这个人说话
6. close() 说完关门
先建一个最简单的 echo 服务,你发什么它原样还给你。
python
# echo_server.py
import socket
HOST = "127.0.0.1"
PORT = 9000
def main():
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((HOST, PORT))
srv.listen(5)
print("服务端在 %s:%d 上等着" % (HOST, PORT), flush=True)
while True:
conn, addr = srv.accept()
print("来了个连接:", addr, flush=True)
with conn:
while True:
data = conn.recv(1024)
if not data:
break
conn.sendall(b"echo: " + data)
print("这个连接断了", flush=True)
if __name__ == "__main__":
main()
客户端是三个动作。
python
# echo_client.py
import socket
HOST = "127.0.0.1"
PORT = 9000
def main():
cli = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
cli.settimeout(5)
cli.connect((HOST, PORT))
print("连上了", flush=True)
for msg in (b"hello", b"python", b"quit"):
cli.sendall(msg)
print("发出 %-8r 收回 %r" % (msg, cli.recv(1024)), flush=True)
cli.close()
if __name__ == "__main__":
main()
开两个终端分别跑,先是服务端。
text
$ python echo_server.py
服务端在 127.0.0.1:9000 上等着
来了个连接: ('127.0.0.1', 54321)
这个连接断了
然后是客户端。
text
$ python echo_client.py
连上了
发出 b'hello' 收回 b'echo: hello'
发出 b'python' 收回 b'echo: python'
发出 b'quit' 收回 b'echo: quit'
addr 里那个 54321 是操作系统给客户端分的临时端口,每次跑都不一样。服务端打印出来的数字跟你屏幕上看到的对不上是正常的。
不过后面几节我不想每次都开两个终端,太麻烦。下面这个脚本把服务端和客户端塞进同一个进程,服务端跑在线程里,效果一样。文章后面凡是"跑起来看看"的地方,都用这个办法。
python
# 一个进程里同时跑服务端和两个客户端,存成 echo_demo.py
import socket
import threading
LOG = []
def serve(sock):
while True:
try:
conn, addr = sock.accept()
except OSError:
return
with conn:
data = conn.recv(1024)
if not data:
continue
LOG.append("%s from %s" % (data.decode(), addr[0]))
conn.sendall(b"server got: " + data)
def client(port, name):
s = socket.create_connection(("127.0.0.1", port), timeout=5)
s.sendall(name)
print(name.decode(), "收到:", s.recv(1024).decode())
s.close()
if __name__ == "__main__":
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(5)
port = srv.getsockname()[1]
threading.Thread(target=serve, args=(srv,), daemon=True).start()
for name in (b"alice", b"bob"):
client(port, name)
print("服务端这边记下的:")
for line in LOG:
print(" " + line)
srv.close()
text
alice 收到: server got: alice
bob 收到: server got: bob
服务端这边记下的:
alice from 127.0.0.1
bob from 127.0.0.1
bind(("127.0.0.1", 0)) 那个 0 有讲究,它的意思是"你随便给我一个空闲端口"。getsockname() 再问一次才知道分到了哪个。写测试代码的时候都用这个办法,免得跟别的程序抢端口。
2.3 单进程里拆开看一遍
上面那两个文件跳过了太多中间状态。交互式地走一遍,每一步到底发生了什么看得更清楚。
python
>>> import socket
>>> server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
>>> server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
>>> server.bind(("127.0.0.1", 0))
>>> server.listen(1)
>>> host, port = server.getsockname()
>>> host
'127.0.0.1'
>>> port > 0
True
到这儿服务端已经在了,但还没有人连过来。接下去建客户端。
python
>>> client = socket.socket()
>>> client.settimeout(3)
>>> client.connect((host, port))
>>> client.getpeername()[0]
'127.0.0.1'
>>> server.settimeout(3)
>>> conn, addr = server.accept()
>>> conn.getpeername()[0]
'127.0.0.1'
accept() 返回的 conn 是一个新的 socket,跟 server 不是同一个东西。server 继续守在门口等下一个人,conn 专门跟眼前这位说话。这个区别初学者最容易搞混,很多 bug 都是把数据发到 server 上去了。
getpeername() 问的是对端的地址,getsockname() 问的是自己的。
现在收发数据。
python
>>> client.sendall(b"ping")
>>> conn.recv(1024)
b'ping'
>>> conn.sendall(b"pong")
>>> client.recv(1024)
b'pong'
send 和 sendall 差一个字,行为差很多。
python
>>> conn.send(b"ab")
2
>>> conn.sendall(b"cd")
>>> client.recv(1024)
b'abcd'
send 返回实际发出去的字节数,可能小于你给的长度。网络缓冲区满了它就只发一部分,剩下的你得自己接着发。sendall 帮你把这件事循环做完,返回 None。
写业务代码一律用 sendall。send 的返回值在某些特殊场合有用,平时用不到,用了还容易漏数据。
对端关掉之后,recv 会返回空字节串。这是 TCP 告诉你"对方说完了"的方式。
python
>>> client.close()
>>> conn.recv(1024)
b''
>>> conn.close()
>>> server.close()
看清这个 b'',它不是"收到一个空消息",它是一个信号。服务端的循环靠它退出,就像上面 echo_server.py 里那个 if not data: break。
关掉的 socket 再用会报错。
python
>>> client.send(b"again")
OSError: [WinError 10038] 在一个非套接字上尝试了一个操作。
2.4 recv 拿到的不一定是"一次"
这是 TCP 新手栽得最狠的一个坑。
你以为发三次,对面就会收到三次。不是这样。TCP 是字节流,它只保证字节的顺序和完整,不保证你一次 send 对应对面一次 recv。
试一下。
python
>>> srv = socket.socket()
>>> srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
>>> srv.bind(("127.0.0.1", 0))
>>> srv.listen(1)
>>> srv.settimeout(3)
>>> cli = socket.socket()
>>> cli.settimeout(3)
>>> cli.connect(srv.getsockname())
>>> conn, _ = srv.accept()
>>> conn.settimeout(1)
>>> cli.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
>>> for i in range(3):
... cli.sendall(b"msg-%d" % i)
>>> import time
>>> time.sleep(0.5)
>>> conn.recv(1024)
b'msg-0msg-1msg-2'
>>> conn.recv(1024)
TimeoutError: timed out
三条消息挤在一起回来了。这叫粘包。反过来也有可能,一条长消息被拆成两半,对面要 recv 两次才收全。这叫拆包。
recv(1024) 那个 1024 是"最多收 1024 字节",不是"收 1024 字节"。有多少收多少,最少 1 字节,一个都没有就等着。
顺便说一句,为什么这儿要加一行 sleep。因为脚本跑得太快,三条数据可能还在路上。睡半秒是让它们都到齐,好让这个现象稳定复现。你在自己机器上试的时候,如果不加这行,也有可能一次只收到一条。这不说明 TCP 保边界,只能说明这次运气好。
那两行 setsockopt 也解释一下。TCP_NODELAY 是关掉 Nagle 算法,那个算法会把小包攒一攒再发,攒的过程会让现象变得不确定。
2.5 给消息定个规矩
粘包的根源是接收方不知道一条消息有多长。那就告诉它。
最常见的办法是定长头部,先把长度发过去,再发正文。
markdown
一条消息在网上的样子
┌──────────┬────────────────────────────┐
│ 4 字节 │ N 字节 │
│ 长度 N │ 正文 │
└──────────┴────────────────────────────┘
大端无符号整数 真正的消息内容
发送方把长度用 struct 打包成 4 个字节,接收方先读 4 个字节拿到长度,再按长度读正文。
python
>>> import struct
>>> def send_msg(sock, data):
... sock.sendall(struct.pack("!I", len(data)) + data)
>>> def recv_exact(sock, n):
... buf = b""
... while len(buf) < n:
... chunk = sock.recv(n - len(buf))
... if not chunk:
... raise EOFError("对端关了")
... buf += chunk
... return buf
>>> def recv_msg(sock):
... (n,) = struct.unpack("!I", recv_exact(sock, 4))
... return recv_exact(sock, n)
recv_exact 是这里的关键。recv 给多少是不确定的,所以要自己在循环里凑够数。少了继续读,读到空说明对端关了。
试一下效果。
python
>>> srv2 = socket.socket()
>>> srv2.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
>>> srv2.bind(("127.0.0.1", 0))
>>> srv2.listen(1)
>>> srv2.settimeout(3)
>>> cli2 = socket.socket()
>>> cli2.settimeout(3)
>>> cli2.connect(srv2.getsockname())
>>> conn2, _ = srv2.accept()
>>> conn2.settimeout(1)
>>> for word in (b"short", b"a-much-longer-message"):
... send_msg(cli2, word)
>>> time.sleep(0.5)
>>> recv_msg(conn2)
b'short'
>>> recv_msg(conn2)
b'a-much-longer-message'
这次两条消息分得清清楚楚。
除了定长头部还有另外两种办法,各有各的场合。
| 办法 | 怎么做 | 适合什么 |
|---|---|---|
| 定长头部 | 先发 4 字节长度,再发正文 | 通用,传二进制也行 |
| 分隔符 | 每条消息末尾加 \n,按行读 |
纯文本,比如聊天、日志 |
| 定长消息 | 每条都固定 N 字节,不够补零 | 协议简单、长度固定的场合 |
按行读的那个,socket 自带一个方法叫 makefile,把它当文件用。
python
>>> f = conn2.makefile("rwb")
>>> f.write(b"line-1\nline-2\n")
14
>>> f.flush()
>>> cli2.recv(100)
b'line-1\nline-2\n'
>>> cli2.sendall(b"back\n")
>>> f.readline()
b'back\n'
>>> f.close()
makefile 给你一个带缓冲的文件对象,之后就能用 readline、read、write 这些熟悉的方法了。用 \n 当分隔符的协议这样写最省事。记得写完要 flush,缓冲区不刷数据不会走。
2.6 一次只服务一个人是不够的
回到 echo_server.py。它有个大问题,accept 拿到一个连接之后,就进内层循环跟这个人说话,说完了才回去接下一个。第二个客户端在这期间能连上来,但说不上话。listen(5) 那个 5 就是排队的人数,第二个客户端排在队列里干等,等到第一个断开才轮到它。
真实的服务端要能同时应付很多人。第十二章的两个工具正好派上用场,线程或者进程。
python
# thread_server.py
import socket
import threading
def handle(conn, addr):
with conn:
while True:
data = conn.recv(1024)
if not data:
break
conn.sendall(b"echo: " + data)
print("断开:", addr, flush=True)
def main():
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 9001))
srv.listen(50)
print("在 9001 上等着", flush=True)
while True:
conn, addr = srv.accept()
threading.Thread(target=handle, args=(conn, addr), daemon=True).start()
if __name__ == "__main__":
main()
每来一个连接就开一个线程。daemon=True 的意思是主程序退出时这些线程跟着一起走,不用等它们。
这套代码有个现成的轮子,socketserver 模块。它把上面那堆模板都写好了,你只需要写"收到数据之后干什么"。
python
>>> import socketserver
>>> import threading
>>> class EchoHandler(socketserver.BaseRequestHandler):
... def handle(self):
... data = self.request.recv(1024)
... self.request.sendall(b"echo: " + data)
>>> srv = socketserver.ThreadingTCPServer(("127.0.0.1", 0), EchoHandler)
>>> srv.daemon_threads = True
>>> host, port = srv.server_address
>>> host
'127.0.0.1'
>>> threading.Thread(target=srv.serve_forever, daemon=True).start()
BaseRequestHandler 里,self.request 就是那条连接的 socket,self.client_address 是对方地址。你要写的只有 handle 方法。
ThreadingTCPServer 是一个连接一个线程。TCPServer 是单线程的,一次只服务一个,调试的时候反而好用。
起四个客户端同时说话。
python
>>> results = []
>>> lock = threading.Lock()
>>> def call(i):
... c = socket.create_connection((host, port), timeout=3)
... c.sendall(("c%d" % i).encode())
... reply = c.recv(100).decode()
... c.close()
... with lock:
... results.append(reply)
>>> ts = [threading.Thread(target=call, args=(i,)) for i in range(4)]
>>> for t in ts:
... t.start()
>>> for t in ts:
... t.join()
>>> sorted(results)
['echo: c0', 'echo: c1', 'echo: c2', 'echo: c3']
四个请求同时被处理完了。这里用 sorted 是因为四个线程谁先回来不一定,直接打印顺序会乱。
用完要关。shutdown() 让 serve_forever 那个循环停下来,server_close() 把监听的 socket 关掉。
python
>>> srv.shutdown()
>>> srv.server_close()
2.7 断线和超时
网络程序的错误有一大半出在"对面不按套路来"。
第一种,对面好好地关了。recv 返回 b'',前面见过。
第二种,对面只关了写这一半。这叫半关闭,用 shutdown 做。
python
>>> s3 = socket.socket()
>>> s3.bind(("127.0.0.1", 0))
>>> s3.listen(1)
>>> s3.settimeout(3)
>>> c3 = socket.socket()
>>> c3.settimeout(3)
>>> c3.connect(s3.getsockname())
>>> conn3, _ = s3.accept()
>>> conn3.settimeout(3)
>>> c3.shutdown(socket.SHUT_WR)
>>> conn3.recv(1024)
b''
>>> c3.send(b"still there")
BrokenPipeError: [WinError 10058] 由于以前的关闭调用,套接字在那个方向已经关闭,发送或接收数据的请求没有被接受。
SHUT_WR 是关掉写方向,意思是"我说完了,但还想听你说"。对面 recv 拿到 b'',你自己再 send 就被拒绝了。
这个用法在传文件的时候有用,客户端发完文件内容,关掉写方向,服务端收到 EOF 就知道传完了,不需要额外发一个长度。
第三种,对面进程直接没了,或者网线断了。这时候你发数据,第一次可能还能成功,第二次就炸了。
python
>>> s4 = socket.socket()
>>> s4.bind(("127.0.0.1", 0))
>>> s4.listen(1)
>>> s4.settimeout(3)
>>> c4 = socket.socket()
>>> c4.settimeout(3)
>>> c4.connect(s4.getsockname())
>>> conn4, _ = s4.accept()
>>> conn4.settimeout(3)
>>> conn4.close()
>>> time.sleep(0.2)
>>> c4.send(b"first")
5
>>> time.sleep(0.3)
>>> c4.send(b"second")
ConnectionAbortedError: [WinError 10053] 你的主机中的软件中止了一个已建立的连接。
第一次 send 老老实实返回 3,因为数据进了本机的缓冲区,还没发现对面没了。等对面回了 RST,第二次就抛异常了。
具体抛哪个异常跟系统和关闭方式有关,ConnectionResetError、ConnectionAbortedError、BrokenPipeError 都可能遇到。写 except 的时候直接抓 OSError 最省事,这三个都是它的子类。
第四种,对面没崩也没关,就是不说话。这时候只能靠超时。
python
>>> c4.settimeout(0.5)
>>> c4.recv(1024)
ConnectionAbortedError: [WinError 10053] 你的主机中的软件中止了一个已建立的连接。
settimeout 有三种用法,这张表收好。
| 调用 | 效果 |
|---|---|
settimeout(None) |
阻塞模式,一直等到有数据 |
settimeout(0) |
非阻塞,没数据立刻报错 |
settimeout(1.5) |
最多等 1.5 秒 |
默认那个阻塞模式最危险。服务端卡在某个 recv 上,整个线程就死了,谁也叫不醒。我现在的习惯是,所有 socket 建完就加一行 settimeout,哪怕给个 30 秒。
2.8 写服务端时顺手加的那几行
有几行代码跟业务逻辑没关系,但不加迟早出事。
第一行是 SO_REUSEADDR。
python
>>> a = socket.socket()
>>> a.bind(("127.0.0.1", 0))
>>> used_port = a.getsockname()[1]
>>> b = socket.socket()
>>> b.bind(("127.0.0.1", used_port))
OSError: [WinError 10048] 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。
>>> b.close()
>>> a.close()
这就是那个经典报错,端口被占。你改完代码重启服务端,经常撞上它,因为上一条连接还处在 TIME_WAIT 状态没散干净。
SO_REUSEADDR 就是治这个的。
python
>>> c = socket.socket()
>>> c.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
>>> c.bind(("127.0.0.1", used_port))
>>> c.getsockname()[0]
'127.0.0.1'
>>> c.close()
绑上去了。服务端开头固定加这一行,能省掉一半的重启报错。
不过得说清楚,它不是万能的。上面这个例子里端口是空闲的所以管用,要是那个端口上真有一个正在监听的程序,加了也照样失败。
还有几件事属于常识级别的。
listen(5) 里的 5 是排队上限,不是并发上限。排满之后新的连接会被直接拒绝。生产环境给大一点,50 或者 128。
recv(1024) 的 1024 只是一次的缓冲大小,跟消息长度没关系。传大文件要自己循环收,就像前面那个 recv_exact。
收发一律用 sendall,别用 send。
close() 也记得调,更稳的写法是 with。
python
>>> with socket.socket() as s5:
... s5.bind(("127.0.0.1", 0))
... print(s5.getsockname()[0])
127.0.0.1
with 的好处是哪怕中间抛异常,socket 也会被关掉。Windows 上一个忘了关的 socket 会一直占着端口,直到你把整个 Python 进程杀掉。
3 UDP 编程
3.1 不握手,也不保证
如果说 TCP 是打电话,UDP 就是寄明信片。写好了往邮筒里一扔,不确认对方收没收到,也不保证两张明信片谁先到。
它没有连接这个概念。你想发就发,sendto 里写上对方地址,一次一个数据报。
代价换来了什么,对比着看最清楚。
| TCP | UDP | |
|---|---|---|
| 要不要先连 | 要,三次握手 | 不要,sendto 直接发 |
| 数据会不会丢 | 不会,丢了重发 | 会,丢了就是丢了 |
| 顺序 | 保证有序 | 不保证 |
| 消息边界 | 没有,是字节流 | 有,一次发对应一次收 |
| 头部大小 | 20 字节起 | 8 字节 |
| 一次能发多少 | 随便,给你切成流 | 单个数据报有上限 |
| 能不能群发 | 不行,一对一 | 能广播、能组播 |
3.2 一个最小的 UDP 收发
服务端这边只有两个动作,bind 和 recvfrom。没有 listen,没有 accept。
python
>>> u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
>>> u.bind(("127.0.0.1", 0))
>>> u.settimeout(3)
>>> u.type
<SocketKind.SOCK_DGRAM: 2>
>>> uc = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
>>> uc.settimeout(3)
>>> uc.sendto(b"hello", u.getsockname())
5
>>> data, addr = u.recvfrom(1024)
>>> data
b'hello'
>>> addr[0]
'127.0.0.1'
sendto 要两个参数,数据和对方地址,返回发出去的字节数。recvfrom 返回两样东西,数据和对方地址。这个地址很重要,服务端想回信就得靠它。
UDP 的 socket 也能 connect,但那个 connect 跟 TCP 的完全是两回事。它不握手,只是在本机记下"以后默认发给谁",之后就能用 send 和 recv 了。
python
>>> uc2 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
>>> uc2.settimeout(3)
>>> uc2.connect(u.getsockname())
>>> uc2.send(b"via connect")
11
>>> u.recvfrom(1024)[0]
b'via connect'
>>> uc2.close()
这么写的额外好处是,这个 socket 之后只收来自那个地址的数据报,别人的直接过滤掉。
拿 uc2 单独做这个演示是有原因的。UDP 的 socket 一旦 connect 过,sendto 就只能发给这一个地址了,再想发给别人会报错。所以别在主 socket 上乱 connect。
3.3 边界保住了
TCP 那边最头疼的粘包,UDP 这边根本不存在。发三次,就是三个独立的数据报。
python
>>> for i in range(3):
... uc.sendto(b"m%d" % i, u.getsockname())
>>> time.sleep(0.3)
>>> u.recvfrom(1024)[0]
b'm0'
>>> u.recvfrom(1024)[0]
b'm1'
>>> u.recvfrom(1024)[0]
b'm2'
>>> u.recvfrom(1024)
TimeoutError: timed out
一次 recvfrom 拿一个,不多不少。最后那个 TimeoutError 说明队列空了。
不过别高兴太早,不粘包的代价是不保证到达。上面这三个很可能有一个在半路上没了,只是本机回环网络上几乎不会发生。
3.4 发出去不等于收到了
这一小节我想让你亲眼看一下 UDP 有多不负责任。
先找一个确定没人在听的端口,然后往那儿发。
python
>>> tmp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
>>> tmp.bind(("127.0.0.1", 0))
>>> dead = tmp.getsockname()
>>> tmp.close()
>>> uc.sendto(b"nobody home", dead)
11
sendto 老老实实返回 11,意思是"我发出去 11 个字节了"。至于有没有人收到,它不知道,也不关心。
实际上没人收到。系统会回一个 ICMP 端口不可达,但这个消息不会立刻告诉你。要等你在这个 socket 上再做一次操作,它才冒出来。
python
>>> uc.recvfrom(1024)
ConnectionResetError: [WinError 10054] 远程主机强迫关闭了一个现有的连接。
这个报错看着像连接出问题了,可 UDP 压根没有连接。它只是把上一步的那个 ICMP 错误补报给你。
所以写 UDP 程序必须自己补两样东西,超时重传和序号。发出去之后等一小会儿,没回音就再发一次;给每个数据报编个号,对面的应答里带上这个号,你才知道应答对应的是哪一次。
TCP 帮你做的那三件事,用 UDP 就得自己干一遍。反过来讲,要是你的场景根本不在乎偶尔丢一个包,那这些代码就纯粹是浪费。
3.5 一个完整的 UDP 例子
写成文件是这个样子。服务端收到什么就转成大写发回去,收到 bye 就退出。
python
# UDP 版的 echo,存成 udp_demo.py
import socket
import threading
def serve(sock):
while True:
data, addr = sock.recvfrom(1024)
if data == b"bye":
return
sock.sendto(data.upper(), addr)
def main():
srv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
srv.bind(("127.0.0.1", 0))
threading.Thread(target=serve, args=(srv,), daemon=True).start()
cli = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
cli.settimeout(2)
for word in (b"hello", b"python"):
cli.sendto(word, srv.getsockname())
reply, _ = cli.recvfrom(1024)
print(word.decode(), "->", reply.decode())
cli.sendto(b"bye", srv.getsockname())
cli.close()
srv.close()
if __name__ == "__main__":
main()
text
hello -> HELLO
python -> PYTHON
跟 TCP 版比一下,少了 listen、少了 accept、少了每个连接一个 socket。服务端从头到尾只有一个 socket,谁发来都收,回信照着 addr 退回去。
3.6 什么场合该用 UDP
我的判断标准特别简单,丢一个包会不会出事。
适合 UDP 的
| 场景 | 为什么 |
|---|---|
| 音视频通话 | 丢一帧画面就是卡一下,重传反而更卡 |
| 网游里的位置同步 | 位置每秒发几十次,丢一次下一次马上补上 |
| DNS 查询 | 一个包问一个包答,没答就重问,简单 |
| 局域网广播找设备 | 根本不知道对方是谁,只能喊一嗓子 |
| 监控数据上报 | 每秒一个点,丢几个不影响看趋势 |
适合 TCP 的
| 场景 | 为什么 |
|---|---|
| 传文件 | 少一个字节文件就坏了 |
| 网页 | HTML、CSS、JS 少一段页面就废了 |
| 数据库 | 命令顺序错了结果就错了 |
| 远程登录 | 敲的字不能丢也不能乱 |
| 任何要按序执行的命令流 | UDP 不保证顺序 |
一句话记法,丢包能忍就 UDP,不能忍就 TCP。
广播那件事稍微说一句。往 255.255.255.255 或者某个网段的广播地址发,整个网段都能收到。要先开一个开关。
python
>>> bc = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
>>> bc.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
>>> bc.sendto(b"who is there", ("255.255.255.255", 37020))
12
>>> bc.close()
不开 SO_BROADCAST 直接发会被拒绝。这个开关存在的意义是防止你手滑把数据广播给全公司。
4 实战: 一个能用浏览器打开的服务器
4.1 要做的事
前面写的都是自定义协议,你发一句我回一句。这一节做一个真东西,一个 HTTP 服务器。
目标很小,但得真能用
- 浏览器打开
http://127.0.0.1:8000/能看到页面 - 能下载文本文件、图片这些静态文件
- 目录没有 index.html 的时候列出文件清单
- 路径不存在返回 404
- 不许跳出根目录
HTTP 本身没什么神秘的。它就是一套规定好的文本格式,跑在 TCP 上面。你用 socket 把这套格式写出来,浏览器就认。
4.2 HTTP 报文长什么样
浏览器发过来的请求,拆开看是这样。
text
GET /index.html HTTP/1.1
Host: 127.0.0.1:8000
User-Agent: Mozilla/5.0
Accept: text/html
第一行叫请求行,三样东西,方法、路径、协议版本,空格隔开。后面若干行是请求头,一行一个,冒号分隔键值。最后是一个空行,表示头说完了,后面没正文了。
这个空行很关键,是 \r\n\r\n 两个回车换行。服务端读到它才算把头读齐。
服务器的回答是同样的结构。
text
HTTP/1.1 200 OK
Server: MiniHttp/1.0
Content-Type: text/html; charset=utf-8
Content-Length: 38
Connection: close
<html>...</html>
第一行是状态行,协议版本、状态码、原因短语。然后是响应头,Content-Length 告诉浏览器正文有多少字节,浏览器照着这个数收完就认为这次结束了。然后空行,然后正文。
状态码记这几个就够用。
| 状态码 | 意思 | 什么时候发 |
|---|---|---|
| 200 | OK | 正常返回内容 |
| 301 / 302 | 重定向 | 让浏览器去别的地址 |
| 403 | Forbidden | 不许访问,比如想跳出根目录 |
| 404 | Not Found | 路径不存在 |
| 405 | Method Not Allowed | 只支持 GET 但收到了 POST |
| 500 | Internal Server Error | 服务端自己崩了 |
4.3 完整代码
一个文件搞定,存成 mini_http.py。
python
# 只用 socket 写出来的一个最小 HTTP 服务器,存成 mini_http.py
"""直接跑是自检,python mini_http.py serve 8000 才是真的开服务。"""
import mimetypes
import shutil
import socket
import socketserver
import sys
import tempfile
import threading
import time
import urllib.parse
import urllib.request
from pathlib import Path
CRLF = "\r\n"
STATUS = {200: "200 OK", 403: "403 Forbidden", 404: "404 Not Found",
405: "405 Method Not Allowed"}
ACCESS_LOG = []
def build_response(code, body=b"", ctype="text/html; charset=utf-8"):
head = ["HTTP/1.1 " + STATUS[code],
"Server: MiniHttp/1.0",
"Content-Type: " + ctype,
"Content-Length: " + str(len(body)),
"Connection: close"]
return (CRLF.join(head) + CRLF + CRLF).encode("ascii") + body
def render_dir(root, rel):
items = []
for p in sorted((root / rel).iterdir()):
name = p.name + ("/" if p.is_dir() else "")
items.append('<li><a href="/%s">%s</a></li>' % ((rel + name).lstrip("/"), name))
return ("<h1>/%s</h1><ul>%s</ul>" % (rel, "".join(items))).encode("utf-8")
class Handler(socketserver.BaseRequestHandler):
def handle(self):
root = self.server.root
self.request.settimeout(5)
buf = b""
while b"\r\n\r\n" not in buf and len(buf) < 65536:
chunk = self.request.recv(4096)
if not chunk:
return
buf += chunk
first = buf.split(b"\r\n\r\n")[0].decode("utf-8", "replace").split("\r\n")[0]
parts = first.split(" ")
if len(parts) < 2:
return
method = parts[0]
path = urllib.parse.unquote(urllib.parse.urlparse(parts[1]).path)
ACCESS_LOG.append("%s %s <- %s" % (method, path, self.client_address[0]))
if method != "GET":
self.reply(405, b"<h1>405</h1>")
return
target = (root / path.lstrip("/")).resolve()
if not str(target).startswith(str(root)):
self.reply(403, b"<h1>403</h1><p>out of root</p>")
return
if target.is_dir():
index = target / "index.html"
if index.exists():
self.reply_file(index)
else:
self.reply(200, render_dir(root, path.strip("/")))
return
if target.exists():
self.reply_file(target)
return
self.reply(404, ("<h1>404</h1><p>no %s</p>" % path).encode("utf-8"))
def reply(self, code, body, ctype="text/html; charset=utf-8"):
self.request.sendall(build_response(code, body, ctype))
def reply_file(self, path):
ctype = mimetypes.guess_type(Path(path).name)[0] or "application/octet-stream"
if ctype.startswith("text/"):
ctype += "; charset=utf-8"
self.reply(200, Path(path).read_bytes(), ctype)
def make_server(port, root, host="127.0.0.1"):
server = socketserver.ThreadingTCPServer((host, port), Handler)
server.daemon_threads = True
server.allow_reuse_address = True
server.root = Path(root).resolve()
return server
def write(path, text):
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
def self_check(port=8123):
root = Path(tempfile.mkdtemp(prefix="minihttp-"))
(root / "sub").mkdir()
write(root / "index.html", "<h1>hello</h1>\n<p>这是首页</p>\n")
write(root / "note.txt", "第一行\n第二行\n")
write(root / "sub" / "data.json", '{"a": 1}\n')
server = make_server(port, root)
threading.Thread(target=server.serve_forever, daemon=True).start()
time.sleep(0.3)
def request(path, method="GET"):
s = socket.create_connection(("127.0.0.1", port), timeout=5)
s.sendall(("%s %s HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"
% (method, path)).encode())
buf = b""
while True:
chunk = s.recv(4096)
if not chunk:
break
buf += chunk
s.close()
head, body = buf.split(b"\r\n\r\n", 1)
lines = head.decode().split("\r\n")
status = lines[0].split(" ")[1]
ctype = [x.split(": ", 1)[1] for x in lines if x.lower().startswith("content-type:")][0]
return status, ctype, body
for path in ("/", "/note.txt", "/sub/", "/sub/data.json", "/nope.html", "/../secret.txt"):
status, ctype, body = request(path)
print("%-15s -> %s %-24s %2d 字节" % (path, status, ctype, len(body)))
status, ctype, body = request("/", method="POST")
print("%-15s -> %s %-24s %2d 字节" % ("POST /", status, ctype, len(body)))
print()
print("首页正文: %r" % request("/")[2].decode("utf-8"))
print()
with urllib.request.urlopen("http://127.0.0.1:%d/" % port, timeout=5) as r:
print("换成 urllib 也一样: %d %s" % (r.status, r.headers.get("Content-Type")))
print(r.read().decode("utf-8"), end="")
print()
print("服务端记录下来的请求:")
for line in ACCESS_LOG:
print(" " + line)
server.shutdown()
server.server_close()
shutil.rmtree(root, ignore_errors=True)
def main(port=8000):
server = make_server(port, Path(__file__).resolve().parent / "www")
print("MiniHTTP 已启动,浏览器打开 http://127.0.0.1:%d/" % port, flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n收到 Ctrl+C", flush=True)
finally:
server.server_close()
print("服务已关闭", flush=True)
if __name__ == "__main__":
args = sys.argv[1:]
if args and args[0] == "serve":
main(int(args[1]) if len(args) > 1 else 8000)
else:
self_check()
几个地方值得单独说。
make_server 里那句 server.root = Path(root).resolve() 是把根目录挂在服务对象上,Handler 里用 self.server.root 取出来。socketserver 允许你往 server 上挂任意属性,这是传配置最省事的办法。
target.resolve() 配上 str(target).startswith(str(root)) 是防目录穿越。用户请求 /../secret.txt,resolve() 会把它算成真实路径,发现跑出根目录了就返回 403。少了这两行,你的服务器能把整个磁盘都给人翻出来。
mimetypes.guess_type 按扩展名猜类型,猜不出来给 application/octet-stream,浏览器会当成下载。text/ 开头的手动补上 charset=utf-8,不然中文页面在浏览器里是乱码。这个坑我踩过,页面上全是问号。
Content-Length 用 len(body),这里数的是字节数不是字符数。中文一个字三个字节,用 len(字符串) 算会算少。
4.4 先自检一遍
直接跑这个文件,它会在临时目录里造几个文件,起服务,发请求,把结果打出来。
text
/ -> 200 text/html; charset=utf-8 35 字节
/note.txt -> 200 text/plain; charset=utf-8 20 字节
/sub/ -> 200 text/html; charset=utf-8 68 字节
/sub/data.json -> 200 application/json 9 字节
/nope.html -> 404 text/html; charset=utf-8 32 字节
/../secret.txt -> 403 text/html; charset=utf-8 30 字节
POST / -> 405 text/html; charset=utf-8 12 字节
首页正文: '<h1>hello</h1>\n<p>这是首页</p>\n'
换成 urllib 也一样: 200 text/html; charset=utf-8
<h1>hello</h1>
<p>这是首页</p>
服务端记录下来的请求:
GET / <- 127.0.0.1
GET /note.txt <- 127.0.0.1
GET /sub/ <- 127.0.0.1
GET /sub/data.json <- 127.0.0.1
GET /nope.html <- 127.0.0.1
GET /../secret.txt <- 127.0.0.1
POST / <- 127.0.0.1
GET / <- 127.0.0.1
GET / <- 127.0.0.1
表里那七次请求全对上了。首页返回 200,/note.txt 认出是纯文本,/sub/ 没有 index.html 所以列了个清单,/nope.html 返回 404,/../secret.txt 被 403 拦下,POST 返回 405。
下面那段日志有九行,GET / 出现了三次。表里一次,取首页正文又一次,urllib 那边还有一次。日志是把每次请求都记下来的,重复访问自然会有重复的行。
最后那两行是用 urllib.request 发的。浏览器干的事跟这差不多,只是它收完还会把 HTML 渲染出来。
4.5 真的开起来
在 mini_http.py 旁边建个 www 目录,随便放点东西进去。
text
mini_http.py
www/
├── index.html
├── note.txt
└── sub/
└── data.json
然后开服务。
text
$ python mini_http.py serve 8000
MiniHTTP 已启动,浏览器打开 http://127.0.0.1:8000/
浏览器里输入那个地址,页面就出来了。换台机器访问你,把 127.0.0.1 换成你这台机器的内网 IP,同时 make_server 里的 host 要改成 0.0.0.0。
这两个地址的区别值得一说。127.0.0.1 只监听本机,外面连不进来,这是最安全的默认设置。0.0.0.0 是监听所有网卡,局域网里的机器都能访问,同一个 Wi-Fi 下的手机也能。改完之后记得防火墙,Windows 第一次跑会弹窗问你放不放行。
4.6 还能往上加什么
这个服务器现在只有 200 多行,但骨架是全的,往上加东西不难。
| 想加的功能 | 怎么做 |
|---|---|
| 支持 POST | 读完头之后按 Content-Length 再读正文 |
| 中文路径 | 请求路径要 unquote,已经做了,注意编码 |
| 断点续传 | 解析 Range 头,返回 206 |
| 长连接 | 别发 Connection: close,按头里的 keep-alive 复用 |
| 压缩 | 看 Accept-Encoding 里有没有 gzip,有就压一下 |
| 访问日志 | ACCESS_LOG 那个列表换成写文件 |
| 限速 | 大文件分块 sendall,块之间睡一下 |
每加一个都会让你对 HTTP 的理解更具体。不过真要对外提供服务,别自己写,用现成的框架,Flask、FastAPI、Django 都比这个周全得多,后面讲 Web 的章节会细说。
5 小结
5.1 这章说了什么
按出场顺序理一遍。
| 小节 | 内容 |
|---|---|
| 1 网络基础 | 四层模型、IP、端口、五元组、socket、字节序 |
| 2 TCP 编程 | 三次握手、服务端五步、粘包与定长头部、socketserver、超时与断线 |
| 3 UDP 编程 | 数据报、保边界、会丢包、广播 |
| 4 实战 | 手写 HTTP 报文、静态文件服务器、目录穿越防护 |
TCP 服务端那套流程,印在脑子里就行。
text
服务端 客户端
socket() socket()
bind() │
listen() │
accept() ◄──── 握手 ───────── connect()
recv() ◄──── 数据 ───────── sendall()
sendall() ──── 数据 ─────────► recv()
close() ──── 挥手 ────────── close()
UDP 服务端那套短得多。
text
服务端 客户端
socket(SOCK_DGRAM) socket(SOCK_DGRAM)
bind() │
recvfrom() ◄── 数据报 ────── sendto(数据, 地址)
sendto(数据, addr) ── 数据报 ► recvfrom()
5.2 TCP 还是 UDP
别背表,抓两个判断就行。
第一个判断,丢一个包会不会出问题。会就用 TCP。
第二个判断,你要不要广播或者组播。要就只能 UDP。
剩下的边界情况,说几个实际的。
传文件、发命令、查数据库、看网页,TCP。
实时语音视频、游戏状态、心跳上报、局域网发现设备,UDP。
拿不准的时候先用 TCP,它帮你兜底。等真的发现握手太慢或者开销太大,再换。
5.3 九个容易踩的坑
按我踩过的顺序排。
第一,忘了设超时。 默认阻塞模式下,recv 能把线程卡到天荒地老。建完 socket 立刻 settimeout。
第二,用 send 当 sendall 用。 send 返回实际发出的字节数,可能比你要发的少。要么用 sendall,要么自己循环。
第三,把数据发给了监听 socket。 accept 返回的 conn 才是跟客户说话的那个,server 只负责接客。发错对象不报错,就是对面永远收不到。
第四,recv 返回 b'' 没处理。 这是对端关了,不是收到一个空消息。不处理就是死循环,CPU 直接跑满。
第五,以为一次 send 对应一次 recv。 TCP 是字节流,没有消息边界。要么定长头部,要么分隔符,要么定长消息。
第六,重启服务端报 10048。 加 SO_REUSEADDR。
第七,UDP 当成可靠的用。 它不保证到达也不保证顺序,要可靠就得自己加序号和重传。
第八,大文件只 recv 一次。 1024 是缓冲上限不是消息长度,收大文件要在循环里凑。
第九,绑错了 IP。 127.0.0.1 只有本机连得上,0.0.0.0 才是所有人都能连。别人连不上你的服务,八成是这个。
5.4 四道小题
第一题,这段代码启动后会报什么错。
python
# wrong_listen.py
import socket
srv = socket.socket()
srv.bind(("127.0.0.1", 9002))
conn, addr = srv.accept()
bind 之后少了 listen。没有进入监听状态的 socket 不能 accept,会抛 OSError: [WinError 10022] 提供了一个无效的参数。
第二题,一个服务端这样收数据,有什么问题。
python
# wrong_recv.py
data = conn.recv(1024)
while data:
print(data)
看着没问题,跑起来是死循环。recv 第二次还是接着上一次的位置读,对端早就发完了,data 永远是第一次那 1024 个字节。正确的写法是在循环里重复调 recv。
python
# right_recv.py
while True:
data = conn.recv(1024)
if not data:
break
print(data)
第三题,客户端连上之后发了两条消息。
python
# wrong_pack.py
cli.sendall(b"login")
cli.sendall(b"password")
服务端一次 recv 拿到的会是什么。
大概率是 b'loginpassword',两条粘在一起。也可能只拿到 b'log'。要给消息加上边界,比如先发 4 字节长度。
第四题,UDP 服务端收到一个数据报之后,想把应答发回去,地址从哪儿来。
recvfrom 的第二个返回值。UDP 没有连接,服务端手里只有那一个 socket,全靠每次 recvfrom 带回的地址知道该回给谁。
python
# udp_reply.py
data, addr = sock.recvfrom(1024)
sock.sendto(b"got it", addr)
5.5 下一章
这一章解决的是"怎么把话说出去"。所有代码都在跟一个看不见的对方打交道,发过去,等回来。
你会发现写这些代码的时候,脑子里一直在想对面会回什么。这是网络编程和之前所有章节最大的不同,程序的行为不再只由你的代码决定。
下一章往上一层走。手工拼 HTTP 报文这件事,日常开发里没人干,大家用 requests。下一章讲网络爬虫,怎么把网页抓下来,怎么从一堆 HTML 里找出你要的那点信息,怎么应付反爬。到那时候这章写的服务器就成了被爬的对象,你可以拿自己写的页面练手。