python2.7 會在 2020 年停止維護, 很多第三方包也在去掉對 python2.7 的支援, 最近終於完成了內部程式碼向 python3 的遷移, 整個過程挺繁瑣的, 記錄一下.

總共需要遷移的程式碼大概有 50w 行(cloc 計算, 去註釋空行), 包括業務程式碼 + ETL + data analysis... 前後花了3個月.

我的大致步驟:

  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)).

下面補充一些文件裡說的不夠或 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 能自動修正

一種比較常見的錯誤寫法:

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

相容寫法:

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 下基本都有問題.