Python进/线程池使用

在 Python 中,进程/线程是个非常重要的概念,特别是 Python 还有 GIL(同一时刻只有一个线程在执行 Python bytecode)限制,使得 Python 线程并不那么好用。但 GIL 更多的是影响 CPU 密集型任务,实际业务场景更多的是 IO 密集型任务,多线程还是适用绝大多数场景。不过话又说回来,很多时候不太好判断是 IO 密集型多还是 CPU 密集型多,需要在多进程、多线程环境下分别验证。

但多线程和多进程手写起来还是有点差别,好在 multiprocessing.Pool 提供了统一的接口,可以无缝切换:

下面介绍下 Pool 类如何使用。

首先是构造函数:

class multiprocessing.Pool([processes[, initializer[, initargs[, maxtasksperchild]]]])

接收 processes initializer initargs maxtasksperchild 4个参数:

processes,就是池里有多少个进程,可以不传,默认 CPU 个数,可以按需多设置几倍

initializer、initargs,如果设置了参数,则会在每个进程初始化的时候调用 initializer(*initargs)。这个非常有用,比如可以在初始化的时候建立连接,连接重用。

maxtasksperchild,用于设置每个子进程执行多少个任务后重启。虽然很简单粗暴,但这是防止内存溢出、资源未释放等问题的常见手段,类似 PHP-FPM 的 pm.max_requests 参数。线程池无此参数。

这里有一个 hack 技巧,将初始化的数据库连接,绑定在了请求函数上,这样调用请求函数发送请求时,就不用重新建立连接,直接使用即可。

我们一般使用线程池有两个场景,一是关注执行结果,比如我们并行去 redis mysql 各个地方请求数据,然后整合这些数据,二是不关注执行结果,比如新开一个线程打印一个请求的审计日志,不阻塞主进程返回数据。这里主要是介绍下 apply 和 apply_async 的区别,其它的都类似。

apply(func[, args[, kwds]]),主要是传一个执行函数和参数,阻塞并得到返回结果。

apply_async(func[, args[, kwds[, callback]]]),也是主要传执行函数和参数,但返回的是一个 multiprocessing.pool.AsyncResult 对象,AsyncResult 主要有 get([timeout])、wait([timeout])、ready()、successful() 4 个方法,都很好理解,用的比较多的是 get 方法,给定超时时间内获取执行的结果,如果超时抛出 multiprocessing.TimeoutError 异常。如果 timeout 是 None,则一直就等待,行为就和 apply 一致了,实际上 apply 也是调用的 apply_async get:

apply_async 还有一个有用的参数 callback,相对于异步回调了,一般用于上述的场景二。下面是一些示例:

运行结果:

注意代码的最后一行,这里不仅仅是为了看回调结果,还因为回调是回调到主进程执行,如果主进(线)程已经退出了,那就处理不到回调了,实际使用需要注意运行环境。

最后,multiprocessing 模块的 pool 功能只是其中很小的一部分,但比较实用,后面有新的心得再介绍其它功能。

参考资料:

https://docs.python.org/2.7/library/multiprocessing.html#module-multiprocessing.pool

Python Requests Session set-cookie不生效的坑

我们知道 Python Requests库 中的 Session 模块有连接池和会话管理的功能,比如请求一个登录接口后,会自动处理 response 中的 set-cookie,下次再请求时会自动把 cookie 带上。但最近出现了一个诡异的事情,cookie 没有自动带上,导致请求 403。

一开始怀疑是登录接口错误了,没有 set-cookie,但抓包发现 response header 中有 set-cookie,打印请求的 response.cookies 也有需要的 cookie。又怀疑是 set-cookie 的格式不对或者其它问题,但用浏览器实际跑了下流程,发现系统一切正常,那基本就是 requests 库的问题了。

没办法,只能 debug 了,单步调试了几轮,基本了解了 requests 的处理方式,首先把请求参数转变为 Request 对象,然后对使用 prepare_request 对 Request 进行预处理,其中有一步 merge_cookies 的操作(还有各种其它处理),把传入的 cookies 和 self.cookies merge 到 RequestsCookieJar 对象上去,这一步也没啥问题,merged_cookies 变量也是对的。后续将预处理过的请求,通过内置的 http adapter 发送出去。http adapter 底层是通过 urllib3.poolmanager 获取到 urllib3.connectionpool 连接(这里是连接池的核心部分),再通过 conn.urlopen 实际发送请求。虽然跟踪了解到了整个请求逻辑,但最终发出的请求还是没有带上需要的 cookie。

问题定位一度陷入僵局,只能再回顾上面的流程,cookie 肯定就是在 merged_cookies 和 conn.urlopen 之间没的,再仔细观察发现,conn.urlopen 请求参数里面压根没有 cookie 字段。

查阅资料发现,urllib3 的作者说,连接池只处理底层连接,cookie 跟踪等事情应该上层来做。大胆猜测,那 cookie 应该是放在 header 里了,往前捣看看 request.headers 是怎么变动的(此时里面的 Cookie 字段确实不正确)。

再走查代码发现 prepare_request 里面是调用了 PreparedRequest.prepare,其中有一步 prepare_cookies,主要是调用了 cookielib.CookieJar.add_cookie_header 最终将 cookie 放到了 self.headers['Cookie']。但里面有个 request.has_header("Cookie") 的判断,header 中没有 Cookie 字段才会放,不知道为什么这么考虑(最新版 3.8 还是这样),估计是 merge cookie 比较麻烦,但问题确实就出在这里,这次请求之前,Requests Session 已经直接通过 headers['Cookie'] 设置了 cookie。复现代码(修改自官方示例):

虽然找到了问题的原因,但又不好解决,总不能不让直接操作 headers['Cookie'] 吧,先不说无法限制使用者,而且之前的代码已经这样做了,改动量非常之大。不过好在,现在自己用的框架是在 Requests Session 上封装了一层,操作 header 都是调用的统一的 update_headers 方法:

对 headers['Cookie'] 的操作拦截一下,变成对 cookies 的操作:

这样即不用改之前的调用方代码,也防止了后人掉坑。

参考资料

https://requests.readthedocs.io/en/master/user/advanced/#session-objects

https://stackoverflow.com/questions/2422922/python-urllib3-and-how-to-handle-cookie-support

https://urllib3.readthedocs.io/en/latest/reference/index.html#module-urllib3.poolmanager

https://urllib3.readthedocs.io/en/latest/reference/index.html#urllib3.connectionpool.HTTPConnectionPool.urlopen

https://docs.python.org/2/library/cookie.html

使用 Python nose 组织 HTTP 接口测试

现在前端 Web、移动端 APP 开发基本上是面向接口编程,后端各种 HTTP 接口已经提供好了,大家只要实现逻辑交互和展示就行。那么问题来了,这么多接口如何方便的进行测试呢?根据个人经验,我认为需要解决三个问题:1. HTTP 接口多种多样,测试程序如何统一?2. 测试程序如何规范组织?3. 接口间有上下文依赖,如何解决?

HTTP 接口多种多样,测试程序如何统一?

后端接口可能来自各个系统,GET/POST 协议的、遵循 RESTful 规范的,使用 session 认证、token 认证的,各式各样的接口都存在。但无论怎么变都无法脱离 HTTP 协议。因为组内的技术栈是 Python,这就很容易想到使用 Python 的 requests 库。首先我们使用requests.Session()会话对象,来进行会话保持和 Header、Cookie 等处理。这里我们可以简单封装一个 HttpSession 类,把一些常用操作和设置封装一下:

通过HttpSession()来获取一个requests session对象,后续的 HTTP 请求都通过它发起。requests 提供 get()、post() 等各种方法调用,但这会让测试代码显得不统一,这里我们直接调用底层的 request 方法,该方法几乎提供了发起一个 HTTP 请求需要的所有参数,非常强大。

这样每一个 HTTP 接口的测试都可以通过准备参数发起请求断言结果三板斧来解决。

测试程序如何规范组织?

前面我们已经解决了如何快捷的发起一个 HTTP 请求的问题,这让我们几行代码就可以测试一个接口的返回值。但你会发现新的问题又来了,各种脚本散乱在一堆,返回值解析各种中间变量,各种配置硬编码在代码中,测试结果全靠 print,这时 nose 框架就派上用场了。nose 是 Python 最流行的一个单测框架,提供测试 case 设置标签、测试 case 查找、强大的断言函数,格式化/可视化报告输出等功能。这样就可以像写单测一样写 HTTP 接口测试了。我们可以把一类接口的测试放在一个类里面,甚至可以把基础变量的定义放在基础测试类里面,其它测试类继承这个基类。

HttpTest基类只做了两个工作:1. 创建http会话对象;2. 读取配置文件到类变量config中。配置文件是一个很好的编程实践,这让我们测试程序和数据能够分离,可以通过调用不同的配置文件来测试不同环境的接口,让我们测试程序不光能做线下测试,还能做线上回归和监控,切换成本非常低。

我这里选择 yaml 语法来组织配置信息,yaml 语法风格类似 json,自带基础数据结构,但更加易于书写和阅读,非常适合做配置文件。通过-env=xxx指定配置文件路径(或者 nose.cfg 配置文件中指定),使用 ruamel.yaml 来解析 yaml,转换为 Python 中能直接操作的数据结构。

这一切都放在setUpClass方法中,在具体测试 case 运行之前就已经准备好这些工作了。 Continue Reading...