最近公司有个项目,我需要写个小爬虫,将爬取到的数据进行统计分析。首先确定用 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. MySQL数据库3

    内容概要 自增特性 约束条件之外键 外键简介 外键关系 外键SQL语句之一对多关系 外键SQL语句之多对多关系 外键SQL语句之一对一关系 查询关键字 数据准备 查询关键字之select与from 查 ...

  2. Kafka 的稳定性

    一.事务 1. 事务简介 1.1 事务场景 producer发的多条消息组成⼀个事务这些消息需要对consumer同时可⻅或者同时不可⻅ producer可能会给多个topic,多个partition ...

  3. 拒绝蛮力,高效查看Linux日志文件!

    原创:扣钉日记(微信公众号ID:codelogs),欢迎分享,转载请保留出处. 简介 日常分析问题时,会频繁地查看分析日志,但如果蛮力去查看日志,耗时费力还不一定有效果,因此我总结了在Linux常用的 ...

  4. MongoDB 的内存使用限制

    本文将简述一下MongoDB的内存限制问题 1. 使用Docker限制 当我们使用docker创建mongo 容器时,可通过使用以下参数,对mongo可以使用的资源进行限制 内存限制 参数 简介 -m ...

  5. application.yml 常用基本配置

    前言 在平时的项目开发中,自己对application.yml的配置的写法较为熟悉,现在自己就application.yml常用的配置进行总结如下: 1.Tomcat 配置 server: #设置请求 ...

  6. 从Mpx资源构建优化看splitChunks代码分割

    背景 MPX是滴滴出品的一款增强型小程序跨端框架,其核心是对原生小程序功能的增强.具体的使用不是本文讨论的范畴,想了解更多可以去官网了解更多. 回到正题,使用MPX开发小程序有一段时间了,该框架对不同 ...

  7. nginx转发rabbitmq

    第一种: 直接加个location块 location /rabbitmq/ { proxy_pass http://127.0.0.1:15672/; } 第二种: location /rabbit ...

  8. freeswitch拨打分机号源代码跟踪

    概述 freeswitch是一款非常好用的开源VOIP软交换平台. 之前我们有介绍过使用fs拨打分机号的方法,其中代码流程是比较复杂的,所以单独开一章介绍. fs拨打分机号,是使用send_dtmf接 ...

  9. Lambda表达式有参数有返回值的练习(自定义接口)和Lambda省略格式&Lambda使用前提

    给定一个计算器Calculator接口,内含抽象方法calc可以将两个int数字相加得到和值 使用L ambdo的标准格式调用invokeCalc方法,完成120和130的相加计算 public in ...

  10. 数学工具类Math

    概述 java.lang.Math 类包含用于执行基本数学运算的方法,如初等指数.对数.平方根和三角函数.类似这样的工具 类,其所有方法均为静态方法,并且不会创建对象,调用起来非常简单 基本运算的方法 ...