Python 的 pickle 模組提供便捷的物件序列化功能,但隨著專案的演進,可能會面臨版本不相容的挑戰。例如,遊戲狀態類別 GameState 在版本更新後新增或移除屬性,舊版存檔的載入就可能引發錯誤。本文將介紹如何使用 copyreg 模組,實作更穩健的序列化機制。

copyreg 模組允許自定義序列化和反序列化函式,從而精確控制 pickle 的行為。首先,我們可以設定預設屬性值,確保反序列化後的物件擁有所有必要的屬性。更進一步,可以定義 pickle_game_stateunpickle_game_state 函式,分別處理序列化和反序列化的邏輯。pickle_game_state 將物件轉換為 copyreg 可處理的引數元組,而 unpickle_game_state 則負責重建物件。

為了處理版本不相容的變更,我們可以在序列化資料中加入版本號。pickle_game_state 函式會在序列化資料中加入版本資訊,unpickle_game_state 函式則根據版本號調整反序列化的流程。例如,當舊版存檔缺少新版本新增的屬性時,unpickle_game_state 可以根據版本號,使用預設值或其他策略來處理缺失的屬性。同樣地,如果新版本移除了舊版的部分屬性,unpickle_game_state 可以忽略這些屬性,避免反序列化錯誤。透過版本控制,可以確保不同版本的程式碼都能正確處理序列化資料,提升程式的穩定性和擴充性。

Python Context Manager 進階應用:玄貓的除錯、日誌與物件序列化實戰

Context Manager 是 Python 中一個強大的特性,它能幫助我們更優雅地管理資源,確保程式碼在特定區塊執行前後,資源能被正確地設定與清理。今天,玄貓將分享 Context Manager 在除錯、日誌紀錄和物件序列化上的實戰應用。

Context Manager 簡介:告別 try/finally 的煩惱

在沒有 Context Manager 之前,我們常常需要使用 try/finally 區塊來確保資源的釋放,例如檔案的關閉、網路連線的斷開等。Context Manager 提供了一種更簡潔的方式,讓我們可以把資源管理的邏輯封裝起來,避免程式碼重複,也更容易維護。

Context Manager 的基本語法如下:

with context_expression [as variable]:
    # 在這個區塊中,資源已被設定
    # 程式碼執行完畢後,資源會被自動清理

Context Manager 實作:contextlib 模組

要建立一個 Context Manager,可以使用 contextlib 模組中的 contextmanager 裝飾器。這個裝飾器可以把一個產生器函式轉換成 Context Manager。

以下是一個簡單的例子,示範如何使用 Context Manager 來改變日誌的層級:

import logging
from contextlib import contextmanager

@contextmanager
def debug_logging(level):
    logger = logging.getLogger()
    old_level = logger.getEffectiveLevel()
    logger.setLevel(level)
    try:
        yield
    finally:
        logger.setLevel(old_level)

def my_function():
    logging.debug('一些除錯資料')
    logging.error('這裡有個錯誤日誌')
    logging.debug('更多除錯資料')

# 設定預設的日誌層級為 WARNING
logging.basicConfig(level=logging.WARNING)

my_function()
# 輸出: Error log here

with debug_logging(logging.DEBUG):
    print('區塊內部:')
    my_function()
    print('區塊結束:')
    my_function()

# 輸出:
# 區塊內部:
# 一些除錯資料
# 這裡有個錯誤日誌
# 更多除錯資料
# 區塊結束:
# 這裡有個錯誤日誌

內容解密

  1. debug_logging 裝飾器
    • @contextmanager:這個裝飾器將 debug_logging 函式轉換為一個 context manager。
  2. debug_logging(level) 函式
    • logger = logging.getLogger():取得 root logger 例項。
    • old_level = logger.getEffectiveLevel():儲存目前的日誌層級,以便稍後還原。
    • logger.setLevel(level):設定 logger 的日誌層級為傳入的 level
    • try...finally 區塊:確保在 with 區塊結束後還原始日誌層級。
    • yieldyield 關鍵字暫停函式的執行,並產生一個值。在這個例子中,yield 沒有產生任何值(yield None),但它標記了 with 區塊的開始和結束。
  3. with debug_logging(logging.DEBUG): 陳述式
    • 這個陳述式使用 debug_logging context manager。當進入 with 區塊時,debug_logging 函式會設定日誌層級為 logging.DEBUG
    • 當離開 with 區塊時,無論是否發生異常,finally 區塊都會執行,將日誌層級還原為原始值。
  4. my_function() 函式
    • 這個函式包含一些日誌訊息,用於示範日誌層級的變更效果。
  5. 日誌訊息
    • logging.debug('Some debug data'):除錯訊息,只會在日誌層級設定為 DEBUG 或更低時顯示。
    • logging.error('Error log here'):錯誤訊息,無論日誌層級如何設定都會顯示。
    • logging.debug('More debug data'):另一個除錯訊息。
  6. 執行結果
    • with 區塊外部呼叫 my_function() 時,只會顯示錯誤訊息,因為預設的日誌層級是 WARNING
    • with 區塊內部呼叫 my_function() 時,所有除錯訊息和錯誤訊息都會顯示,因為日誌層級被設定為 DEBUG

Context Manager 的目標物件:as 的妙用

Context Manager 除了可以管理資源的設定與清理,還可以透過 yield 傳回一個物件,讓 with 區塊中的程式碼可以直接與這個物件互動。

以下範例示範如何建立一個 Context Manager,傳回一個 Logger 例項,並在 with 區塊中使用這個 Logger 例項來記錄日誌:

import logging
from contextlib import contextmanager

@contextmanager
def log_level(level, name):
    logger = logging.getLogger(name)
    old_level = logger.getEffectiveLevel()
    logger.setLevel(level)
    try:
        yield logger
    finally:
        logger.setLevel(old_level)

with log_level(logging.DEBUG, 'my-log') as logger:
    logger.debug('這是我的訊息!')
    logging.debug('這不會印出來')

# 輸出: This is my message!

logger = logging.getLogger('my-log')
logger.debug('Debug 不會印出來')
logger.error('Error 會印出來')

# 輸出: Error will print

內容解密

  1. log_level(level, name) 函式
    • logger = logging.getLogger(name):取得指定名稱的 logger 例項。如果該名稱的 logger 不存在,則會建立一個新的 logger。
    • old_level = logger.getEffectiveLevel():儲存目前的日誌層級,以便稍後還原。
    • logger.setLevel(level):設定 logger 的日誌層級為傳入的 level
    • try...finally 區塊:確保在 with 區塊結束後還原始日誌層級。
    • yield loggeryield 關鍵字暫停函式的執行,並產生 logger 例項。這個 logger 例項會被指定給 with 陳述式中的 as logger
  2. with log_level(logging.DEBUG, 'my-log') as logger: 陳述式
    • 這個陳述式使用 log_level context manager。當進入 with 區塊時,log_level 函式會設定 logger 的日誌層級為 logging.DEBUG,並將 logger 例項指定給 logger 變數。
    • 當離開 with 區塊時,無論是否發生異常,finally 區塊都會執行,將日誌層級還原為原始值。
  3. 日誌訊息
    • logger.debug('This is my message!'):除錯訊息,只會在日誌層級設定為 DEBUG 或更低時顯示。這個訊息會在 with 區塊內部顯示,因為日誌層級被設定為 DEBUG
    • logging.debug('This will not print'):另一個除錯訊息,但這個訊息使用 root logger,其日誌層級預設為 WARNING,因此不會顯示。
    • logger.debug('Debug will not print'):除錯訊息,在 with 區塊外部呼叫,由於日誌層級已還原為原始值,因此不會顯示。
    • logger.error('Error will print'):錯誤訊息,無論日誌層級如何設定都會顯示。

pickle 模組:物件序列化的風險與防範

pickle 模組是 Python 中用於物件序列化的標準工具。它可以將 Python 物件轉換成位元組流,方便儲存到檔案或透過網路傳輸。然而,pickle 模組也存在安全風險。

重要提醒pickle 模組的序列化格式本質上是不安全的。序列化的資料包含重建原始 Python 物件的指令。這表示惡意的 pickle 封包可能被用來攻擊反序列化它的 Python 程式。

因此,pickle 模組不應該用於在互不信任的程式之間傳輸資料。如果需要與外部系統交換資料,應該使用更安全的格式,例如 json

以下範例示範如何使用 pickle 模組來儲存遊戲狀態:

import pickle

class GameState(object):
    def __init__(self):
        self.level = 0
        self.lives = 4

state = GameState()
state.level += 1
state.lives -= 1

state_path = '/tmp/game_state.bin'
with open(state_path, 'wb') as f:
    pickle.dump(state, f)

with open(state_path, 'rb') as f:
    loaded_state = pickle.load(f)

print(loaded_state.level)
print(loaded_state.lives)

內容解密

  1. GameState 類別
    • 定義了一個 GameState 類別,用於表示遊戲的狀態,包含 levellives 兩個屬性。
  2. 建立 GameState 例項
    • state = GameState():建立一個 GameState 例項。
    • state.level += 1:將遊戲等級增加 1。
    • state.lives -= 1:將生命值減少 1。
  3. 序列化並儲存 GameState 例項
    • state_path = '/tmp/game_state.bin':定義儲存遊戲狀態的檔案路徑。
    • with open(state_path, 'wb') as f::以二進位寫入模式開啟檔案。
    • pickle.dump(state, f):使用 pickle.dump() 函式將 state 例項序列化並儲存到檔案中。
  4. 從檔案載入並反序列化 GameState 例項
    • with open(state_path, 'rb') as f::以二進位讀取模式開啟檔案。
    • loaded_state = pickle.load(f):使用 pickle.load() 函式從檔案中載入資料並反序列化為 GameState 例項。
  5. 驗證載入的遊戲狀態
    • print(loaded_state.level):印出載入的遊戲等級。
    • print(loaded_state.lives):印出載入的生命值。

玄貓提醒:Context Manager 的注意事項

  • Context Manager 可以簡化資源管理,但過度使用可能會讓程式碼變得難以理解。
  • 在使用 pickle 模組時,務必注意安全風險,避免在不信任的環境中使用。
  • Context Manager 的 __enter____exit__ 方法應該盡可能保持簡單,避免在其中執行複雜的邏輯。

Context Manager 是 Python 中一個非常實用的特性,可以幫助我們編寫更簡潔、更易於維護的程式碼。希望透過今天的分享,大家能更深入地瞭解 Context Manager 的應用,並在實際開發中善用這個工具。

如何最佳化 Python Pickle 模組:玄貓的實戰經驗分享

Python 的 pickle 模組在序列化物件時非常方便,但隨著專案擴大,你可能會遇到一些意想不到的問題。身為玄貓,我將分享如何透過 copyreg 模組來最佳化 pickle,讓你的程式更可靠。

Pickle 的隱憂:擴充性與版本問題

當你的遊戲或應用程式不斷新增功能時,pickle 的問題就會浮現。假設我們有一個 GameState 類別,用於儲存遊戲狀態:

class GameState(object):
    def __init__(self):
        self.lives = 4
        self.level = 0

如果我們新增一個 points 屬性:

class GameState(object):
    def __init__(self):
        self.lives = 4
        self.level = 0
        self.points = 0

新版的 GameState 可以正常序列化與反序列化,但舊的遊戲存檔會發生什麼事呢?

import pickle

state = GameState()
serialized = pickle.dumps(state)
state_after = pickle.loads(serialized)
print(state_after.__dict__)

你會發現 points 屬性不見了!雖然 state_after 仍然是 GameState 的例項,但缺少了新屬性,這會導致程式出錯。

玄貓解法:利用 copyreg 模組

copyreg 模組允許我們註冊自定義的序列化和反序列化函式,從而控制 pickle 的行為。

1. 設定預設屬性值

最簡單的方法是在建構子中使用預設引數,確保物件在反序列化後擁有所有屬性。

class GameState(object):
    def __init__(self, level=0, lives=4, points=0):
        self.level = level
        self.lives = lives
        self.points = points

2. 定義序列化與反序列化函式

接著,我們定義兩個輔助函式:pickle_game_stateunpickle_game_statepickle_game_stateGameState 物件轉換為 copyreg 模組可用的引數元組,而 unpickle_game_state 則負責從序列化資料中重建 GameState 物件。

import copyreg

def pickle_game_state(game_state):
    kwargs = game_state.__dict__
    return unpickle_game_state, (kwargs,)

def unpickle_game_state(kwargs):
    return GameState(**kwargs)

copyreg.pickle(GameState, pickle_game_state)

3. 驗證結果

現在,即使我們新增了 magic 屬性:

class GameState(object):
    def __init__(self, level=0, lives=4, points=0, magic=5):
        self.level = level
        self.lives = lives
        self.points = points
        self.magic = 5

舊的遊戲存檔也能正確載入,並使用 magic 屬性的預設值。

版本控制:處理不相容的變更

有時候,我們需要移除舊的屬性,這會導致反序列化失敗。例如,我們決定移除 lives 屬性:

class GameState(object):
    def __init__(self, level=0, points=0, magic=5):
        self.level = level
        self.points = points
        self.magic = 5

這時,unpickle_game_state 會嘗試將 lives 傳遞給建構子,導致 TypeError

1. 加入版本引數

解決方法是在 copyreg 的函式中加入版本引數。新的序列化資料會包含版本號,而舊的資料則沒有,我們可以根據版本號來調整反序列化的過程。

def pickle_game_state(game_state):
    kwargs = game_state.__dict__
    kwargs['version'] = 2
    return unpickle_game_state, (kwargs,)

2. 調整反序列化函式

unpickle_game_state 中,我們檢查版本號是否存在,並據此調整傳遞給建構子的引數。

def unpickle_game_state(kwargs):
    version = kwargs.pop('version', 1)  # 預設版本為 1
    if version == 1:
        kwargs.pop('lives', None)  # 移除 lives 屬性
    return GameState(**kwargs)

玄貓提醒:程式碼範例

以下是整合的版本控制範例,確保程式碼的邏輯性和可執行性:

import pickle
import copyreg

class GameState(object):
    def __init__(self, level=0, points=0, magic=5):
        self.level = level
        self.points = points
        self.magic = 5

    def __repr__(self):
        return f'GameState(level={self.level}, points={self.points}, magic={self.magic})'

def pickle_game_state(game_state):
    kwargs = game_state.__dict__
    kwargs['version'] = 2
    return unpickle_game_state, (kwargs,)

def unpickle_game_state(kwargs):
    version = kwargs.pop('version', 1)  # 預設版本為 1
    if version == 1:
        kwargs.pop('lives', None)  # 移除 lives 屬性
    return GameState(**kwargs)

copyreg.pickle(GameState, pickle_game_state)

# 模擬舊版 GameState
old_state = GameState(level=1, points=100, magic=3)
old_state.__dict__['lives'] = 3  # 舊版包含 lives 屬性
serialized_old = pickle.dumps(old_state.__dict__)  # 序列化 __dict__ 而非物件本身

# 模擬新版 GameState
new_state = GameState(level=2, points=200, magic=5)
serialized_new = pickle.dumps(new_state)

# 反序列化舊版
unpickled_old = pickle.loads(serialized_old)
print("Old Game State:", unpickled_old)

# 反序列化新版
unpickled_new = pickle.loads(serialized_new)
print("New Game State:", unpickled_new)

程式碼解密

  1. GameState 類別: 定義遊戲狀態,包含 level, points, magic 屬性。
  2. pickle_game_state 函式:
    • GameState 物件的 __dict__ (包含所有屬性) 提取出來。
    • 加入 version 屬性,設定為 2
    • 傳回 unpickle_game_state 函式和包含屬性的 kwargs
  3. unpickle_game_state 函式:
    • kwargs 中取出 version,如果沒有則預設為 1
    • 如果 version1,則移除 lives 屬性 (因為新版已移除)。
    • 使用剩餘的 kwargs 建立 GameState 物件。
  4. copyreg.pickle(GameState, pickle_game_state): 註冊 GameState 的序列化和反序列化函式。
  5. 模擬舊版: 建立一個包含 lives 屬性的舊版遊戲狀態,並序列化其 __dict__
  6. 模擬新版: 建立一個新版遊戲狀態,並序列化物件本身。
  7. 反序列化: 分別反序列化舊版和新版的資料,並印出結果。