編寫乾淨清晰的Python代碼的一種通用設計
在 python 和 shell 之間選擇
如果有Python環境,應該大部分時候都選擇使用 Python 來編寫系統。這是因爲:
- Python 更易於維護,適合模塊化設計(class, 多文件 import,層次文件夾支持,成熟的庫依賴)
- Shell 腳本缺乏模塊化設計,容易依賴大量的全局環境變量,難以閱讀(模塊和數據依賴關係)
所以:
- 如果你的項目內混合使用 Python 和 Shell,應該都是用 Python 來編寫。
- 而不應該一會兒 Python,一會兒使用 Shell,帶來不一致性以及維護上的困難,例如Python有成熟的命令行傳參支持,Shell只有位置參數支持
設計Python的參數解析模塊:環境變量,命令行參數和配置文件
一個有明顯邊界的 Python 模塊,應該從一開始就設計好命令行參數解析模塊和配置文件解析模塊。參考的目錄結構如下:
- xx_options.py
- xx_config.py
有一個常見的問題是:應該選擇使用命令行的參數,還是配置文件的參數。一個清晰的原則是:
如果同一個配置字段,命令行傳參就明確使用命令行傳的參數,否則使用配置文件的參數。
命令行參數可以在設計上包含:
- 設計一組明確功能的字段,例如 --build-dir
- 功能字段可以根據目的,帶前綴分組,例如
1. --build-dir, --build-batch-numbers ...
2. --test-data, --test-batch-numbers ...
3. 如果是多組共用的就可以去掉分組前綴作爲公用參數 - 設計一個額外的公共自定義參數提供自由度,可以約定是一個json字符串
- --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 方法。
通過這種方式,代碼可以組織的乾淨直接。