正確配置Python應用程序
讓我們來討論一下如何配置Python應用程序,特別是那些可能存在于多個環境中的應用程序——開發環境、模擬環境、生產環境等等……
應用程序中使用的工具和框架并不是特別重要,因為我將在下面概述的方法是基于普通Python的。這種方法的出現是由于使用Django設置會令人懊惱,此外,這種方法還是我將要處理的任何Python應用程序的首選。
概述:Python模塊和包
我最喜歡的Python特性之一是,構成你應用程序的文件和目錄與你在代碼中導入和使用它們的方式是一一對應的。
例如,給定這個import語句:
我們可以推斷出以下目錄結構:
許多語言和框架都依賴于這個新概念,包括Clojure和ES6。
在我們的示例中,Python將utils目錄視為一個Package。當你在一個目錄中放置一個空的__init__.py時,該目錄就變成了一個包。
作為一名Python黑客,你可能會遇到這樣一個常見的場景,其中有一個utils.py文件最終會變得太大,因此你將它拆分成一個包含許多較小文件的utils//目錄。
當遇到這種情況時,我們可以做以下事情:
所以現在我們已經看到了一個Python包是由一個目錄中是否存在一個__init__.py文件決定的…但是如果這個文件不是空的呢?
在__init__.py中放入代碼
由于它只是一個普通的舊Python文件,你可以把任何你想要的東西放在其中,該文件會在第一次導入包時被執行。
>>>旁注
通常我們不贊成將代碼放在__init__.py中,因為它會在導入時帶來意想不到的副作用。
你可以自己測試一下。創建一個名為foo的目錄,并給它一個空的__init__.py文件。
從相同目錄中的Python REPL運行以下代碼:
這里沒有輸出是很好的,這意味著語句成功運行。
現在讓我們編輯我們的__init__.py文件以包含以下代碼:
sys.exit()通常用于使一個進程以特定的狀態退出。
在一個新的REPL中重新運行相同的實驗,你將觀察到你的Python shell在導入之后會立即退出。在更大的應用程序中,效果會更明顯:整個應用程序將退出。
這樣,我們了解了基本原理,并了解了如何惡意使用此功能。
也許我們可以用它來做好事?
多個環境&十二因素應用程序
你的應用程序可能存在于多個環境中。你的本地開發環境可能是第一個,并且你可能有一個位于Jenkins或另一個CI平臺上的測試環境。你的代碼被部署到一個生產或活動環境中。一些系統可能有一個模擬環境,在實際運行之前使用。
即使你只認為自己是一個業余愛好者,在本地開發代碼并將其部署到一個vps或類Heroku平臺上也意味著你要處理多個環境。
我在構建應用程序時遵循的一個規則是,我應該能夠將代碼庫部署到任何環境中(無需修改),前提是我們有辦法告訴系統它在哪里運行。
與此相比,為每個部署目標構建多個部件,需要額外的時間和復雜性來構建和保持。這些部件通常被設計為在單一目標環境中運行,因此在本地或測試模式中運行它們通常是困難的或不可能的。
著名的十二因素方法論也認同這一觀點,并且認為所有配置都應該作為環境變量存在。我在一定程度上同意這一點,但有時有一種趨勢,就是把所有東西都變成一個環境變量,很快就會變得難以支持。
如果你的系統的每個旋鈕和刻度盤都是一個環境變量,那么你將發現,你最終會將各種變量的組合存儲在某個地方,以便運行或調試。在這里看到問題了嗎?我們將配置從一個區域(代碼,通常保存在版本控制中)移出,并將它們移到更容易出現錯誤和人為錯誤的區域。
我用來劃定界限的一般準則:
- 不經常更改的靜態內容,或者顯著影響系統行為的內容應該存在于代碼中。
- 頻繁更改的動態內容或應該保密的內容(API鍵/憑據)應該存在于代碼之外。
我們如何切換環境?
為了讓應用程序在不同環境之間改變其行為,我們需要一種方法來告訴它,它正在哪里運行。依賴于環境變量(看到模式了嗎?),我傾向于使用ENV(或變體)來實現此目的。
- Ruby/Rails生態系統使用RACK_ENV或RAILS_ENV
- Javascript項目通常會利用NODE_ENV
>>>旁注
在改變底層框架或工具的運行時行為的標志與特定應用程序的操作模式的標志之間劃一條線是很重要的。例如,有時一個簡單的DEBUG=True/False并不夠好。
我最近為一個客戶完成一個帶有以下約定的項目:
- 我的本地開發環境沒有設置一個ENV變量,因此系統默認情況下會推斷開發環境。
- AWS CodePipeline上的測試環境使用ENV=test
- EC2上的生產環境使用ENV=production
注意:考慮不設置這個變量的后果是很重要的。這會是災難性的嗎?例如,應用程序能否在生產集群內部以DEV模式啟動,并最終向公眾顯示回溯信息?對于某些應用程序,默認設置應該是production。這里沒有正確或錯誤的答案,但它需要被考慮。
最終的目標
從開發者的角度來看,我們想要像這樣訪問我們的配置:
上面的導入行不包含任何能提示我們所處環境的內容。我們在任何地方都沒有看到development或production這樣的詞。相反,我們只導入了我們需要的,并允許配置系統來決定它來自何處。
我們利用文件系統和語言本身來提供一個用于讀取配置的API。
在幕后,這是config目錄在磁盤上的樣子:
- common.py包含我們所有的公共或共享配置。這些東西在不同的環境中并沒有太大的不同。你可以稱其為base或shared配置,如果你愿意。
- environments/development.py包含開發配置。該文件可以排除在版本控制之外,這樣團隊中的每個開發人員都可以實現自己的配置設置。
- environments/(production|staging).py包含每個環境特有的配置。
讓我們來看看common.py:
這是一個人為的例子,所以請不要太深入地了解細節。需要注意的是,這是一個相當靜態的配置,不會經常改變。
現在讓我們來看看environment/development.py:
- 我們首先導入common配置,以便在默認情況下繼承所有公共配置。現在我們可以添加、替換或增加參數,而不需要從父配置進行復制粘貼。
- 為了支持本地開發, 我可以自定義在我的環境中使用的AWS資源。系統的其余部分沒有改變,但是現在我的本地系統使用我自己的Dynamo表和S3 bucket。
- 因為該文件不在版本控制中,所以我可以放心地存儲機密信息,比如我自己的GOOGLE_CLIENT_ credentials。
- 因為可以訪問公共的DEFAULT_NAMESERVERS,所以我可以擴展它們,而不是復制粘貼任何公共值到我自己的配置中。
- 在生產環境中,systemd命令用于在響應某些管理操作時重新啟動應用程序。因為我的Mac沒有systemd,所以我用一個簡單的no-op替換了system reboot命令,從而完全避免了這個問題。
它是如何工作的
回到我們的config/__init__.py文件,我們可以在這里實現什么來實現它呢?其實很簡單:
我們正在利用import-time evaluation來動態地從相應的子環境中獲取必要的配置。讓我們一步一步來:
1. 首先,我們導入importlib模塊(文檔),它為我們提供了一些用代碼導入代碼的方便工具。
2. 使用我們建立的約定—ENV環境變量—我們獲取當前運行的環境的名稱。
3. 如果沒有設置環境,我們就選擇development作為默認設置,但是如前所述,這個決定將根據系統的不同而有所不同。
我們甚至可以考慮阻止應用程序啟動,除非定義了這個變量。下面是一個這樣的例子:
4. 接下來我們使用importlib.import_module函數將包含特定環境代碼的模塊加載到局部變量module中。
5. 最后,我們更新這個模塊的globals,將development.py文件中設置合并到其中。
6. 最終,你將看到一些便利的工具(a-la Rails),使基于環境切換具體的邏輯變得更加容易。它們作為函數保存,以便將實現隔離到此模塊,而不是隔離到使用它的任何地方。
這種方法深受Ruby on Rails配置的啟發,它實現了一個非常相似的外觀,只是底層實現有所不同。
一個真實的例子
為了提供另一個實際的例子,以下是本網站的配置:
首先,這是我的config目錄的確切目錄結構:
- development.py在本地使用
- production.py用于Heroku
- test.py用于帶有pytest的本地單元測試
base.py包含靜態配置:
- 一個在項目的其他地方使用的集中式日志格式。
- 通用目錄和一個使路徑相關的工作更容易的助手函數。
- 我的服務的時區。
- 當頁面不提供自己的標題時使用的默認標題。
在development.py中,站點標題會被覆蓋,這樣,當我在編輯的時候,我就知道我正在查看的是一個本地副本。我還定義了一些本地的Redis配置,它們與Production有很大的不同。
- SENTRY_DSN只在production.py中被定義,而沒有在base或其他環境中定義。這是為了防止Sentry(集中式錯誤日志)在開發或測試情況下被激活。
- 在Heroku上,Redis連接細節來自一個URL,因此我們在這里進行了配置。
最后,為了演示如何在應用程序的其他地方使用這個設置,我們來看看Redis連接是如何建立的:
注意最后一行:RedisManager.from_config()用于隔離關注點。RedisManager的其余部分不知道config中的數據是什么樣子的,也不需要知道。這是配置層和系統其余部分之間的一個切換點。
結論
我在所有的Python項目中都使用了這種方法,但還沒有發現這種方法(或其變體)不起作用的情況。
- 我們有創造無限數量環境的靈活性。例如,如果我們想為一個拉取請求啟動一個臨時環境:我們只需要使用“cp environments/staging.py environments/PR_402.py and ENV=PR_402”就可以了。
- 當在本地進行開發時,我們可以在生產模式下運行系統,方法是在它前面加上ENV=production,反之亦然,我們也可以在開發或測試模式下在其他任何地方運行軟件。
- 開發人員可以通過查看每個環境被覆蓋的配置來快速收集每個環境之間的主要差異。這使得將新的團隊成員加入到你的代碼庫中變得更加容易。
- 類似地,團隊中的每個開發人員都可以有自己獨特的配置。這不會過多地影響中心配置,因為你的系統有一些不同于其他系統的設置。
- 我們可以通過顯式地將environments/test.py 中的某些變量設置為None來保護我們的測試環境,以避免意外地訪問生產環境資源。
- 我們消除了在各種CLI工具(如Docker等等)之間傳遞較大鍵/值配置映射的負擔(盡管現在的工具越來越能夠從文件中讀取env)
- 我們將我們的配置公開為一個普通的Python包,因此與其他Python工具幾乎沒有學習曲線和互操作性問題。
- 我們避免了支持外部庫/依賴項所需要的成本。
總而言之,這種方法并不是很吸引人,而這正是我們在構建可靠、可維護和高效的系統時所想要的。使用一些簡單的舊Python和幾行特殊代碼,我們就已經在我們的系統配置中釋放了大量的靈活性和強大功能。