python装饰器实战

装饰器简单介绍

  • 装饰器是可调用的对象,其参数是另一个函数(被装饰的函数)。 装饰器可能会处理被装饰的函数,然后把它返回,或者将其替换成另一个函数或可调用对象。下面看个简单的例子:
@decorate
def target():
    pass
  • 其实就是:
target = decorate(target)
  • 装饰器的一大特性是,能把被装饰的函数替换成其他函数。第二个特性是,装饰器在加载模块时立即执行
  • 也就是说,只要你给某个函数新增了装饰器,这个函数就已经通过自由变量的方式传入给了装饰器函数,这个函数内存指向了装饰器所在的函数。这里的加载时你可以理解为导入时,这样就可以在函数运行前做一些其他的操作。下面通过单例模式简单分析下装饰器。

装饰器之单例模式分析

  • 所谓单例模式,简单来说就是类的实例只能存在一个。下面直接看代码进行分析:
from functools import wraps

def Singleton(cls):  # 传入类(cls)而不是实例
    """单例模式之装饰器实现"""
    instance_dict = {}  # 使用字典存储类的实例

    @wraps(cls)  # 消除被装饰函数内置属性和方法被替换的影响
    def wrapper(*args, **kwargs):  # 解包传入参数
        if cls not in instance_dict: 
            # 如果类 cls 不在字典 instance_dict 的 key 中,则调用类cls构造方法新建实例
            instance_dict[cls] = cls(*args, **kwargs)
        return instance_dict[cls]  # 返回类cls的实例

    return wrapper  # 返回闭包函数
  • 通过代码逐行分析应该很清楚,只要给某个类添加了装饰器,这个类在初始化时直接进入装饰器中的闭包函数返回类的唯一实例。
  • 但是这个装饰器还有个显而易见的问题:线程不安全。当有多个线程同时去获取这个单例资源时,装饰器是不会对线程进行限制的。解决方法也很简单,给装饰器中的闭包函数加锁即可,也就是说,该资源只能被一个线程访问,只有该线程释放了锁,其他线程才能访问。如下示例
import threading
from functools import wraps


def synchronized(func):
    '''线程锁装饰器'''
    func.__lock__ = threading.Lock()

    def syn_func(*args, **kwargs):
        with func.__lock__:
            return func(*args, **kwargs)

    return syn_func


def Singleton(cls):
    instance_dict = {}

    @synchronized
    @wraps(cls)
    def wrapper(*args, **kwargs):
        if cls not in instance_dict:
            instance_dict[cls] = cls(*args, **kwargs)
        return instance_dict[cls]

    return wrapper


@Singleton
class Foo:
    pass


if __name__ == '__main__':
    f1 = Foo()
    f2 = Foo()
    print(f1 is f2)  # True
  • 给wrapper闭包函数加锁,这样无论有多少线程,只要没有获取锁资源,都会进行等待,直至上一个线程释放锁,这样就解决了多线程下资源的安全获取。
  • 单例模式是个经典的例子,下面步入正题,说说在实际项目中如何使用装饰器

使用装饰器进行参数校验

  • 在项目开发中进行参数校验是个再正常不过得事情,毕竟我们无法保证前端传入正确的数据。比如在 fastapi 中最常使用的就是 pydantic 库,这个库十分强大,使用起来也很简单。但是这不是我们讨论的主题,现在说下该如何使用装饰器对传入的参数进行校验。
  • 比如你想对传入的参数进行非空校验,但是又不想在函数里面调用其他函数进行参数处理,使用装饰器就可以很优雅的实现,这其实就是设计模式的思想,AOP就是这样做的。下面直接看代码:
def para_none_check(func):
    """参数非空校验"""

    @wraps(func)
    def none_check(data):
        if not data:
            raise TypeError("传入参数不能为空")
        return func(data)

    return none_check
  • 下面来简单测试下
@para_none_check
def func_test(data):
    print("func_test running")
   

if __name__ == '__main__':
    try:
        func_test([])
    except TypeError as e:
        print(e)  # 传入参数不能为空
  • 可知,当传入参数为空时抛出 TypeError 错误,如果不想抛出错误也可以,直接返回错误提示也可以,比如把 raise TypeError("传入参数不能为空") 替换为 return {"code": "01", "error": "传入参数不能为空"}
  • 如果我想传入指定的关键字参数,这又该如果做呢?比如我想传入的参数里面必须包含 name和 age。你可能第一时间想到这样限定:
def func(name, age, **kwargs):
  • 但是实际情况很多时候往往无法直接获取 name 和 age,因为数据是从前端获取的,我们只能拿到传入的数据,并不知道这些数据是不是包含这些字段。不过有了前面的的基础,现在对参数进行校验也就不难了,示例如下:
def para_verification(required_fields: list):  # 指定要传入的参数字段列表
    """参数校验,必须传入装饰器指定的参数"""

    def decorate(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            para = list(kwargs.keys())  # 获取传入的全部参数字段列表
            for field in required_fields:
                if field not in para:
                    return {"code": "01", "error": "传入参数有误", "required_fields": required_fields, 'kwargs': kwargs}
            return func(*args, **kwargs)

        return wrapper

    return decorate
  • 如果难以理解的话先来看个测试示例:
@para_verification(['name', 'age'])  # 指定func_test必须传入name和age
def func_test(*args, **kwargs):
    print("func_test running")
    return True


if __name__ == '__main__':
    kw1 = {"name": "张三", "height": 175}
    kw2 = {"name": "张三", "height": 175, "age": 18}
    print(func_test(**kw1))
    # {'code': '01', 'error': '传入参数有误', 'required_fields': ['name', 'age'], 'kwargs': {'name': '张三', 'height': 175}}
    print(func_test(**kw2))
    # func_test running
    # True
  • 通过示例得出只要在装饰器参数中指定要传入的字段列表,实际运行函数的时候会先用装饰器中的参数和传入的参数进行校验,如果指定要传入的参数都存在,则校验通过,直接运行该函数。很多框架都利用了这样的思想。
  • 当然,参数校验可不是这么简单的事情,这里只是做了初步分析,实际场景可能远远比这复杂,但是万变不离其中,你只要理解了装饰器的原理,编码自然水到渠成。
  • 说了这么多只是为了让你对装饰器有更深的体会。而且举出的实例都是很可能在实际项目中使用的,而且可以直接拿来使用。下面讲讲如何利用装饰器以及一些模块进行内存占用检测。

接口内存泄露检测

  • 可能有人会问,python作为一种动态语言,也会存在内存泄露吗?pyhton确实不容易发生内存泄露,但是并不表示不会发生。循环引用就是一个典型的例子,python解释器会在对象的引用计数归零时删除该对象。
  • 那到底什么是引用计数呢?比如a = [1, 2],给 a 赋值了一个列表对象,那么 a 就指向了这个列表的内存地址,这称为强引用,因为弱应用不太常见,所以强引用一般就称为引用,如果我们再赋值 b = a, 这样列表对象又有了一个引用,引用计数为2。如果我们这时候删除a:del a,这样其实是删除了变量 a 对列表对象的引用,并没有直接删除列表这个对象。
  • 简单来说,就是所有指向对象的变量都不在存在后才会去删除这个对象,接着上面,给b重新赋值改变b的内存指向:b = [1, 4],这时候列表对象 [1,2] 没有任何变量指向它,引用计数归零,该对象被gc删除回收内存。
  • 简单了解了python 的垃圾回收机制,我们也就知道为什么会出现内存泄露了:只要一个对象的引用计数没有归零,那么这个对象就不会被删除。但是实际项目中难的不是如何解决内存泄露,而是如何检测出哪里发生了内存泄露。
  • 检测内存泄露的方式有很多,比如使用pyrasite库进行远程检测。其实内存检测是提前设置的,我们只需要在接口上添加装饰器,然后在装饰器中统计接口运行时各个对象的占用内存或者哪些文件的第几行代码占用内存。这里使用python3 内置的 tracemalloc 库就可以了。
  • 先说下tracemalloc 的用法,如下示例:


  • 打印结果如下


  • 可以看到第40行占用了3532kb内存,这样就能知道哪行代码存在性能问题从而进行优化。
  • 也可以在不同地方新建快照,比较代码运行后的性能差异:


  • 测试打印结果


  • 可以看到占到用较高的就只有第40行。
  • 学会了tracemalloc 的基本使用,接下来使用装饰器来实现:
import tracemalloc as tc
from functools import wraps
from loguru import logger

def interface_memory_leak_check(func):
    """接口内存占用检测"""

    @wraps(func)
    def wrapper(*args, **kwargs):
        tc.start()  # 开始跟踪内存分配
        snapshot1 = tc.take_snapshot()  # 建立快照
        re_data = func(*args, **kwargs)
        snapshot2 = tc.take_snapshot()  # 建立快照
        top_stats = snapshot2.compare_to(snapshot1, 'lineno')  # 比较两段快照之间的内存
        logger.info("--------------------[ Top 10 differences ]----------------------")
        for stat in top_stats[:10]:
            logger.info(stat)
        tc.stop()
        return re_data

    return wrapper
  • 这里其实就是对接口运行前后内存占用进行检测,然后日志打印占用最高内存的十个地方。
  • 接下来我用实际项目接口进行测试


  • 然后使用测试工具测试该接口,我这里使用的yaki,一般来说postman也就足够了。测试后看下docker容器部分日志如下所示:


  • 可以看出占用内存前十的基本都是第三方库,说明该接口我们自己写的代码在性能这块没有出现大的问题。
  • 当然,这只是性能检测的小试牛刀 ,内存占用检测很多实际情况是比较复杂的,但是这已经足以说明装饰器的强大了。
  • 如果你完全没有用过装饰器,那现在开始还不晚,前提是你得知道装饰器的原理,理解什么是自由变量和闭包。

你可能感兴趣的:(python装饰器实战)