yokon's blog

flask应用缓存实现的疑惑和答案

2018.02.09

前言

最近自己一直在折腾站点程序,觉得自己的站点响应速度越来越慢。就想着引入一些缓 存,毕竟这是提高速度最简单的方式了。但是动态博客不像静态博客那样,不需要考虑数 据的更新,全局添加缓存。所以若想加入缓存就需要考虑数据的更新问题。

这里需要分为管理员更新信息和游客浏览信息,管理员更新属于可控的更新,比如管理员 自己对于网站的更新(更新文章,发布文章,更新站点信息等)操作,我们可以自己控制 清除对应缓存等待新的缓存,而且管理员不会很频繁的有什么大的更新。游客浏览的信息 分为频繁更新页面和少有更新页面。

对于少有信息更新的页面,比如关于,说说还有标签,分类等页,我们可以直接设置 30 天的缓存期限。但是一旦页面有一些信息的更新,这里比如love me按钮的次数,一旦有 游客点击(只有不曾点击过的游客才会请求到路由)并且成功请求到love-me路由,就清 除所有的缓存,再更新新的缓存数据。

而一些经常更新信息的页面,比如文章页和首页需要更新文章浏览次数,所以不应该使用 缓存,但是用了缓存后速度确实有很大的提升,所以就给每一篇文章加入5分钟的缓存期 限。

其实就本站程序而言,唯一拖慢速度的程序就是app/main/__init__.py文件里定义更新 全局信息的global_datas函数,而函数返回的信息除了love-me按钮次数外都是管理员 更新的数据,所以此函数必须添加缓存。

使用 redis

虽然werkzeug库中为我们实现了基础的缓存支持,但是为了方便免去自己封装,我决定 使用flask-cache插件。 插件文档

下面就是给对应的路由添加对应的缓存,这里只要按照文档的来做就可以了。

由于文档给我们的例子使用的是simple类型,该类型使用的是本地Python字典作为缓 存的存储,但是这只推荐用于测试开发环境,不推荐用于生产环境。所以我建议使用 redis类型,使用redis数据库做缓存存储。

配置 redis

Windows系统下载地址 here

其他系统地址 here

安装 redis 操作库:pip install redis。这一步很关键,没了他我们并不能成功的 使用python操作redis

配置 redis 类型信息

# YuBlog/config.py
...
class Config(object):
	
	...

	# cache 使用 Redis 数据库缓存配置
    CACHE_TYPE = 'redis'
    CACHE_REDIS_HOST = '127.0.0.1'
    CACHE_REDIS_PORT = 6379
    CACHE_REDIS_DB = os.getenv('CACHE_REDIS_DB') or ''
    CHCHE_REDIS_PASSWORD = os.getenv('CHCHE_REDIS_PASSWORD') or ''

    @staticmethod
    def init_app(app):
        pass
# YuBlog/app/__init__.py
...
from flask_cache import Cache

cache = Cache()

def create_app(config_name):
	...
	cache.init_app(app)
	...

缓存分页疑问

添加缓存只需要按照文档的例子,给路由或者函数加对应的装饰器就可以了。

在给首页加缓存后,点击首页确实速度提升了很多,但是由于首页是分页显示全部内容 的。当点击第二页和第三页时,发现响应的内容并不是应该出现的内容,而是之前缓存的 首页也就是第一页的内容。

这是为什么呢?原因很简单,cache.cached(timeout=None, key_prefix='view/%s', unless=None)装饰器有三个参数:

  1. timeout: 指的是缓存过期时间,默认永不过期;
  2. key_prefix: 指缓存项键值的前缀,默认'view/%s';
  3. unless: 回调函数,当返回True时,缓存不起作用,默认缓存有效。

不明白默认的key_prefix具体是什么,而且文档也没有详细的说明,索性看他的源码:

class Cache(object):
	
	...

	def cached(self, timeout=None, key_prefix='view/%s', unless=None):
		def decorator(f):
            @functools.wraps(f)
            def decorated_function(*args, **kwargs):
                #: Bypass the cache entirely.
                if callable(unless) and unless() is True:
                    return f(*args, **kwargs)

                try:
                    cache_key = decorated_function.make_cache_key(*args, **kwargs)
                    rv = self.cache.get(cache_key)
                except Exception:
                    if current_app.debug:
                        raise logger.exception("Exception possibly due to cache backend.")
                    return f(*args, **kwargs)

                if rv is None:
                    rv = f(*args, **kwargs)
                    try:
                        self.cache.set(cache_key, rv, timeout=decorated_function.cache_timeout)
                    except Exception:
                        if current_app.debug:
                            raise logger.exception("Exception possibly due to cache backend.")
                        return f(*args, **kwargs)
                return rv

            def make_cache_key(*args, **kwargs):
                if callable(key_prefix):
                    cache_key = key_prefix()
                elif '%s' in key_prefix:
                    cache_key = key_prefix % request.path
                else:
                    cache_key = key_prefix

                return cache_key

            decorated_function.uncached = f
            decorated_function.cache_timeout = timeout
            decorated_function.make_cache_key = make_cache_key

            return decorated_function
        return decorator

这段代码是cache.cached()装饰器核心部分了,我后面的疑问在看完这段代码后就很明 白了。首先,key_prefix参数以%s作为占位符,用来格式化request.path

...
elif '%s' in key_prefix:
    cache_key = key_prefix % request.path
...

那么request.path是什么了,request引自flask库,我们将他放到路由函数中去, 打印一次就知道了。很显然这是路由url的路径了,首页路径就是/index,归档页就是 /archives。而上面之所以首页的第二页第三页缓存的内容是一样的,就是因为,他们虽 然url/index?page=2/index?page=3,但是他们的路径是一样的,即: /index。装饰器把他们当作是一个缓存项了。

分页缓存的解决

那这种情况如何处理,在我查了一圈网络上的博客后,发现千篇一律不是照搬文档,就是 稍加改变的文档实例。没有关于这种情况的处理实例,既然文档也没有,那还是得看源 码。

其实从上面的源码中我们可以发现,key_prefix参数,不仅可以接收一个字符串,还可 以接受一个函数。

# 返回缓存 key 的函数
def make_cache_key(*args, **kwargs):
    if callable(key_prefix):
        cache_key = key_prefix()
    elif '%s' in key_prefix:
        cache_key = key_prefix % request.path
    else:
        cache_key = key_prefix

    return cache_key

Pythoncallable()函数检测key_prefix是否是一个可调用函数,如果是,即调用 函数返回缓存key

所以我们只需实现一个函数,使得key_prefix不在只是为路径,还需加上页数信息,这 样每一页的键值就不一样了。

def cache_key(*args, **kwargs):
    """
    自定义缓存键:
        首页和归档页路由 url 是带参数的分页页数组成:/index?page=2
        flask-cache 缓存的 key_prefix 默认值获取 path :/index
        需要自定义不同页面的 cache_key : /index/page/2
    """
    path = request.path
    args = dict(request.args.items())

    return (path + '/page/' + str(args['page'])) if args else path

request.args.items()返回的是一个什么呢,我们放入路由函数打印出来看看:

@app.route('/index')
def index():
	path = request.path
	args = request.args.items()
	print(path)
    print(args)
    print(type(args))
    print(args.__next__())

    ...

调用返回:

/index
<generator object items at 0x000000000626A948>
<class 'generator'>
('page', '3')

可见args是一个生成器类型,我们使用生成器的__next__()方法打印他的值,返回的就 是我们的页数了,我们将他们组成字典方便调用。

如此我们就解决了分页路由函数的缓存键值相同的问题了,接下来只要将路由函数上的装 饰器key_prefix参数的值改为函数cache_key就可以了。

最后

对于写一个个人博客,使用flask就感觉恰到好处。如果用django就觉得是在用大炮打 蚊子,杀鸡用牛刀。但是折腾的劲来了,最求的功能多了,插件库用的多了,flask就变 得越来越臃肿,没有起初的从容优雅,显得越来越像django

其实折腾多了,就会发现,写个博客就为了记录,要太多的功能其实并没有太大的卵用。 一个简单的静态博客远远满足需求。