編寫乾淨清晰的 Python 代碼的一種通用模塊設計

編寫乾淨清晰的Python代碼的一種通用設計

在 python 和 shell 之間選擇

如果有Python環境,應該大部分時候都選擇使用 Python 來編寫系統。這是因爲:

  1. Python 更易於維護,適合模塊化設計(class, 多文件 import,層次文件夾支持,成熟的庫依賴)
  2. Shell 腳本缺乏模塊化設計,容易依賴大量的全局環境變量,難以閱讀(模塊和數據依賴關係)

所以:

  1. 如果你的項目內混合使用 Python 和 Shell,應該都是用 Python 來編寫。
  2. 而不應該一會兒 Python,一會兒使用 Shell,帶來不一致性以及維護上的困難,例如Python有成熟的命令行傳參支持,Shell只有位置參數支持

設計Python的參數解析模塊:環境變量,命令行參數和配置文件

一個有明顯邊界的 Python 模塊,應該從一開始就設計好命令行參數解析模塊和配置文件解析模塊。參考的目錄結構如下:

  • xx_options.py
  • xx_config.py

有一個常見的問題是:應該選擇使用命令行的參數,還是配置文件的參數。一個清晰的原則是:
如果同一個配置字段,命令行傳參就明確使用命令行傳的參數,否則使用配置文件的參數。

命令行參數可以在設計上包含:

  1. 設計一組明確功能的字段,例如 --build-dir
  2. 功能字段可以根據目的,帶前綴分組,例如
    1. --build-dir, --build-batch-numbers ...
    2. --test-data, --test-batch-numbers ...
    3. 如果是多組共用的就可以去掉分組前綴作爲公用參數
  3. 設計一個額外的公共自定義參數提供自由度,可以約定是一個json字符串
  4. --customize 或 --parms

xx_options.py 的設計一般是

import sys

class XXOptions:
  def __init__(self):
    self.build_dir_ = None
    self.test_data_ = None
    self.prams_ = None
   
  @property
  def build_dir(self):
    return self.build_dir_
  
  @property
  def test_data(self):
    return self.test_data_
  
  @property
  def prams(self):
    return self.prams_
    
  def setup(self):
    # 定義命令行參數
    pass
    
  def parse(self):
    # 解析,設置屬性
    pass

xx_config.py 的設計一般是

class XXCOnfig:
  def __init__(self, config_path, options):
    self.config_path_ = config_path
    self.options = options # 從命令行來的參數
    self.build_dir_ = None
    self.test_data_ = None
    self.prams_ = options.prams
    
  @property
  def config_path(self):
    return self.config_path_
  
  @property
  def build_dir(self):
    return self.build_dir_
  
  @property
  def test_data(self):
    return self.test_data_
    
  @property
  def prams(self):
    # 允許子模塊自由定製的命令行參數
    return self.prams_
    
  @property(self):
  def get_env(self, key):
     # 讀取環境變量,做單一控制點,必要的校驗
     return ...
     
  @property(self):
  def set_env(self, key):
     # 設置環境變量,做單一控制點,必要的校驗以及白名單機制
     ... 
    
  def setup(self):
    # 先從命令行裝配
    self.__setup_from_options()
    
    # 再從配置文件裝配,如果命令行已經裝配就忽略配置文件對應配置
    self.__setup_from_config_path()
    
    # 如果環境變量影響配置,做必要的處理,不要讓環境變量的修改和讀取點擴散
    self.__setup_from_env()
    
  def dump(self):
    self.__dump_to_file()

經過這樣的轉換,config 就可以提供給下游的所有模塊,作爲必須的構造函數參數,config 隔離了命令行、配置文件,環境變量,甚至未來潛在的服務器配置 與 程序系統之間的邊界。一個子模塊的設計大概是這樣的:

class XXAction1
  def __init__(self, config):
    # 所有的配置信息,從 config 獲取    
    self.config = config
    
    # 應該直接把 config 傳遞給子模塊,而非傳單個碎片參數
    self.sub_action = XXSubAction1(self.config)
    
class XXSubAction1
  def __init__(self, config):
    # 所有的配置信息,從 config 獲取    
    self.config = config

通過這種方式,我們也會讓絕大部分的 class 之間傳遞參數保持乾淨,和參數數量很少。這裏沒有使用類繼承,推薦使用組合而非繼承來實現共用,絕大部分情況下,組合帶來的設計是更乾淨的。這裏沒有把 options 傳遞給下游,因爲options 已經被config處理過,策略部分固化在config裏,但是保留了用戶自定義部分 params 的透傳,這樣下游仍然可以實現按需的自定義參數傳遞和使用。實際上也可以近一步用 python 的 collections 的 ChanMap 吧 env,options,config 一次性融合:

from collections import ChainMap
context = ChainMap(env, options, config)

設計Python工具鏈的核心模型:目錄結構模型

寫程序的核心還是“數據結構”與“算法”。對於工具鏈系統,看上去很容易寫成一堆零散的腳本,很靈活自由。但是這種靈活和自由往往帶來系統設計上的缺失,一個關鍵的原因是沒有建立起所處理對象的不變部分“數據結構”,也就是模型。
注意:

  • 你應該在 Config 設計後,第一時間考慮你的 DirectoryModel 的設計,數據結構穩定了,「算法」才能圍繞一個核心模型來穩定展開。

例如這樣的一個目錄結構約定:

 - config
   - <project>
     - config.json
 - output_dir
   - build
   - logs
   - repps_dir
     - base
       - <base_module_name>
         - common
     - <module_name>

但是這個結構是存在一堆隱式的約定中,並沒有顯式的在程序中構建起一個模型對象來語義化這個世界模型,會導致很多隱含的目錄結構拼接和組合。
一個改進的方式是,建立起一個明確的目錄結構模型:

class DirectoryModel:
  '''
  目錄結構模型
  - config
   - <project>
     - config.yaml
  - output_dir
   - build
   - logs
   - repps_dir
     - base
       - <base_module_name>
         - common
     - <module_name>
  '''
  def __init__(self, config):
    self.config = config
    
  @property
  def root_dir(self):
     # 任何一個目錄結構模型,都應該有個 root_dir
     ...
  
  @property
  def output_dir(self):
      ...
      
  @property
  def project_dir(self):
      ...
  
  @property
  def logs_dir(self):
      ...
  
  @property
  def build_dir(self):
      ...
      
  @property
  def repos_dir(self):
      ...
      
  @property
  def base_dir(self):
      ...

這部分模型應該單獨出來一個子模塊,這樣整體類會變成這樣:

class System:
  def __init__(self, config):
    self.config = config
    self.dir_model = DirectoryModel(config)
    self.sub_action = SubAction(config, dir_model)
    
class SubAction:
  def __init__(self, config, dir_model):
    self.config = config
    self.dir_model = dir_model

如果下層目錄結構有類似的子目錄結構約定,應該類似設計一個子模塊的子目錄結構模型。其中一個需要注意的點是:

每當下游對目錄結構做拼接的時候,都要重新 review 下:這個目錄結構是否應該由 DirectoryModel 直接提供,而非自己拼接?通過這種方式,我們就能逐漸區分:這是一個應該由上游提供的目錄結構模型,還是應該由子子模塊自己的目錄模型提供。

設計管道處理模塊:Action、Pipeline

一個工具鏈Python模塊,一般是由層層的鏈式處理子程序構成的
因此,工具鏈 Python 程序的模塊設計大部分可以歸結爲:
一組處理子程序(StageAction)構成的一個管道處理程序(Pipeline)

注意:

  • 明確將 子 Action 設計成一個類,儘可能不要在一個函數裏寫大量代碼實現一個Action 子類應做的事情

怎樣組織這樣的代碼才能保持代碼精簡呢?
首先,明確設計每個 StageAction 的接口格式,僅僅約定即可,不需要設計父類。如下:

class SourceAction:
  def __init__(self, config, ..)
    self.config = config
    
  @property
  def name(self):
    return 'source'
  
  def run(self):
    ...

class BuildAction:
  def __init__(self, config, ..)
    self.config = config
    
  @property
  def name(self):
    return 'buld'
  
  def run(self):
    ...

class PackageAction:
  def __init__(self, config, ..)
    self.config = config
    
  @property
  def name(self):
    return 'package'
  
  def run(self):
    ...

每個StageAction只需提供一個 run 方法即可。外層的Pipeline 程序使用起來大概這樣:

class Piepeline:
  def __init__(self, config,..):
    self.config = config
    self.actions = [
        SourceAction(config, ..),
        BuildAction(config,..),
        PackageAction(config, ..),
        ...
    ]

  def run(self):
    for action in self.actions:
      if action.name in self.config.stages:
        action.run()

這裏通過職責鏈,讓StageAction在Pipeline裏面的組合非常簡單。

管道多階段緩存與時間統計

當我們有了管道,有了多階段 StageAction,如果希望:

  • 程序可以重複運行,除非強制刷新,否則已經執行過的階段不再重複執行
  • 增加時間耗時統計

在上述設計下就可以比較便利的設計一個 SkipCache 和 TimeSpan 來解決。注意:

  • 對於多道處理程序,一定要考慮緩存的設計,緩存的設計應該是多層級的:
    • 管道處理的大環節的粒度
    • 數據對象的小條目粒度
    • 正確的緩存設計將節省所有人 365nm 次試錯成本,考慮到m是我們一次處理數據的耗時,例如編譯完300w+代碼一遍的成本
  • 不要裸用 time.time() ,大量碎片代碼會把你的代碼質量搞得很低

首先看下 SkipCache:

class SkipCache:
    def __init__(self, cache_path, clear_cache):
        self.cache_path = cache_path
        self.current_key = None
        self.enter_callback = None
        if not clear_cache and os.path.exists(self.cache_path):
            with open(self.cache_path, 'r') as f:
                self.key_value = json.load(f)
        else:
            self.key_value = {}
            with open(self.cache_path,'w') as f:
                json.dump(self.key_value, f, indent=4)
    
    def set_enter_callback(self, callback):
        self.enter_callback = callback
    
    def exit(self):
        self.key_value[self.current_key] = True
        self.current_key = None
        with open(self.cache_path,'w') as f:
            json.dump(self.key_value, f, indent=4)
    
    def enter(self, key):
        assert self.current_key is None
        if self.enter_callback is not None:
            self.enter_callback(key)
        if self.key_value.get(key):
            return False
        else:
            self.current_key = key
            return True

通過 SkipCache,Pipeline的 運行函數稍加修改就可以獲得「斷點續跑」能力

class Pipeline:
  ...
  def run(self):
    skip = SkipCache(self.config.skip_cache_path, self.config.clear_stage_cache)
    for action in self.actions:
      if action.name in self.config.stages:
        if skip.enter(action.name):
          action.run()
          skip.exit()

效果就是:

  • 如果指定了 --clear-stage-cache,則會完整重跑
  • 否則,同樣的命令反覆跑,已經執行過的Action不會再跑,斷點續跑,這在反覆排錯過程中很有用,特別是前置處理流程很耗時的時候。

再進一步增加時間統計:

class TimeSpan:
    def __init__(self):
        self.records = {}
        self.last_name = None
        self.start = time.time()
    
    def step(self, name):
        now = time.time()
        self.records[name] = {
            'start': now
        }
        
        if self.last_name is not None:
            last_record = self.records[self.last_name]
            last_record['end'] = now
            elapsed_time = last_record['end'] - last_record['start']
            
            print("")
            print("------------------")
            print(f"The code: {self.last_name} took {elapsed_time/60} minus to run")
            print("------------------")
            print("")
            
        self.last_name = name
    
    def finish(self):
        self.step('finish')
        print("")
        print("------------------")
        now = time.time()
        total_elapsed_time = now - self.start
        has_section = False
        for name in self.records:
            record = self.records[name]
            if record.get('end') is not None:
                elapsed_time = record['end'] - record['start']
                print(f"The code: {name} took {elapsed_time/60} minus to run")
                has_section = True
        
        if has_section:
            print("------------------")
            print(f"The code: took {total_elapsed_time/60} minus to run")
            print(f"Done.")
            print("------------------")
            print("")

則 Pipeline 的run 方法可以再次調整:

class Pipeline:
  ...
  def run(self):
    skip = SkipCache(self.config.skip_cache_path, self.config.clear_stage_cache)
    timespan = TimeSpan()
    skip.set_enter_callback(lambda key: self.timespan.step(key))
    for action in self.actions:
      if action.name in self.config.stages:
        if skip.enter(action.name):
          action.run()
          skip.exit()
    timespan.finish()

極簡封裝一個類:只提供一個 run 公共方法

注意:

  • 儘可能保持單一職責原則,一個類只應該對外有越少的公共接口越好,其中最簡化的就是隻有一個 run,其他都是內部方法。

回顧下上面的例子。我們將內部的方法全部用 Python 私有 method 的規範來命名,內部方法都是採用雙下劃線前綴做明顯的區分,這樣每個處理類,對外只有少數的明顯的對外方法,大部分情況下,只需要提供一個 run 方法即可。 這在 Python 裏可以做的更近一步是把 run 方法都實現成 call 方法,但是爲了簡化,我們決定僅僅只用通常的 class 和 method 做抽象就夠了,儘可能少引入語法糖。

這個類也展示瞭如何在 run 裏清晰的表達代碼內部子處理 method 的組織和編排。一般來說代碼越是線性的越好。上面的這些內部方法的實現其實都是轉發給子模塊(SubAction)去做的,大部分時候就是構造對象,調用其 run 方法。

通過這種方式,代碼可以組織的乾淨直接。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章