你也可以手敲一个高速下载器(四)通用请求类

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第16天,点击查看活动详情


你也可以手敲一个高速下载器(四)通用请求类

前言

记不记得前几天在 “爬虫之常用技术下” 中提到的httpx框架,今天我们就使用这么框架封装一个网络请求的通用类,要注意的是这个类是基于异步的,对 python 的异步不太了解的可以去看看我前面的文章:Python 并行实践(下)

基本属性

对于一个通用的网络请求类来说,一定要是有一些参数是可配的,并且是尽量降低与业务之间的关系,做到插件化。

这里由于属性的参数比较多,所以使用了@dataclass 的方法,不了解的去看上一篇文章,里面有提到这个

必传参数

这里必传参数有两个,分别是url,和method,是请求的地址和请求的方法,有了这两个就可以发起一个最基本的请求。

@dataclass
class Request(object):
    # 请求的方法
    method: str
    # 请求的地址
    url: str
复制代码

可选参数

可以参数分为设置请求头的、(其中包括请求头本身,UA,Cookies)、请求的 JSON 数据、请求的超时时间、代理设置、重试次数、重试延时、允许的状态码、控制并发用的信号量等,完整属性代码如下:

@dataclass
class Request(object):
    # 请求的方法
    method: str
    # 请求的地址
    url: str
    # 信号量,控制并发
    sem: asyncio.Semaphore = field(default=None)
    # 请求的JSON数据
    json: dict = field(default=None)
    # 请求UA
    user_agent: str = field(default=None)
    # 请求头
    headers: dict = field(default=None)
    # 传递的cookies
    cookies: dict = field(default=None)
    # 请求超时时间
    timeout: float = field(default=None)
    # 设置允许的状态码
    allow_codes: list = field(default_factory=list)
    # 代理设置
    proxies: Union[Dict, str] = field(default=None, init=False)
    # 重试次数
    retrys_count: int = field(default=15)
    # 重试延时
    retries_delay: int = field(default_factory=int)
复制代码

初始化

由于我们使用了@dataclass方法装饰了类,所以就不能使用__init__进行初始化了,要改为使用__post_init__方法进行初始化。初始化代码我们分别的三个部分:

一、设置 httpx 异步请求会话

由于我们是使用的异步代码,所以要先创建一个请求的会话,使用的类是AsyncClient,要注意的是我们上面设置了属性“proxies”,这个属性是不可以后期改变的只是在创建会话的时候传进去,同时为了避免不必要的错误,我们可以把 ssl 的验证设置为False,代码如下:

# 设置httpx会话
self.session = AsyncClient(proxies=self.proxies, verify=False)
复制代码

二、设置 session 的信息

这里是设置 Session 的基本信息,请求头、cookies、超时时间、UA,代码如下:

# 设置session的headers、cookies、timeout
self.session.headers = self.headers
self.session.cookies = self.cookies
self.session.timeout = Timeout(self.timeout)

# user_agent设置到headers中
if self.user_agent:
    self.session.headers.update({"user-agent": self.user_agent})

self.headers = dict(self.session.headers)
复制代码

这里是判断是是否设置了 UA,如果设置 UA 则会覆盖请求头中的 UA

三、设置允许的状态码

因为我们要判断请求是否成功,一般情况下是直接判断状态码是否等于200,但又很多情况下不等于200也是成功的,比如这次就会遇到206的状态码,所以这里我们使用200~300之间的状态作为默认允许的状态,如果手动设置了则加入到里面的去,代码如下:

# 设置允许的状态码 默认是200-300之间
allow_codes = list(range(200, 300))
if not self.allow_codes:
    self.allow_codes = allow_codes
else:
    self.allow_codes.extend(allow_codes)
复制代码

关闭

这里的关闭很简单,就是关闭 httpx 的会话,使用的是aclose()方法,代码如下:

async def close(self):
    """
    关闭session
    :return:
    """
    await self.session.aclose()
复制代码

异常类

定义了两个会用到的异常类,一个是在请求中发生的异常,一个是状态码不在规定列表中抛出的异常,其中状态码的异常是由一个参数的,参数就是状态码本身,尔另外一个异常则是没有过多的操作。

小提示:抛出异常,是为了让上一层更好的处理异常,而捕获异常则是为了处理异常,而不是回避异常

实际请求

这部分是实际发出请求的部分,通过调用httpx的方法,获取响应,先看代码:

async def _request(self) -> Response:
    """
    使用httpx发起请求
    :return: 返回响应
    """
    # 打印一个DEBUG级别的日志
    logger.debug(f"[{self.method}] {self.url}")

    # 锁定信号量
    if self.sem:
        await self.sem.acquire()

    # 进行请求,并获取响应
    response = await self.session.request(
        self.method,
        self.url,
        json=self.json,
    )

    # 释放信号量
    if self.sem:
        self.sem.release()

    # 如果状态码不在允许范围当中,则抛出异常
    if response.status_code not in self.allow_codes:
        raise RequestStateException(response.status_code)

    return response
复制代码

注意是要注意信号量部分,要注意的是,我们的信号量不是一个必传的参数,所以是可能为空的,既然的可以为空的,那么就不能像,官方文档里面那么使用,否则代码就变成了这个样子:

if sem:
  async with sem:
    resp = await self.session.request(...)
else:
  resp = await self.session.request(...)
复制代码

可以看出来,这样的代码虽然也行,但是毕竟不优雅,获取响应那里明显的重复了,所以我们,只要让async with sem的部分按条件来就好了,所以就要改造一下,那么重所周知,上下文管理器是通过调用__enter__方法,和__exit__方法来执行的,那么我们只要找到这两个方法中的逻辑就可以模拟这种情况,跳进去看一下:

是没有那俩个方法的,然后我们再去基类里面看一下:

已经找到了,所以我们只要在请求开始之前调用:await self.acquire()方法,在请求结束之后调用:self.release()方法就好了,就可以达到和async with sem一样的效果

请求入口,包含异常重试的

这里是请求的入口,是会使用我们在上面定义的重试相关的参数来检查是否需要重试及重试的参数,看代码:

  async def request(self) -> Response:
      """
      使用httpx发起请求 带重试机制
      :return: 返回响应
      """
      if self.retrys_count < 1:
          return await self._request()

      # 设置重试的等候时间
      if self.retries_delay:
          wait = wait_fixed(self.retries_delay) + wait_random(0, 2)
      else:
          wait = wait_fixed(0)

      # 异步重试
      r = AsyncRetrying(
          stop=stop_after_attempt(self.retrys_count),
          after=self.retry_handler,
          retry_error_callback=self.retry_handler,
          wait=wait
      )
      resp = await r.wraps(self._request)()
      return resp
复制代码

这里会先检查重试次数,可以小于一则不需要重试,之间返回_request方法,下面就是根据参数设置每次重试等待的时间,并下随机值,最后通过重试的类调用self._request方法

异常处理回调方法

上面的代码可以看出来,每次重试之后和最终重试失败时都会调用方法:self.retry_handler,我们来看下这里关于重试的是怎么处理的:

  def retry_handler(self, retry_state: RetryCallState):
      """
      处理重试之后和重试失败
      :param retry_state:
      :return:
      """
      outcome: Future = retry_state.outcome
      attempt_number = retry_state.attempt_number
      exception = outcome.exception()
      exception_type = type(exception).__name__
      exception_msg = list(exception.args)

      if isinstance(exception, RequestStateException):
          exception_msg = f"响应状态:{exception.code} 不在规定的列表内:{self.allow_codes}"
      log_msg = f"[{self.method}] {self.url} 发生异常: [{exception_type}]: {exception_msg}"

      if attempt_number == self.retrys_count:
          logger.error(f"{log_msg} {attempt_number}次重试全部失败")
          raise RequestException()
      else:
          logger.error(f"{log_msg}{attempt_number}次重试")
复制代码

所谓重试后和重试失败执行的代码,既然都已经重试了,已经失败了还执行它啥用了?当然是看看为啥要重试啊,为啥一直失败啊,所以这里的主要目的只要一个就是输出日志,详细的日志,更加异常的不同输出不同的日志,然后在全部失败的失败重试抛出异常让上一层处理

结语

这节说了一个通用的请求类的编写,也只是实现了基本的方法,像这种通用的类,也是尽量做到和业务关联性小一些,最后是可以单独拿出来用的。好就这样,下节更精彩!!!

猜你喜欢

转载自juejin.im/post/7132121389087064078