Kaito's Blog

致力成为一枚silver bullet.

0%

在上一篇文章Python进阶——如何正确使用魔法方法?(上)中,我们主要介绍了关于构造与初始化、类的表示、访问控制这几类的魔法方法,以及它们的使用场景。

这篇文章,我们继续介绍剩下的魔法方法,主要包括:比较操作、容器类操作、可调用对象、序列化。

比较操作

比较操作的魔法方法主要包括以下几种:

  • __cmp__
  • __eq__
  • __ne__
  • __lt__
  • __gt__

__cmp__

从名字我们就能看出来这个魔法方法的作用,当我们需要比较两个对象时,我们可以定义 __cmp__ 来实现比较操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Person(object):
def __init__(self, uid):
self.uid = uid

def __cmp__(self, other):
if self.uid == other.uid:
return 0
if self.uid > other.uid:
return 1
return -1

p1 = Person(1)
p2 = Person(2)
print p1 > p2 # False
print p1 < p2 # True
print p1 == p2 # False

从例子中我们可以看到,比较两个对象的具体逻辑:

  • 如果 __cmp__ 返回大于 0 的整数(一般为1),说明 self > other
  • 如果 __cmp__ 返回大于 0 的整数(一般为-1),说明 self < other
  • 如果 __cmp__ 返回 0,说明 self == other

当然,这种比较方式有一定的局限性,如果我有 N 个属性,当比较谁大时,我们想用属性 A 来比较。当比较谁小时,我们想用属性 B 来比较,此时 __cmp__ 就无法很好地实现这个逻辑了,所以它只适用于通用的比较逻辑。

那如何实现复杂的比较逻辑?

这就需要用到 __eq____ne____lt____gt__ 这些魔法方法了,我们看下面这个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# coding: utf8

class Person(object):

def __init__(self, uid, name, salary):
self.uid = uid
self.name = name
self.salary = salary

def __eq__(self, other):
"""对象 == 判断"""
return self.uid == other.uid

def __ne__(self, other):
"""对象 != 判断"""
return self.uid != other.uid

def __lt__(self, other):
"""对象 < 判断 根据len(name)"""
return len(self.name) < len(other.name)

def __gt__(self, other):
"""对象 > 判断 根据alary"""
return self.salary > other.salary


p1 = Person(1, 'zhangsan', 1000)
p2 = Person(1, 'lisi', 2000)
p3 = Person(1, 'wangwu', 3000)

print p1 == p1 # uid 是否相同
print p1 != p2 # uid 是否不同
print p2 < p3 # name 长度比较
print p3 > p2 # salary 比较

__eq__

__eq__ 我们在上一篇文章已经介绍过,它配合 __hash__ 方法,可以判断两个对象是否相等。

但在这个例子中,当判断两个对象是否相等时,实际上我们比较的是 uid 这个属性。

__ne__

同样地,当需要判断两个对象不相等时,会调用 __ne__ 方法,在这个例子中,我们也是根据 uid 来判断的。

__lt__

当判断一个对象是否小于另一个对象时,会调用 __lt__ 方法,在这个例子中,我们根据 name 的长度来做的比较。

__gt__

同样地,在判断一个对象是否大于另一个对象时,会调用 __gt__ 方法,在这个例子中,我们根据 salary 属性判断。

在 Python3 中,__cmp__被取消了,因为它和其他魔法方法存在功能上的重复。

阅读全文 »

在做 Python 开发时,我们经常会遇到以双下划线开头和结尾的方法,例如 __init____new____getattr____setitem__ 等等,这些方法我们通常称之为「魔法方法」,而使用这些「魔法方法」,我们可以非常方便地给类添加特殊的功能。

这篇文章,我们就来分析一下,Python 中的魔法方法都有哪些?使用这些魔法方法,我们可以实现哪些实用的功能?

魔法方法概览

首先,我们先对 Python 中的魔法方法进行归类,常见的魔法方法大致可分为以下几类:

  • 构造与初始化
  • 类的表示
  • 访问控制
  • 比较操作
  • 容器类操作
  • 可调用对象
  • 序列化

由于魔法方法分类较多,这篇文章我们先来看前几个:构造与初始化、类的表示、访问控制。剩下的魔法方法,我们会在下一篇文章进行分析讲解。

构造与初始化

首先,我们来看关于构造与初始化相关的魔法方法,主要包括以下几种:

  • __init__
  • __new__
  • __del__

__init__

关于构造与初始化的魔法方法,我们使用最频繁的一个就是 __init__ 了。

我们在定义类的时候,通常都会去定义构造方法,它的作用就是在初始化一个对象时,定义这个对象的初始值。

1
2
3
4
5
6
7
8
9
10
# coding: utf8

class Person(object):

def __init__(self, name, age):
self.name = name
self.age = age

p1 = Person('张三', 25)
p2 = Person('李四', 30)

__new__

在初始化一个类的属性时,除了使用 __init__ 之外,还可以使用 __new__ 这个方法。

我们在平时开发中使用的虽然不多,但是经常能够在开源框架中看到它的身影。实际上,这才是「真正的构造方法」。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# coding: utf8

class Person(object):

def __new__(cls, *args, **kwargs):
print "call __new__"
return object.__new__(cls, *args, **kwargs)

def __init__(self, name, age):
print "call __init__"
self.name = name
self.age = age

p = Person("张三", 20)

# Output:
# call __new__
# call __init__

从例子我们可以看到,__new__ 会在对象实例化时第一个被调用,然后才会调用 __init__,它们的区别如下:

  • __new__ 的第一个参数是 cls,而 __init__ 的第一个参数是 self
  • __new__ 返回值是一个实例对象,而 __init__ 没有任何返回值,只做初始化操作
  • __new__ 由于返回的是一个实例对象,所以它可以给所有实例进行统一的初始化操作

了解了它们之间的区别,我们来看 __new__ 在什么场景下使用?

由于 __new__ 优先于 __init__ 调用,而且它返回的是一个实例,所以我们可以利用这个特性,在 __new__ 方法中,每次返回同一个实例来实现一个单例类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# coding: utf8

class Singleton(object):
"""单例"""
_instance = None
def __new__(cls, *args, **kwargs):
if not cls._instance:
cls._instance = super(Singleton, cls).__new__(cls, *args, **kwargs)
return cls._instance

class MySingleton(Singleton):
pass

a = MySingleton()
b = MySingleton()

assert a is b # True

另外一个使用场景是,当我们需要继承内置类时,例如想要继承 intstrtuple,就无法使用 __init__ 来初始化了,只能通过 __new__ 来初始化数据:

1
2
3
4
5
6
7
8
9
10
# coding: utf8

class g(float):
"""千克转克"""
def __new__(cls, kg):
return float.__new__(cls, kg * 2)

a = g(50) # 50千克转为克
print a # 100
print a + 100 # 200 由于继承了float,所以可以直接运算,非常方便!

在这个例子中,我们实现了一个类,这个类继承了 float,之后,我们就可以对这个类的实例进行计算了,是不是很神奇?

除此之外,__new__ 比较多的应用场景是配合「元类」使用,关于「元类」的原理,我会在后面的文章中讲到。

阅读全文 »

在 Python 开发中,我们经常会看到使用装饰器的场景,例如日志记录、权限校验、本地缓存等等。

使用这些装饰器,给我们的开发带来了极大的便利,那么一个装饰器是如何实现的呢?

这篇文章我们就来分析一下,Python 装饰器的使用及原理。

一切皆对象

在介绍装饰器前,我们需要理解一个概念:在 Python 开发中,一切皆对象

什么意思呢?

就是我们在开发中,无论是定义的变量(数字、字符串、元组、列表、字典)、还是方法、类、实例、模块,这些都可以称作对象

怎么理解呢?在 Python 中,所有的对象都会有属性和方法,也就是说可以通过「.」去获取它的属性或调用它的方法,例如像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# coding: utf8

i = 10 # int对象
print id(i), type(i)
# 140703267064136, <type 'int'>

s = 'hello' # str对象
print id(s), type(s), s.index('o')
# 4308437920, <type 'str'>, 4

d = {'k': 10} # dict对象
print id(d), type(d), d.get('k')
# 4308446016, <type 'dict'>, 10

def hello(): # function对象
print 'Hello World'
print id(hello), type(hello), hello.func_name, hello()
# 4308430192, <type 'function'>, hello, Hello World

hello2 = hello # 传递对象
print id(hello2), type(hello2), hello2.func_name, hello2()
# 4308430192, <type 'function'>, hello, Hello World

# 构建一个类
class Person(object):

def __init__(self, name):
self.name = name

def say(self):
return 'I am %s' % self.name

print id(Person), type(Person), Person.say
# 140703269140528, <type 'type'>, <unbound method Person.say>

person = Person('tom') # 实例化一个对象
print id(person), type(person),
# 4389020560, <class '__main__.Person'>
print person.name, person.say, person.say()
# tom, <bound method Person.say of <__main__.Person object at 0x1059b2390>>, I am tom

我们可以看到,常见的这些类型:intstrdictfunction,甚至 classinstance 都可以调用 idtype 获得对象的唯一标识和类型。

例如方法的类型是 function,类的类型是 type,并且这些对象都是可传递的。

对象可传递会带来什么好处呢?

这么做的好处就是,我们可以实现一个「闭包」,而「闭包」就是实现一个装饰器的基础。

闭包

假设我们现在想统计一个方法的执行时间,通常实现的逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# coding: utf8

import time

def hello():
start = time.time() # 开始时间
time.sleep(1) # 模拟执行耗时
print 'hello'
end = time.time() # 结束时间
print 'duration time: %ds' % int(end - start) # 计算耗时

hello()

# Output:
# hello
# duration time: 1s

统计一个方法执行时间的逻辑很简单,只需要在调用这个方法的前后,增加时间的记录就可以了。

但是,统计这一个方法的执行时间这么写一次还好,如果我们想统计任意一个方法的执行时间,每个方法都这么写,就会有大量的重复代码,而且不宜维护。

如何解决?这时我们通常会想到,可以把这个逻辑抽离出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# coding: utf8

import time

def timeit(func): # 计算方法耗时的通用方法
start = time.time()
func() # 执行方法
end = time.time()
print 'duration time: %ds' % int(end - start)

def hello():
time.sleep(1)
print 'hello'

timeit(hello) # 调用执行

这里我们定义了一个 timeit 方法,而参数传入一个方法对象,在执行完真正的方法逻辑后,计算其运行时间。

这样,如果我们想计算哪个方法的执行时间,都按照此方式调用即可。

1
2
timeit(func1)   # 计算func1执行时间
timeit(func2) # 计算func2执行时间

虽然此方式可以满足我们的需求,但有没有觉得,本来我们想要执行的是 hello 方法,现在执行都需要使用 timeit 然后传入 hello 才能达到要求,有没有一种方式,既可以给原来的方法加上计算时间的逻辑,还能像调用原方法一样使用呢?

答案当然是可以的,我们对 timeit 进行改造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# coding: utf8

import time

def timeit(func):
def inner():
start = time.time()
func()
end = time.time()
print 'duration time: %ds' % int(end - start)
return inner

def hello():
time.sleep(1)
print 'hello'

hello = timeit(hello) # 重新定义hello
hello() # 像调用原始方法一样使用

请注意观察 timeit 的变动,它在内部定义了一个 inner 方法,此方法内部的实现与之前类似,但是,timeit 最终返回的不是一个值,而是 inner 对象。

所以当我们调用 hello = timeit(hello) 时,会得到一个方法对象,那么变量 hello 其实是 inner,在执行 hello() 时,真正执行的是 inner 方法。

我们对 hello 方法进行了重新定义,这么一来,hello 不仅保留了其原有的逻辑,而且还增加了计算方法执行耗时的新功能。

回过头来,我们分析一下 timeit 这个方法是如何运行的?

在 Python 中允许在一个方法中嵌套另一个方法,这种特殊的机制就叫做「闭包」,这个内部方法可以保留外部方法的作用域,尽管外部方法不是全局的,内部方法也可以访问到外部方法的参数和变量。

装饰器

明白了闭包的工作机制后,那么实现一个装饰器就变得非常简单了。

Python 支持一种装饰器语法糖「@」,使用这个语法糖,我们也可以实现与上面完全相同的功能:

1
2
3
4
5
6
7
8
# coding: utf8

@timeit # 相当于 hello = timeit(hello)
def hello():
time.sleep(1)
print 'hello'

hello() # 直接调用原方法即可

看到这里,是不是觉得很简单?

这里的 @timeit 其实就等价于 hello = timeit(hello)

装饰器本质上就是实现一个闭包,把一个方法对象当做参数,传入到另一个方法中,然后这个方法返回了一个增强功能的方法对象。

这就是装饰器的核心,平时我们开发中常见的装饰器,无非就是这种形式的变形而已。

阅读全文 »

之前买的科学上网服务从不稳定到不可用,也没有通知一声,貌似跑路了,太没有职业道德,这年头只想稳定的上网真的这么难么?

还好,之前买过一台VPS,本想业余时间开发一些小东西,目前只部署了个爬虫在上面跑,这次先用它搭个梯子代理,至少自己动手,比别人的要稳定吧!

原理

首先说一下shadowsocks的工作原理,正常情况下,如果你想访问外面的世界,由于有GFW的存在,直连是访问不了的,但如果你有一台外面世界的VPS,而你可以直连到这台VPS上,那么就可以通过这台VPS搭一条通道,也就是所谓的梯子,来让我们间接的连通外面的世界。

说白了就是在这台VPS上自己搭建一个服务,可以使我们的请求数据包经过这个服务转发出去,然后将响应数据包再通过这个服务正常返回给我们,但是由于我们上网都会经过GFW,它会验证我们的数据包,如果是被屏蔽的域名或IP,则会拒绝访问。怎样才能跨越屏障呢?

那就是在发送请求数据给代理服务时,先进行加密,这样就能跨越GFW到达代理服务,代理服务再通过同样的算法解密数据包,然后转发到我们请求的网站,之后得到响应后再通过先加密后解密的方式返回到我们本机,这样就能实现与外部连通。所以我们需要客户端服务端配合来完成,大致流程如下:

明白了原理之后,我们怎样搭建这样一个服务呢?

shadowsocks就已经实现了这些东西,我们只需要经过配置就可以轻松完成这个服务。

阅读全文 »

背景

在后端服务中,经常有这样一种场景,写数据库操作在异步队列中执行,且这个异步队列是多进程运行的,这时如果对同一资源进行写库操作,很有可能产生数据被覆盖等问题,于是就需要业务层在更新数据库之前进行加锁,这样保证在更改同一资源时,没有其他更新操作干涉,保证数据一致性。

但如果在更新前对数据库更新加锁,那此时又来了新的更新数据库的请求,但这个更新操作不能丢弃掉,需要延迟执行,那这就需要添加到延迟队列中,延迟执行。

那么如何实现一个延迟队列?利用RedisSortedSetString这两种结构,就可以轻松实现。

具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# coding: utf8

"""Delay Queue"""

import json
import time
import uuid

import redis


class DelayQueue(object):

"""延迟队列"""

QUEUE_KEY = 'delay_queue'
DATA_PREFIX = 'queue_data'

def __init__(self, conf):
host, port, db = conf['host'], conf['port'], conf['db']
self.client = redis.Redis(host=host, port=port, db=db)

def push(self, data):
"""push

:param data: data
"""
# 唯一ID
task_id = str(uuid.uuid4())
data_key = '{}_{}'.format(self.DATA_PREFIX, task_id)
# save string
self.client.set(data_key, json.dumps(data))
# add zset(queue_key=>data_key,ts)
self.client.zadd(self.QUEUE_KEY, data_key, int(time.time()))

def pop(self, num=5, previous=3):
"""pop多条数据

:param num: pop多少个
:param previous: 获取多少秒前push的数据
"""
# 取出previous秒之前push的数据
until_ts = int(time.time()) - previous
task_ids = self.client.zrangebyscore(
self.QUEUE_KEY, 0, until_ts, start=0, num=num)
if not task_ids:
return []

# 利用删除的原子性,防止并发获取重复数据
pipe = self.client.pipeline()
for task_id in task_ids:
pipe.zrem(self.QUEUE_KEY, task_id)
data_keys = [
data_key
for data_key, flag in zip(task_ids, pipe.execute())
if flag
]
if not data_keys:
return []
# load data
data = [
json.loads(item)
for item in self.client.mget(data_keys)
]
# delete string key
self.client.delete(*data_keys)
return data
阅读全文 »

上一篇文章:Scrapy源码分析(三)核心组件初始化,我们已经分析了 Scrapy 核心组件的主要职责,以及它们在初始化时都完成了哪些工作。

这篇文章就让我们来看一下,也是 Scrapy 最核心的抓取流程是如何运行的,它是如何调度各个组件,完成整个抓取工作的。

运行入口

还是回到最初的入口,在Scrapy源码分析(二)运行入口这篇文章中我们已经详细分析过了,在执行 Scrapy 命令时,主要经过以下几步:

  • 调用 cmdline.pyexecute 方法
  • 找到对应的 命令实例 解析命令行
  • 构建 CrawlerProcess 实例,调用 crawlstart 方法开始抓取

crawl 方法最终是调用了 Cralwer 实例的 crawl,这个方法最终把控制权交给了Engine,而 start 方法注册好协程池,就开始异步调度执行了。

我们来看 Cralwercrawl 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@defer.inlineCallbacks
def crawl(self, *args, **kwargs):
assert not self.crawling, "Crawling already taking place"
self.crawling = True
try:
# 创建爬虫实例
self.spider = self._create_spider(*args, **kwargs)
# 创建引擎
self.engine = self._create_engine()
# 调用spider的start_requests 获取种子URL
start_requests = iter(self.spider.start_requests())
# 调用engine的open_spider 交由引擎调度
yield self.engine.open_spider(self.spider, start_requests)
yield defer.maybeDeferred(self.engine.start)
except Exception:
if six.PY2:
exc_info = sys.exc_info()
self.crawling = False
if self.engine is not None:
yield self.engine.close()
if six.PY2:
six.reraise(*exc_info)
raise

这里首先会创建出爬虫实例,然后创建引擎,之后调用了 spiderstart_requests 方法,这个方法就是我们平时写的最多爬虫类的父类,它在 spiders/__init__.py 中定义:

1
2
3
4
5
6
7
8
def start_requests(self):
# 根据定义好的start_urls属性 生成种子URL对象
for url in self.start_urls:
yield self.make_requests_from_url(url)

def make_requests_from_url(self, url):
# 构建Request对象
return Request(url, dont_filter=True)

构建请求

通过上面这段代码,我们能看到,平时我们必须要定义的 start_urls 属性,原来就是在这里用来构建 Request 的,来看 Request 的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Request(object_ref):

def __init__(self, url, callback=None, method='GET', headers=None, body=None,
cookies=None, meta=None, encoding='utf-8', priority=0,
dont_filter=False, errback=None):
# 编码
self._encoding = encoding
# 请求方法
self.method = str(method).upper()
# 设置url
self._set_url(url)
# 设置body
self._set_body(body)
assert isinstance(priority, int), "Request priority not an integer: %r" % priority
# 优先级
self.priority = priority
assert callback or not errback, "Cannot use errback without a callback"
# 回调函数
self.callback = callback
# 异常回调函数
self.errback = errback
# cookies
self.cookies = cookies or {}
# 构建Header
self.headers = Headers(headers or {}, encoding=encoding)
# 是否需要过滤
self.dont_filter = dont_filter
# 附加信息
self._meta = dict(meta) if meta else None

Request 对象比较简单,就是封装了请求参数、请求方法、回调以及可附加的属性信息。

当然,你也可以在子类中重写 start_requestsmake_requests_from_url 这 2 个方法,用来自定义逻辑构建种子请求。

引擎调度

再回到 crawl 方法,构建好种子请求对象后,调用了 engineopen_spider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@defer.inlineCallbacks
def open_spider(self, spider, start_requests=(), close_if_idle=True):
assert self.has_capacity(), "No free spider slot when opening %r" % \
spider.name
logger.info("Spider opened", extra={'spider': spider})
# 注册_next_request调度方法 循环调度
nextcall = CallLaterOnce(self._next_request, spider)
# 初始化scheduler
scheduler = self.scheduler_cls.from_crawler(self.crawler)
# 调用爬虫中间件 处理种子请求
start_requests = yield self.scraper.spidermw.process_start_requests(start_requests, spider)
# 封装Slot对象
slot = Slot(start_requests, close_if_idle, nextcall, scheduler)
self.slot = slot
self.spider = spider
# 调用scheduler的open
yield scheduler.open(spider)
# 调用scrapyer的open
yield self.scraper.open_spider(spider)
# 调用stats的open
self.crawler.stats.open_spider(spider)
yield self.signals.send_catch_log_deferred(signals.spider_opened, spider=spider)
# 发起调度
slot.nextcall.schedule()
slot.heartbeat.start(5)

在这里首先构建了一个 CallLaterOnce,之后把 _next_request 方法注册了进去,看此类的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class CallLaterOnce(object):
# 在twisted的reactor中循环调度一个方法
def __init__(self, func, *a, **kw):
self._func = func
self._a = a
self._kw = kw
self._call = None

def schedule(self, delay=0):
# 上次发起调度 才可再次继续调度
if self._call is None:
# 注册self到callLater中
self._call = reactor.callLater(delay, self)

def cancel(self):
if self._call:
self._call.cancel()

def __call__(self):
# 上面注册的是self 所以会执行__call__
self._call = None
return self._func(*self._a, **self._kw)

这里封装了循环执行的方法类,并且注册的方法会在 twistedreactor 中异步执行,以后执行只需调用 schedule,就会注册 selfreactorcallLater 中,然后它会执行 __call__ 方法,最终执行的就是我们注册的方法。

而这里我们注册的方法就是引擎的 _next_request,也就是说,此方法会循环调度,直到程序退出。

之后调用了爬虫中间件的 process_start_requests 方法,你可以定义多个自己的爬虫中间件,每个类都重写此方法,爬虫在调度之前会分别调用你定义好的爬虫中间件,来处理初始化请求,你可以进行过滤、加工、筛选以及你想做的任何逻辑。

这样做的好处就是,把想做的逻辑拆分成多个中间件,每个中间件功能独立,而且维护起来更加清晰。

阅读全文 »

在上一篇文章:Scrapy源码分析(二)运行入口,我们主要剖析了 Scrapy 是如何运行起来的核心逻辑,也就是在真正执行抓取任务之前,Scrapy 都做了哪些工作。

这篇文章,我们就来进一步剖析一下,Scrapy 有哪些核心组件?以及它们主要负责了哪些工作?这些组件为了完成这些功能,内部又是如何实现的。

爬虫类

我们接着上一篇结束的地方开始讲起。上次讲到 Scrapy 运行起来后,执行到最后到了 Crawlercrawl 方法,我们来看这个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@defer.inlineCallbacks
def crawl(self, *args, **kwargs):
assert not self.crawling, "Crawling already taking place"
self.crawling = True
try:
# 从spiderloader中找到爬虫类 并实例化爬虫实例
self.spider = self._create_spider(*args, **kwargs)
# 创建引擎
self.engine = self._create_engine()
# 调用爬虫类的start_requests方法 拿到种子URL列表
start_requests = iter(self.spider.start_requests())
# 执行引擎的open_spider 并传入爬虫实例和初始请求
yield self.engine.open_spider(self.spider, start_requests)
yield defer.maybeDeferred(self.engine.start)
except Exception:
if six.PY2:
exc_info = sys.exc_info()
self.crawling = False
if self.engine is not None:
yield self.engine.close()
if six.PY2:
six.reraise(*exc_info)
raise

执行到这里,我们看到首先创建了爬虫实例,然后创建了引擎,最后把爬虫交给引擎来处理了。

在上一篇文章我们也讲到,在 Crawler 实例化时,会创建 SpiderLoader,它会根据我们定义的配置文件 settings.py 找到存放爬虫的位置,我们写的爬虫代码都在这里。

然后 SpiderLoader 会扫描这些代码文件,并找到父类是 scrapy.Spider 爬虫类,然后根据爬虫类中的 name 属性(在编写爬虫时,这个属性是必填的),生成一个 {spider_name: spider_cls} 的字典,最后根据 scrapy crawl <spider_name> 命令中的 spider_name 找到我们写的爬虫类,然后实例化它,在这里就是调用了_create_spider方法:

1
2
3
def _create_spider(self, *args, **kwargs):
# 调用类方法from_crawler实例化
return self.spidercls.from_crawler(self, *args, **kwargs)

实例化爬虫比较有意思,它不是通过普通的构造方法进行初始化,而是调用了类方法 from_crawler 进行的初始化,找到 scrapy.Spider 类:

1
2
3
4
5
6
7
8
9
10
11
@classmethod
def from_crawler(cls, crawler, *args, **kwargs):
spider = cls(*args, **kwargs)
spider._set_crawler(crawler)
return spider

def _set_crawler(self, crawler):
self.crawler = crawler
# 把settings对象赋给spider实例
self.settings = crawler.settings
crawler.signals.connect(self.close, signals.spider_closed)

在这里我们可以看到,这个类方法其实也是调用了构造方法,进行实例化,同时也拿到了 settings 配置,来看构造方法干了些什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Spider(object_ref):
name = None
custom_settings = None

def __init__(self, name=None, **kwargs):
# name必填
if name is not None:
self.name = name
elif not getattr(self, 'name', None):
raise ValueError("%s must have a name" % type(self).__name__)
self.__dict__.update(kwargs)
# 如果没有设置start_urls 默认是[]
if not hasattr(self, 'start_urls'):
self.start_urls = []

看到这里是不是很熟悉?这里就是我们平时编写爬虫类时,最常用的几个属性:namestart_urlscustom_settings

  • name:在运行爬虫时通过它找到我们编写的爬虫类;
  • start_urls:抓取入口,也可以叫做种子URL;
  • custom_settings:爬虫自定义配置,会覆盖配置文件中的配置项;

引擎

分析完爬虫类的初始化后,还是回到 Crawlercrawl 方法,紧接着就是创建引擎对象,也就是 _create_engine 方法,看看初始化时都发生了什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ExecutionEngine(object):
"""引擎"""
def __init__(self, crawler, spider_closed_callback):
self.crawler = crawler
# 这里也把settings配置保存到引擎中
self.settings = crawler.settings
# 信号
self.signals = crawler.signals
# 日志格式
self.logformatter = crawler.logformatter
self.slot = None
self.spider = None
self.running = False
self.paused = False
# 从settings中找到Scheduler调度器,找到Scheduler类
self.scheduler_cls = load_object(self.settings['SCHEDULER'])
# 同样,找到Downloader下载器类
downloader_cls = load_object(self.settings['DOWNLOADER'])
# 实例化Downloader
self.downloader = downloader_cls(crawler)
# 实例化Scraper 它是引擎连接爬虫类的桥梁
self.scraper = Scraper(crawler)
self._spider_closed_callback = spider_closed_callback

在这里我们能看到,主要是对其他几个核心组件进行定义和初始化,主要包括包括:SchedulerDownloaderScrapyer,其中 Scheduler 只进行了类定义,没有实例化。

也就是说,引擎是整个 Scrapy 的核心大脑,它负责管理和调度这些组件,让这些组件更好地协调工作。

下面我们依次来看这几个核心组件都是如何初始化的?

调度器

调度器初始化发生在引擎的 open_spider 方法中,我们提前来看一下调度器的初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Scheduler(object):
"""调度器"""
def __init__(self, dupefilter, jobdir=None, dqclass=None, mqclass=None,
logunser=False, stats=None, pqclass=None):
# 指纹过滤器
self.df = dupefilter
# 任务队列文件夹
self.dqdir = self._dqdir(jobdir)
# 优先级任务队列类
self.pqclass = pqclass
# 磁盘任务队列类
self.dqclass = dqclass
# 内存任务队列类
self.mqclass = mqclass
# 日志是否序列化
self.logunser = logunser
self.stats = stats

@classmethod
def from_crawler(cls, crawler):
settings = crawler.settings
# 从配置文件中获取指纹过滤器类
dupefilter_cls = load_object(settings['DUPEFILTER_CLASS'])
# 实例化指纹过滤器
dupefilter = dupefilter_cls.from_settings(settings)
# 从配置文件中依次获取优先级任务队列类、磁盘队列类、内存队列类
pqclass = load_object(settings['SCHEDULER_PRIORITY_QUEUE'])
dqclass = load_object(settings['SCHEDULER_DISK_QUEUE'])
mqclass = load_object(settings['SCHEDULER_MEMORY_QUEUE'])
# 请求日志序列化开关
logunser = settings.getbool('LOG_UNSERIALIZABLE_REQUESTS', settings.getbool('SCHEDULER_DEBUG'))
return cls(dupefilter, jobdir=job_dir(settings), logunser=logunser,
stats=crawler.stats, pqclass=pqclass, dqclass=dqclass, mqclass=mqclass)

可以看到,调度器的初始化主要做了 2 件事:

  • 实例化请求指纹过滤器:主要用来过滤重复请求;
  • 定义不同类型的任务队列:优先级任务队列、基于磁盘的任务队列、基于内存的任务队列;

请求指纹过滤器又是什么?

在配置文件中,我们可以看到定义的默认指纹过滤器是 RFPDupeFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class RFPDupeFilter(BaseDupeFilter):
"""请求指纹过滤器"""
def __init__(self, path=None, debug=False):
self.file = None
# 指纹集合 使用的是Set 基于内存
self.fingerprints = set()
self.logdupes = True
self.debug = debug
self.logger = logging.getLogger(__name__)
# 请求指纹可存入磁盘
if path:
self.file = open(os.path.join(path, 'requests.seen'), 'a+')
self.file.seek(0)
self.fingerprints.update(x.rstrip() for x in self.file)

@classmethod
def from_settings(cls, settings):
debug = settings.getbool('DUPEFILTER_DEBUG')
return cls(job_dir(settings), debug)

请求指纹过滤器初始化时,定义了指纹集合,这个集合使用内存实现的 Set,而且可以控制这些指纹是否存入磁盘以供下次重复使用。

也就是说,指纹过滤器的主要职责是:过滤重复请求,可自定义过滤规则。

在下篇文章中我们会介绍到,每个请求是根据什么规则生成指纹的,然后是又如何实现重复请求过滤逻辑的,这里我们先知道它的功能即可。

下面来看调度器定义的任务队列都有什么作用?

调度器默认定义了 2 种队列类型:

  • 基于磁盘的任务队列:在配置文件可配置存储路径,每次执行后会把队列任务保存到磁盘上;
  • 基于内存的任务队列:每次都在内存中执行,下次启动则消失;

配置文件默认定义如下:

1
2
3
4
5
6
# 基于磁盘的任务队列(后进先出)
SCHEDULER_DISK_QUEUE = 'scrapy.squeues.PickleLifoDiskQueue'
# 基于内存的任务队列(后进先出)
SCHEDULER_MEMORY_QUEUE = 'scrapy.squeues.LifoMemoryQueue'
# 优先级队列
SCHEDULER_PRIORITY_QUEUE = 'queuelib.PriorityQueue'

如果我们在配置文件中定义了 JOBDIR 配置项,那么每次执行爬虫时,都会把任务队列保存在磁盘中,下次启动爬虫时可以重新加载继续执行我们的任务。

如果没有定义这个配置项,那么默认使用的是内存队列。

细心的你可能会发现,默认定义的这些队列结构都是后进先出的,什么意思呢?

也就是在运行我们的爬虫代码时,如果生成一个抓取任务,放入到任务队列中,那么下次抓取就会从任务队列中先获取到这个任务,优先执行。

这么实现意味什么呢?其实意味着:Scrapy 默认的采集规则是深度优先!

如何改变这种机制,变为广度优先采集呢?这时候我们就要看一下 scrapy.squeues 模块了,在这里定义了很多种队列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 先进先出磁盘队列(pickle序列化)
PickleFifoDiskQueue = _serializable_queue(queue.FifoDiskQueue, \
_pickle_serialize, pickle.loads)
# 后进先出磁盘队列(pickle序列化)
PickleLifoDiskQueue = _serializable_queue(queue.LifoDiskQueue, \
_pickle_serialize, pickle.loads)
# 先进先出磁盘队列(marshal序列化)
MarshalFifoDiskQueue = _serializable_queue(queue.FifoDiskQueue, \
marshal.dumps, marshal.loads)
# 后进先出磁盘队列(marshal序列化)
MarshalLifoDiskQueue = _serializable_queue(queue.LifoDiskQueue, \
marshal.dumps, marshal.loads)
# 先进先出内存队列
FifoMemoryQueue = queue.FifoMemoryQueue
# 后进先出内存队列
LifoMemoryQueue = queue.LifoMemoryQueue

如果我们想把抓取任务改为广度优先,我们只需要在配置文件中把队列类修改为先进先出队列类就可以了!从这里我们也可以看出,Scrapy 各个组件之间的耦合性非常低,每个模块都是可自定义的。

如果你想探究这些队列是如何实现的,可以参考 Scrapy 作者写的 scrapy/queuelib 项目,在 Github 上就可以找到,在这里有这些队列的具体实现。

阅读全文 »

在上篇文章:Scrapy源码分析(一)架构概览,我们主要从整体上了解了 Scrapy 的架构和数据流转,并没有深入分析每个模块。从这篇文章开始,我将带你详细剖析 Scrapy 的运行原理。

这篇文章,我们先从最基础的运行入口来讲,来看一下 Scrapy 究竟是如何运行起来的。

scrapy 命令从哪来?

当我们基于 Scrapy 写好一个爬虫后,想要把我们的爬虫运行起来,怎么做?非常简单,只需要执行以下命令就可以了。

1
scrapy crawl <spider_name>

通过这个命令,我们的爬虫就真正开始工作了。那么从命令行到执行爬虫逻辑,这个过程中到底发生了什么?

在开始之前,不知道你有没有和我一样的疑惑,我们执行的 scrapy 命令从何而来?

实际上,当你成功安装好 Scrapy 后,使用如下命令,就能找到这个命令文件,这个文件就是 Scrapy 的运行入口:

1
2
$ which scrapy
/usr/local/bin/scrapy

使用编辑打开这个文件,你会发现,它其实它就是一个 Python 脚本,而且代码非常少。

1
2
3
4
5
6
7
8
import re
import sys

from scrapy.cmdline import execute

if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(execute())

安装好 Scrapy 后,为什么入口点是这里呢?

答案就在于 Scrapy 的安装文件 setup.py 中,我们找到这个文件,就会发现在这个文件里,已经声明好了程序的运行入口处:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from os.path import dirname, join
from setuptools import setup, find_packages

setup(
name='Scrapy',
version=version,
url='http://scrapy.org',
...
entry_points={ # 运行入口在这里:scrapy.cmdline:execute
'console_scripts': ['scrapy = scrapy.cmdline:execute']
},
classifiers=[
...
],
install_requires=[
...
],
)

我们需要关注的是 entry_points 配置,它就是调用 Scrapy 开始的地方,也就是cmdline.pyexecute 方法。

也就是说,我们在安装 Scrapy 的过程中,setuptools 这个包管理工具,就会把上述代码生成好并放在可执行路径下,这样当我们调用 scrapy 命令时,就会调用 Scrapy 模块下的 cmdline.pyexecute 方法。

而且在这这里,我们可以学到一个小技巧——如何用 Python 编写一个可执行文件?其实非常简单,模仿上面的思路,只需要以下几步即可完成:

  1. 编写一个带有 main 方法的 Python 模块(首行必须注明 Python 执行路径)
  2. 去掉.py后缀名
  3. 修改权限为可执行(chmod +x 文件名)
  4. 直接用文件名就可以执行这个 Python 文件

例如,我们创建一个文件 mycmd,在这个文件中编写一个 main 方法,这个方法编写我们想要的执行的逻辑,之后执行 chmod +x mycmd 把这个文件权限变成可执行,最后通过 ./mycmd 就可以执行这段代码了,而不再需要通过 python <file.py> 方式就可以执行了,是不是很简单?

运行入口(execute.py)

现在,我们已经知道了 Scrapy 的运行入口是 scrapy/cmdline.pyexecute 方法,那我们就看一下这个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def execute(argv=None, settings=None):
if argv is None:
argv = sys.argv

# --- 兼容低版本scrapy.conf.settings的配置 ---
if settings is None and 'scrapy.conf' in sys.modules:
from scrapy import conf
if hasattr(conf, 'settings'):
settings = conf.settings
# -----------------------------------------

# 初始化环境、获取项目配置参数 返回settings对象
if settings is None:
settings = get_project_settings()
# 校验弃用的配置项
check_deprecated_settings(settings)

# --- 兼容低版本scrapy.conf.settings的配置 ---
import warnings
from scrapy.exceptions import ScrapyDeprecationWarning
with warnings.catch_warnings():
warnings.simplefilter("ignore", ScrapyDeprecationWarning)
from scrapy import conf
conf.settings = settings
# ---------------------------------------

# 执行环境是否在项目中 主要检查scrapy.cfg配置文件是否存在
inproject = inside_project()

# 读取commands文件夹 把所有的命令类转换为{cmd_name: cmd_instance}的字典
cmds = _get_commands_dict(settings, inproject)
# 从命令行解析出执行的是哪个命令
cmdname = _pop_command_name(argv)
parser = optparse.OptionParser(formatter=optparse.TitledHelpFormatter(), \
conflict_handler='resolve')
if not cmdname:
_print_commands(settings, inproject)
sys.exit(0)
elif cmdname not in cmds:
_print_unknown_command(settings, cmdname, inproject)
sys.exit(2)

# 根据命令名称找到对应的命令实例
cmd = cmds[cmdname]
parser.usage = "scrapy %s %s" % (cmdname, cmd.syntax())
parser.description = cmd.long_desc()
# 设置项目配置和级别为command
settings.setdict(cmd.default_settings, priority='command')
cmd.settings = settings
# 添加解析规则
cmd.add_options(parser)
# 解析命令参数,并交由Scrapy命令实例处理
opts, args = parser.parse_args(args=argv[1:])
_run_print_help(parser, cmd.process_options, args, opts)

# 初始化CrawlerProcess实例 并给命令实例添加crawler_process属性
cmd.crawler_process = CrawlerProcess(settings)
# 执行命令实例的run方法
_run_print_help(parser, _run_command, cmd, args, opts)
sys.exit(cmd.exitcode)

这块代码就是 Scrapy 执行的运行入口了,我们根据注释就能看到,这里的主要工作包括配置初始化、命令解析、爬虫类加载、运行爬虫这几步。

了解了整个入口的流程,下面我会对每个步骤进行详细的分析。

阅读全文 »

在爬虫开发领域,使用最多的主流语言主要是 Java 和 Python 这两种,如果你经常使用 Python 开发爬虫,那么肯定听说过 Scrapy 这个开源框架,它正是由Python编写的。

Scrapy 在开源爬虫框架中名声非常大,几乎用 Python 写爬虫的人,都用过这个框架。而且业界很多开源的爬虫框架都是模仿和参考 Scrapy 的思想和架构实现的,如果想深入学习爬虫,研读 Scrapy 的源码还是很有必要的。

从这篇文章开始,我就和你分享一下当时我在做爬虫时,阅读 Scrapy 源码的思路和经验总结。

这篇文章我们先来介绍一下 Scrapy 的整体架构,从宏观层面上学习一下 Scrapy 运行的流程。之后的几篇文章,我会带你深入到每个模块,剖析这个框架的实现细节。

介绍

首先,我们先来看一下 Scrapy 的官方是如何介绍它的。从官方网站,我们可以看到 Scrapy 如下定义。

Scrapy 是一个基于 Python 语言编写的开源爬虫框架,它可以帮你快速、简单的方式构建爬虫,并从网站上提取你所需要的数据。

也就是说,使用 Scrapy 能帮你快速简单的编写一个爬虫,用来抓取网站数据。

本篇文章不再介绍 Scrapy 的安装和使用,这个系列主要通过阅读源码讲解 Scrapy 的实现思路,关于如何安装和使用的问题,请参考官方网站官方文档学习。(注:写本篇文章时,Scrapy 版本为1.2,虽然版本有些低,但与最新版的实现思路基本没有很大出入。)

使用 Scrapy 开发一个爬虫非常简单,这里使用 Scrapy 官网上的例子来说明如何编写一个简单爬虫:

简单来讲,编写和运行一个爬虫只需以下几步:

  1. 使用 scrapy startproject 命令创建一个爬虫模板,或自己按模板编写爬虫代码
  2. 定义一个爬虫类,并继承 scrapy.Spider,然后重写 parse 方法
  3. parse 方法里编写网页解析逻辑,以及抓取路径
  4. 使用 scrapy runspider <spider_file.py> 运行这个爬虫

可见,使用 Scrapy 编写简单的几行代码,就能采集到一个网站页面的数据,非常方便。

但是在这背后到底发生了什么?Scrapy 到底是如何帮助我们工作的呢?

架构

要想知道 Scrapy 是如何工作的,首先我们来看一下 Scrapy 的架构图,从宏观角度来了解一下它是如何运行的:

核心模块

从架构图可以看到,Scrapy 主要包含以下五大模块:

  • Scrapy Engine:核心引擎,负责控制和调度各个组件,保证数据流转;
  • Scheduler:负责管理任务、过滤任务、输出任务的调度器,存储、去重任务都在此控制;
  • Downloader:下载器,负责在网络上下载数据,输入待下载的 URL,输出下载结果;
  • Spiders:我们自己编写的爬虫逻辑,定义抓取意图;
  • Item Pipeline:负责输出结构化数据,可自定义格式和输出的位置;

如果你观察地比较仔细的话,可以看到还有两个模块:

  • Downloader middlewares:介于引擎和下载器之间,可以在网页在下载前、后进行逻辑处理;
  • Spider middlewares:介于引擎和爬虫之间,在向爬虫输入下载结果前,和爬虫输出请求 / 数据后进行逻辑处理;

了解了这些核心模块,我们再来看使用 Scrapy 时,它内部的采集流程是如何流转的,也就是说各个模块是如何交互协作,来完成整个抓取任务的。

运行流程

按照上面架构图标识出的序号,我们可以看到,Scrapy 运行时的数据流转大概是这样的:

  1. 引擎自定义爬虫中获取初始化请求(也叫种子 URL);
  2. 引擎把该请求放入调度器中,同时调度器向引擎获取待下载的请求;
  3. 调度器把待下载的请求发给引擎;
  4. 引擎发送请求给下载器,中间会经过一系列下载器中间件
  5. 这个请求通过下载器下载完成后,生成一个响应对象,返回给引擎,这中间会再次经过一系列下载器中间件
  6. 引擎接收到下载器返回的响应后,发送给爬虫,中间会经过一系列爬虫中间件,最后执行爬虫自定义的解析逻辑
  7. 爬虫执行完自定义的解析逻辑后,生成结果对象新的请求对象给引擎,再次经过一系列爬虫中间件
  8. 引擎把爬虫返回的结果对象交由结果处理器处理,把新的请求通过引擎再交给调度器
  9. 重复执行1-8,直到调度器中没有新的请求处理,任务结束;
阅读全文 »

背景

不知道大家在用Markdown语法写博客时有没有遇到这样的问题?想使用Markdown语法插入一张图,大概要经过以下几个步骤:

  • 截图保存图片到本地
  • 打开并登陆注册好的图床网站
  • 上传图片至图床
  • 复制生成好的图片地址
  • 用Markdown语法插入图片

如果插入图片过多,这样来回操作多次,简直要崩溃!经过网上搜索,貌似有2种解决方案:

  • 付费购买此类软件
  • 自己写一个小工具,简化工作

我当然是属于第二种,想一想这个功能也不复杂,参考了有人已经实现出来的代码和思路,但是不忍其代码写的太渣太low,便自己造了这个轮子。

功能

先来看效果图:

主要功能就是:复制本地图片或截图,快速上传图片至七牛云空间,并获取Markdown格式的图片地址。

阅读全文 »