最近在研究BitCoin,有一个Excel想要自动更新一些数据,想了一下拿VBA写还不如搭个服务器用Python写主要逻辑来得简单方便,于是先用Python写个爬虫整理好数据再拿给Excel吃好了。

准备

Tornado

不管大家认不认识Tornado,还是简要地介绍一下吧。
Tornado是Facebook的一个开源的高性能Web服务器,最大的优势在于它基于事件的设计,使得它在IO密集型(比如代理或爬虫)应用上有着基于线程web框架无法比拟的巨大优势。

除此之外Tornado有一个非常赞的设计(当然Twisted等别的事件框架也有),基于Generator的协程模型。具体可参见这里,官方的example浅显易懂,一眼就可以看出协程比回调(包括闭包)的优势。

数据源

几乎所有的CryptoCoin相关网站都会提供很方便的Web API,另外也有一些第三方的API接口供使用。
有趣的是,这些API几乎全部都是Json格式的(究其原因主要是Bitcoin客户端就使用的Json表示,于是programmer更倾向于这种方式),所以使用起来很方便。

演进

下面我们以btc-e的ticker API为例讲述一下代码演进的过程,对比一下几种实现方式的区别。

同步

def get_btce():
    global current
    res = tornado.httpclient.HTTPClient().fetch(
            "https://btc-e.com/api/2/ltc_btc/ticker"
        )
    current['btce'] = tornado.escape.json_decode(res.body)['ticker']['last']

最简单的同步写法。注意这种写法是不可以实际使用的,因为同步请求会阻塞当前线程,而一般来说所有的事件处理在同一个线程,所以会导致整个服务器的所有其余动作(包括客户端请求的处理)都要在这个请求返回之后才可以被处理。

回调

def get_btce():
    def callback(res):
        global current
        current['btce'] = tornado.escape.json_decode(res.body)['ticker']['last']
    tornado.httpclient.AsyncHTTPClient().fetch(
            "https://btc-e.com/api/2/ltc_btc/ticker",
            callback=callback,
        )

朴素的callback就是这样使用的,由于Python没有闭包所以写起来异常恶心(好吧),接下来我们继续演进它。

 

协程

上面提到的Tornado有利用Python Generator实现的协程,相应的模块叫做tornado.gen,从Python 3.0开始gen可以吃Future所以导致写起来比之前更简洁。

@tornado.gen.coroutine
def get_btce():
    global current
    res = yield tornado.httpclient.AsyncHTTPClient().fetch(
            "https://btc-e.com/api/2/ltc_btc/ticker"
        )
    current['btce'] = tornado.escape.json_decode(res.body)['ticker']['last']

会发现这个已经和同步几乎一样了,除了添加了一个修饰器gen.coroutine表示协程,以及在AsyncHTTPClient.fetch()的返回值上用了yield而已,是不是很方便w
讲得更细节一点:
首先使用gen.coroutine来表示协程。coroutine会生成一个wrapper,在外面层吃yield point,在原函数yield的时候会传给外层的wrapper处理,执行完之后再send()回来;
然后ASyncHTTPClient()返回一个Future(也向后兼容之前的callback方式)供回调,需要的童鞋可以自己看Future的写法;
当Future执行完成后,coroutine的wrapper会把结果(res)用generator的send()方法传递回用户代码,赋值给res变量,用户代码继续执行。

重复执行

我们需要在执行完一次请求之后等待1分钟再次请求,在线程写法中time.sleep()可以很好地解决这个问题,但是事件写法的话就不可以sleep了(和上面同步请求一样会阻塞)。
于是Tornado提供了一个方法叫IOLoop.instance().add_timeout(),这个方法吃一个回调函数,然后可以指定在某时间或经过多长时间后执行这个函数。于是我们让它吃自己就可以了。

@tornado.gen.coroutine
def get_btce():
    global current
    res = yield tornado.httpclient.AsyncHTTPClient().fetch(
            "https://btc-e.com/api/2/ltc_btc/ticker"
        )
    current['btce'] = tornado.escape.json_decode(res.body)['ticker']['last']
    tornado.ioloop.IOLoop.instance().add_timeout(
        datetime.timedelta(milliseconds=delta),
        get_btce()
    )

修饰器

但是呢,每次写起来会很麻烦,本着“以代码重用为荣,以复制粘贴为耻”的精神,我们把这个功能抽出来,用有着“Python两大黑科技”的修饰器(另一个是metaclass)来实现。

def loop_call(delta=60*1000):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            func(*args, **kwargs)
            tornado.ioloop.IOLoop.instance().add_timeout(
                datetime.timedelta(milliseconds=delta),
                wrapper,
            )
        return wrapper
    return decorator

这是一个典型的三层(带参)修饰器写法,使用如下(注意@coroutine要放在@loop_call下面)

@loop_call()
@tornado.gen.coroutine
def get_btce():
    global current
    res = yield tornado.httpclient.AsyncHTTPClient().fetch(
            "https://btc-e.com/api/2/ltc_btc/ticker"
        )
    current['btce'] = tornado.escape.json_decode(res.body)['ticker']['last']

修饰器分为带参和无参两种,前者使用@deco()调用,后者使用@deco;前者在被应用的时候会调用deco()函数,这个函数必须返回一个无参修饰器(也就是decorator()那一层)
最外层的函数的函数名用做(带参)修饰器的名字,用于产生一个修饰器;
中间层的函数是一个无参修饰器,用于被带参修饰器返回;
最里层的函数是被修饰之后的新函数,总使用@functools.wraps(原函数)修饰,这个函数用于把原来函数的一些属性(__name__、__doc__一类)应用到新建的函数(wrapper)上。

修饰器应用流程:
以缺省参数delta=60*1000执行loop_call(),返回一个新函数decorator();
以参数func=get_btce执行decorator(),返回被wrap(get_btce)修饰的新函数wrapper作为新的get_btce();
在get_btce()被调用时,会先执行func(也就是原来的get_btce()),然后用最外层的delta参数调用add_timeout()

代码

总体来说效果就是这样的,其余部分都是sample级的代码所以不多说。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import tornado.web
import tornado.gen
import tornado.httputil
import tornado.escape
import tornado.httpclient
import datetime
import functools

current = {}
def loop_call(delta=60*1000):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            func(*args, **kwargs)
            tornado.ioloop.IOLoop.instance().add_timeout(
                datetime.timedelta(milliseconds=delta),
                wrapper,
            )
        return wrapper
    return decorator

# These two decorators must be applied in order
@loop_call()
@tornado.gen.coroutine
def get_btce():
    global current
    res = yield tornado.httpclient.AsyncHTTPClient().fetch(
            "https://btc-e.com/api/2/ltc_btc/ticker"
        )
    current['btce'] = tornado.escape.json_decode(res.body)['ticker']['last']
    
@loop_call()
@tornado.gen.coroutine
def get_btcc():
    global current
    res = yield tornado.httpclient.AsyncHTTPClient().fetch(
            "https://blog.mimvp.com"
        )
    current['btcc'] = tornado.escape.json_decode(res.body)['ticker']['last']
    
@loop_call()
@tornado.gen.coroutine
def get_diff():
    global current
    res = yield tornado.httpclient.AsyncHTTPClient().fetch(
        "http://api.ltcd.info/difficulty"
    )
    current['diff'] = tornado.escape.json_decode(res.body)['current-difficulty']
    
class TickerHandler(tornado.web.RequestHandler):
    def get(self,key):
        global current
        self.write(str(current.get(key,"N/A")))
        
class RootHandler(tornado.web.RequestHandler):
    def get(self):
        self.write("Ticker")
        
tornado.web.Application([
    (r'/',RootHandler),
    (r'/(.*)',TickerHandler),
],debug=True).listen(8733)

get_btcc()
get_btce()
get_diff()
tornado.ioloop.IOLoop.instance().start()