代碼的語義正確真的就夠了嗎?
剛畢業之后工作的頭一年多的時間里,我的工作都是圍繞一個業務系統展開的。開發業務系統的一個感覺就是,作為開發者基本上不用去考慮 RPC 應該怎么寫,消息隊列、數據庫、緩存應該怎么選型,配置文件用 ini 還是 YMAL 之類的問題,只需要專注在借助集團多年沉淀下來的技術棧上把業務系統搭建出來,確保系統穩定運行,幫助業務團隊實現業績目標就好了。
因為一直寫的都是業務代碼,在大量調用其他團隊提供的 API 的同時,也大量提供 API 供其他團隊調用,然后就漸漸地形成了一種思維慣性,理所當然地認為 API 的行為和它的命名是一致的,如果我調用某個 API 之后發現它的行為不符合它的命名描述,還可以推動 API 的維護團隊去提供一個「政治正確」的 API。
業務代碼寫久了,每天每天和頂層設計打交道,就容易遠離系統底層的各種細節,眼里看的腦里想的都是 API、架構之類的寬泛而空洞的概念,代碼上的硬功夫一天一天退步,一個不小心就成了傳說中的「API 程序員」。
雖然我不愿意承認,但是實際上我距離 API 程序員也不遠了。出來混總是要還的,前幾天我就被「語義正確」坑了一把。
代碼是用 Python 寫的。我最近幾個月又開始用 Python 了,不過就目前來說我最擅長的語言還是 Java。
我需要打開一個 ini 格式的配置文件,讀取其中的配置內容,然后修改幾個配置文件,然后再將配置變更保存。考慮到未來可能有多個進程同時操作,還需要借助文件鎖來避免進程間的競爭條件。寫出來的代碼是這樣子的。
import ConfigParser
import fcntl
config = ConfigParser.ConfigParser()
with open('config.ini', 'r+') as f:
fcntl.flock(f, fcntl.LOCK_EX)
config.readfp(f)
if not config.has_section('section x'):
config.add_section('section x')
config.write(f)
多么簡潔漂亮的代碼!從語義上看,這段代碼完全符合上面的需求,先用讀寫權限打開文件,默認假設文件存在;然后給文件加上排他鎖,使得后面的代碼都處于臨界區;然后把文件內容讀取到內存中并處理成特定的數據結構;然后是一些裝模作樣的配置變更;最后把配置寫回文件,然后關閉文件,進程結束。
在 API 程序員的理想國中,這段代碼的語義確實和需求是一致的,理論上應該工作良好。即如果從 config.ini 是一個空文件開始,無論這段代碼運行多少次, config.ini 的內容都應該是下面這樣的:
[section x]
然而讓 API 程序員累覺不愛的現實世界是怎樣的呢?讓我們揭曉謎底:
執行第一次, config.ini 的內容是這樣,一切安好:
[section x]
執行第二次, config.ini 的內容是這樣,肯定是上帝開了個玩笑:
[section x]
[section x]
執行第三次, config.ini 的內容是這樣,我討厭這個丑陋的世界:
[section x]
[section x]
[section x]
看到這里,懂行的讀者應該會心一笑,這完全是文件游標在搞的鬼啊。進入臨界區后, config.readfp(f) 讀取了整個文件的內容,于是文件游標指向了文件的末尾;然后在出臨界區前, config.write(f) 從游標所在位置開始,向文件寫入內容。
那么能夠給出正確 config.ini 的寫法是怎樣的呢?
import ConfigParser
import fcntl
config = ConfigParser.ConfigParser()
with open('config.ini', 'r+') as f:
fcntl.flock(f, fcntl.LOCK_EX)
config.readfp(f)
if not config.has_section('section x'):
config.add_section('section x')
f.seek(0)
f.truncate(0)
config.write(f)
在 config.write(f) 之前加上 f.seek(0) ,能夠確保文件游標回到文件開始的地方,然后用 f.truncate(0) 清空文件內容,最后才寫配置然后關閉文件并將緩存刷到磁盤上。
故事并沒有到這里結束。注意到我們在上面的代碼中對文件執行了兩次寫操作。先是清空文件,然后才是寫入配置內容。如果恰好在清空文件和寫入配置兩步之間,程序崩潰了,會有什么后果?
后果很嚴重,文件內容全部丟失,簡直就是人間慘劇!
不要說感覺說這個時間窗口很狹小,碰上的概率太小就不管它,這種黑天鵝事件不發生就算了,一旦發生那可是大故障。且不說這種數據全部丟失的事情發生在一個關鍵生產系統上會怎樣,只要看看當初 Atom 編輯器的一個會造成文件內容全部丟失的缺陷 issue (https://github.com/atom/atom/issues/3158),就能感受到問題的嚴重性。
關系數據庫為了保證數據高可靠,會在執行數據變更前記錄日志并將日志刷到磁盤上,我們可以采取類似的做法,在清空配置文件前先將數據保存一份到備份文件中,然后成功寫入配置文件之后將備份刪除。如果寫入配置文件前進程崩潰了,那么在重入的時候先從備份文件中拿數據,然后把數據復制一份寫到備份文件中。
import ConfigParser
import fcntl
import os
config = ConfigParser.ConfigParser()
with open('config.ini', 'r+') as f:
fcntl.flock(f, fcntl.LOCK_EX)
if os.path.exists('config.ini.bak'):
with open('config.ini.bak') as bak:
config.readfp(bak)
bak.seek(0)
f.truncate()
for line in bak:
f.write(line)
f.flush()
os.remove('config.ini.bak')
else:
config.readfp(f)
if not config.has_section('section x'):
config.add_section('section x')
with open('config.ini.bak', 'w') as bak:
config.write(bak)
f.seek(0)
f.truncate()
config.write(f)
f.flush()
os.remove('config.ini.bak')
這樣一來不管進程在執行到那一步的時候崩潰,都不影響數據的完整性和一致性了,把進程拉起來后重新執行就好。
果然之前做業務開發的時候一直秉承的「正確的語義帶來正確的代碼」,在我現在的開發工作中就行不通了呢。