一文搞懂 Python 的模塊和包,在實戰中的最佳實踐

最近公司有個項目,我需要寫個小爬蟲,將爬取到的數據進行統計分析。首先確定用 Python 寫,其次不想用 Scrapy,因為要爬取的數據量和頻率都不高,沒必要上爬蟲框架。於是,就自己搭了一個項目,通過不同的文件目錄來組織代碼。然而,這就繞不過模塊和包,遇到了一些必踩的問題,一番研究之後,記錄如下。

我的項目結構

首先,我並不是一個經驗豐富的 Python 開發者,一般像我這樣水平的,要麼用框架,以其預置的代碼結構來管理代碼文件和邏輯;要麼,就是調包俠,將代碼寫在同一個或多個 .py 文件中,不用文件目錄組織,而是全部處於同一層級,這樣方便各自互相調用。

對於有點追求的人來說,不用框架,自己搭建代碼結構,當然希望代碼之間有着合理的關係和邏輯,而不是一股腦的丟在一塊兒,或更甚者,所有的業務邏輯全寫在一個代碼文件之中。

所以,我搭建了以下的代碼結構:

項目入口文件 main.py,負責所有爬蟲的調度。爬蟲的代碼,全都放入 spider 目錄,然後又分門別類的歸入其各自類別的子目錄:比如 live 目錄存放跟直播相關的爬蟲,realtime 目錄存放與實時統計相關的爬蟲。而 spider 目錄其下,還存在一些在爬蟲代碼中需要調用的自定義工具模塊文件:如 config.py 配置信息,db.py MySQL數據庫操作快捷函數 和 utils.py 常用函數。

下面是完整的目錄結構:

我希望我搭建的這個目錄結構,能夠按照預想的正常工作。然而,由於 Python 導包機制一套組合拳,讓我一度陷入了迷茫。

我遇到的第一個問題

首先,來看一下我的 main.py 主程序:

簡單介紹一下業務邏輯,就是從多個直播賬號中,去爬取數據,代碼示例中的 realtime.overview.crawl(account)live.overview.crawl(account) 就是分別從 實時統計 和 直播概覽 兩個不同頁面接口去爬取數據。

請關注這裡,realtimelive 兩個目錄,也就是 package 包,下面都含有 overview.py 模塊文件,如果我在導入模塊的時候,用下面這種方式,是會名稱衝突的:

from spider.realtime import overview
from spider.live import overview

後導入的會覆蓋前者。於是,就需要給它們各自加上別名:

from spider.realtime import overview as realtime_overview
from spider.live import overview as live_overview

好煩瑣,那不導到 overview 模塊這一級,而導到上一級各自的包,再用 包名.模塊名 的方式調用,不香么。

在設計之初,我就考慮到了模塊重名的問題,所以在 main.py 文件頭部,我並沒有 from 包 import 模塊,而是 from 包 import 包,以避免模塊命名衝突的問題。

想法是好的,但是很不幸,當我用 from spider import realtimespider 包導入 realtime 包時,運行卻報錯了:AttributeError: module 'spider.realtime' has no attribute 'overview'

基本概念

要解決上面的問題,需要先了解一些基本概念:什麼是模塊,什麼是包,包里的 __init__.py 又是幹什麼的,以及 import 導包究竟做了什麼事?

首先,模塊的定義非常簡單,一個 .py 文件其實就是一個 Python 模塊,你可以將不同的業務邏輯代碼,放在不同的模塊文件中,最後通過相互之間的導入,來聯合起來運行,形成一個整體的運行系統。

其次,雖然我們可以用模塊來隔離不同的業務代碼,但如果都一股腦兒的堆放在項目根目錄下,項目的結構就過於扁平了,看起來是又臭又長。為了把業務的隔離,做的更立體化,使得功能相關性的模塊聚在一起,就可以用文件夾,將模塊分門別類的存放其中,這些文件夾,就是 package 包。包其實也是一種特殊的模塊,你可以用 print(type(包名)) 打印出來看看,一定是 <class 'module'>

Python 3.3 版本以前,文件夾下必須要包含一個 __init__.py 文件,此文件夾才會被視為包,而 Python 3.3 版本之後,文件夾直接被視為包,無須顯式的包含 __init__.py 文件。

然而為了兼容性,和很多時候確實需要 __init__.py 文件,所以建議將此文件,始終新建放入要作為包的目錄中,這也是用 PyCharm 創建包的默認操作。

那麼 __init__.py 初始化文件,到底是幹什麼的。顧名思義,就是做初始化用的。你可以在此文件中,導入其他模塊,定義 變量函數 等,進行一些預定義的工作,然後在用 import 導入包或包里的模塊時,被導入的包下的初始化文件會被自動調用執行。

最後,import 導入究竟做了什麼事。從本質上來講,import 會把要導入的模塊或包,執行一遍,然後將裏面導入的其他模塊,和定義的 變量函數 等都保存在此模塊獨立的名稱空間中,並且被導入的模塊自身的名稱符號,也會加入引用者自己的名稱空間,這樣在導入後只需用 模塊名.符號名 的方式,來引用其中的變量、類或調用其中定義的函數,而不必擔心命名衝突的問題。

那如果,導入的不是模塊,而是一個包,比如 from spider import realtimespiderrealtime 都是文件夾,也就是包,那會執行什麼代碼呢?其實執行的是包里的 __init__.py 初始化代碼,而且這兩個包的初始化文件代碼,都會依次執行。

不論導入的是模塊,還是包,模塊代碼和包的初始化代碼,只會執行一次,後續無論再用 import 導入相同的模塊或包多少次,其初始化代碼均不會重複執行。

最後的最後,我知道可能有些人已經不耐煩了,原理性的東西,是有些煩瑣,馬上就完,暫且忍耐一下下。我們想看當前通過 import 已經導進來了哪些變量、函數、類、模塊或包,我們可以用 dir() 函數,來查看當前作用域內有哪些名稱符號。比如,修改上面報錯的代碼如下:

看下執行結果:

['__annotations__', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'live', 'realtime', 'spider']

前面一堆,是 Python 內置名稱符號,拉到最後,可以看到我的程序自己的名稱符號:live、realtime 和 spider,它們是通過 import 導進來的。

dir() 函數還可以傳入參數,來看傳入的對象的名稱符號。上面報錯信息說,我的 realtime 下沒有 overview 屬性,那我們就把 realtime 傳入 dir() 函數:dir(realtime),來看看其中有什麼:

['__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__path__', '__spec__']

一堆內置符號,果然沒有 overview。至此瞭然,上面的報錯:spider.realtime 下沒有 overview,也就不足為奇了,可怎麼解決?

解決第一個問題

既然 from spider import realtime 是從 spider 包導入 realtime 包,期間會依次執行各自的 __init__.py,我們只需在 realtime 包下的 __init__.py 文件中,導入需要的 overview 模塊,這樣 realtime 私有名稱空間中就有了 overview 名稱符號,我們就可以用 realtime.overview 來調用此模塊下面的函數了。

Let’s do it.

首先,在 realtime 目錄下的 __init__.py 文件加入代碼:from . import overview。這裡牽扯相對導入,後文再說。

然後,重新運行帶有 dir(realtime) 代碼的主程序,來看看名稱符號的輸出:

['__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__path__', '__spec__', 'overview']

與預期一致,多了 overview,最後,刪除測試代碼,重新運行主程序,不再報錯,正常運行了。

後面如法炮製,live 目錄下,也有兩個模塊文件:livelist.pyoverview.py,同樣需要在 __init__.py 文件中加入導入模塊的代碼:

from . import livelist
from . import overview

如此,我們便可以通過 包名.模塊名 的方式,來訪問其中的模塊了。

絕對導入與相對導入

我之前所用的 import 導入方式,除了在 __init__.py 中的是相對導入以外,其餘均是絕對導入。

當我在 spider/realtime/overview.py 文件中,寫爬蟲的實際業務邏輯代碼時,我又遇到了相對導入和絕對導入的問題。

先看一下爬蟲代碼:

最上方的 from spider import config 是從 spider 包導入 config 模塊,裏面存放了爬蟲爬取信息需要的登錄賬號和 HTTP HEADER 相關配置信息。此處用的是絕對導入。

當我從項目根目錄的 main.py 主程序運行時,一切正常。可是,通常情況下,對於每個自己寫的模塊,我們都希望能夠單獨運行它,進行局部的模塊測試,而無須依賴主程序。所以,在此模塊代碼的最下方,我寫了如下代碼:

if __name__ == '__main__':
    crawl(list(config.accounts.keys())[0])

稍微有點經驗的 Python 開發者,都知道這是幹什麼的。當某個模塊,以 script 腳本的方式運行時,其 __name__ 的值一定是 __main__ 字符串,所以可以用這個技巧,用來在此判斷分支中,寫模塊測試代碼,而不用擔心此模塊被 import 導入時,最下方的測試代碼也會被執行。

然而,當我想以腳本的形式,運行此模塊,進行測試的時候,卻又報錯了:ModuleNotFoundError: No module named 'spider'

這是因為 Python 腳本在運行時,會默認將腳本所在的當前目錄加入 sys.path 中,以便於在其中查找你要導入的模塊,而當我用 python spider/realtime/overview.py 以腳本的方式運行模塊時,此時 overview.py 所在的當前目錄為 xxx/spider/realtime,於是 Python 解釋器就會在 realtime 目錄及其子目錄下,去查找要導入的模塊。而 from spider import config 中的 config 模塊,很明顯位於 realtime 當前目錄的上一層 spider 中,而它卻不在 sys.path 的查找範圍中,所以自然報錯說:找不到 spider 模塊。

既然執行模塊腳本時,腳本程序無法以絕對導入的方式,引用父級目錄中的模塊,那麼我用相對導入的方式,是否可以解決?

於是,我將代碼調整為相對導入:from .. import config

–spider
–|–config.py
–|–realtime
—-|–overview.py

以當前模塊所在的包 realtime 為基準,從 .. 上級目錄 導入 config 模塊。看起來合情合理,運行一下看看。

首先,運行主程序 python main.py,一切正常。再以腳本的形式運行模塊 python spider/realtime/overview.py,報錯:ImportError: attempted relative import with no known parent package

經過一番搜索,查閱了一些文章,終於搞明白,原來在 Python 中,相對導入的實現,是極度依賴 __name__ 內置變量的。當模塊以 import 導入的方式加載調用時,其模塊的 __name__ 變量會含有包名和模塊名這些重要信息,以用於相對導入;而當模塊以腳本的方式直接運行時,其 __name__ 的值始終為 __main__ 字符串,則相對導入無法從中分析出父級包的信息,自然會報上面的錯誤:「嘗試從未知的父包中進行相對導入」,瞭然。

二者選其一,如何抉擇

絕對導入和相對導入都不能滿足我想要的效果:既支持從主程序執行,也支持單獨測試某個模塊。而現在,二者在不做任何特殊處理的情況下,均不支持單獨以腳本直接執行的方式,測試某個模塊。要如何解決?

解決方案有3種,前兩種針對絕對導入,最後一種針對相對導入。

  1. 使用 sys.path.append() 追加類庫搜索目錄【極不推薦】

    既然 sys.path 中不包含我們期望的路徑,那麼我們可以通過 sys.path.append(xxx) 手動的將要包含的路徑追加進去。比如:

    import sys
    sys.path.append('..') # 這裡可用相對路徑,也可用絕對路徑
    
    from spider import config
    

    此方案不再贅述,因為代碼醜陋,耦合過緊,兼容性和可移植性差,極不推薦。

  2. 設置 PYTHONPATH 環境變量 【推薦】

    在 Python 中,其實我們還可以通過設置 PYTHONPATH 環境變量的方式,來指定追加的類庫搜索目錄,底層原理等同於使用 sys.path.append(),但此方案非常簡潔,且 PyCharm 就是用這種方式,支持模塊直接以腳本方式運行,而又能使用絕對導入的。

    在 Windows 中,可以在命令行中使用 set PYTHONPATH=項目絕對路徑 命令,設置此環境變量。

    在 Linux 或 Mac 上,通過 export PYTHONPATH=項目絕對路徑 設置此環境變量。

    為了更省事,我在 virtualenv 的 bin 目錄的 activate 激活虛擬環境的 shell 腳本中,加入了 PYTHONPATH 環境變量設置的代碼,這樣,在用 source venv/bin/activate 激活虛擬環境後,PYTHONPATH 環境變量也就自動設置好了。Windows 下的同理。

  3. 使用 python -m xxx.xxx.模塊名 的運行方式,測試模塊【不推薦】

    在包中的模塊代碼,使用相對導入的方式,運行時不要採取 python xxx/xxx/xxx.py 腳本運行的方式,而是採取模塊運行的方式:python -m xxx.xxx.模塊名,前面的 xxx 是包名,這樣,模塊的 __name__ 值就會包含實際的包名和模塊名,可以讓相對導入正常工作。

    但是,此方案一是有違正常 Python 程序運行的習俗,二是在 PyCharm 中的某個模塊文件,直接右鍵運行時,是默認採取 python xxx/xxx/xxx.py 的方式執行的,所以此方案不推薦。

由此看來,我推薦的方式是,大多數情況下,總是以絕對導入的形式,來引用你項目的包和模塊。那相對導入就無用武之地了嗎?還記得上面的 __init__.py 么,那裡頭用的就是相對導入,因為我們永遠不會以腳本的方式直接運行 python xxx/__init__.py,所以,這裡頭的相對導入,永遠都是安全的。

並且,如果你正在寫一個類庫,寫完之後要發佈出去,分發給全世界的人去用,那麼你寫的這個工具包裡頭的代碼,都要使用相對導入來引用本地的包和模塊。

而通常情況下,我們自己寫的包和模塊,僅僅在本項目內使用,完全可以藉助於 PYTHONPATH 環境變量,使用絕對導入來引用本地任意模塊,使用相對導入在 __init__.py 中引用包中的模塊。

小彩蛋

上文提到,import 的過程,實際上就是把要導入的包和模塊的名稱,加入 Python 的符號表中,也就是官方文檔上說的 namespace【名稱空間】,並且用 Python 內置的 dir() 函數,可以打印當前的作用域中,加載了哪些名稱符號。

而我在使用 pymsql 第三方包時,看到其官方文檔上的示例代碼,感到有些迷惑:

我原先的錯誤認知是,import pymysql.cursors ,我就只能引用 pymysql.cursors,而如果想再引用上一級 pymysql,則需像下面這樣:

import pymysql
import pymysql.cursors

但看了 pymsql 的示例代碼後,我經過了一番認真的思索和測試,領悟到,原來 import pymysql.cursors 僅僅是先將 pymysql 這個名稱符號,加入到當前正在運行的模塊的名稱空間內,再將 cursors 加入 pymysql 的私有名稱空間內,用 dir()dir(pymysql) 分別打印當前運行的模塊和 pymysql 包的名稱符號列表後,可以看的很清楚,而有了 pymysql 的名稱符號,自然可以在其私有的名稱空間下,繼續引用 pymysql.cursors,繼而在 pymysql.cursors 模塊下,再繼續引用 pymysql.cursors.DictCursor

但當你換了一種導入方式後,則完全不同了:from pymsql import cursors,這隻會將 cursors 加入當前符號表,只能引用 cursors,而 pymysql 不在當前模塊的名稱空間內,所以無法直接引用,比如:pymysql.connect(...) 的調用,就會報錯:NameError: name 'pymysql' is not defined

總結

最後吐槽一下,Python 的模塊和包的導入機制,確實讓人迷惑,這在我查閱資料的時候,看到好多國外開發者都吐槽過。並且它支持導入包、模塊、變量、函數、類等,在使用一些第三方類庫的包和模塊時,參考它們的官方文檔寫代碼,你壓根就不知道,你導進來的到底是個什麼東西,讓人心裏很沒底。在這一點上,Java 就很清晰,它導進來的,一定是類。

本文以我正在實際開發的一個小爬蟲項目為背景,講述了項目搭建從鴻蒙初開到迷霧散盡的整個心路歷程,期間由於自己在 Python 上的儲備不夠,又翻閱了大量的網上資料,潛心研究、領悟,最後融會貫通,寫就此文。

此項目看似麻雀雖小,但五臟俱全,在模塊和包的整體工作機制上,各個原理、特性和缺陷均有體現,是 Python 開發者繞不過去的一道坎。

希望此文做到了深入淺出,不同層次的 Python 開發者都可以從中有所收穫,如果這篇文章對你有幫助,請不吝給作者點個推薦,也不枉我嘔心瀝血成此長文。

參考資料:

Python 官方文檔: //docs.python.org/3/tutorial/modules.html
Python Modules and Packages – An Introduction: //realpython.com/python-modules-packages/
Absolute vs Relative Imports in Python: //realpython.com/absolute-vs-relative-python-imports/
How to import local modules with Python: //fortierq.github.io/python-import/
Relative imports for the billionth time: //stackoverflow.com/questions/14132789/relative-imports-for-the-billionth-time

Tags: