先把话说清楚:Cooperiano/wechat 不是一套微信 SDK,也不是能直接上线接客的公众号后台。用 gh api repos/Cooperiano/wechat/git/trees/HEAD?recursive=1 把树拉平,只有四个 blob:
| 路径 | 大小 | 实际内容 |
|---|---|---|
wechat.py |
455 B | 一个 Flask app、一个 / 路由、GET 与 POST 两个分支 |
requirements.txt |
95 B | 六个精确版本号,没有范围符 |
README.md |
38 B | # wechat 和一段 MIT 声明 |
LICENSE |
1087 B | MIT 原文 |
README 只有 38 字节,意味着文档量为零,代码本身就是规格说明。这个体量决定了文章的写法也得跟着变:如果去分析它的"架构分层"和"模块职责",全是空话,因为它没有分层,也没有模块,一个文件从头到尾。但它确实做了一个明确的技术决策------把微信服务器接入这件事压缩到最小可运行形态,先验证协议跑得通,其余全部留白。这个决策值得拆开看看,因为绝大多数人第一次接公众号,卡住的恰恰不是业务逻辑,而是那两个几乎看不见的握手细节。
两个阶段,一个路由,两种语义
微信公众平台的服务器接入在协议上被切成两段,而这段代码用同一个 URL、同一个处理函数把两段都收了:
| 阶段 | HTTP 方法 | 触发时机 | 代码里的处理 | 返回体 |
|---|---|---|---|---|
| 服务器地址校验 | GET |
在后台点"提交"时打一次 | request.args.get('echostr', '') |
echostr 原文 |
| 消息推送 | POST |
用户给公众号发消息时 | xml_data = request.data |
字符串 success |
数据流只有三步,没有中间层:请求进 Flask → 按 method 分叉 → 直接 return 字符串。没有 ORM、没有蓝图、没有配置文件、没有中间件。对于原型阶段这是对的,因为此时唯一需要被验证的命题是"微信能不能连上我",而不是"我怎么组织代码"。
GET 分支:一个正确决定和一个空洞
原文件全部内容如下,注意注释是作者自己写的,位置很说明问题:
python
from flask import Flask, request
app = Flask(__name__)
@app.route('/', methods=['GET', 'POST'])
def wechat():
if request.method == 'GET':
# 微信服务器验证接口用
return request.args.get('echostr', '')
if request.method == 'POST':
# 微信推送消息
xml_data = request.data
# ... 这里处理微信消息
return 'success'
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
request.args.get('echostr', '') 这一行做对了两件事。第一,它认准了校验阶段唯一要通过的判据是"把 echostr 原样吐回去",微信侧比对的是回显内容,不是状态码。第二,默认值给的是空字符串而不是 None,所以哪怕参数缺失,函数也永远返回 str,Flask 不会因为 None 抛 TypeError,接口不会 500。
但这一行同时也是这个仓库最大的空洞:它没有验签 。按微信的约定,GET 请求会带 signature、timestamp、nonce、echostr 四个 query 参数,正确做法是把 token、timestamp、nonce 三者字典序排序后拼成字符串、做 sha1、再和 signature 比对,通过了才回 echostr。这段代码跳过了整个比对,谁来请求都回显。在"只想确认 URL 通不通"的阶段这不算错,因为校验请求本来就是微信自己发的;但它把"接口能被任何人以微信的身份调用"这个假设固化进了骨架,后面每加一个功能都继承这个前提。
补上验签的最小形态大概是这样,注意它不在仓库里,是我按同一结构续写的:
python
import hashlib, os
TOKEN = os.environ['WECHAT_TOKEN'] # 与后台"令牌"一致
def sign_ok(signature: str, timestamp: str, nonce: str) -> bool:
raw = ''.join(sorted([TOKEN, timestamp, nonce]))
return hashlib.sha1(raw.encode('utf-8')).hexdigest() == signature
@app.route('/', methods=['GET', 'POST'])
def wechat():
if request.method == 'GET':
if not sign_ok(request.args.get('signature', ''),
request.args.get('timestamp', ''),
request.args.get('nonce', '')):
return 'forbidden', 403
return request.args.get('echostr', '')
TOKEN 走环境变量而不是写死在文件里,是因为单文件原型一旦被 git push,硬编码的 token 就等于公开了------而 token 是这套验签机制里唯一的秘密。
POST 分支:'success' 是最小但正确的契约
POST 分支只有三行有效代码,其中 return 'success' 是最容易被新手改坏的一行。微信侧对推送有超时预算,如果服务器在预算内没有给出它认的响应,它会认为投递失败并重试。所以原型阶段最重要的事情不是"回对内容",而是"先回一个成功 ",把重试风暴掐掉,业务逻辑哪怕只写了个 # ... 注释也不影响链路存活。这个 'success' 不是占位符,它是契约里必须存在的一环。
第二个细节是 request.data 的返回类型。它拿到的是原始请求体 bytes,不是解析后的 dict,也不是 request.form。原因是微信推的是 application/xml,而 Flask 的 request.form 只对表单类 content-type 生效,XML 不会被自动解析。这些字节现在被赋给 xml_data 然后原封不动地丢掉------变量名起对了,但下游是空的。
要注意 requirements.txt 里没有任何 XML 相关依赖,所以真要解析只能走标准库:
python
import xml.etree.ElementTree as ET
root = ET.fromstring(xml_data) # xml_data 是 bytes,可直接传入
msg_type = root.findtext('MsgType') # text / image / event ...
from_user = root.findtext('FromUserName') # OpenID
ET.fromstring 接受 bytes,所以 request.data 可以直接喂进去,不用先 decode。但如果这段代码要接触公网,标准库的 ElementTree 对实体膨胀类攻击是没有防护的,Python 官方文档自己就提示了这一点,稳妥做法是换成 defusedxml.ElementTree。
requirements.txt 把整棵依赖树钉死了
95 字节里塞了六个精确版本,这个选择比代码本身更值得说:
text
Flask==2.0.3
Jinja2==3.0.3
MarkupSafe==2.0.1
itsdangerous==2.0.1
Werkzeug==2.0.3
click==8.0.4
后四个其实都是 Flask 的传递依赖,正常写 Flask==2.0.3 就够了。把整棵树显式钉死,是原型阶段一种很实用的取舍:部署环境不可控(尤其是别人的机器、老镜像、共享 Python),锁全树能把"我这里能跑"的方差压到最小。代价是升级时要手动同步五个包,而且不写注释的话,后人不知道为什么是这几个数字。
具体到这一组数字,有一处是被无意中躲过去的坑:Jinja2 3.0.3 会 import markupsafe 的 soft_unicode,而 MarkupSafe 2.1.0 把这个名字删了,于是 Jinja2 < 3.1 配 MarkupSafe >= 2.1 会在启动时直接 ImportError。这里钉的 MarkupSafe==2.0.1 恰好落在安全侧。换句话说,这组 pin 现在是自洽的,但它是靠精确版本号硬撑住的,一旦有人"顺手升个小版本",最先炸的就是启动。
三个坑,都是照原样跑才会遇到的
其一,port=80 起不来。 app.run(host='0.0.0.0', port=80) 在 Linux 和 macOS 上都需要特权:非 root 用户绑定 1024 以下端口会拿到 PermissionError。原型阶段更省事的写法是留空让 Flask 用默认的 5000,或者用环境变量 PORT 兜底;真要 80 端口,交给 Nginx 反代或者 setcap 才是正路。host='0.0.0.0' 这一半是对的------微信服务器要从公网打进来,绑 127.0.0.1 就白配了。
其二,app.run() 是开发服务器。 Flask 2.0 的 app.run() 默认 threaded=True,所以并发请求不会被完全串行化,暂时不会因为一条慢消息卡死全站;但它启动时会打印"不要用于生产"的警告,且没有优雅重启、没有 worker 管理。在"只验证握手"这个目标下它完全够用,一旦要接真实流量,换成 gunicorn 之类的 WSGI 服务器是必须的,而且那时候 if __name__ == '__main__' 这一段会整个被绕过------这也是为什么原型代码里塞的 server 配置,通常活不过第一周。
其三,没有验签等于没有身份。 前面提过 GET 分支跳过比对,POST 分支同样没有任何校验:任何人往 https://你的域名/ POST 一段 XML,都会拿到 success,而你的业务代码会把它当成真实用户消息处理。这类问题在原型阶段看不见,因为此时下游是空的;等到真接了自动回复、积分、订单,它才变成事故。
边界与代价
把边界写明白,比夸它简洁更有用:
- 它解决的:微信服务器地址能不能配通、消息能不能到达、Flask 能不能在目标环境跑起来。三个问题,455 字节答完了。
- 它没解决的 :验签、XML 解析、消息分发、回复构造(被动回复需要按 XML 结构返回,且要在超时预算内)、去重(微信重试会带来重复投递)、日志、测试。
README.md的 38 字节和wechat.py里那句# ... 这里处理微信消息是同一件事的两种表达。 - 它的可维护性上限 :一个路由承担两种协议语义,两个
if靠request.method分叉。要加"事件推送走另一套逻辑",很快就会变成在一个函数里堆分支。到那一步的正确动作不是继续加if,而是拆成/wechat(校验)、/wechat/msg(消息)两条路由,或者干脆上蓝图。 - 它的文档债务 :README 只有标题和许可证。这个仓库的后来者(包括三个月后的作者本人)需要从代码反推"这段该怎么部署",而唯一能反推出来的线索是
port=80。
小结
这个仓库的价值不在于它写得多好,而在于它把"先跑通"这件事做得足够诚实的:两个协议阶段各留一个分支,成功响应固定为 success,依赖树钉死,其余全部空着------空得很明确,连注释都标好了留给谁。先跑通再谈工程化的意思不是"以后再说",而是每一步都知道自己跳过了什么:跳过了验签,跳过了 XML 解析,跳过了 80 端口的权限问题。知道跳过什么,才谈得上补。