Python 持久性管理

使用序列化存儲 Python 對象

持久性就是指保持對象,甚至在多次執行同一程序之間也保持對象。通過本文,您會對 Python 對象的各種持久性機制(從關係數據庫到 Python 的 pickle 以及其它機制)有一個總體認識。另外,還會讓您更深一步地瞭解 Python 的對象序列化能力。

什麼是持久性?

持久性的基本思想很簡單。假定有一個 Python 程序,它可能是一個管理日常待辦事項的程序,您希望在多次執行這個程序之間可以保存應用程序對象(待辦事項)。換句話說,您希望將對象存儲在磁盤上,便於以後檢索。這就是持久性。要達到這個目的,有幾種方法,每一種方法都有其優缺點。

例如,可以將對象數據存儲在某種格式的文本文件中,譬如 CSV 文件。或者可以用關係數據庫,譬如 Gadfly、MySQL、PostgreSQL 或者 DB2。這些文件格式和數據庫都非常優秀,對於所有這些存儲機制,Python 都有健壯的接口。

這些存儲機制都有一個共同點:存儲的數據是獨立於對這些數據進行操作的對象和程序。這樣做的好處是,數據可以作爲共享的資源,供其它應用程序使用。缺點是,用這種方式,可以允許其它程序訪問對象的數據,這違背了面向對象的封裝性原則 — 即對象的數據只能通過這個對象自身的公共(public)接口來訪問。

另外,對於某些應用程序,關係數據庫方法可能不是很理想。尤其是,關係數據庫不理解對象。相反,關係數據庫會強行使用自己的類型系統和關係數據模型(表),每張表包含一組元組(行),每行包含具有固定數目的靜態類型字段(列)。如果應用程序的對象模型不能夠方便地轉換到關係模型,那麼在將對象映射到元組以及將元組映射回對象方面,會碰到一定難度。這種困難常被稱爲阻礙性不匹配(impedence-mismatch)問題。

對象持久性

如果希望透明地存儲 Python 對象,而不丟失其身份和類型等信息,則需要某種形式的對象序列化:它是一個將任意複雜的對象轉成對象的文本或二進制表示的過程。同樣,必須能夠將對象經過序列化後的形式恢復到原有的對象。在 Python 中,這種序列化過程稱爲 pickle,可以將對象 pickle 成字符串、磁盤上的文件或者任何類似於文件的對象,也可以將這些字符串、文件或任何類似於文件的對象 unpickle 成原來的對象。我們將在本文後面詳細討論 pickle。

假定您喜歡將任何事物都保存成對象,而且希望避免將對象轉換成某種基於非對象存儲的開銷;那麼 pickle 文件可以提供這些好處,但有時可能需要比這種簡單的 pickle 文件更健壯以及更具有可伸縮性的事物。例如,只用 pickle 不能解決命名和查找 pickle 文件這樣的問題,另外,它也不能支持併發地訪問持久性對象。如果需要這些方面的功能,則要求助類似於 ZODB(針對 Python 的 Z 對象數據庫)這類數據庫。ZODB 是一個健壯的、多用戶的和麪向對象的數據庫系統,它能夠存儲和管理任意複雜的 Python 對象,並支持事務操作和併發控制。(請參閱參考資料,以下載 ZODB。)令人足夠感興趣的是,甚至 ZODB 也依靠 Python 的本機序列化能力,而且要有效地使用 ZODB,必須充分了解 pickle。

另一種令人感興趣的解決持久性問題的方法是 Prevayler,它最初是用 Java 實現的(有關 Prevaylor 方面的 developerWorks 文章,請參閱參考資料)。最近,一羣 Python 程序員將 Prevayler 移植到了 Python 上,另起名爲 PyPerSyst,由 SourceForge 託管(有關至 PyPerSyst 項目的鏈接,請參閱參考資料)。Prevayler/PyPerSyst 概念也是建立在 Java 和 Python 語言的本機序列化能力之上。PyPerSyst 將整個對象系統保存在內存中,並通過不時地將系統快照 pickle 到磁盤以及維護一個命令日誌(通過此日誌可以重新應用最新的快照)來提供災難恢復。所以,儘管使用 PyPerSyst 的應用程序受到可用內存的限制,但好處是本機對象系統可以完全裝入到內存中,因而速度極快,而且實現起來要比如 ZODB 這樣的數據庫簡單,ZODB 允許對象的數目比同時在能內存中所保持的對象要多。

既然我們已經簡要討論了存儲持久對象的各種方法,那麼現在該詳細探討 pickle 過程了。雖然我們主要感興趣的是探索以各種方式來保存 Python 對象,而不必將其轉換成某種其它格式,但我們仍然還有一些需要關注的地方,譬如:如何有效地 pickle 和 unpickle 簡單對象以及複雜對象,包括定製類的實例;如何維護對象的引用,包括循環引用和遞歸引用;以及如何處理類定義發生的變化,從而使用以前經過 pickle 的實例時不會發生問題。我們將在隨後關於 Python 的 pickle 能力探討中涉及所有這些問題。

一些經過 pickle 的 Python

pickle 模塊及其同類模塊 cPickle 向 Python 提供了 pickle 支持。後者是用 C 編碼的,它具有更好的性能,對於大多數應用程序,推薦使用該模塊。我們將繼續討論pickle ,但本文的示例實際是利用了 cPickle 。由於其中大多數示例要用 Python shell 來顯示,所以先展示一下如何導入cPickle ,並可以作爲 pickle 來引用它:

>>> import cPickle as pickle

現在已經導入了該模塊,接下來讓我們看一下 pickle 接口。 pickle 模塊提供了以下函數對: dumps(object) 返回一個字符串,它包含一個 pickle 格式的對象;loads(string) 返回包含在 pickle 字符串中的對象; dump(object, file) 將對象寫到文件,這個文件可以是實際的物理文件,但也可以是任何類似於文件的對象,這個對象具有write() 方法,可以接受單個的字符串參數; load(file) 返回包含在 pickle 文件中的對象。

缺省情況下, dumps() 和 dump() 使用可打印的 ASCII 表示來創建 pickle。兩者都有一個 final 參數(可選),如果爲True ,則該參數指定用更快以及更小的二進制表示來創建 pickle。 loads() 和 load() 函數自動檢測 pickle 是二進制格式還是文本格式。

清單 1 顯示了一個交互式會話,這裏使用了剛纔所描述的 dumps() 和 loads() 函數:

清單 1. dumps() 和 loads() 的演示
>>> import cPickle as pickle  
>>> t1 = ('this is a string', 42, [1, 2, 3], None)  
>>> t1  
('this is a string', 42, [1, 2, 3], None)  
>>> p1 = pickle.dumps(t1)  
>>> p1  
"(S'this is a string'/nI42/n(lp1/nI1/naI2/naI3/naNtp2/n."  
>>> print p1  
(S'this is a string'  
I42  
(lp1  
I1  
aI2  
aI3  
aNtp2  
.  
>>> t2 = pickle.loads(p1)  
>>> t2  
('this is a string', 42, [1, 2, 3], None)  
>>> p2 = pickle.dumps(t1, True)  
>>> p2  
'(U/x10this is a stringK*]q/x01(K/x01K/x02K/x03eNtq/x02.'  
>>> t3 = pickle.loads(p2)  
>>> t3  
('this is a string', 42, [1, 2, 3], None)

注:該文本 pickle 格式很簡單,這裏就不解釋了。事實上,在 pickle 模塊中記錄了所有使用的約定。我們還應該指出,在我們的示例中使用的都是簡單對象,因此使用二進制 pickle 格式不會在節省空間上顯示出太大的效率。然而,在實際使用複雜對象的系統中,您會看到,使用二進制格式可以在大小和速度方面帶來顯著的改進。

接下來,我們看一些示例,這些示例用到了 dump() 和 load() ,它們使用文件和類似文件的對象。這些函數的操作非常類似於我們剛纔所看到的dumps() 和 loads() ,區別在於它們還有另一種能力 — dump() 函數能一個接着一個地將幾個對象轉儲到同一個文件。隨後調用load() 來以同樣的順序檢索這些對象。清單 2 顯示了這種能力的實際應用:

清單 2. dump() 和 load() 示例
>>> a1 = 'apple'  
>>> b1 = {1: 'One', 2: 'Two', 3: 'Three'}  
>>> c1 = ['fee', 'fie', 'foe', 'fum']  
>>> f1 = file('temp.pkl', 'wb')  
>>> pickle.dump(a1, f1, True)  
>>> pickle.dump(b1, f1, True)  
>>> pickle.dump(c1, f1, True)  
>>> f1.close()  
>>> f2 = file('temp.pkl', 'rb')  
>>> a2 = pickle.load(f2)  
>>> a2  
'apple'  
>>> b2 = pickle.load(f2)  
>>> b2  
{1: 'One', 2: 'Two', 3: 'Three'}  
>>> c2 = pickle.load(f2)  
>>> c2  
['fee', 'fie', 'foe', 'fum']  
>>> f2.close()

Pickle 的威力

到目前爲止,我們講述了關於 pickle 方面的基本知識。在這一節,將討論一些高級問題,當您開始 pickle 複雜對象時,會遇到這些問題,其中包括定製類的實例。幸運的是,Python 可以很容易地處理這種情形。

可移植性

從空間和時間上說,Pickle 是可移植的。換句話說,pickle 文件格式獨立於機器的體系結構,這意味着,例如,可以在 Linux 下創建一個 pickle,然後將它發送到在 Windows 或 Mac OS 下運行的 Python 程序。並且,當升級到更新版本的 Python 時,不必擔心可能要廢棄已有的 pickle。Python 開發人員已經保證 pickle 格式將可以向後兼容 Python 各個版本。事實上,在pickle 模塊中提供了有關目前以及所支持的格式方面的詳細信息:

清單 3. 檢索所支持的格式
>>> pickle.format_version

'1.3'

>>> pickle.compatible_formats

['1.0', '1.1', '1.2']

在 Python 中,變量是對象的引用。同時,也可以用多個變量引用同一個對象。經證明,Python 在用經過 pickle 的對象維護這種行爲方面絲毫沒有困難,如清單 4 所示:

清單 4. 對象引用的維護

>>> a = [1, 2, 3]

>>> b = a

>>> a

[1, 2, 3]

>>> b

[1, 2, 3]

>>> a.append(4)

>>> a

[1, 2, 3, 4]

>>> b

[1, 2, 3, 4]

>>> c = pickle.dumps((a, b))

>>> d, e = pickle.loads(c)

>>> d

[1, 2, 3, 4]

>>> e

[1, 2, 3, 4]

>>> d.append(5)

>>> d

[1, 2, 3, 4, 5]

>>> e

[1, 2, 3, 4, 5]

循環引用和遞歸引用

可以將剛纔演示過的對象引用支持擴展到 循環引用(兩個對象各自包含對對方的引用)和 遞歸引用(一個對象包含對其自身的引用)。下面兩個清單着重顯示這種能力。我們先看一下遞歸引用:

> 清單 5. 遞歸引用
>>> l = [1, 2, 3]
>>> l.append(l)
>>> l
[1, 2, 3, [...]]
>>> l[3]
[1, 2, 3, [...]]
>>> l[3][3]
[1, 2, 3, [...]]
>>> p = pickle.dumps(l)
>>> l2 = pickle.loads(p)
>>> l2
[1, 2, 3, [...]]
>>> l2[3]
[1, 2, 3, [...]]
>>> l2[3][3]
[1, 2, 3, [...]]

現在,看一個循環引用的示例:

清單 6. 循環引用
>>> a = [1, 2]
>>> b = [3, 4]
>>> a.append(b)
>>> a
[1, 2, [3, 4]]
>>> b.append(a)
>>> a
[1, 2, [3, 4, [...]]]
>>> b
[3, 4, [1, 2, [...]]]
>>> a[2]
[3, 4, [1, 2, [...]]]
>>> b[2]
[1, 2, [3, 4, [...]]]
>>> a[2] is b
1
>>> b[2] is a
1
>>> f = file('temp.pkl', 'w')
>>> pickle.dump((a, b), f)
>>> f.close()
>>> f = file('temp.pkl', 'r')
>>> c, d = pickle.load(f)
>>> f.close()
>>> c
[1, 2, [3, 4, [...]]]
>>> d
[3, 4, [1, 2, [...]]]
>>> c[2]
[3, 4, [1, 2, [...]]]
>>> d[2]
[1, 2, [3, 4, [...]]]
>>> c[2] is d
1
>>> d[2] is c
1

注意,如果分別 pickle 每個對象,而不是在一個元組中一起 pickle 所有對象,會得到略微不同(但很重要)的結果,如清單 7 所示:

清單 7. 分別 pickle vs. 在一個元組中一起 pickle
>>> f = file('temp.pkl', 'w')
>>> pickle.dump(a, f)
>>> pickle.dump(b, f)
>>> f.close()
>>> f = file('temp.pkl', 'r')
>>> c = pickle.load(f)
>>> d = pickle.load(f)
>>> f.close()
>>> c
[1, 2, [3, 4, [...]]]
>>> d
[3, 4, [1, 2, [...]]]
>>> c[2]
[3, 4, [1, 2, [...]]]
>>> d[2]
[1, 2, [3, 4, [...]]]
>>> c[2] is d
0
>>> d[2] is c
0

相等,但並不總是相同

正如在上一個示例所暗示的,只有在這些對象引用內存中同一個對象時,它們纔是相同的。在 pickle 情形中,每個對象被恢復到一個與原來對象相等的對象,但不是同一個對象。換句話說,每個 pickle 都是原來對象的一個副本:

清單 8. 作爲原來對象副本的被恢復的對象
>>> j = [1, 2, 3]
>>> k = j
>>> k is j
1
>>> x = pickle.dumps(k)
>>> y = pickle.loads(x)
>>> y
[1, 2, 3]
>>> y == k
1
>>> y is k
0
>>> y is j
0
>>> k is j
1

同時,我們看到 Python 能夠維護對象之間的引用,這些對象是作爲一個單元進行 pickle 的。然而,我們還看到分別調用 dump() 會使 Python 無法維護對在該單元外部進行 pickle 的對象的引用。相反,Python 複製了被引用對象,並將副本和被 pickle 的對象存儲在一起。對於 pickle 和恢復單個對象層次結構的應用程序,這是沒有問題的。但要意識到還有其它情形。

值得指出的是,有一個選項確實允許分別 pickle 對象,並維護相互之間的引用,只要這些對象都是 pickle 到同一文件即可。 pickle 和cPickle 模塊提供了一個 Pickler (與此相對應是 Unpickler ),它能夠跟蹤已經被 pickle 的對象。通過使用這個Pickler ,將會通過引用而不是通過值來 pickle 共享和循環引用:

清單 9. 維護分別 pickle 的對象間的引用
>>> f = file('temp.pkl', 'w')
>>> pickler = pickle.Pickler(f)
>>> pickler.dump(a)
<cPickle.Pickler object at 0x89b0bb8>
>>> pickler.dump(b)
<cPickle.Pickler object at 0x89b0bb8>
>>> f.close()
>>> f = file('temp.pkl', 'r')
>>> unpickler = pickle.Unpickler(f)
>>> c = unpickler.load()
>>> d = unpickler.load()
>>> c[2]
[3, 4, [1, 2, [...]]]
>>> d[2]
[1, 2, [3, 4, [...]]]
>>> c[2] is d
1
>>> d[2] is c
1

不可 pickle 的對象

一些對象類型是不可 pickle 的。例如,Python 不能 pickle 文件對象(或者任何帶有對文件對象引用的對象),因爲 Python 在 unpickle 時不能保證它可以重建該文件的狀態(另一個示例比較難懂,在這類文章中不值得提出來)。試圖 pickle 文件對象會導致以下錯誤:

清單 10. 試圖 pickle 文件對象的結果
>>> f = file('temp.pkl', 'w')
>>> p = pickle.dumps(f)
Traceback (most recent call last):
  File "<input>", line 1, in ?
  File "/usr/lib/python2.2/copy_reg.py", line 57, in _reduce
    raise TypeError, "can't pickle %s objects" % base.__name__
TypeError: can't pickle file objects

類實例

與 pickle 簡單對象類型相比,pickle 類實例要多加留意。這主要由於 Python 會 pickle 實例數據(通常是 _dict_ 屬性)和類的名稱,而不會 pickle 類的代碼。當 Python unpickle 類的實例時,它會試圖使用在 pickle 該實例時的確切的類名稱和模塊名稱(包括任何包的路徑前綴)導入包含該類定義的模塊。另外要注意,類定義必須出現在模塊的最頂層,這意味着它們不能是嵌套的類(在其它類或函數中定義的類)。

當 unpickle 類的實例時,通常不會再調用它們的 _init_() 方法。相反,Python 創建一個通用類實例,並應用已進行過 pickle 的實例屬性,同時設置該實例的_class_ 屬性,使其指向原來的類。

對 Python 2.2 中引入的新型類進行 unpickle 的機制與原來的略有不同。雖然處理的結果實際上與對舊型類處理的結果相同,但 Python 使用 copy_reg 模塊的 _reconstructor() 函數來恢復新型類的實例。

如果希望對新型或舊型類的實例修改缺省的 pickle 行爲,則可以定義特殊的類的方法 _getstate_() 和 _setstate_() ,在保存和恢復類實例的狀態信息期間,Python 會調用這些方法。在以下幾節中,我們會看到一些示例利用了這些特殊的方法。

現在,我們看一個簡單的類實例。首先,創建一個 persist.py 的 Python 模塊,它包含以下新型類的定義:

清單 11. 新型類的定義
class Foo(object):
    def __init__(self, value):
        self.value = value

現在可以 pickle Foo 實例,並看一下它的表示:

清單 12. pickle Foo 實例
>>> import cPickle as pickle
>>> from Orbtech.examples.persist import Foo
>>> foo = Foo('What is a Foo?')
>>> p = pickle.dumps(foo)
>>> print p
ccopy_reg
_reconstructor
p1
(cOrbtech.examples.persist
Foo
p2
c__builtin__
object
p3
NtRp4
(dp5
S'value'
p6
S'What is a Foo?'
sb.
>>>

可以看到這個類的名稱 Foo 和全限定的模塊名稱 Orbtech.examples.persist 都存儲在 pickle 中。如果將這個實例 pickle 成一個文件,稍後再 unpickle 它或在另一臺機器上 unpickle,則 Python 會試圖導入Orbtech.examples.persist 模塊,如果不能導入,則會拋出異常。如果重命名該類和該模塊或者將該模塊移到另一個目錄,則也會發生類似的錯誤。

這裏有一個 Python 發出錯誤消息的示例,當我們重命名 Foo 類,然後試圖裝入先前進行過 pickle 的 Foo 實例時會發生該錯誤:

清單 13. 試圖裝入一個被重命名的 Foo 類的經過 pickle 的實例
>>> import cPickle as pickle
>>> f = file('temp.pkl', 'r')
>>> foo = pickle.load(f)
Traceback (most recent call last):
  File "<input>", line 1, in ?
AttributeError: 'module' object has no attribute 'Foo'

在重命名 persist.py 模塊之後,也會發生類似的錯誤:

清單 14. 試圖裝入一個被重命名的 persist.py 模塊的經過 pickle 的實例
>>> import cPickle as pickle
>>> f = file('temp.pkl', 'r')
>>> foo = pickle.load(f)
Traceback (most recent call last):
  File "<input>", line 1, in ?
ImportError: No module named persist

我們會在下面 模式改進這一節提供一些技術來管理這類更改,而不會破壞現有的 pickle。

特殊的狀態方法

前面提到對一些對象類型(譬如,文件對象)不能進行 pickle。處理這種不能 pickle 的對象的實例屬性時可以使用特殊的方法( _getstate_() 和_setstate_() )來修改類實例的狀態。這裏有一個 Foo 類的示例,我們已經對它進行了修改以處理文件對象屬性:

清單 15. 處理不能 pickle 的實例屬性
class Foo(object):
    def __init__(self, value, filename):
        self.value = value
        self.logfile = file(filename, 'w')
    def __getstate__(self):
        """Return state values to be pickled."""
        f = self.logfile
        return (self.value, f.name, f.tell())
    def __setstate__(self, state):
        """Restore state from the unpickled state values."""
        self.value, name, position = state
        f = file(name, 'w')
        f.seek(position)
        self.logfile = f

pickle Foo 的實例時,Python 將只 pickle 當它調用該實例的 _getstate_() 方法時返回給它的值。類似的,在 unpickle 時,Python 將提供經過 unpickle 的值作爲參數傳遞給實例的_setstate_() 方法。在 _setstate_() 方法內,可以根據經過 pickle 的名稱和位置信息來重建文件對象,並將該文件對象分配給這個實例的logfile 屬性。

模式改進

隨着時間的推移,您會發現自己必須要更改類的定義。如果已經對某個類實例進行了 pickle,而現在又需要更改這個類,則您可能要檢索和更新那些實例,以便它們能在新的類定義下繼續正常工作。而我們已經看到在對類或模塊進行某些更改時,會出現一些錯誤。幸運的是,pickle 和 unpickle 過程提供了一些 hook,我們可以用它們來支持這種模式改進的需要。

在這一節,我們將探討一些方法來預測常見問題以及如何解決這些問題。由於不能 pickle 類實例代碼,因此可以添加、更改和除去方法,而不會影響現有的經過 pickle 的實例。出於同樣的原因,可以不必擔心類的屬性。您必須確保包含類定義的代碼模塊在 unpickle 環境中可用。同時還必須爲這些可能導致 unpickle 問題的更改做好規劃,這些更改包括:更改類名、添加或除去實例的屬性以及改變類定義模塊的名稱或位置。

類名的更改

要更改類名,而不破壞先前經過 pickle 的實例,請遵循以下步驟。首先,確保原來的類的定義沒有被更改,以便在 unpickle 現有實例時可以找到它。不要更改原來的名稱,而是在與原來類定義所在的同一個模塊中,創建該類定義的一個副本,同時給它一個新的類名。然後使用實際的新類名來替代NewClassName ,將以下方法添加到原來類的定義中:

清單 16. 更改類名:添加到原來類定義的方法
def __setstate__(self, state):
    self.__dict__.update(state)
    self.__class__ = NewClassName

當 unpickle 現有實例時,Python 將查找原來類的定義,並調用實例的 _setstate_() 方法,同時將給新的類定義重新分配該實例的_class_ 屬性。一旦確定所有現有的實例都已經 unpickle、更新和重新 pickle 後,可以從源代碼模塊中除去舊的類定義。

屬性的添加和刪除

這些特殊的狀態方法 _getstate_() 和 _setstate_() 再一次使我們能控制每個實例的狀態,並使我們有機會處理實例屬性中的更改。讓我們看一個簡單的類的定義,我們將向其添加和除去一些屬性。這是是最初的定義:

清單 17. 最初的類定義
class Person(object):
    def __init__(self, firstname, lastname):
        self.firstname = firstname
        self.lastname = lastname

假定已經創建並 pickle 了 Person 的實例,現在我們決定真的只想存儲一個名稱屬性,而不是分別存儲姓和名。這裏有一種方式可以更改類的定義,它將先前經過 pickle 的實例遷移到新的定義:

清單 18. 新的類定義
class Person(object):
    def __init__(self, fullname):
        self.fullname = fullname
    def __setstate__(self, state):
        if 'fullname' not in state:
            first = ''
            last = ''
            if 'firstname' in state:
                first = state['firstname']
                del state['firstname']
            if 'lastname' in state:
                last = state['lastname']
                del state['lastname']
            self.fullname = " ".join([first, last]).strip()
        self.__dict__.update(state)

在這個示例,我們添加了一個新的屬性 fullname ,併除去了兩個現有的屬性 firstname 和 lastname 。當對先前進行過 pickle 的實例執行 unpickle 時,其先前進行過 pickle 的狀態會作爲字典傳遞給 _setstate_() ,它將包括firstname 和 lastname 屬性的值。接下來,將這兩個值組合起來,並將它們分配給新屬性 fullname 。在這個過程中,我們刪除了狀態字典中舊的屬性。更新和重新 pickle 先前進行過 pickle 的所有實例之後,現在可以從類定義中除去_setstate_() 方法。

模塊的修改

在概念上,模塊的名稱或位置的改變類似於類名稱的改變,但處理方式卻完全不同。那是因爲模塊的信息存儲在 pickle 中,而不是通過標準的 pickle 接口就可以修改的屬性。事實上,改變模塊信息的唯一辦法是對實際的 pickle 文件本身執行查找和替換操作。至於如何確切地去做,這取決於具體的操作系統和可使用的工具。很顯然,在這種情況下,您會想備份您的文件,以免發生錯誤。但這種改動應該非常簡單,並且對二進制 pickle 格式進行更改與對文本 pickle 格式進行更改應該一樣有效。

結束語

對象持久性依賴於底層編程語言的對象序列化能力。對於 Python 對象即意味着 pickle。Python 的 pickle 爲 Python 對象有效的持久性管理提供了健壯的和可靠的基礎。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/2Vdt0o9xbBt6YU5DSjvkpw