aiohttp.ClientSession` 的优雅管理:为何选择 `async with`

在异步编程中,尤其是在使用 aiohttp 进行网络请求时,如何正确管理 ClientSession 的生命周期至关重要。一个未被关闭的 session 会导致资源泄漏和 Python 的 ResourceWarning 警告。

本文档详细解释了我们如何在 GithubService 中解决 aiohttp.ClientSession 自动释放的问题,并阐述了为何我们选择了异步上下文管理器 (async with) 而不是其他方案。

1. 核心问题

aiohttp.ClientSession 对象管理着一个连接池。为了高效地复用 TCP 连接和释放资源,它必须在使用完毕后被显式地关闭。关闭操作本身是一个异步方法:

await session.close()

我们的目标是:找到一种机制,能在 GithubService 对象使用完毕后,自动、安全且可靠地调用 await session.close()


2. 方案一 (错误):使用 __del__ 析构函数

很多有其他语言背景的开发者首先会想到析构函数 (Destructor)。在 Python 中,它对应的是 __del__ 方法。

为什么 __del__ 在这里完全行不通?

  1. __del__ 是同步的__del__ 是一个普通的同步方法,你无法在其中使用 await 关键字。而 session.close() 必须被 await,这就产生了一个无法解决的矛盾。

  2. 调用时机不确定__del__ 的调用是由 Python 的垃圾回收器 (Garbage Collector) 决定的,其运行时机非常不确定。它可能在你期望的时刻(对象不再被引用时)不被调用,甚至在程序退出时才被调用。依赖这种不确定的机制来释放关键资源(如网络连接)是极其危险的。

结论__del__ 绝对不能用于管理需要异步关闭的资源。


3. 方案二 (低效):在每个 API 调用方法中创建 Session

另一个看似可行的方案是,不在 GithubService 中持有 session 实例,而是在每个需要调用 GitHub API 的方法内部创建它。

# 一个低效的示例
class GithubService:
    def __init__(self, oauth_token):
        self.token = oauth_token

    async def get_user(self):
        # 每次调用都创建一个新的 session
        async with aiohttp.ClientSession() as session:
            gh = GitHubAPI(session, "app-name", oauth_token=self.token)
            return await gh.getitem("/user")

    async def get_repos(self):
        # 这里也创建一个新的 session
        async with aiohttp.ClientSession() as session:
            gh = GitHubAPI(session, "app-name", oauth_token=self.token)
            return await gh.getiter("/user/repos")

这种方法的致命缺陷是什么?

  1. 极其低效ClientSession 的主要优势在于连接复用 (Connection Pooling)。每次都创建一个新的 session 意味着每个 API 请求都要经历一次完整的 TCP 连接建立和销毁过程(握手、关闭等),这会带来巨大的性能开销,尤其是在需要连续进行多次 API 调用的场景下。

  2. 代码冗余:违反了 DRY (Don’t Repeat Yourself) 原则。每个方法里都重复着创建 sessionGitHubAPI 实例的逻辑,难以维护。

结论:这种方法虽然能保证 session 被关闭,但牺牲了性能和代码质量,是不可取的。


4. 方案三 (正确且优雅):异步上下文管理器 (async with)

这正是我们在 GithubService 中采用的最终方案。通过实现 __aenter____aexit__ 这两个特殊方法,我们让 GithubService 类自身变成了一个异步上下文管理器

__aenter____aexit__ 简介

这两个方法是 Python 异步上下文管理协议 的核心。任何一个类,只要实现了这两个 async def 方法,就可以在 async with 语句中使用。

  • __aenter__(self): 当程序执行进入 async with 代码块时,__aenter__ 方法会被 await。它的返回值通常是上下文管理器对象本身(通过 return self),并被赋值给 as 后面的变量。它的主要职责是准备和建立资源。

  • __aexit__(self, exc_type, exc_val, exc_tb): 当程序执行离开 async with 代码块时(无论是正常结束还是因为异常),__aexit__ 方法会被 await。它的主要职责是清理和释放资源。exc_type, exc_val, exc_tb 这三个参数用于接收异常信息,如果代码块正常退出,它们的值都为 None

现在,我们来看看它们在 GithubService 中是如何应用的。

__aenter__ (进入时)

  • async with 语句块开始时被调用。
  • 负责创建并持有 aiohttp.ClientSession 实例。
  • 初始化 GitHubAPI 客户端。
  • return selfwith 块可以访问到 GithubService 实例自身。

__aexit__ (退出时)

  • async with 语句块结束时必定被调用(无论是否发生异常)。
  • 这是我们执行清理工作的完美地点。
  • 在这里,我们安全地调用 await self._session.close()

最终实现

class GithubService:
    def __init__(self, oauth_token: str):
        self.oauth_token = oauth_token
        self._session: aiohttp.ClientSession | None = None
        self.gh: GitHubAPI | None = None

    async def __aenter__(self):
        self._session = aiohttp.ClientSession()
        self.gh = GitHubAPI(self._session, "app-name", oauth_token=self.oauth_token)
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        if self._session and not self._session.closed:
            await self._session.close()

使用方式

async def main():
    token = "..."
    async with GithubService(token) as gh_service:
        # 进入 __aenter__,session 已创建
        # 在这里可以高效地进行多次 API 调用,复用同一个 session
        user_info = await gh_service.gh.getitem("/user")
        repos = await gh_service.gh.getiter("/user/repos")
        
    # 退出 async with 块,__aexit__ 被自动调用,session 被安全关闭

结论async with 方案集所有优点于一身:

  • 保证资源释放:无论代码如何退出 with 块,清理逻辑都会执行。
  • 性能高效:在一次业务逻辑中复用同一个 session 和连接池。
  • 代码清晰:将资源管理的逻辑封装在类内部,使用者只需关心业务逻辑。

这是在 asyncio 中管理网络连接、数据库连接等资源的标准最佳实践


5. 进阶讨论:单例模式与 async with

一个常见的问题是:“我能否将 GithubService 实现为单例(Singleton),在整个应用中共享同一个实例,然后还能使用 async with 吗?”

答案是:不能async with 的“用完即毁”模式和单例的“全局共享”模式在设计上是根本冲突的。

5.1 冲突的原因

  • async with 的生命周期async with 被设计用来管理一个有明确作用域的资源。当代码块执行完毕,__aexit__ 必须被调用以关闭资源。它的生命周期是短暂的、局部的

  • 单例的生命周期:单例的设计目标是让一个对象在整个应用的生命周期内只存在一个实例。它的生命周期是长久的、全局的

如果你强行将一个实现了 __aenter__/__aexit__ 的类作为单例,第一个使用它的请求在结束时会调用 __aexit__ 并关闭内部资源(如 aiohttp.ClientSession)。后续所有请求再尝试使用这个单例时,都会因为资源已被关闭而失败。

5.2 正确的做法:在应用级别管理资源

在 FastAPI 这类 Web 框架中,如果你希望共享一个 ClientSession 以提升性能,正确的做法是在应用的启动和关闭事件中管理它。

FastAPI 提供了 lifespan 上下文管理器来优雅地处理这类任务。

第一步:重构 Service,使其接收外部资源

修改 GithubService,让它不再自己创建 session,而是接收一个外部传入的 session。这样它就不再需要是上下文管理器了。

# src/services/gh_service.py (重构后)

class GithubService:
    def __init__(self, session: aiohttp.ClientSession, oauth_token: str):
        self.gh = GitHubAPI(session, "py-webhook-svc", oauth_token=oauth_token)

    async def get_my_user_info(self):
        return await self.gh.getitem("/user")

第二步:在 FastAPI 的 lifespan 中管理全局 Session

在应用的入口(如 main.py)来创建和销毁这个全局的 session

# src/main.py

from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
import aiohttp

app_state = {} # 用于在 lifespan 和其他部分之间传递状态

@asynccontextmanager
async def lifespan(app: FastAPI):
    # 应用启动时:创建 session
    app_state["http_session"] = aiohttp.ClientSession()
    yield
    # 应用关闭时:关闭 session
    await app_state["http_session"].close()

app = FastAPI(lifespan=lifespan)

# 第三步:使用依赖注入 (Dependency Injection)

def get_github_service() -> GithubService:
    """
    FastAPI 依赖项:
    每次请求时,它都会创建一个新的 GithubService 实例,
    但注入的是【同一个】全局共享的 http_session。
    """
    token = "..." # 从配置或环境变量获取
    return GithubService(session=app_state["http_session"], oauth_token=token)

@app.get("/user")
async def get_user(gh_service: GithubService = Depends(get_github_service)):
    user_info = await gh_service.get_my_user_info()
    return user_info

通过这种方式,aiohttp.ClientSession 成为了一个由应用生命周期管理的真正单例,而 GithubService 则通过依赖注入在每次请求时被轻量级地创建,并共享同一个 session。这才是现代异步 Web 框架中的最佳实践。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

nvd11

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值