第 4 篇 · 兵器谱:网络编程 · Socket 从入门到实战
行走江湖,得有趁手的兵器。
IP 是江湖坐标,端口是暗号,TCP/UDP 是传音入密的内功心法。
本篇为你打造网络编程的「屠龙刀」,客户端与服务端通信,一剑封喉!
1. 网络设备的三要素
- IP地址:网络环境中电脑的唯一标识,通过 IP 可以找到某台计算机
- 端口:一台服务器上会有 N 个应用,通过唯一的端口映射服务器中某个应用(服务)
- 协议:网络编程协议 — TCP / UDP
1.1 域名解析
输入www.baidu.com的时候,会进入 DNS 解析:
- 本地 host 文件 → 本地 DNS 解析 → 远程 DNS 解析 → 获取 IP 地址 → 找到服务器
8.8.8.8 是比较靠谱的 DNS 解析器
绝大部分网站的端口是 80 端口,默认不需要写
1.2 IP 版本
- IPv4:IP 地址量约 2 的 32 次方 ≈ 43 亿,已接近耗尽
- IPv6:IP 地址量约 2 的 128 次方,几乎无穷无尽
1.3 端口可用范围
端口号的可用范围是0 ~ 65535,作用是帮你找到在计算机上独一无二的服务。
不要去抢占知名端口号:0 ~ 1023。但也有一些不在这个范围内,比如 MySQL 的 3306。
IP 地址帮你锁定网络中的计算机设备,端口号帮你在这个设备上找到对应的服务。
2. TCP 与 UDP 的区别
TCP(传输控制协议)面向连接、可靠;UDP(用户数据报协议)无连接、不可靠,都属于传输层协议。
2.1 核心对比
| 对比项 | TCP | UDP |
|---|---|---|
| 连接特性 | 面向连接,三次握手建立连接,四次挥手断开连接 | 无连接,发数据前不需要建立连接,直接发包 |
| 可靠性 | 可靠传输:确认应答、超时重传、丢包重传;不丢包、不乱序 | 不可靠:不保证到达,不保证顺序,可能丢包、乱序 |
| 流量/拥塞控制 | 有流量控制(滑动窗口)、拥塞控制 | 无,只管发送,不感知网络拥堵 |
| 报文 | 面向字节流,数据是流,没有报文边界 | 面向数据包,保留报文边界,一个 UDP 包就是一条消息 |
| 头部开销 | 头部 20~60 字节,开销大 | 头部固定 8 字节,开销小 |
| 速度 | 较慢,握手、重传、确认带来额外开销 | 速度快,延迟低,开销小 |
| 适用场景 | 文件传输、HTTP/HTTPS、SSH、数据库访问,要求数据不能丢 | 直播、视频通话、游戏、DNS,允许少量丢包,追求低延迟 |
2.2 形象比喻
- TCP = 打电话:拨号(三次握手)→ 通话(可靠传输)→ 挂断(四次挥手);说话对方一定能收到,顺序不变,但是流程多
- UDP = 寄明信片:直接扔出去,不用先联系对方;明信片可能丢失、顺序乱掉,但是发得快,成本低
3. TCP 的三次握手
客户端主动发起连接,服务端被动接收,一共 3 次报文交互。
3.1 三次握手流程
- 第一次握手:客户端 → 服务端【SYN】
客户端发送SYN=1,随机初始序列号seq=x,客户端进入SYN_SENT状态。
含义:客户端请求建立连接。 - 第二次握手:服务端 → 客户端【SYN+ACK】
服务端收到 SYN,回复SYN=1,ACK=1;ack=x+1(确认客户端),自己随机初始序列号seq=y;服务端进入SYN_RCVD状态。
含义:同意连接,同时也向客户端发起连接请求。 - 第三次握手:客户端 → 服务端【ACK】
客户端收到 SYN+ACK,回复ACK=1,ack=y+1;客户端进入ESTABLISHED(已连接);服务端收到这个 ACK 后,也进入ESTABLISHED,连接正式建立,可以传数据。
3.2 为什么是三次,不是两次?
- 两次握手:服务端收到 SYN 就直接建立连接。如果客户端旧的失效 SYN 延迟到达服务器,服务器会建立无效连接,白白占用资源。
- 三次握手:双方收发能力都得到验证:客户端能发能收、服务端能发能收。第三次 ACK 可以拒绝过期的历史连接。
SYN=1 表示握手连接
客户端发 SYN=1,发 seq=x
服务端回 SYN=1,发 ack=x+1,seq=y
客户端回 ack=y+1
4. TCP 的四次挥手
任何一方都可以主动关闭,下面以客户端主动关闭举例。
4.1 四次挥手流程
- 第一次挥手:客户端 → 服务端【FIN】
客户端发送FIN=1,seq=u,客户端进入FIN_WAIT_1。
含义:客户端不再发送数据,但还可以接收数据。 - 第二次挥手:服务端 → 客户端【ACK】
服务端回复ACK=1,ack=u+1,seq=v;服务端进入CLOSE_WAIT;客户端收到 ACK 进入FIN_WAIT_2。
含义:收到关闭请求;此时服务端还可以继续向客户端发送剩余数据。 - 第三次挥手:服务端 → 客户端【FIN】
服务端数据全部发送完毕,发送FIN=1,ACK=1,seq=w,ack=u+1;服务端进入LAST_ACK。
含义:服务端也没有数据要发了,请求关闭。 - 第四次挥手:客户端 → 服务端【ACK】
客户端回复ACK=1,ack=w+1,seq=u+1;客户端进入TIME_WAIT(等待 2MSL);服务端收到 ACK,立刻进入CLOSED,连接关闭。客户端等待2MSL之后,也进入 CLOSED。
FIN=1 表示挥手连接
客户端发 FIN=1,seq=u
服务端回 ack=u+1 — 把剩余的数据传输完成
服务端发 FIN=1,ack=u+1,seq=w
客户端回 ACK=1,ack=w+1
4.2 为什么挥手要 4 次,握手只要 3 次?
握手时 SYN 和 ACK 可以合并成一个报文;挥手时,服务端收到 FIN,可能还有业务数据没发完,不能立刻发 FIN,只能先回复 ACK,等数据发完再发 FIN,所以拆成两次,总共 4 次。
5. Socket 套接字
5.1 Socket 套接字对象介绍
使用 socket 来进行网络通信编程工具。
# 引入socket模块importsocket# 创建一个socket对象server_socket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)# 地址族选用 IPv4,传输类型选用字节流print(server_socket)- 客户端(学名 Client):前端 / HTML / CSS / JS / Android / iOS / 小程序 / Flutter / RN
- 服务端(学名 Server):Python / Java / Go / Rust / Node.js / C#
5.2 Socket 客户端和服务端通信流程分析
服务端流程
- 创建一个 socket 对象 — 大管家(不负责和客户端直接对话)
bind()绑定 IP 和端口号 — 表示通过这个地址和端口能找到该服务listen(5)— 设置最大挂起的 socket 数量,默认值为 1,最大为 128,一般设置为 5accept()— 接收客户端发起连接,生成和客户端对话的 socket 对象,返回客户端信息
accept 是阻塞的:它不走完,下面谁也别走send()— 发送数据recv()— 接收数据(发送和接收没有强制顺序要求,但发送的数据一定是字节流)close()— 释放当前和客户端对话的 socket
5.3 二进制编解码操作
str="rucy,今天是个好日子"print(str.encode("gbk").decode("gbk"))print(str.encode("utf-8").decode("utf-8"))- GBK 编码:一个中文占 2 个字节,英文字符占 1 个字节,支持简体 + 繁体,部分日文
- UTF-8 编码:一个中文占 3 个字节,英文字符占 1 个字节,支持全球所有字符
如果内容中不包含中文,直接在字符串前加
b即表示该字符为 bytes
5.4 Socket 套接字对象创建(服务端)
注意:socket 对象默认是阻塞模式,阻塞的方法有:
accept()(服务端专用)recv(bufsize)(客户端/服务端)connect()(客户端专用)
阻塞:当前代码未执行完毕,绝不可能执行下一行代码
importsocketprint("1.创建socket服务器对象")server_socket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)print("2.设置端口号在进程结束立刻释放")server_socket.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,True)print("3.绑定端口号和ip地址")server_socket.bind(("127.0.0.1",9999))print("4.设置监听并设置挂起最大监听量")server_socket.listen(5)print("5.等待客户端连接")client_socket,client_info=server_socket.accept()print("6.客户端连接成功",client_info)print("7.给客户端发送数据")client_socket.send("我是你的服务器对象".encode("utf-8"))print("8.接收客户端消息")data=client_socket.recv(1024)print(f"收到客户端发的消息:{data.decode('utf-8')}")print("9.关闭和客户端通讯的连接")client_socket.close()# server_socket 一般不关5.5 Socket 套接字对象创建(客户端)
importsocketprint("1.创建客户端socket对象")client_socket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)print("2.通过ip端口连接服务器")client_socket.connect(("127.0.0.1",9999))print("3.连接成功")content=client_socket.recv(1024)print(f"服务器给你说:{content.decode('utf-8')}")send_content=input("跟服务说:")client_socket.send(send_content.encode("utf-8"))print("关闭客户端连接")client_socket.close()5.6 实现无限聊天版本 — 服务端
importsocket server_socket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)server_socket.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,True)server_socket.bind(("127.0.0.1",9999))server_socket.listen(5)# 阻塞方法client_socket,client_info=server_socket.accept()print(f"和客户端{client_info}连接成功")client_socket.send("你好,我是rucy,欢迎你来到我的聊天房间".encode("utf-8"))# accept recvwhileTrue:data=client_socket.recv(1024)ifnotdata:breakprint(f"收到客户端:{client_info}的消息:{data.decode('utf-8')}")client_socket.close()5.7 实现无限聊天 — 客户端
importsocket client_socket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)client_socket.connect(("127.0.0.1",9999))data=client_socket.recv(1024)print(f"服务器对你说:{data.decode('utf-8')}")whileTrue:send_content=input("对服务器说(quit退出)")ifsend_content=='quit':breakclient_socket.send(send_content.encode('utf-8'))client_socket.close()5.8 模拟多任务实现多人版本聊天(服务端)
支持多人聊天的服务端。
importsocket server_socket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)server_socket.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,True)server_socket.bind(("192.168.25.170",9999))server_socket.listen(5)print("完成服务端初始化所有功能")# 模拟多任务 — 多进程 > 多线程 > 多协程whileTrue:client_socket,client_info=server_socket.accept()client_socket.send("欢迎你兄弟来连接rucy的电脑".encode("utf-8"))data=client_socket.recv(1024)print(f"收到客户端{client_info}消息:{data.decode('utf-8')}")client_socket.close()5.9 模拟多任务多人版本聊天(客户端)
importsocket client_socket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)client_socket.connect(("127.0.0.1",8888))data=client_socket.recv(1024)print(f"收到服务端消息:{data.decode('utf-8')}")content=input("跟rucy说:")client_socket.send(content.encode("utf-8"))client_socket.close()6. 扩展功能
6.1 文件选择器对象
需求:当输入 img 的时候,自动弹出文件选择器,自动过滤文件,只选择图片。
importtkinterfromtkinterimportfiledialog content=input("对服务器说(img选择图片):")ifcontent=='img':print("选择图片")root=tkinter.Tk()root.withdraw()# 防止弹出多余小窗口file_path=filedialog.askopenfilename(title="请选择图片",filetypes=[("图片","*.png;*.jpg;*.jpeg;*.gif")])print(file_path)root.destroy()6.2 解析图片获取图片的字节流及详细信息
importosimporttkinterfromtkinterimportfiledialog content=input("输入img选择图片")ifcontent=='img':root=tkinter.Tk()root.withdraw()file_path=filedialog.askopenfilename(title="请选择图片",filetypes=[("图片","*.jpg *.jpeg *.png")])ext=os.path.splitext(file_path)[1][1:]# 拿到图片的扩展名withopen(file_path,"rb")asf:img_data=f.read()# 拿到图片的二进制数据print(f"拿到了图片的扩展名{ext}, 长度大小:{len(img_data)}, 二进制流数据:{img_data}")# TCP协议 二进制字节流 jpeg|42196|image_data6.3 选择一张图片存储到 data 目录下
6.3.1 快速创建目录
importos# 在当前目录下创建一个abc目录,如果存在则不创建os.makedirs("./abc",exist_ok=True)6.3.2 查询某个文件夹目录下的文件个数
importos os.makedirs("./abc",exist_ok=True)print(len(os.listdir("./")))# 返回当前目录下所有的文件列表6.3.3 完整需求实现
- 当用户输入 img 的时候,弹出选择图片窗口
- 选择完毕图片解析图片,得到图片的后缀、长度、二进制
- 在当前目录下创建一个 data 目录,如果存在则不创建
- 在 data 目录下写入刚刚读取到的图片,保留原来的后缀,图片的命名为 图片_1、图片_2……
importosimporttkinterfromtkinterimportfiledialog content=input("请输入您的内容(img)")ifcontent=='img':root=tkinter.Tk()root.withdraw()file_path=filedialog.askopenfilename(title="选择图片",filetypes=[("图片","*.png *.jpeg *.jpg")])ext=os.path.splitext(file_path)[1][1:]# 得到后缀withopen(file_path,"rb")asf:img_data=f.read()size=len(img_data)os.makedirs("./data",exist_ok=True)flag=len(os.listdir("./data"))+1withopen(f"./data/图片_{flag}.{ext}","wb")asf:f.write(img_data)print(f"图片已经存储到对应的位置:路径:./data/图片_{flag}.{ext}")6.4 选择图片通过 Socket(客户端)
importos.pathimportsocketimporttkinterfromtkinterimportfiledialogprint("1.创建客户端对象")client_socket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)client_socket.connect(("127.0.0.1",9999))print("2.客户端连接成功")content=input("请输入您要的内容(img传图片)")ifcontent=='img':root=tkinter.Tk()root.withdraw()file_path=filedialog.askopenfilename(title="请选择图片",filetypes=[("图片","*.jpg *.jpeg *.png")])ext=os.path.splitext(file_path)[1][1:]withopen(file_path,"rb")asf:img_data=f.read()size=len(img_data)# 不但要传字节流,还要传后缀、长度client_socket.sendall(f"IMG|{ext}|{size}".encode("utf-8")+img_data)# 等待服务端图片接收完成,给一个图片上传成功的消息print(client_socket.recv(1024).decode("utf-8"))client_socket.close()6.5 选择图片通过 Socket(服务端)
importosimportsocketprint("1.创建服务端socket对象")server_socket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)print("2.设置端口释放")server_socket.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,True)print("3.绑定端口")server_socket.bind(("127.0.0.1",9999))print("4.监听")server_socket.listen(5)print("5.接受客户端连接")client_socket,client_info=server_socket.accept()print("6.客户端连接成功",client_info)# 阻塞方法: accept recv connect# 第一次接收,得到一定是图片的元信息: IMG|jpeg|10086|x01first_data=client_socket.recv(1024)iffirst_data.startswith(b"IMG|"):parts=first_data.split(b"|",maxsplit=3)ext=parts[1].decode("utf-8")size=int(parts[2])img_data=parts[3]# 循环接收直到收完所有图片数据whilelen(img_data)<size:img_data+=client_socket.recv(1024)os.makedirs("./data",exist_ok=True)flag=len(os.listdir("./data"))+1withopen(f"./data/图片_{flag}.{ext}","wb")asf:f.write(img_data)client_socket.send(f"图片上传成功,图片路径:./data/图片_{flag}.{ext}".encode("utf-8"))client_socket.close()7. Socket 编程基础模板(必背)
7.1 服务端基础模板
python
import socket # 1. 创建 socket 对象(IPv4,TCP 字节流) server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 2. 设置端口复用(防止程序崩溃后端口被占用) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True) # 3. 绑定 IP 和端口 server_socket.bind(("127.0.0.1", 9999)) # 4. 开启监听,设置最大挂起连接数 server_socket.listen(5) # 5. 等待客户端连接(阻塞) client_socket, client_info = server_socket.accept() # 6. 接收数据(阻塞) data = client_socket.recv(1024) print(data.decode("utf-8")) # 7. 发送数据 client_socket.send("消息内容".encode("utf-8")) # 8. 关闭连接 client_socket.close() # server_socket.close() # 服务端一般不关,如需关闭则取消注释7.2 客户端基础模板
python
import socket # 1. 创建 socket 对象(IPv4,TCP 字节流) client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 2. 连接服务器 client_socket.connect(("127.0.0.1", 9999)) # 3. 发送数据 client_socket.send("消息内容".encode("utf-8")) # 4. 接收数据(阻塞) data = client_socket.recv(1024) print(data.decode("utf-8")) # 5. 关闭连接 client_socket.close()