Fastapi框架-冷饭再炒-基础知识补充篇(9)-aioredis异步缓存篇

4,284 阅读12分钟

在一个应用里面,对于缓存的需求是必不可少的一部分。对于缓存,通常我们的主要居多主要是reids.但是对于客户端的异步支持的库,相对目前来说比较少点:

1 redis异步客户端支持库:

大概主要几个异步客户端的支持库有:

  • aioredis
  • asyncio_redis
  • aredis (似乎好像感觉也不错!比aioredis的友好且更加简单使用上手!)

这篇暂时先主要去了解一下aioredis,看官网说是已经出了2.00且全部的进行改造了! 最想版本的也是1.3.1的版本 停留在了19年那!

2:aioredis的简单使用篇

1:安装

pip install aioredis

官方建议还需要安装上:

pip install hiredis 

hiredis主要是起到加速的作用!

上面的操作会同时安装aioredis, async-timeout这两个库。

2: 最简示例

2.1 修正官网的提供的示例:

import asyncio

import aioredis


async def main():
    redis = aioredis.from_url("redis://localhost")
    await redis.set("my-key", "value")
    value = await redis.get("my-key")
    print(value)


asyncio.run(main())

可惜!上面的示例是跑不通了!以为from_url已经被移除了!

所以创建的的方式改为下面的结构可以看得到的几个方法:

image.png

修改创建的redis示例: 把from_url修改为create_redis

import asyncio

import aioredis


async def main():
    redis = await aioredis.create_redis('redis://127.0.0.1:6379/0')
    await redis.set("my-key", "value")
    value = await redis.get("my-key")
    print(value)


asyncio.run(main())

上面示例的输出的输出的结果是:

b'value'

上面的到的结果是一个bytes的类型的!这个有一点点不适应!再修改一下,直接的返回字符串的类型!

import asyncio
import aioredis

async def main():
    redis = await aioredis.create_redis('redis://127.0.0.1:6379/0?encoding=utf-8')
    await redis.set("my-key", "value")
    value = await redis.get("my-key")
    print(value)

asyncio.run(main())

上面的到的结果就是一个字符串类似的了!主要区别就是加一个编码的方式!

2.2创建客户端的其他方式

  • redis_cli = await aioredis.create_connection(('localhost', 6379), loop=loop)

  • redis_cli= await aioredis.create_pool(('localhost', 6379), minsize=3, maxsize=20, loop=loop)

  • redis_cli = await aioredis.create_redis( ("localhost", 6379), loop=loop)

  • redis_cli = await aioredis.create_redis(address, password=password)(需要密码)

  • redis_cli = await aioredis.create_redis(address)(不需要密码)

  • redis_cli = create_redis_pool(f"redis://:root12345@127.0.0.1:6379/0?encoding=utf-8")

3: hash(字典)的操作:hmset_dic

  • hash(字典)的操作:hmset_dict
  • 管道操作:pipeline

来自官网的示例:

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

import asyncio
import aioredis

async def main():
    redis = await aioredis.create_redis('redis://127.0.0.1:6379/0?encoding=utf-8')
    await redis.set("my-key", "value")
    value = await redis.get("my-key")
    print(value)
    print("HASH字典的操作")
    await redis.hmset_dict("hash", key1="value1", key2="value2", key3=123)
    result = await redis.hgetall("hash", encoding="utf-8")
    # 断言
    assert result == {
        "key1": "value1",
        "key2": "value2",
        "key3": "123",  # note that Redis returns int as string
    }
    print(result)
   


asyncio.run(main())

输出结果:

value
HASH字典的操作
{'key1': 'value1', 'key2': 'value2', 'key3': '123'}

字典在redis显示的结果为:

image.png

4: 队列的操作相关:

import asyncio
import async_timeout
import aioredis


async def main():
    pass
    redis = await aioredis.create_redis('redis://127.0.0.1:6379/0?encoding=utf-8')
    sd =await redis.rpush('one:1', '你好222')
    print("===============1",sd)
    sd =await redis.lpush('one:2', '说黄道黑')
    print("===============2",sd)
asyncio.run(main())

最终结果:

image.png

5: 管道操作示例:

来自官网的示例————很遗憾的是,这个示例行不通了!玩不转!而且很诡异的甚至异常信息和情况都不会提示!

import asyncio
import async_timeout
import aioredis
from aioredis import Channel
from typing import Tuple

async def main():
    pass
    redis = await aioredis.create_redis('redis://127.0.0.1:6379/0?encoding=utf-8')
   

    async def get_with_ttl( key: str) -> Tuple[int, str]:
        p = redis.pipeline()
        p.ttl(key)
        p.get(key)
        return await p.execute()
    print('1111111111111')
    sd = await get_with_ttl('my-key2')
    print('2222222222')
    print(sd)


asyncio.run(main())
 

关于链路似请求不支持的说明: image.png

PS: 上面进行: 管道对象不支持这种链路形式的操作,必须分割开! 如下:p.get("foo").incr("bar")的时候会异常了!且不会出现任何的情况的反馈!

官网提供的示例跑不通滴!:

image.png

6: 发布和订阅模式

6.1 订阅特定频道进行消费

订阅端:

import asyncio
import async_timeout
import aioredis
from aioredis import Channel

async def main():
    pass
    redis = await aioredis.create_redis('redis://127.0.0.1:6379/0?encoding=utf-8')
    # print("1")
    ps = await redis.pubsub_channels()
    print("2",ps)
    channels = await redis.subscribe('收到货')
    # channels = await redis.subscribe('收到货')


    async def reader(channel: Channel) -> None:
        message2 = await channel.wait_message()
        print(message2)
        sasd =await channel.get()
        print(sasd.decode('utf-8'))

    for channel in channels:
        print('sssssssssss', channel)
        sada = await reader(channel)

    # ps.subscribe('收到货')  # 从liao订阅消息

# loop = asyncio.get_event_loop()
# loop.run_until_complete(main())
# loop.run_forever()

asyncio.run(main())

发布端:

import asyncio
import async_timeout
import aioredis
from aioredis import Channel

async def main():
    pass
    redis = await aioredis.create_redis('redis://127.0.0.1:6379/0?encoding=utf-8')
    # 发送消息到某个频道上
    sd= await redis.publish('收到货', '收到什么货')

asyncio.run(main())

首先先启动我们的订阅端,然后再进行启动我们的发布端,此时,订阅端就能接收到我们的数据.

3:结合fastapi进行缓存处理

3.1 简单方式引入到fastapi

通常我们的再框架里面使用的时候,一般是使用池化的方式进入引入。

具体的引入步骤可以是

3.1.1 创建redis_pool的对象

async def get_redis_pool() -> Redis:
    redis = await create_redis_pool(f"redis://:root12345@127.0.0.1:6379/0?encoding=utf-8")
    return redis

3.1.2 注册到Fastapi的app对象的上下文中

注册的方式一般是通过事件监听的方式,再启动的时候进行注册,监听服务的关闭的时候,进行链接的释放。

@app.on_event('startup')
async def startup_event():
    """
    获取链接
    :return:
    """
    app.state.redis = await get_redis_pool()


@app.on_event('shutdown')
async def shutdown_event():
    """
    关闭
    :return:
    """
    app.state.redis.close()
    await app.state.redis.wait_closed()

上面的注册事件的方式也可以使用下面的:

app.add_event_handler("startup", create_redis_conn_handler(app))
app.add_event_handler("shutdown", create_redis_disconnect_handler(app))

然后再对应的函数里面进行关联。

def create_redis_conn_handler(app: FastAPI):
    async def start_app():
        pool = await create_redis_pool("redis://:6379")
        app.state.redis_conn = RedisConn(pool)
    return start_app

def create_redis_disconnect_handler(app: FastAPI):
    async def stop_app():
        await app.state.redis_conn.disconnect()
    return stop_app

3.1.3 使用的方式

因为我们的fastapi中有对应的Request请求上下文中包含有我们的app对象,我们可以在请求的上下文对象,获取我们的当前的app的对象,然后再进行获取相关的实例进行操作。

await request.app.state.redis.set("nihao", num)
# redis读取
v = await request.app.state.redis.get("nihao")

4:使用开源fastapi-cache库

这个库支持相关的类似flask中的flask_cacheing中装饰器等的使用,对于有这样的需求的来说,比较友好!

首先是安装依赖库:fastapi-cache的异步是依赖于aioredis,所以使用它需要也安装好我们的aioredis。当然它也支持memcache的作为缓存存贮器,如果你需要使用到我话。

 pip install fastapi-cache2

4.1 fastapi-cache的官网示例

这里我直接贴它官网的示例的代码吧:

import aioredis
from fastapi import FastAPI
from starlette.requests import Request
from starlette.responses import Response

from fastapi_cache import FastAPICache
from fastapi_cache.backends.redis import RedisBackend
from fastapi_cache.decorator import cache

app = FastAPI()


@cache()
async def get_cache():
    return 1


@app.get("/")
@cache(expire=60)
async def index(request: Request, response: Response):
    return dict(hello="world")


@app.on_event("startup")
async def startup():
    redis = await aioredis.create_redis_pool("redis://localhost", encoding="utf8")
    FastAPICache.init(RedisBackend(redis), prefix="fastapi-cache")

如果你需要自定义我们的装饰器中的key的话,还可以如下的操作:

def my_key_builder(
    func,
    namespace: Optional[str] = "",
    request: Request = None,
    response: Response = None,
    *args,
    **kwargs,
):
    prefix = FastAPICache.get_prefix()
    cache_key = f"{prefix}:{namespace}:{func.__module__}:{func.__name__}:{args}:{kwargs}"
    return cache_key

@app.get("/")
@cache(expire=60,coder=JsonCoder,key_builder=my_key_builder)
async def index(request: Request, response: Response):
    return dict(hello="world")

4.2 fastapi-cache源码分析

对于源码的分析主要是深入到里面去了解如何给我们的异步的协程函数加上对应的装饰器的处理方法。 通过源码的方式一探究竟!

image.png

4.2.1 FastAPICache的分析

首先就是我们的FastAPICache,它主要负责处理对应的缓存对象的管理和设置。

从类的初始化方法init中我们的可以得到,它的实例化,需要指定的信息有:

 @classmethod
    def init(
        cls,
        backend,
        prefix: str = "",
        expire: int = None,
        coder: Coder = JsonCoder,
        key_builder: Callable = default_key_builder,
    ):
  • backend : 指定使用的缓存的储存器是redis还是memcache的对象
  • prefix : 缓存的key前缀
  • expire : 全局的过期的时间的设置
  • coder : 指定相关的数据的打包和解包的方式
  • key_builder :指定一个自定的key的方法,必须是一个可调用的对象!默认的生成key的方法是在key_builder.py里面进行生成的。

4.2.2 key_builder 缓存字段的key生成算法

具体源码为:

import hashlib
from typing import Optional

from starlette.requests import Request
from starlette.responses import Response


def default_key_builder(
    func,
    namespace: Optional[str] = "",
    request: Optional[Request] = None,
    response: Optional[Response] = None,
    args: Optional[tuple] = None,
    kwargs: Optional[dict] = None,
):
    from fastapi_cache import FastAPICache

    prefix = f"{FastAPICache.get_prefix()}:{namespace}:"
    cache_key = (
        prefix
        + hashlib.md5(  # nosec:B303
            f"{func.__module__}:{func.__name__}:{args}:{kwargs}"
        ).hexdigest()
    )
    return cache_key

分析源码可以的到的结论就是:主要依赖我们的当前:前缀+函数所属的模块+函数名称还有传入的参数信息= key

4.2.3 cache缓存装饰器

from functools import wraps
from typing import Callable, Optional, Type

from fastapi_cache import FastAPICache
from fastapi_cache.coder import Coder


def cache(
    expire: int = None,
    coder: Type[Coder] = None,
    key_builder: Callable = None,
    namespace: Optional[str] = "",
):
    """
    cache all function
    :param namespace:
    :param expire:
    :param coder:
    :param key_builder:
    :return:
    """

    def wrapper(func):
        @wraps(func)
        async def inner(*args, **kwargs):
        
           #nonlocal声明的变量不是局部变量,也不是全局变量,而是外部嵌套函数内的变量
            # 定义了三个用于内嵌函数的变量信息
            nonlocal coder
            nonlocal expire
            nonlocal key_builder
            
           
            # 浅拷贝一份当前函数的所有的传入的参数值信息
            copy_kwargs = kwargs.copy()
            
            # 提取当前request和response并弹出
            request = copy_kwargs.pop("request", None)
            response = copy_kwargs.pop("response", None)
            
            # 判断请头里面是使用是使用no-store来说明,我们的当前请求的内容都不会被保存到缓存或Internet临时文件中
            
            if request and request.headers.get("Cache-Control") == "no-store":
                return await func(*args, **kwargs)
    
           # 是否指定使用的解析器
            coder = coder or FastAPICache.get_coder()
            # 是否指定了当前的过期的时间,如果没有则默认使用全局的过期的时间
            expire = expire or FastAPICache.get_expire()
            # 是否自定义的key生成的方式,如果没有,则使用FastAPICache的默认的方式
            key_builder = key_builder or FastAPICache.get_key_builder()
            
            # 获取 指定的存贮器是redis还是memcache
            backend = FastAPICache.get_backend()
            
            # 开始创建我们的缓存的key值
            
            cache_key = key_builder(
                func, namespace, request=request, response=response, args=args, kwargs=copy_kwargs
            )
            
            # 返回当前的执行key以秒为单位返回 key 的剩余过期时间和值
            ttl, ret = await backend.get_with_ttl(cache_key)
            
            # 如果不存在我们的请求对象
            if not request:
                # 如果当前值存在信息,就是有缓存数据
                if ret is not None:
                    # 使用的我们数据解析返回对应的缓存数据
                    return coder.decode(ret)
                    
                # 没有缓存的话,继续往下执行
                ret = await func(*args, **kwargs)
                # ret请求结果返回之后,开始存到我们的存贮器上
                await backend.set(cache_key, coder.encode(ret), expire or FastAPICache.get_expire())
                # 然后返回我们的结果
                return ret
            
            # 如果存在请求的对象,如果不是的get的方法的就直接继续跑了!
            if request.method != "GET":
                return await func(request, *args, **kwargs)
            # 判断if-none-match处理机制,如果是已经存在版本信息,我们的不会继续请求,而是直接的缓存缓存的数据,并在指定304的响应码
            if_none_match = request.headers.get("if-none-match")
            # 如果我们的缓存结果对象不为空
            if ret is not None:
               # 我们的响应体信息存在的话
                if response:
                    response.headers["Cache-Control"] = f"max-age={ttl}"
                    
                    # Etag是属于HTTP 1.1属性,它是由服务器生成返回给前端
                    # 当你第一次发起HTTP请求时,服务器会返回一个Etag,
                    # 并在你第二次发起同一个请求时,客户端会同时发送一个If-None-Match,而它的值就是Etag的值(此处由发起请求的客户端来设置)。
                    # 服务器会比对这个客服端发送过来的Etag是否与服务器的相同
                    # 如果相同,就将If-None-Match的值设为false,返回状态为304,客户端继续使用本地缓存,不解析服务器返回的数据(这种场景服务器也不返回数据,因为服务器的数据没有变化)
                    #如果不相同,就将If-None-Match的值设为true,返回状态为200,客户端重新解析服务器返回的数据
                    
                 
                    # 对结果进行一次hash
                    etag = f"W/{hash(ret)}"
                    # 如果值的哈希和前端传入的值一样的话,说明我们的没有必要继续处理,直接让客户端使用本地的缓存处理即可,此时我们的响应体也不会返回
                    if if_none_match == etag:
                        response.status_code = 304
                        return response
                    # 如果不一样,设置一个新的ETag值返回
                    response.headers["ETag"] = etag
                return coder.decode(ret)
            
            如果没有缓存
            ret = await func(*args, **kwargs)
            
            await backend.set(cache_key, coder.encode(ret), expire or FastAPICache.get_expire())
            return ret

        return inner

    return wrapper

我在代码上标注了一些注释,梳理一下这个装饰的一起处理过程,其实可以理解为是类似中间的方式,所以,之前我们的对于有日志的需求的话,还可以通过这种装饰器的方式来进行日志的处理!

4.2.3 backends包

这个包下面定义主要是几个支持的缓存的扩展们通常一般就是reids和memcache还有我们的自带的内存,

通常这些都有会有基类,

import abc
from typing import Tuple


class Backend:
    @abc.abstractmethod
    async def get_with_ttl(self, key: str) -> Tuple[int, str]:
        raise NotImplementedError

    @abc.abstractmethod
    async def get(self, key: str) -> str:
        raise NotImplementedError

    @abc.abstractmethod
    async def set(self, key: str, value: str, expire: int = None):
        # Expire 命令用于设置 key 的过期时间,key 过期后将不再可用。单位以秒计。
        raise NotImplementedError

    @abc.abstractmethod
    async def clear(self, namespace: str = None, key: str = None) -> int:
        raise NotImplementedError

然后我们的对应的需要扩展的需要去实现上面的对应的方法即可。

如redis的扩展的实现:

from typing import Tuple

from aioredis import Redis

from fastapi_cache.backends import Backend


class RedisBackend(Backend):
    def __init__(self, redis: Redis):
        self.redis = redis

    async def get_with_ttl(self, key: str) -> Tuple[int, str]:
        p = self.redis.pipeline()
        p.ttl(key)
        p.get(key)
        return await p.execute()

    async def get(self, key) -> str:
        return await self.redis.get(key)

    async def set(self, key: str, value: str, expire: int = None):
        return await self.redis.set(key, value, expire=expire)

    async def clear(self, namespace: str = None, key: str = None) -> int:
        if namespace:
            lua = f"for i, name in ipairs(redis.call('KEYS', '{namespace}:*')) do redis.call('DEL', name); end"
            return await self.redis.eval(lua)
        elif key:
            return await self.redis.delete(key)

感觉这个设计的还是可以理解的!没那么复杂!

其他的开源库的,其实大体的思路都和上面的一样。这里暂时不展开太多了!!!哈哈!!

4.2.4 设置过期时间的一个方法

设置过期的时间的一些扩展方法: ttl = calculate_ttl(expire)


def calculate_ttl(expire: Union[int, timedelta]) -> int:
    if isinstance(expire, timedelta):
        expire = int(expire.total_seconds())
    return min(expire, ONE_YEAR_IN_SECONDS)

4.2.5 使用functools.partial偏函数扩展装饰器功能

如上面的装饰器cache,我们如果需要扩展出几个默认过期时间的新的函数的名的话,可以如下操作:

# 基于partial的基础上设置expire默认值,并新建出一个新的函数名
cache_one_minute = partial(cache, expire=60)
cache_one_hour = partial(cache, expire=60*60)
cache_one_day = partial(cache, expire=60*60*24)
cache_one_week = partial(cache, expire=60*60*24*7)
cache_one_month = partial(cache, expire=60*60*24*30)
cache_one_year = partial(cache, expire=60*60*24*365)

#,使用update_wrapper(),来装饰函数partial对象
update_wrapper(cache_one_minute, cache)
update_wrapper(cache_one_hour, cache)
update_wrapper(cache_one_day, cache)
update_wrapper(cache_one_week, cache)
update_wrapper(cache_one_month, cache)
update_wrapper(cache_one_year, cache)

使用的时候也和上面cache一个鸟样的直接:

@app.get("/test_cache_one_day")
@cache_one_day()
def partial_cache_one_day(response: Response):
    return 'ok'

结尾

简单小笔记!仅供参考!

END

简书:www.jianshu.com/u/d6960089b…

掘金:juejin.cn/user/296393…

公众号:微信搜【小儿来一壶枸杞酒泡茶】

小钟同学 | 文 【原创】【欢迎一起学习交流】| QQ:308711822