装饰器的哲学
在写程序的时候,我们往往会遇到这样的情况:我们写了一大堆功能函数,但是我们突然发现我们需要对这些功能函数进行一些相同的额外操作。当然,我们可以在每个函数中都写一遍这些额外操作,但是这样做会让代码变得冗余,而且不利于维护。这时候,我们就可以使用装饰器来解决这个问题。
装饰器的本质其实也是一个函数,它可以对其他函数进行“装饰”,即能做到在不改变原函数代码的情况下,为其添加额外的功能,或对函数的执行情况进行监控等。
装饰器的使用
Python 中的装饰器是通过@符号来使用的,基本上有两种常见的结构,即不带参数和带参数。
# 不带参数
@decorator
def func():
pass
# 带参数
@decorator(arg1, arg2)
def func():
pass
Python 内置了很多装饰器,比如在创建类的时候,我们可以使用@dataclass来自动实现__init__方法,或者使用@property来实现属性的 getter 和 setter 方法。还有可以使用@staticmethod和@classmethod来实现静态方法和类方法。
带参数的装饰器例如 Python 著名 Web 框架 Flask 中的@app.route('/path'),它可以将一个函数绑定到一个 URL 上,当用户访问这个 URL 的时候,就会执行这个函数。
装饰器的构建
在刚接触这些装饰器的时候,我感觉它们非常神奇,只用给自己写的函数带个帽子,就能完成一些特定的逻辑。现在,我学习这门语言已经有一段时间了,我觉得我可以用自己的话来解释一下这些装饰器的原理了。我一直认为,各种编程教程里使用Hello World来解释编程语言的原理是不够的,因为它太简单了,人们没有办法理解到在真实使用时,这种语法结构能带来的便利。所以,我用我自己的项目里的真实例子来解释一下。
数据下载和读取需求
例如,我有一个复杂的函数,它的功能是从网络数据库中获取一些数据,然后进行一些格式转换,然后将结果传给下一个函数,方便进行下一步的逻辑。这个函数的代码如下:
def get_data(url, start_date, end_date, label_name):
"""从网络数据库中获取数据
Args:
url (str): 数据接口地址
start_date (str): 开始日期 如:2023-03-11
end_date (str): 结束日期 如:2023-03-12
label_name (str): 标签字段名称
Returns:
list: 数据列表
"""
# 从网络数据库中获取数据
data = requests.get(
url,
params={
'start_date': start_date,
'end_date': end_date,
'label_name': label_name
}
)
# 转换成pandas的DataFrame格式
data = pd.DataFrame(data)
return data
因为我在做数据分析的项目,我的分析代码随时都在改进,所以我每次重写分析步骤后都要重新运行这个函数。有时候获取的数据非常大,网速也一般,所以后来经常需要比较久的时间才能把数据拉下来。
我首先想到的办法,就是我直接把这个函数改改,把下载下来的数据保存到本地,学过 pandas 的朋友可能对这个办法很熟悉,例如:
data = pd.to_csv('data.csv')
然后我在之后调用的时候再专门写一个函数来读取数据,
def read_data():
data = pd.read_csv('data.csv')
# 熟悉的人可能也知道,pandas的to_csv和read_csv不是互为逆运算
# 即read_csv读取的数据和保存之前的df不是完全一样的
# 所以还需要再做一些格式转换。
return data
这个办法暂时解决了我的问题。但是随着我项目的推进,我发现我要获取的数据越来越多越来越杂,就刚刚的函数get_data来说,url会改变,日期范围也会变化,还有一些其他的参数也会变化。难道我要针对每个数据获取函数都写一个read_data函数吗?
缓存机制
这时候我便想到了,找一个办法,把数据保存和提取的逻辑抽象出来,写成一个函数,然后让每个数据获取函数运行的时候同时执行保存和提取的逻辑。这便是互联网浏览器的缓存机制,当你访问一个网页的时候,浏览器会先检查本地是否有缓存,如果有,就直接使用缓存,如果没有,就去服务器上下载。这样就可以减少网络请求,提高网页的加载速度。
在我的数据分析项目里,我也可以这样做。我首次拉数据的时候,把它保存下来,我下次再拉相同数据的时候,就直接从本地读取,不用再去网络上拉取了。这样就可以减少网络请求,提高数据获取的速度。
这里面还有一些细节需要思考,在浏览器的缓存机制中,浏览器访问一个 url 的时候,会先问一下服务器,这个 url 对应的数据有没有更新,如果有更新,就会重新下载,如果没有更新,就会使用本地的缓存。也就是说,浏览器会先做一个流量很小的请求,获取数据是否更新的信息。然而在我的拉数据的过程里,我没有办法解构 url 的访问过程机制,所以我要想一个办法,判断访问的数据是否已经下载过了。这个办法很简单,我只要把每次访问的参数都保存下来,然后下次再访问的时候,先判断这些参数是否和上次一样,如果一样,就直接从本地读取,如果不一样,就重新拉取数据。
这个办法依然不够完备,万一遇到数据更新与访问参数无关的情况,就会出现问题。但是对于我目前的项目来说,这个办法已经足够了。逻辑已经想好了,接下来就是实现了,像这种功能,装饰器的便利就大大体现出来了。
具体实现
保存和读取
在 Python 中,我们可以使用 Pickle 来保存和读取数据。Pickle 是 Python 自带的一个序列化模块,可以把 Python 中的对象转换成字节流,然后再把字节流转换成 Python 对象。
import pickle
# 保存数据
with open('data.pkl', 'wb') as f:
pickle.dump(data, f)
# 读取数据
with open('data.pkl', 'rb') as f:
data = pickle.load(f)
pickle 的好处是,它的保存和读取是互为逆运算的,也就是说,我们可以把任何 Python 对象保存到本地,然后再从本地读取出来,而不用担心格式转换的问题。当然,它的缺点是,它不是以文本的形式保存数据,所以我们无法像 csv 一样直接查看。
文件名哈希值化
有时候,我们的参数可能有一些特殊字符,或者函数包含了一些不可序列化的对象,这时候我们就无法直接把参数保存到文件名中。这时候我们可以把参数转换成哈希值。哈希值的计算是一种单向的加密算法,也就是说,我们可以把任意的字符串转换成哈希值,但是我们无法从哈希值中反向推导出原始的字符串。
这种转换方式被广泛用于密码的存储,因为我们可以把用户的密码转换成哈希值,然后把哈希值保存到数据库中,这样即使数据库被黑,黑客也无法从哈希值中反向推导出用户的密码。
我们在缓存逻辑中用它来生成合法而且不会重复的文件名。
这里要提一口,这种算法也不是完备的,也就是说,不同的数据可能会产生相同的哈希值,这种情况被称为哈希碰撞。但是哈希碰撞的概率非常小,对于我的项目来说,可以忽略不计。
我在这里写一个简单的哈希函数,用来把参数转换成哈希值。
import hashlib
def params_to_hash(*args, **kwargs):
"""将函数的参数转换为32个字符串的哈希值
Args:
*args: 位置参数
**kwargs: 关键字参数
Returns:
str: 参数的哈希值前32位
"""
# 用'_'把参数拼接起来
args_str = '_'.join([str(arg) for arg in args])
kwargs_str = '_'.join([f'{key}:{value}' for key, value in kwargs.items()])
# 用'.'把位置参数和关键字参数拼接起来
total_str = '.'.join([args_str, kwargs_str])
# 用sha256算法计算哈希值
result = hashlib.sha256(total_str.encode('utf-8')).hexdigest()[:32]
return result
编写装饰器
先写一个不带参数的装饰器。
from functools import wraps
def cache(func):
# 使用python内置的functools.wraps装饰器
# 保留原函数的元信息
# 这样可以通过func.__name__获取到被装饰的函数名
# 否则,func.__name__ 会变成 'wrapper'
@wraps(func)
def wrapper(*args, **kwargs):
# 保存目录
cache_dir = 'data'
# 文件名
cache_file = f'{cache_dir}/{func.__name__}/{params_to_hash(*args, **kwargs)}.pkl'
# 保存和读取数据的逻辑
def read():
# 读取数据,直接返回数据
with open(cache_file, 'rb') as f:
return pickle.load(f)
def exec_and_save():
# 执行函数,获取结果
result = func(*args, **kwargs)
# 创建保存目录如果目录不存在
os.makedirs(os.path.dirname(cache_file), exist_ok=True)
# 保存结果
with open(cache_file, 'wb') as f:
pickle.dump(result, f)
# 返回结果
return result
# 如果文件存在,就读取文件,否则就执行函数并保存结果
if os.path.exists(cache_file):
return read()
else:
return exec_and_save()
return wrapper
那么它的使用方式就是这样的。
@cache
def get_data(url, start_date, end_date, label_name):
...
运行一次之后,我们就可以在工作目录找到data/get_data目录,里面就是我们的缓存数据了。
现在我的要求比较高,我希望可以指定保存数据的文件夹名,这时候就需要给装饰器加上参数了。我们把刚才的装饰器改改。
def cache(cache_dir='data'):
def inner(func):
@wraps(func)
def wrapper(*args, **kwargs):
cache_file = f'{cache_dir}/{func.__name__}/{params_to_hash(*args, **kwargs)}.pkl'
def read():
with open(cache_file, 'rb') as f:
return pickle.load(f)
def exec_and_save():
result = func(*args, **kwargs)
os.makedirs(os.path.dirname(cache_file), exist_ok=True)
with open(cache_file, 'wb') as f:
pickle.dump(result, f)
return result
if os.path.exists(cache_file):
return read()
else:
return exec_and_save()
return wrapper
return inner
这样我们就可以指定缓存的目录了。
# 不改变默认保存目录
@cache()
def get_data(url, start_date, end_date, label_name):
...
或者
# 目录名改成api
@cache(cache_dir='api')
def get_data(url, start_date, end_date, label_name):
...
'@'是语法糖
最后说一下,@是 python 的语法糖,它的作用是把装饰器的调用放到函数定义的上面,这样就不用在每次调用函数的时候都把原函数包装一次了。
下面的写法与在函数定义的上面加@是等价的。
# 对于不带参数的装饰器
get_data = cache(get_data)
# 对于带参数的装饰器
get_data = cache(cache_dir='api')(get_data)
腻害👍