最近公司有个项目,我需要写个小爬虫,将爬取到的数据进行统计分析。首先确定用 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 官方文档: https://docs.python.org/3/tutorial/modules.html

Python Modules and Packages – An Introduction: https://realpython.com/python-modules-packages/

Absolute vs Relative Imports in Python: https://realpython.com/absolute-vs-relative-python-imports/

How to import local modules with Python: https://fortierq.github.io/python-import/

Relative imports for the billionth time: https://stackoverflow.com/questions/14132789/relative-imports-for-the-billionth-time

一文搞懂 Python 的模块和包,在实战中的最佳实践的更多相关文章

  1. 一文搞懂Python迭代器和生成器

    很多童鞋搞不懂python迭代器和生成器到底是什么?它们之间又有什么样的关系? 这篇文章就是要用最简单的方式让你理解Python迭代器和生成器! 1.迭代器和迭代过程 维基百科解释道: 在Python ...

  2. 一文搞懂Python Unittest测试方法执行顺序

    大家好~我是米洛! 欢迎关注我的公众号测试开发坑货,一起交流!点赞收藏关注,不迷路. Unittest unittest大家应该都不陌生.它作为一款博主在5-6年前最常用的单元测试框架,现在正被pyt ...

  3. 一文搞懂Python可迭代、迭代器和生成器的概念

    关于我 一个有思想的程序猿,终身学习实践者,目前在一个创业团队任team lead,技术栈涉及Android.Python.Java和Go,这个也是我们团队的主要技术栈. Github:https:/ ...

  4. 一文搞懂Python中的所有数组数据类型

    关于我 一个有思想的程序猿,终身学习实践者,目前在一个创业团队任team lead,技术栈涉及Android.Python.Java和Go,这个也是我们团队的主要技术栈. Github:https:/ ...

  5. 一文搞懂Python函数(匿名函数、嵌套函数、闭包、装饰器)!

    Python函数定义.匿名函数.嵌套函数.闭包.装饰器 目录 Python函数定义.匿名函数.嵌套函数.闭包.装饰器 函数核心理解 1. 函数定义 2. 嵌套函数 2.1 作用 2.2 函数变量作用域 ...

  6. 三文搞懂学会Docker容器技术(下)

    接着上面一篇:三文搞懂学会Docker容器技术(上) 三文搞懂学会Docker容器技术(中) 7,Docker容器目录挂载 7.1 简介 容器目录挂载: 我们可以在创建容器的时候,将宿主机的目录与容器 ...

  7. 一文搞懂如何使用Node.js进行TCP网络通信

    摘要: 网络是通信互联的基础,Node.js提供了net.http.dgram等模块,分别用来实现TCP.HTTP.UDP的通信,本文主要对使用Node.js的TCP通信部份进行实践记录. 本文分享自 ...

  8. 一文搞懂指标采集利器 Telegraf

    作者| 姜闻名 来源|尔达 Erda 公众号 ​ 导读:为了让大家更好的了解 MSP 中 APM 系统的设计实现,我们决定编写一个<详聊微服务观测>系列文章,深入 APM 系统的产品.架构 ...

  9. 一文搞懂RAM、ROM、SDRAM、DRAM、DDR、flash等存储介质

    一文搞懂RAM.ROM.SDRAM.DRAM.DDR.flash等存储介质 存储介质基本分类:ROM和RAM RAM:随机访问存储器(Random Access Memory),易失性.是与CPU直接 ...

随机推荐

  1. c++ 快速乘

    First 在一些数学题中,两个数相乘运算很多,同时又很容易溢出,如两个 long long 相乘 今天本蒟蒻来总结一下快速乘的两种方法 1:二进制 和快速幂的原理一样,优化一个一个加的算法,复杂度\ ...

  2. 基于MybatisPlus代码生成器(2.0新版本)

    一.模块简介 1.功能亮点 实时读取库表结构元数据信息,比如表名.字段名.字段类型.注释等,选中修改后的表,点击一键生成,代码成即可提现出表结构的变化. 单表快速转化restful风格的API接口并对 ...

  3. 全新升级的AOP框架Dora.Interception[1]: 编程体验

    多年之前利用IL Emit写了一个名为Dora.Interception(github地址,觉得不错不妨给一颗星)的AOP框架.前几天利用Roslyn的Source Generator对自己为公司写的 ...

  4. 从一道算法题实现一个文本diff小工具

    众所周知,很多社区都是有内容审核机制的,除了第一次发布,后续的修改也需要审核,最粗暴的方式当然是从头再看一遍,但是编辑肯定想弄死你,显然这样效率比较低,比如就改了一个错别字,再看几遍可能也看不出来,所 ...

  5. python之多进程and多线程

    图文来自互联网 一.什么是进程和线程 (https://jq.qq.com/?_wv=1027&k=rX9CWKg4) 进程是分配资源的最小单位,线程是系统调度的最小单位. 当应用程序运行时最 ...

  6. 微服务追踪SQL(支持Isto管控下的gorm查询追踪)

    效果图 SQL的追踪正确插入到微服务的调用链之间 详细记录了SQL的执行内容和消耗时间 搜索SQL的类型 多线程(goroutine)下的追踪效果 在 Kubernetes 中部署微服务后,通过 Is ...

  7. 7.Spark SQL

    1.分析SparkSQL出现的原因,并简述SparkSQL的起源与发展. SparkSQL出现是因为关系数据库已经不能满足各种在大数据时代新增的用户需求.首先,用户需要在不同的结构化和非结构化数据中执 ...

  8. NC25025 [USACO 2007 Nov G]Sunscreen

    NC25025 [USACO 2007 Nov G]Sunscreen 题目 题目描述 To avoid unsightly burns while tanning, each of the \(C\ ...

  9. WCF全局捕获日志

    /// <summary> /// WCF服务端异常处理器 /// </summary> public class WCF_ExceptionHandler : IErrorH ...

  10. day03 对象流与序列化

    对象流 java.io.ObjectOutputStream和ObjectInputSteam 对象流是一对高级流,在流连接中的作用是进行对象的序列化与反序列化. 对象序列化:将一个java对象按照其 ...