ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python装饰器从入门到实战:原理、闭包与经典应用场景

Python装饰器从入门到实战:原理、闭包与经典应用场景 写装饰器之前先聊聊我为什么觉得这玩意儿值得单独写一篇。我见过不少Python开发者基础语法学得挺熟for循环、dict、list切片都信手拈来但一看到项目代码里login_required、app.route这种写法就懵了。其实语法就是Python装饰器最常见的落地形态你天天在用Flask、Django、FastAPI早就被装饰器包围了只是没意识到而已。这篇东西适合两类人一是刚把def和class搞清楚、准备进阶的Python入门读者二是写了一段时间Python但一直是“会用但说不清”状态、想彻底弄懂装饰器原理的人。我尽量不用那些教科书式的绕口定义直接扒开装饰器的底裤让你看完能自己动手写并且明白什么场景该用、什么场景别硬用。1. 装饰器解决什么问题先从一段重复代码说起1.1 没有装饰器之前你的代码长什么样假设你现在接了个活儿要给公司内部的一个数据处理模块加上运行日志。需求很简单每个函数执行完打印一行日志记录函数名、入参、耗时。在没有装饰器的写法里你可能会在一个公共模块里写一个log_call函数然后在每个需要打日志的函数里手动调用它。我拿一个最简单的加法函数举例你感受一下import time def log_call(func_name, args, cost_ms): print(f[LOG] {func_name} 被调用参数: {args}耗时: {cost_ms:.2f} ms) def add(a, b): start time.perf_counter() result a b cost (time.perf_counter() - start) * 1000 log_call(add, (a, b), cost) return result def sub(a, b): start time.perf_counter() result a - b cost (time.perf_counter() - start) * 1000 log_call(sub, (a, b), cost) return result print(add(3, 5)) print(sub(8, 2))这代码能跑但问题一眼就能看出来start计时、log_call调用这段逻辑在add和sub里复制粘贴了两遍。如果业务代码里有三十个函数你就得复制三十遍而且每加一个函数都要记得去贴这段代码。更麻烦的是哪天后端要求把日志输出到文件你得改log_call函数——好这个还好但如果你要改的是“调用前打印一条日志、调用后打印一条日志”这种顺序你就要把几十处代码全部翻出来改一遍。这种问题在工程上叫横切关注点。什么意思就是“打日志”“权限校验”“性能计时”这类逻辑它不属于某个具体业务函数的核心计算逻辑而是横跨在很多函数之上的一层公共需求。传统的函数调用方式没法很好地处理这种横切逻辑只能靠手动在每个函数里插入公共代码。1.2 装饰器的核心思想在一个函数的外面包一层装饰器的思路其实特别朴素我不改add函数内部的任何代码我在add函数的外面包一层“壳”。这一层壳负责在调用add之前做点事情比如记录开始时间调用add之后再做点事情比如计算耗时、打日志然后把add的返回值原样交给调用方。这样一来add函数本身只关心两数相加代码干净。公共逻辑只写一次通过“包壳”的方式复用到所有函数上。你可能会说这不就是“代理模式”吗对Python装饰器从设计模式的角度看就是面向切面编程和代理模式在语言层面的直接实现。而且Python做得更绝它用语法把这个“包壳”的过程变成了一行注解式声明让代码的可读性大幅提升。这里有个关键前提必须理解Python函数是一等公民。意思是函数和整数、字符串、列表一样可以被赋值给变量可以作为参数传给另一个函数也可以作为另一个函数的返回值。装饰器正是基于这个特性实现的。如果你对“函数可以作为参数传递”这个说法感觉不自在我建议你先把下面这段基础代码跑一遍def say_hello(): print(hello) # 函数赋值给变量 f say_hello f() # 输出 hello # 函数作为参数传入另一个函数 def call_twice(func): func() func() call_twice(say_hello) # 输出 hello # 输出 hello理解了这两点装饰器的大门就已经打开了一半。2. 从零手写一个装饰器语法与写法演进2.1 函数式装饰器的最小实现代码是最有说服力的。下面这个timer装饰器就是最小可用的版本作用是把被装饰函数的执行耗时打印出来import time def timer(func): def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) cost (time.perf_counter() - start) * 1000 print(f{func.__name__} 耗时 {cost:.2f} ms) return result return wrapper timer def add(a, b): time.sleep(0.1) # 模拟耗时操作 return a b print(add(3, 5))你把这代码跑一下输出大致是add 耗时 100.23 ms 8我们一句一句拆解这个装饰器到底干了什么timer是一个普通函数它的参数func接收的是被装饰的函数对象也就是add本身。在timer内部定义了wrapper函数这个wrapper是真正的“壳”。它用*args, **kwargs接收任意位置参数和关键字参数这样不管被装饰的函数签名是什么wrapper都能接得住。wrapper内部先记录开始时间然后调用func(*args, **kwargs)——注意这行才是真正执行业务逻辑的地方。wrapper把func的返回值用return result原样返回。这个return极其关键很多人第一次写装饰器容易忘了它导致被装饰函数返回的结果变成None。最后timer返回wrapper函数对象。而timer这个语法糖等价于执行了add timer(add)。也就是说你写完timer之后再调用add实际调用的已经不是原来那个add函数了而是timer(add)返回的wrapper函数。add这个名字被重新绑定到了wrapper上。这就是理解装饰器最重要的一句话装饰器本质是“把原函数替换成了一个新函数”只不过这个新函数在内部会调用原函数。2.2 闭包为什么wrapper还能用func这里有一个很自然的疑问wrapper里面调用了func但wrapper是在timer内部定义的按道理说timer执行完返回wrapper之后timer的局部变量func就应该被销毁了那wrapper再调用func不会报错吗这就牵扯到Python的闭包机制。简单说当一个内层函数引用了外层函数的局部变量时Python会把外层函数的这个变量“打包”进内层函数的环境里哪怕外层函数已经执行完毕这个变量也依然存活。拿生活打个比方timer像是办了一张健身卡func就是那张卡wrapper是拿着卡的你。timer办完卡关店走人了但卡还能用因为卡已经在你手里了。在Python里你可以通过wrapper.__closure__看到捕获的环境变量。感兴趣的话可以自己打印一下你会看到闭包单元里存的正是func函数对象。闭包是装饰器的地基理解了闭包你不仅能看懂装饰器还能自己造出带状态的高级用法。2.3 别忘了 functools.wraps不写会踩大坑如果这辈子只能记住装饰器的一条最佳实践我推荐记住这句定义装饰器时别忘了加functools.wraps(func)。你可能会觉得上面那个timer不是挺好的吗能跑、能打印耗时、能返回结果。但问题是它把add函数最重要的身份信息给弄丢了。不信你看print(add.__name__)不加functools.wraps时输出是wrapper而不是add。这意味着IDE 的函数签名提示会变得混乱。调试时看到的函数名全是wrapper排查问题非常痛苦。某些框架比如Flask、Django的URL路由、DRF的权限校验依赖__name__、__doc__等元信息做映射丢了这个会直接出bug。修复方法很简单给wrapper加上一行装饰器import time import functools def timer(func): functools.wraps(func) def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) cost (time.perf_counter() - start) * 1000 print(f{func.__name__} 耗时 {cost:.2f} ms) return result return wrapper timer def add(a, b): 返回 a 与 b 的和 return a b print(add.__name__) # add print(add.__doc__) # 返回 a 与 b 的和functools.wraps做的事情其实就是把func的__name__、__doc__、__module__、__dict__等属性复制到wrapper上让wrapper伪装得和原函数一模一样。它本身也是一个装饰器所以可以说wraps是“装饰器里的装饰器”。3. 进阶玩法带参装饰器与类装饰器3.1 当装饰器本身需要参数时再包一层上面写的timer用法很简单timer后面没有括号。但实际业务里经常遇到这种需求我想给装饰器传参比如“日志级别”“重试次数”“缓存过期时间”。这时候timer这种写法就不够了得换成timer(levelINFO)这种带括号的写法。带参装饰器的实现诀窍是在原来的装饰器外面再包一层函数。这一层函数接收装饰器的参数返回真正的装饰器。我直接上代码import functools import time def timer(levelINFO): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) cost (time.perf_counter() - start) * 1000 print(f[{level}] {func.__name__} 耗时 {cost:.2f} ms) return result return wrapper return decorator timer(levelWARNING) def add(a, b): return a b print(add(3, 5))看到没timer(levelWARNING)执行后返回的是decorator函数然后decorator再对add进行修饰。所以带参装饰器的执行流程是add timer(levelWARNING)(add)拆开来就是两步decorator timer(levelWARNING) add decorator(add)很多初学者在这里会绕晕我给你的记忆锚点是带参装饰器就是三层函数。第一层接收参数第二层接收函数第三层是wrapper接收实际业务参数。如果你的装饰器逻辑很复杂三层的写法虽然有点繁琐但结构是最清晰的。3.2 用类写装饰器哪些场景更适合面向对象函数式装饰器足够覆盖90%的场景了但还有一种情况适合用类来实现装饰器装饰器需要维护内部状态。比如你要实现一个“记录函数被调用次数”的功能用函数式装饰器也能写但要声明一个nonlocal变量用类写着会更直观。先看类装饰器的最小形态import functools class Counter: def __init__(self, func): functools.update_wrapper(self, func) self.func func self.count 0 def __call__(self, *args, **kwargs): self.count 1 print(f{self.func.__name__} 已调用 {self.count} 次) return self.func(*args, **kwargs) Counter def say_hi(): print(hi) say_hi() say_hi()这里Counter等价于say_hi Counter(say_hi)。Counter是一个类实例化时接收原函数并保存到self.func同时初始化self.count 0。之后每次调用say_hi实际上调用的是Counter实例的__call__方法也就是把say_hi这个变量变成了一个“可调用对象”。类装饰器的优势在于状态存储非常自然比如计数器、缓存、连接池这类场景直接把状态挂在self上就行不用纠结闭包变量的nonlocal声明。而且在__init__里还可以接收额外参数来配置装饰器行为扩展性很强。但我个人在实际项目里的习惯是简单的装饰器用函数式一两屏能写完逻辑复杂、状态多、还带参数配置的才上类装饰器。函数式装饰器在多数团队里可读性仍然是最高的类装饰器如果__call__写得太多反而不好维护。3.3 多个装饰器叠加时执行顺序怎么算项目里你很可能看到这种代码login_required timer(levelINFO) def dashboard(): return dashboard data这时候执行顺序是什么很多人会以为是先执行login_required再执行timer实际上装饰器的顺序和这个看起来是“反”的。我们拆解一下dashboard login_required(timer(levelINFO)(dashboard))也就是说离函数定义最近的装饰器先执行。timer先包装dashboard然后login_required再包装“被timer包装过的函数”。调用顺序也是嵌套的先进入login_required的逻辑再进入timer的逻辑然后才进入dashboard本体。你可以把装饰器想象成洋葱皮调用是从最外层一层层往里剥返回是从最里层一层层往外传。实际业务中常见的错误是把login_required和timer顺序搞反导致login_required拿到的是没有被正确包装的函数元信息从而引发权限判断失败。这个坑我后面会详细说。4. 真实业务场景日志、鉴权、重试、缓存和性能监控4.1 日志记录一个即插即用的日志装饰器日志是装饰器最经典的应用之一。我以前维护过一个内部数据同步服务里面有几十个同步任务函数每个函数执行完都要记录执行情况。最开始大家各写各的日志逻辑格式还都不统一后来我写了一个log_call装饰器把所有函数统一套上去日志格式立刻整齐了。给你看一个可以抄作业的版本import functools import logging import time logging.basicConfig(levellogging.INFO) def log_call(levellogging.INFO): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): logging.log(level, f开始调用 {func.__name__}, args{args}, kwargs{kwargs}) start time.perf_counter() try: result func(*args, **kwargs) cost (time.perf_counter() - start) * 1000 logging.log(level, f{func.__name__} 执行成功, 耗时 {cost:.2f} ms) return result except Exception as e: logging.log(level, f{func.__name__} 执行失败: {e}, exc_infoTrue) raise return wrapper return decorator log_call() def process_data(data): if data is None: raise ValueError(data 不能为空) return len(data) print(process_data([1, 2, 3]))这个装饰器同时处理了成功和异常两种情况并且异常时会记录traceback后重新抛出不影响上层调用方的异常处理逻辑。注意我在wrapper里用了try/except但没有吞掉异常这是日志装饰器的一个关键设计决策日志装饰器只负责记录不应该改变函数原有的异常行为。4.2 鉴权校验被 login_required 支配的日子写过Web应用的人对权限装饰器再熟悉不过了Flask里写login_requiredDjango里写login_required或自定义permission_required。这类装饰器的核心逻辑是检查当前请求上下文里有没有登录用户没有就跳转登录页或返回401有就放行。我拿一个Flask风格的鉴权装饰器举例import functools from flask import request, session, redirect, url_for, jsonify def login_required(view_func): functools.wraps(view_func) def wrapped_view(*args, **kwargs): # 检查 session 中是否有当前用户 if session.get(user_id) is None: # 如果请求期望 JSON返回 401 而不是重定向 if request.path.startswith(/api/): return jsonify({code: 401, message: 请先登录}), 401 return redirect(url_for(login)) return view_func(*args, **kwargs) return wrapped_view app.route(/dashboard) login_required def dashboard(): return 欢迎回来这段代码的核心逻辑只有一块判断session里有没有user_id。有就调用原视图函数没有就拦截。但就这么点逻辑如果不用装饰器你得在每个需要登录的视图函数开头复制七八行一样的代码。装饰器把这个横切关注点抽得干干净净。鉴权装饰器一个容易踩的坑是装饰器的判断时机。如果你把login_required放在路由注册装饰器之后比如写成login_required app.route(/dashboard) def dashboard(): ...执行顺序就错了。app.route还没执行login_required拿到的是原始函数而不是Flask注册的视图函数很可能导致权限装饰器不生效。所以牢记路由装饰器要放最上面权限、日志这层放下面。4.3 自动重试爬虫场景里的救星做爬虫的人应该都有这种体会单次请求偶尔会超时或者返回状态码异常但不代表网站封了你可能只是网络抖动。如果你不做重试整个爬虫任务可能就断在这里如果你在业务代码里写一堆重试逻辑代码又会变得很难看。用装饰器把重试逻辑抽出来是我在实际爬虫项目里觉得最值的一笔投入。看这个版本import functools import time import random def retry(max_retries3, delay1, exceptions(Exception,)): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, max_retries 1): try: return func(*args, **kwargs) except exceptions as e: if attempt max_retries: raise sleep_time delay * attempt random.uniform(0, 0.5) print(f{func.__name__} 第 {attempt} 次失败: {e}, {sleep_time:.2f}s 后重试) time.sleep(sleep_time) return None return wrapper return decorator retry(max_retries3, delay1, exceptions(TimeoutError, ConnectionError)) def fetch_page(url): # 模拟请求偶尔失败 if random.random() 0.6: raise TimeoutError(连接超时) return fhtml{url}/html print(fetch_page(https://example.com))我特意做了两个设计一是重试间隔递增第一次等1秒、第二次等2秒避开了“同步重试固定间隔”容易在服务端形成固定节奏轰炸的问题第二是在递增间隔上加了一点随机抖动random.uniform(0, 0.5)这是从负载均衡策略里借鉴的思路能降低多个客户端齐刷刷重试造成的“惊群效应”。这个装饰器可以应用在几乎所有网络请求、数据库连接、文件上传等场景。我之前在爬虫里配合代理IP池一起用效果很好请求失败就重试连续失败多次就换代理——换代理的逻辑也可以用另一个装饰器来写装饰器之间彼此独立、可以组合。4.4 结果缓存用空间换时间的典型用法有些函数计算成本很高但输入参数可能反复出现。比如一个查询数据库的函数可能同一批参数被调用很多次。如果每次都查库既慢又浪费连接资源。缓存装饰器就可以把最近的计算结果存起来下次同样的参数直接命中缓存。import functools import time def cache_result(func): cache {} functools.wraps(func) def wrapper(*args, **kwargs): # 这里简化为只以 args 作为 key key args if key in cache: print(f[缓存命中] args{args}) return cache[key] result func(*args, **kwargs) cache[key] result return result return wrapper cache_result def expensive_calc(n): print(f正在计算 {n}...) time.sleep(2) return n * n print(expensive_calc(10)) # 计算耗时2秒 print(expensive_calc(10)) # 命中缓存立即返回需要注意这是一个非常简化的版本实际生产环境要小心几个问题缓存key的生成args直接作为key如果参数是不可哈希对象比如list会直接报错。所以实际项目中一般会用参数对象的一个稳定标识比如id来生成key。缓存何时失效如果被缓存的数据会变化你可能需要支持过期时间否则会出现“缓存的是旧数据”这种问题。内存膨胀无限缓存会导致内存占用越来越大通常要限制缓存大小或者实现LRU淘汰策略。这些功能Python自带了一个利器functools.lru_cache。它实现了带LRU淘汰策略的缓存官方出品、性能可靠用法极其简单import functools functools.lru_cache(maxsize128) def expensive_calc(n): print(f正在计算 {n}...) return n * n所以如果你只是想用缓存先别自己造轮子用lru_cache就好。自己写缓存装饰器更多是为了理解原理或者缓存逻辑比较复杂比如需要支持分布式缓存Redis的时候再去扩展。4.5 性能监控线上接口耗时排查利器最后一个场景来自一个真实的线上事故。我之前负责的一个服务某个接口在高峰期响应贼慢但一开始大家不知道慢在哪个函数上。后来我在关键路径的几个函数上挂了性能监控装饰器把每次调用的耗时都记录下来推送到监控系统很快就定位到了一个正则匹配特别耗时的工具函数。性能监控装饰器的核心逻辑和timer差不多但会增加一些生产环境的考虑比如总耗时阈值、慢请求告警、以及把耗时指标上报到监控平台。import functools import time import threading def monitor(threshold_ms100): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) cost_ms (time.perf_counter() - start) * 1000 # 实际项目中这里会上报给 Prometheus/StatsD 等 if cost_ms threshold_ms: print(f[慢请求] {func.__name__} 耗时 {cost_ms:.2f} ms超过阈值 {threshold_ms} ms) else: print(f{func.__name__} 耗时 {cost_ms:.2f} ms) return result return wrapper return decorator monitor(threshold_ms50) def query_user(user_id): time.sleep(0.1) return {id: user_id, name: Alice} query_user(1)这类监控装饰器在微服务架构里几乎每家都有本质上和日志装饰器是同一个套路只是上报目标从日志变成了监控系统。5. 常见坑与排查技巧实录5.1 坑一忘了 return result这个坑我见过太多次了简直是装饰器入门第一坑。如果你的wrapper里调用了func(*args, **kwargs)但忘记return func(...)的结果那么所有被装饰函数的返回值都会变成None。排查方法很简单写一个返回值的测试函数直接print(func())如果输出的是None优先检查装饰器里有没有return。很多人在装饰器内部写了result func(...)然后打印了result但忘了把result返回出去。5.2 坑二丢失函数元信息前面提过不加functools.wraps被装饰函数的__name__、__doc__都会变成wrapper的。这个问题的坑爹之处在于很多情况下它不报错函数功能完全正常你根本注意不到。直到有一天你要做API文档自动生成或者用inspect.signature去解析函数签名才会发现拿到的全是(*args, **kwargs)原函数的参数信息彻底丢失。所以我现在写装饰器的肌肉记忆就是先写functools.wraps(func)再写wrapper的body。没有例外。5.3 坑三多个装饰器顺序搞反装饰器的执行顺序和视觉顺序相反这个前面已经说过。在实际项目中我见过一个很真实的场景有人写Flask视图函数时把login_required放在app.route上面结果权限装饰器执行的时候app.route还没注册成功路由匹配不到界面直接404。这里的经验法则是# 正确顺序路由装饰器在最外层最上面 app.route(/dashboard) login_required def dashboard(): ... # 错误顺序权限装饰器跑到路由上面 login_required app.route(/dashboard) def dashboard(): ...只要记住“离函数越远的装饰器越先执行”这个规则就不会搞错。最外层是框架级别的路由往里是功能级别的权限、日志、重试。5.4 坑四类装饰器里忘了call类装饰器的实现依赖于__call__方法如果你写了一个类当作装饰器但忘了定义__call__调用被“装饰”后的函数时就会报TypeError: MyDecorator object is not callable。这个报错信息其实非常典型因为“对象不可调用”基本就是在提醒你要么类没有实现__call__要么你把类实例当成函数用了。写类装饰器时建议先在脑子里过一遍“实例化之后要能直接被()调用”那就必须要有__call__。5.5 坑五滥用装饰器导致排查困难最后说一个设计层面的坑装饰器虽然好但别什么都往上面套。如果一个函数被十几个装饰器包裹调用链路会非常长一旦出了bug排查时得一层层解开洋葱皮成本很高。我的建议是装饰器职责单一每个装饰器只做一件事。超过三个装饰器叠加就要考虑是不是该把逻辑合并或者换个方案。装饰器的逻辑要保持轻量不要在装饰器里做重计算、发外部请求否则会影响所有被装饰函数的性能。给装饰器写清楚docstring说明参数含义和行为不然后人根本不敢动。某次我在一个遗留项目里看到一个函数头上挂了6个装饰器包括login_required、cache、permission_required、monitor、retry、log_call我排查问题的时候光理清调用顺序就花了半天。从那以后我在团队里的代码规范里明确写了单个函数的装饰器原则上不超过3个。写在最后的一点个人体会装饰器这东西刚接触时会觉得有点绕尤其是三层嵌套和类装饰器的写法很容易让人劝退。但我觉得理解装饰器最好的方式就是自己从“复制粘贴公共代码”的痛苦里走一遍然后再去看装饰器你会觉得“这设计真妙”。我现在写Python几乎每个项目里都会自己定义几个装饰器要么是给内部服务做统一重试和日志要么是在脚本工具里做参数校验和计时。如果你正在入门装饰器我的建议是别急着背语法先把“在函数外面包一层壳”这个思想吃透然后动手写三个练习一个timer计时装饰器、一个retry重试装饰器、一个login_required鉴权装饰器。写完这三个你对装饰器的理解基本就到能干活的程度了。后面遇到更复杂的用法比如带状态的类装饰器、装饰器的装饰器回过头再看这篇文章里的代码就会有“原来是这么回事”的感觉。
返回列表