在使用 Python 的时候,我们可能会经常听到一个名词 —— Moneky Patch
,尤其是在搜索 Python 协程相关的信息的时候。那么什么是 Moneky Patch
呢?它和 Python 的协程有什么关系呢?它和 eventlet
有什么关系呢?最重要的,它和猴子有什么关系呢?ᶘ ͡°ᴥ͡°ᶅ
最近在研究 OpenStack 的 loopingcall
,它主要就是用 eventlet
启动协程实现并发执行多个函数的,为了后面写相关的博客,本着刨根问底的原则,先从 Moneky Patch
开始吧。
猴子补丁?
什么是 Moneky Patch
首先 Moneky Patch
不是一个官方库,也不是一个第三方库,它说的是一种用法,可以说是一种 Magic 方法(更多 Python 黑魔法可以参考 Python 黑魔法手册)。当然,你去PyPi官网去搜monkeypatch
,是可以搜到很多库,它们都是具体场景下的 Moneky Patch
的具体实现。
利用动态语言的特点,在运行时替换指定函数的代码,达到实时更改函数行为的效果,这就叫做“打Moneky Patch
”。Python 中实现Moneky Patch
,主要是利用 Python 的 import 机制和“一切皆对象”的特点。
和 Python 的协程的关系
在 Python 3 提供 async/await
关键字用来支持协程之前,有很多第三方库都是利用yield表达式
来实现协程的,例如eventlet
和greenlet
。
所谓协程,就是由程序员自己控制代码的上下文切换,在一个协程的代码执行到 IO 阻塞的地方时,将 CPU 切换到其他不需要 IO 的协程上去,这样既实现了并发又提高了效率。
而一些涉及到 IO 的 Python 库,比如 socket 这种,已经封装好了很多底层接口,要从这些接口中增加控制上下文切换的代码,就要重写很多 Python 标准库。以前的那些库已经被大量使用了,人们也比较熟悉,搞几个新的库来替代显然不合适,于是greenlet
就采用打Moneky Patch
的方式来动态替换这些 IO 相关的库里的方法来实现协程。
和猴子的关系
至于为什么叫猴子补丁,我感觉是因为猴子给人的印象是比较爱捣乱,而打了Moneky Patch
之后,源码在运行时才会被动态替换。你如果没看到哪里打了Moneky Patch
,看到的源码和实际运行时的情况就不同了,整个代码结构也会不可避免地被打乱,这就像一只在你的代码里捣乱的猴子一样,好像还挺形象的吧。
这让我想起来前几年很火的各大视频网站的破解会员的浏览器插件,也都是叫“油猴脚本”、“暴力猴”这种带猴子的名字,其实这种会员破解脚本的破解原理就是在javascript
脚本里打Moneky Patch
,在浏览器加载网页的javascript
脚本时,动态替换某个会员认证的函数,跳过会员认证的逻辑。现在这种方式早已被网站们做了防护。
使用示例
上面说了很多,可能比较抽象,其实用一个例子来看就很容易理解,看完示例代码。再去看上面的解释就好理解多了。
定义一个Dog
类,有一个speak
方法。
# Python3
class Dog:
def speak(self):
print('wang wang wang')
Dog.speak()
运行结果:
wang wang wang
后来我们想让Dog
说中国话,又不想改原来的代码,就在外面重新定义了一个说话
方法,然后把Dog.speak
直接替换成新方法。
# Python3
class Dog:
def speak(self):
print('wang wang wang')
def 说话():
print('汪 汪 汪')
Dog.speak = 说话
Dog.speak()
运行结果:
汪 汪 汪
由于 Python 中一切皆对象,这个替换过程就像把一个变量赋值两次,第一次的值直接被覆盖掉了。
# a的值最终是2
a = 1
a = 2
上面这个例子可能看上去有点奇怪,因为大多数情况我们是不需要使用 Moneky Patch
的。之所以用这种方式主要是在于两点:一个是不想改原来的代码,一个是不改变原来代码的调用方式。
需要使用 Moneky Patch
的少数场景有哪些呢?
使用场景
首先要明确一点,使用Moneky Patch
会打乱原有的代码结构,而且代码看上去会很奇怪,所以应该坚持能不使用Moneky Patch
就不使用的原则。如果整个项目都是自己的代码,那我们也大可不必使用 Moneky Patch
,直接修改源码或者封装多态或者搞个子类继承,都比上面这种方式更清晰更优雅。
但是,如果想要改变第三方包的某个行为,又或者是想要改变公共模块的某个行为,这时候就不得不使用 Moneky Patch
了。
1. 最常见的使用场景就是在单元测试中使用的mock
库。
为什么我们可以在测试用例中随意伪造业务代码的返回值呢?就是因为mock
单元测试在运行的时候,动态地把业务代码里的函数给替换成了其他函数,源代码的调用方式没有改变,返回结果却成了我们设定的 fake 值。
2. 某个第三方库硬编码了某些东西,我们无法通过接口传参来实现特定的效果。
由于 Python 库的代码和业务代码一般不在同个工程下,而且 Python 库是公共使用的,我们不能因为自己的特殊需要就去更改第三方库的源码。
例如,有一个第三方库third_party_module
,自己的代码my_code.py
# third_party_module.py
def step1():
print('step1')
def step2():
print('step2')
def run():
step1()
step2()
# my_code.py
import third_party_module
third_party_module.run()
运行结果:
step1
step2
我们想在step1
和step1
中间加一个自己的步骤step3
,就可以像下面这样:
# my_code.py
import third_party_module
def step3():
print('step3')
# 函数名可以随意定义,dog_patch、cat_patch,不带patch都可以
def monkey_patch():
def my_run():
third_party_module.step1()
step3()
third_party_module.step2()
third_party_module.run = my_run
monkey_patch()
third_party_module.run()
运行结果:
step1
step3
step2
3. 想要改变公共代码模块的某个行为实现自己的特殊用法。
公共代码模块虽然不像第三方库那样不能修改,但是如果许多不同的业务组都在使用同一个公共模块,而我们的需求只是自己的特殊场景使用,也是不宜直接修改源码的,这时候就可以打Moneky Patch
来解决,使用方法类似上面第三方库的场景。
更好的使用示例
如果我们不想让替换的代码全局生效,其实可以参考mock
库的上下文管理器的用法,来实现局部生效的效果。
结合上面的例子:
# my_code.py
import mock
import third_party_module
def step3():
print('step3')
def my_run():
third_party_module.step1()
step3()
third_party_module.step2()
print('invoke1 ...')
with mock.patch.object(third_party_module, 'run', my_run):
third_party_module.run()
print('invoke2 ...')
third_party_module.run()
运行结果:
invoke1 …
step1
step3
step2
invoke2 …
step1
step2
最后再说一句,使用Moneky Patch
会打乱原有的代码结构,破坏代码的可读性,在实际开发中我们应该尽量不使用Moneky Patch
。