从 python2.7 迁移到 python3.6

python2.7 会在 2020 年停止维护, 很多第三方包也在去掉对 python2.7 的支持, 最近终于完成了内部代码向 python3 的迁移, 整个过程挺繁琐的, 记录一下.

总共需要迁移的代码大概有 50w 行(cloc 计算, 去注释空行), 包括业务代码 + ETL + data analysis... 前后花了3个月.

做之前确保已读过官方的 migration guide: https://docs.python.org/3/howto/pyporting.html

我的大致步骤:

  1. 清查依赖包, 不支持 python3 的 lib 寻找替代品(常用 lib 基本都没问题).
  2. 将现有代码转写成 py2/3 兼容代码.
  3. 修复单元测试,用 tox 在 python2.7 和 python3.6 下跑单元测试, 保证后续代码不会 broken.
  4. 替换本地开发的 devbox 和 sandbox 环境.
  5. 灰度切换线上环境.

升级 celery 的坑

celery 从 3.1.25 升级到 4.2.0, 问题挺多的.

CELERY_ACCEPT_CONENT, 从4.0 开始默认只接受 json, 按需修改.

CELERY_RESULT_SERIALIZER, 默认从 pickle 变成了 json , 务必不要使用pickle, python2/3 不兼容. 不过 json 不能序列化 binary, 有需要的话用 msgpack,或自己把task 结果 base64 encode.

4.0 开始如果用 redis 作为 broker, 当设置需要 task 的执行结果时, celery 内部会用 redis 的 pubsub 监听结果, 但 redis-py 的 pubsub 不是线程安全的, 在用 gevent 做 worker 时, pubsub 的 socket 会在多个greenlet 中被访问, 报错, workaround 是不设置 result_backend, 或给task 设置 ignore_result=True.

在 py2.7 下 celery 4.X 的 AsyncResult 对象还有内存泄漏问题. 提了一个临时的 pull request: https://github.com/celery/celery/pull/4839 官方要在 4.3 里才会修复这个问题. 泄漏的原因是在一个有循环引用的 class 内部重载了 __del__ 函数, 在 python3.4 以前这种代码会内存泄漏.

最后把线上环境切换到 py3 的时候, 记得 celery 的 worker 节点要最后切换, 保证所有 producer 都是 py3 环境. 原因是 py2 入队的任务, 如果用的是 msgpack 作序列化, worker 是py3 的话, 解出来函数参数名都会变成 bytes, celery 内部对参数 unpack (**kwargs) 的时候就会报错.

编写 py2/3 兼容代码

这部分是最繁琐的, 有自动化工具可以辅助修改, 主要有 2to3, future, modernize

2to3 是单向修改,生成的代码并不兼容 python2, 所以没有用.

future 这个工具尝试模拟 py3 里一些 class 的行为, 把对代码的修改限定在头部的 import 语句, 但实际试下来问题很大, 尤其是重载了 class 的一些 magic method, 会有各种问题, 不建议使用.

modernize 靠谱, 它会用 six 转写代码, 只发现一种情况改错了 isinstance(i, (int, float, long)) 会被改成 isinstance(i, (int, float, int)), 正确的写法是 isinstance(i, (six.integer_types, float)).

关于 py2/3 的兼容写法,可以看这份文档 http://python-future.org/compatible_idioms.html, 忽略它里面 future 的写法, 自己用 six 转写.

下面补充一些文档里说的不够或 modernize 无法识别的

bytes and str

首先请确保自己 100% 理解 py2 里 str 和 unicode 的各种行为, 下面代码在 py2 下哪些成功? 成功结果是 unicode 还是 str, 失败的结果是 UnicodeEncodeError 还是 UnicodeDecodeError

'a' + u'啊'
u'a' + '啊'
'%s' % '啊'
'%s a' % u'啊'
u'%s 啊'  % 'a'
u'.'.join(['a', '啊'])
'Hi {}'.format('啊')
'Hi {}'.format(u'啊')
u'Hi {}'.format('啊')

基本规则是:

  • '...'.format() 这种遵循前面的 format string, format string 是 str, 就自动把后面的参数中的 unicode 用ascii encode. format string 是 unicode, 将参数里的 str 用 ascii decode.
  • +, join, replace, "%s" % (...), 都视为字符串拼接,如果拼接的每部分都是 unicode, 结果就是 unicode. 每部分都是 str, 结果就是 str. 其中有一个是 unicode, 会将其他部分自动按 ascii 解码成 unicode.

然后编写一个相对正确的 to_unicode 函数:

def to_unicode(v):
    if isinstance(v, six.text_type):
        return v
    if isinstance(v, six.binary_type):
        return v.decode('utf-8')
    else:
        # if v is int, will be converted to unicode string
        return six.text_type(v)

对传入参数模糊不清,又确实需要 unicode 的地方使用.

base64 encode/decode 的结果在 py3 下是 bytes, 而且 encode 参数只接受 bytes.

hashlib 中的函数接受的参数都是 bytes.

写一个 to_bytes 函数:

def to_bytes(v):
    if isinstance(v, bytes):
        return v
    if isinstance(v, six.text_type):
        return v.encode('utf-8')
    else:
        # if v is int, will be converted to byte
        v = six.text_type(v)
        return v.encode('utf-8')

在 py3 下 bytes 拿去做 string format 不会报错,会得到 bytes 的 __str__ 形式:

 "%s" % b"abc"  # "b'abc'"
 "{}".format(b"abc")  # "b'abc'"

比较容易出错的地方有 base64 decode/encode, redis client 的返回结果, 都是 bytes, 直接拿去作 string format 就有问题, 还不会报错(py2 下可能没问题).

标准库中的 json.dumps, 如果传入的值中混了 bytes, 会序列化失败, 但用 simplejson.dumps 可以自动 decode. requests.post(json=value) 底层会检查是否安装了 simplejson, 如果有就用simplejson, 否则用标准库.

dict

iterkeys(), itervalues(), iteritems(), 这种在 py3 里去除的, modernize 能自动修正

keys(), values(), items(), 在 py3 下返回的是 view object, https://docs.python.org/3/library/stdtypes.html#dictionary-view-objects, 不能直接取 slice, 需要转成 list.

一种比较常见的错误写法:

d = {'a': 1}
for k in d.keys():
    if k == 'a':
        d.pop(k)

在 py3 下会报 RuntimeError: dictionary changed size during iteration, 因为 .keys() 返回的是 dict key 的 view 对象, 遍历它实际在遍历 dict 自己 (类似遍历 list 的时候不能删除 item), 需要用 list(d.keys()) 获得 key 的拷贝.

division

py2 里的除法默认是 floor division, py3 里是 true division, from __future__ import division 可以将py2 里的除法变成 py3 的行为.

In py2:

1/2 # 0

In py3:

1/2 # 0.5

如果需要 floor division, 显示用//. py3 里,operator.div 不存在了, 分成了 operator.truedivoperator.floordiv

modernize 默认不会修改用到除法的地方, 可以用 python-modernize -f classic_division ., 让它帮我们找出代码中所有用到除法的地方, 人工修正语意, 比如一些计算图片宽高的代码, 除法结果一定需要整数, range(len(days) / 7) 这种代码就改成 //.... 比较繁琐,只能人工 review 代码.

Exception

捕获的 exception 作用域在 py3 中只存在 except 的 block 里, 下面代码会访问不到 e:

try:
    1/0
except Exception as e:
    pass
print(e)

py2 里可以用 e.message, py3 里没有了, 需要访问message, 直接用 str(e), 在py2/3 中都 work.

StringIO and io

py2 里的 StringIO/cStringIO 没有了, 使用 io.BytesIOio.StringIO 替换, 有个坑是和 csv模块一起工作的时候, py2 里要用 io.BytesIO, py3 里要用 io.String()

__iter__

In py2:

hasattr('abc', '__iter__') # False
hasattr(u'abc', '__iter__') # False

In py3:

hasattr('abc', '__iter__')  # True
hasattr(b'abc', '__iter__')  # True

不要用 __iter__ 来区分 str 和 list/tuple, 直接用 isinstance .

Comparable

In py2:

None > 0  # False
None > {} # False
None > () # False
...

{} > 1 # True
() > 1 # True
...

在 py3 中都直接会报 TypeError, 这种错误其实还挺多的, 比如:

d = {'a': None}
if d.get('a') > 0:
    pass

类似代码在 py2 中不会报错, 逻辑其实不对, 到 py3 下就暴露了.只能靠单元测试覆盖.

sort without cmp

list.sort()sorted() 函数不再接受 cmp 参数: https://docs.python.org/3/howto/sorting.html#the-old-way-using-the-cmp-parameter

兼容写法:

if six.PY2:
    l.sort(cmp=cmp_func)
else:
    from functools import cmp_to_key
    l.sort(key=cmp_to_key(cmp_func))

hash

python2 中的 hash 实现输出的是一个固定数值, python3 中的 hash 算法改了, 并且默认开启random seed, 每次进程重启都会被重置,
所以每次重启进程 hash 的输出结果都不一样. 使用 hashlib 中的稳定算法替代.

但有些 hash 的结果被持久化的存下来了怎么办? 可以实现一个 python3 的 c extension, 将python2 里的 fnv hash 算法 backport 到 python3: https://github.com/monsterxx03/legacyhash/blob/master/hash.c, 我只支持了 对 bytes, unicode, int 的 hash 计算.尽量不要用这种方式, 使用一个跨语言的稳定算法.

round

round 也有个小坑

In py2:

round(Decimal(1.1), 2) # -> float 1.1  

In py3:

round(Decimal(1.1), 2) # -> Decimal(1.10)

一些建议

  • 老代码能删的就删, 整个 migration 过程读代码的时间其实比动手改代码的时间长, 减少负担.
  • 兼容性修改尽快合入主分支并上线, 不要长期维护单独的分支.
  • 一个 repo 中的主要修改完成后打个 tag, 定期和新merge 的代码做 diff review.
  • 修 unit test 和升级依赖可以交叉进行, 有些依赖升级风险挺大的, 跑 test 时候碰到确实在 py3 下有问题的依赖优先升级.
  • 尽量将所有依赖包升级到能升的最高版本, 有坑在 py2 下解决.
  • 跨网络调用, 文件读写的地方一般都会有 str/unicode 的问题
  • 老代码里显示写 .encode('utf-8') 的地方在 py3 下基本都有问题.