ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python 2 SDK 迁移至 Python 3.8 的完整适配指南与踩坑记录

Python 2 SDK 迁移至 Python 3.8 的完整适配指南与踩坑记录 你是不是也遇到过这种情况手头有个老项目业务逻辑没毛病运行得也挺稳就是底层那个第三方 SDK 还是 Python 2 的写法print 当语句用、urllib2 满天飞、字符串还在用 unicode 那套。别人跑在 Python 3.8 上干干净净你的项目一启动就 SyntaxError连 import 都过不去。这段时间我刚好把一个某门户平台的老版 Python SDK 完整适配到了 Python 3.8 环境整个过程比预想中闹心但也比想象中套路化。很多坑其实是固定的踩过一次下次就会了。这篇就把我实际的改造过程、踩坑记录和最终的验证方案完整梳理一遍给同样被老 SDK 绑住手脚的同学一个可参考的路线。1. 为什么还有人在折腾老 SDK很多人会问都什么年代了还有必要给 Python 2 时代的 SDK 做适配吗直接换新的、或者自己封装 HTTP 请求不香吗1.1 老 SDK 背后的技术债实际情况是不少业务系统的核心接口调用逻辑早已和旧 SDK 深度耦合。某个老牌门户平台早期开放接口时官方只维护了 Python 2 版本的 SDK后续政策调整后新接口文档虽然还在但官方 Python 3 版本 SDK 要么缺失、要么只覆盖了新功能老接口反而没人管。这就形成了一种很尴尬的局面业务依赖的老接口只能用旧 SDK 调通但项目整体已经迁移到了 Python 3 环境。这时候摆在面前的选择只有两个要么在 Python 3 环境里强行保留一套 Python 2 运行时要么把这个老 SDK 改造到兼容 Python 3。前者的坑更大。同一台服务器上同时维护两套 Python 解释器和两套依赖部署、监控、排障的复杂度翻倍。而底座不统一团队协作和代码回溯都会越来越痛。所以我选了后者直接在 Python 3 环境里把 SDK 改造到能跑、能测、能上线。1.2 适配前先摸清家底动手之前先把 SDK 的代码结构和依赖摸清楚。我拿到的是一个典型的旧版 SDK 包源码目录大概是这样的sdk/ ├── __init__.py ├── client.py # 客户端封装负责签名和请求 ├── exceptions.py # 自定义异常 ├── requests_wrapper.py # HTTP 请求封装 ├── utils.py # 签名、时间戳、序列化等工具 └── resources/ ├── __init__.py ├── users.py # 用户相关接口 ├── posts.py # 内容相关接口 └── comments.py # 评论相关接口这个 SDK 的体积不算大核心文件加起来几千行但麻雀虽小五脏俱全。它涉及了 Python 2 到 Python 3 迁移中的几乎所有典型问题内置模块改名、语法差异、字符串编码、类库接口变化、异常处理变化等等。用它当案例来讲适配过程非常有代表性。在正式动手前我用2to3工具做了一次预扫描。2to3 是 Python 自带的迁移辅助工具虽然它不能解决所有问题但能快速定位哪些文件涉及语法层面的不兼容。2to3 -w -n sdk/-w表示直接写入修改-n表示不生成备份文件。这里建议第一次扫描时不要加-w先跑一遍只输出 diff心里有个底再实际改。2. 迁移适配的核心改造清单这个环节是整个适配过程的重头戏。Python 2 到 Python 3 的差异点非常多但并不是每一项都会在同一个 SDK 里出现。我把自己遇到的高频差异点整理成了一套清单照着改基本不会漏。2.1 语法层面的机械替换语法差异是最好处理的因为基本都是固定映射关系。我列几个实际遇到的print 语句改 print() 函数旧 SDK 的调试日志里全是这种写法print request url: , url print response: , response在 Python 3 里必须改成函数调用形式而且要注意逗号分隔的多个参数会默认用空格隔开输出格式可能变化print(request url: , url) print(response: , response)异常捕获语法旧 SDK 里有很多这种写法try: result client.call(api_name, params) except ApiException, e: print api error: , ePython 3 要求必须用as关键字try: result client.call(api_name, params) except ApiException as e: print(api error: , e)这个改动不复杂但遗漏率很高。因为旧代码里异常捕获往往分散在各处不能只盯着except关键字看还要看捕获后的处理逻辑。整除运算符Python 2 里/是整数除法3 里是浮点除法。SDK 在做分页参数计算时用了/比如页码转偏移量offset (page - 1) * page_size total_pages total_count / page_size到 Python 3 里这段代码会返回浮点数后续拿去拼 URL 参数直接出问题。需要显式改成//total_pages total_count // page_size这一步不能用 2to3 全自动处理因为某些地方我们可能确实需要浮点结果需要人工逐行确认业务意图。2.2 标准库模块迁移这是老 SDK 适配里最繁琐的部分。Python 3 重新组织了标准库很多在 Python 2 里单独存在的模块被合并或改名了。我在这套 SDK 里遇到的情况整理成了一张表Python 2Python 3改动说明urllib2urllib.request/urllib.error请求和异常处理拆分urlliburllib.parsequote、urlencode等工具函数转移urlparseurllib.parse解析 URL 的模块合并cookielibhttp.cookiejarCookie 处理模块改名md5hashlib哈希算法统一管理StringIOio.StringIO/io.BytesIO字符串缓冲区分了文本和二进制HTMLParserhtml.parser命名风格统一为小写最典型的例子是这个 SDK 里封装请求时用到urllib2的地方旧代码长这样import urllib2 request urllib2.Request(url, dataurlencoded_data) request.add_header(Content-Type, application/x-www-form-urlencoded) response urllib2.urlopen(request, timeout10)Python 3 环境下这套代码直接 import 就失败了。改造后import urllib.request import urllib.error request urllib.request.Request(url, dataurlencoded_data) request.add_header(Content-Type, application/x-www-form-urlencoded) response urllib.request.urlopen(request, timeout10)注意urllib.error也要单独 import因为捕获HTTPError和URLError时需要用。如果只 import 了urllib.request后面的异常捕获就会用全限定名称代码会很难看。urlparse模块在 SDK 里用来拼接回调地址。Python 2 时代是这样from urlparse import urlparse, urlunparse parsed urlparse(callback_url)Python 3 直接改成from urllib.parse import urlparse, urlunparse这种 import 路径变化可以靠 IDE 的自动修复功能处理一部分但仍需要人工完整跑一遍确认没有遗漏因为某些第三方库内部也有自己的urllib2兼容逻辑运行时才会暴露问题。2.3 字符串与编码处理字符串编码是 Python 2 迁移到 Python 3 时最隐蔽的坑。Python 2 里str和unicode可以混用Python 3 里str是文本、bytes是二进制两者界限分明。这套 SDK 里签名计算部分是重灾区。Python 2 的写法是直接用字符串做拼接和哈希import md5 import hashlib def sign(params, secret): raw_string for key in sorted(params.keys()): raw_string {}{}.format(key, params[key]) raw_string secret return hashlib.md5(raw_string).hexdigest()这段代码在 Python 3 里运行时会直接抛出TypeError因为hashlib.md5()接收的参数必须是 bytes-like 对象而不是 str。改造思路也很明确先编码再哈希import hashlib def sign(params, secret): raw_string for key in sorted(params.keys()): raw_string {}{}.format(key, params[key]) raw_string secret return hashlib.md5(raw_string.encode(utf-8)).hexdigest()这里有一个容易被忽略的细节params[key]的值可能是数字、可能是布尔值但拼接成字符串后统一编码为 UTF-8 是安全的。真正麻烦的是如果params里某个值是bytes类型format()输出的形式是不同的。旧 SDK 里参数来源相对可控目前没碰到这种极端情况但如果你的 SDK 要处理文件上传就得多留一个心眼。StringIO的迁移也值得单独提一句。在 Python 2 里StringIO.StringIO既可以处理文本也可以处理二进制。Python 3 分成了文本和二进制两个类。这套 SDK 里有一个 XML 响应解析的辅助函数里面用了StringIO来接收响应内容import StringIO def parse_response(xml_str): buf StringIO.StringIO(xml_str) # 解析逻辑Python 3 改为from io import StringIO def parse_response(xml_str): buf StringIO(xml_str) # 解析逻辑如果解析的是从网络读取的原始字节流则需要用BytesIO。根据实际响应格式选择文本用StringIO、二进制用BytesIO不能混。3. 实操把请求封装改成 Python 3 风格语法和模块层面的改动是基础但真正让一个 SDK 在 Python 3 环境里稳定跑起来核心在于请求封装和响应处理这两个环节能不能扛住实际流量。3.1 网络请求层改造这套 SDK 的请求封装集中在requests_wrapper.py里。最初的请求函数逻辑大概是这样的import urllib2 import urllib def _http_request(url, params, methodGET): if method GET: url url ? urllib.urlencode(params) req urllib2.Request(url) else: data urllib.urlencode(params) req urllib2.Request(url, datadata) req.add_header(User-Agent, python-sdk/2.x) req.add_header(Accept, application/json) try: resp urllib2.urlopen(req, timeout15) return resp.read() except urllib2.HTTPError as e: raise ApiException(HTTP error: str(e.code)) except urllib2.URLError as e: raise ApiException(Network error: str(e.reason))改造后的版本import urllib.request import urllib.parse import urllib.error def _http_request(url, params, methodGET): if method GET: url url ? urllib.parse.urlencode(params) req urllib.request.Request(url) else: data urllib.parse.urlencode(params).encode(utf-8) req urllib.request.Request(url, datadata) req.add_header(User-Agent, python-sdk/3.x) req.add_header(Accept, application/json) try: with urllib.request.urlopen(req, timeout15) as resp: return resp.read() except urllib.error.HTTPError as e: raise ApiException(HTTP error: str(e.code)) except urllib.error.URLError as e: raise ApiException(Network error: str(e.reason))两个细节需要留意。第一POST 请求时urlencode得到的是字符串但Request的data参数在 Python 3 里要求是 bytes 类型必须显式.encode(utf-8)。不编码的话代码不会报错但请求体直接传字符串会被 urllib 按默认方式处理内容类型字段就会出现问题。第二旧代码返回的是resp.read()的字符串Python 3 里返回的是 bytes。上层代码拿到后再做json.loads()会失败必须先在请求层就完成统一的解码。我在改造中直接做了编码处理return resp.read().decode(utf-8)这样上层业务代码几乎不用动。3.2 签名与参数处理这类平台型 SDK 一般都有参数签名校验机制用来保证请求来自合法客户端。签名逻辑对字符串处理极其敏感是适配的重灾区。旧 SDK 里的签名函数有这样一个片段def _build_signature(method, path, params, secret): parts [] for key in sorted(params.iteritems()): parts.append({}{}.format(key[0], key[1])) raw .join(parts) raw secret return md5.new(raw).hexdigest()问题有两处。第一params.iteritems()在 Python 3 里不存在了字典迭代方法统一成了.items()。第二md5.new(raw)在 Python 3 里也不存在了而且raw还需要先编码。改造后def _build_signature(method, path, params, secret): parts [] for key, value in sorted(params.items()): parts.append({}{}.format(key, value)) raw .join(parts) raw secret return hashlib.md5(raw.encode(utf-8)).hexdigest()这里sorted(params.items())默认会按键值做字典序排序如果某些平台要求只按键排序需要显式指定keylambda x: x[0]否则排列规则会变签名就会对不上。这个坑我实际踩过平台返回的验签错误信息通常很模糊排查了半天才发现是排序问题。3.3 回调数据解析某门户平台的接口回调数据格式比较多样有的接口返回 JSON有的返回 XMLSDK 里为此写了一个自动判断格式的解析器。旧代码里有一个比较典型的判断写法def _parse_response(body): if isinstance(body, basestring): body body.decode(utf-8) if body.strip().startswith({): return json.loads(body) elif body.strip().startswith(): return xml_parser.parse(body) else: return body这里的核心问题是basestring。Python 2 里它是str和unicode的公共父类Python 3 里已经不存在。如果 SDK 兼容层没有兜底这个函数一 import 就报 NameError。改造后def _parse_response(body): if isinstance(body, str): body body.encode(utf-8).decode(utf-8) elif isinstance(body, bytes): body body.decode(utf-8) if body.strip().startswith({): return json.loads(body) elif body.strip().startswith(): return xml_parser.parse(body) else: return body这里多了一个bytes分支的判断。原因在于 urllib 请求层返回的数据类型已经从 str 变成了 bytes如果 SDK 的请求函数先做了 decode那这里直接处理 str 即可。但为了健壮性我把请求层统一改成了response.read().decode(utf-8)所以解析器里实际上只需要处理 str 类型。这个双分支属于防御性写法保留它没有坏处。不过要注意的是json.loads()在 Python 3 里如果接收到 bytes 类型其实也能自动处理但 XML 解析器对 bytes 的支持就不那么稳定了。所以统一在请求层 decode 是更干净的做法。3.4 依赖治理与虚拟环境适配过程中还有一个所有 Python 项目都绕不开的问题SDK 依赖的第三方库在 Python 3 环境下是否有对应的版本。这套某门户 SDK 本身尽量少用第三方依赖但有两个库需要注意一个是 XML 解析用的lxml另一个是可选的requests库。lxml在 Python 3 环境下需要安装对应版本直接在环境中执行pip install lxml如果项目原本用的是 Python 2 的旧版lxml建议升级到 4.x 以上因为旧版本对 Python 3 的支持不完整容易出现运行时崩溃。另外一个更保险的做法是在项目里单独建立虚拟环境把 SDK 的所有依赖装在里面不要和全局环境混用python3 -m venv sdk_env source sdk_env/bin/activate pip install -r requirements.txt这样做的最大好处是SDK 里一些临时性依赖比如某个解析库的旧版本不会污染其他业务项目的运行环境也方便日后整体升级。4. 适配过程中的问题排查实录前面讲的都是理论层面的改造方案实际操作中真正消耗时间的其实是各种奇怪异常的排查过程。我把自己在适配这套 SDK 时遇到的高频问题记录在这里希望能帮你少走弯路。4.1 HTTP 请求出现 SSL 证书错误怎么办第一次在 Python 3.8 环境下跑通 SDK 时发起 HTTPS 请求直接抛了SSL: CERTIFICATE_VERIFY_FAILED。这个错误在 Python 3.4 以上的版本非常常见原因在于 Python 3 默认会校验 HTTPS 证书而某些老平台的证书链可能未更新到新版本 Python 的信任库中。如果服务端证书本身有效只是本地环境缺少根证书可以通过安装系统证书解决pip install certifi然后在请求代码中显式指定证书import ssl import certifi context ssl.create_default_context(cafilecertifi.where()) resp urllib.request.urlopen(req, timeout15, contextcontext)不建议直接在代码里写ssl._create_unverified_context()绕过证书校验。虽然短时间内能跑通但这种做法等于把安全机制关掉了一旦请求被中间人截获泄露的就是签名用的密钥和业务数据。4.2dict.iteritems()迁移遗漏很多时候第一个报错点不在 SDK 主文件而在一些边缘的辅助函数里。我之前在跑单元测试时发现某个只在异常分支里出现的函数仍然在用.iteritems()平时不触发一旦走到异常处理路径就抛 AttributeError。这里给一个排查小技巧用grep在整个 SDK 目录里搜一遍所有 Python 2 独有方法名grep -rn iteritems\|iterkeys\|itervalues\|basestring\|xrange sdk/把搜索到的每一个结果都改掉不要只改报错的部分。Python 2 时代的代码有很多惰性求值方法在 Python 3 里被移除了漏一个就是一颗定时炸弹。我实际还遇到过dict.has_key()的调用。这个函数在 Python 3 里也删除了if params.has_key(page): # do something需要改成if page in params: # do something这种改动可以用 2to3 自动完成但自动化工具偶尔也会漏建议改完后再用 grep 搜一遍。4.3 回调数据是 bytes 还是 str这个问题的隐蔽程度比前面几个都高。老 SDK 在 Python 2 里统一用字符串处理响应体Python 3 里 request 层返回 bytes。如果只在请求函数的最外层做 decode那么底层辅助函数的编码假设就会全部失效。我遇到的一个典型场景是SDK 里有一个_extract_error_code()函数它会从响应体的 XML 片段里截取错误码标签。旧代码直接用正则匹配字符串match re.search(rerror_code(.*?)/error_code, body)如果body是 bytesre.search()的第一个参数是 strPython 3 会直接报 TypeError提示无法在 bytes 和 str 之间混用。解决办法依然是统一入口处转成 str或者在正则里也把模式串编码成 bytes。我选择了前者因为业务层更习惯处理 str。这里有一个更省心的方案在 SDK 的公共请求函数里顺手写一个编码规整函数无论拿到什么类型都先规整成 str 再往下传def _ensure_str(data): if isinstance(data, bytes): return data.decode(utf-8) return data所有内部函数在处理任何外部输入时先过一遍_ensure_str()后面就能少掉很多编码相关的隐蔽问题。4.4 第三方依赖的兼容性问题除了标准库本身的迁移这套 SDK 还依赖了一个老版本的 XML 解析库。该库在 Python 3 环境下只支持解析 bytes 不支持解析 str和 SDK 的其他模块产生了矛盾。排查时我的思路是这样的先在虚拟环境里单独跑一个最小测试脚本直接调用 XML 解析库处理str和bytes两种输入看哪个环节报错。确认是输入类型不匹配后在 SDK 调用解析库的地方统一转成 bytes 传入不改变上层协议。如果第三方库已经停止维护且无法升级就需要考虑替换实现。以上面这个 XML 解析为例标准库xml.etree.ElementTree完全可以替代旧库的功能只需要把接口调用方式做一层薄包装。import xml.etree.ElementTree as ET def _parse_xml(xml_str): root ET.fromstring(xml_str) # 后续节点遍历这样做的好处是去掉了一个有兼容风险的第三方依赖坏处是如果旧库的解析行为有特殊细节替换后可能在某些边缘 XML 结构上表现不一致。所以替换后一定要用线上真实数据做批量回归。5. 回归测试与最终验证方案代码改完了环境跑通了最怕的是业务参数一变又炸出新的兼容性问题。所以在适配工作收尾阶段我专门整理了一套回归验证方案确保 SDK 在 Python 3 环境下的行为与 Python 2 时代保持一致。5.1 单元测试补充原来的 SDK 几乎没有自带的单元测试。为了让适配工作可验证我补了一套基于标准库unittest的测试脚本覆盖了最核心的几个函数。import unittest from sdk.utils import _build_signature from sdk.requests_wrapper import _ensure_str class TestSdkAdapter(unittest.TestCase): def test_signature(self): params {name: test, page: 1} result _build_signature(GET, /api/items, params, secret) self.assertEqual( result, hashlib.md5( nametestpage1secret.encode(utf-8) ).hexdigest(), ) def test_ensure_str(self): self.assertEqual(_ensure_str(bhello), hello) self.assertEqual(_ensure_str(hello), hello) if __name__ __main__: unittest.main()这套测试虽然简单但能保证改造过程中最核心的两个行为不跑偏签名计算和输入类型规整。5.2 端到端冒烟测试单元测试只验证了函数级别的正确性真正让人放心的是用真实接口做一轮端到端的冒烟测试。由于涉及真实密钥和接口地址这部分脚本我没有写进公开代码而是单独放在本地运行。冒烟测试的重点有几项用新的签名函数发起一次真实请求确认服务端验签通过用 GET 和 POST 两种方法各请求一个接口确认参数编码和请求体格式正确故意使用一个错误的参数值确认错误码能正常解析和抛出上传一个不超过 1MB 的二进制文件确认BytesIO路径没问题这四个用例跑通后SDK 在 Python 3 环境下的核心链路基本就稳了。5.3 与线上数据做对比这是最费时间但最有效的一步。我从线上日志里抽取了一批历史请求记录包含当时 Python 2 环境下生成的请求参数和响应。然后用适配后的 Python 3 SDK 重放这些请求对比请求参数和返回结果是否一致。这一步能发现两类问题一是新旧 SDK 对参数默认值的处理不同导致的请求差异二是编码转换过程中出现的隐式行为变化。比如我在这一阶段就发现旧的 Python 2 代码里整型参数会保留1L这样的长整型后缀而 Python 3 里统一成了1导致个别接口的参数格式不同签名结果也随之变化。好在服务端兼容了这两种表达方式否则这里就是个大事故。5.4 适配后还能优化什么SDK 跑通只是第一步适配过程中我发现老 SDK 还有一些设计和写法可以顺势优化不改变对外行为的前提下提升可维护性。用 requests 库替换 urlliburllib 的接口设计相对底层超时、重试、代理这些功能都要自己写。如果项目环境允许引入第三方依赖用requests替代 urllib 能省掉大量代码。只需要在请求封装内部做替换对外方法和返回结构保持原样。统一异常体系原来的异常只是简单继承Exception没有分类。适配时可以顺带把网络错误、业务错误、参数错误分开定义方便上层代码按需捕获。引入类型标注Python 3 环境最大的优势之一是支持类型标注。在适配过程中给主要函数补充参数类型和返回类型的标注后续维护会轻松很多。配合 mypy 做静态检查迁移后的 SDK 在代码规范和健壮性上都能上一个台阶。6. 适配老 SDK 的几点经验沉淀操作层面的改造细节讲了很多最后沉淀几条原则性的经验。第一任何自动迁移工具都只是起点。2to3 能处理语法层面的机械替换但处理不了隐含的行为差异。真正决定适配成功率的是对模块变化和编码模型的理解。第二改造前先梳理好模块依赖图。分清哪些文件是核心链路、哪些是边缘辅助先改主链路、再补边缘逻辑能显著降低排查难度。我这次先把请求封装和签名逻辑改完跑通再处理各个资源模块的细节条理清晰很多。第三一定要保证适配前后的行为一致性。当两个版本行为不一致时先判断哪个行为符合服务端预期而不是盲目追求代码上的等价。签名排序、参数编码、默认值处理这些细节都需要以服务端的实际校验逻辑为准。第四隔离环境测试。永远不要直接在全局环境里改除非你能保证整个团队只有你在动环境。用虚拟环境隔离是基本操作哪怕只是临时验证一个函数也应该这样做。这次的 SDK 适配过程让我对 Python 3 的编码模型有了更深的理解。每次遇到 TypeError、AttributeError 时沿着 traceback 一层层往上查最后都会回到同一个根源类型的边界在 Python 2 环境里模糊在 Python 3 环境里清晰。理解了这一点老代码翻新其实没有想象中那么神秘。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进