第十三章 网络编程

第十三章 网络编程


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 里找出你要的那点信息,怎么应付反爬。到那时候这章写的服务器就成了被爬的对象,你可以拿自己写的页面练手。

相关推荐
外收内放1 小时前
Python基础语法练习题(72)
开发语言·python
171320330计算机毕设编程1 小时前
计算机毕业设计 基于Hadoop的新能源汽车发展前景分析 LW PPT
java·大数据·hadoop·python·算法·随机森林·线性回归
haluhalu.2 小时前
深入理解 AI Agent(1.2):Harness 工程——模型之外的竞争力
前端·数据结构·人工智能·python·langchain·个人开发
东莞市云毅网络有限公司2 小时前
把网页表格与列表还原成结构化条目:抽取与对账实现
python·文本处理·数据抽取
夜瞬2 小时前
从零微调 Qwen3-8B:基于 LoRA 与 LLaMA-Factory 打造专属 AI 编程助教
人工智能·python·llama
小小张说故事2 小时前
Python 字典为什么这么快?一次查找背后的哈希表、扰动函数和开放寻址
python
障碍的枫子3 小时前
我的第一个Agent
开发语言·python·langchain
打工仔折腾 AI3 小时前
260+异构同步链路实战:如何用语义化自动翻译破解CDC DDL同步痛点
人工智能·后端·python·自然语言处理·性能优化·langchain·机器翻译
曲径海盗3 小时前
【Python 学习第 33 天】条件判断 if/else
python·学习笔记·编程入门