这周主要的时间都在搓新轮子 reqsign,用于对用户的请求进行签名,使得用户不再需要依赖完整的 SDK,我将其概括为 Signing API requests without effort。今天这期周报就来聊聊为什么要造这个轮子。

背景

开发云上服务难免需要使用 SDK,它帮助用户处理认证,构造请求,解析响应等任务,使得用户不需要关心服务内部的细节。对于绝大多数用户来说,使用 SDK 已经足够满足需求,但使用 SDK 也有不小的弊端。

首先,不同服务的 SDK 维护质量参差不齐。大多数 SDK 都无法及时响应来自用户的需求,没有定期的漏洞补丁和安全审查。对小众语言来说这个问题尤为严重,以 Rust 为例,对象存储服务中提供官方 SDK 支持的只有 AWS S3,其他的服务都只有社区维护的 SDK。

其次,跨 SDK 的行为很难统一。云上服务不可避免地会出现偶尔的内部错误和超时,所以很多 SDK 都包装了自己的重试和超时逻辑。应用如果想设置统一的重试和超时行为,就需要逐个服务的去研究如何进行配置。底层的数据库服务还会需要操作 SDK 内部的 Client,控制并发,实现限流限速,控制内存占用等,这就更难以实现了。

最后,代码生成的 SDK 往往复杂、臃肿又难用。为了降低 SDK 的维护成本,很多服务使用代码生成的方式来构建 SDK。为了统一内部不同服务的接口,往往需要增加无数层抽象,把简单的 HTTP API 变成了一大堆复杂的 HTTP Middleware。为了使用 aws-sdk-s3,需要依赖 aws-endpointaws-httpaws-sig-authaws-sigv4aws-config 等一大包 crate。这个问题在业务只需要使用 SDK 中特定几个方法的时候变得尤为严重,为了发送一个 get_object 请求,应用的构建时间增加了 120 秒,二进制体积变大了 6 MiB。为了支持从 AWS STS 服务获取临时的 token,aws-config 需要依赖 aws-sdk-stsaws-sdk-sso,在 aws-sdk-sts 中同样需要走一遍完整的请求构建流程。

反思

于是我开始思考,为什么简单的 HTTP API 变成了如今这样?核心逻辑明明只有 HTTP/1.1 GET https://127.0.0.1/abc ,我们为什么需要做这么多额外的工作?如果自己来构造这个请求会不会变得更简单?

一个星期之前,我在 opendal 的讨论区记录下了这个想法:Self maintianed SDK。但是我很快意识到,这样的做法是不可取的:在项目中为用到的每一个服务维护独立的 SDK 在未来会变成一个沉重的负担。更糟糕的是,我在重复造轮子,所有现有的 SDK 踩过的坑,实现的细节我需要在项目中重新趟一遍。不仅如此,其他的开源项目无法复用我的工作成果,从整个开源社区来看是收益比极低的做法。只能满足项目的需求对我来说是不够的,要怎么做才能最大程度的使得整个开源社区受益呢?

紧接着,我开始思考为什么维护 SDK 的成本会这么高。以 opendal 为例,它只需要使用 aws-sdk-s3 中的五个接口,需要做哪些事情能让它在不依赖 aws-sdk-s3 的前提下运作起来,而其中成本最高的部分又在哪里?很快我意识到了症结:签名认证。

实现 get_object, delete_object 等接口是非常简单的:

pub(crate) async fn delete_object(&self, path: &str) -> Result<hyper::Response<hyper::Body>> {
  let mut req =
    hyper::Request::delete(&format!("{}/{}/{}", self.endpoint, self.bucket, path))
        .body(hyper::Body::empty())
        .expect("must be valid request");

  self.client.request(req).await.map_err(|e| {
    error!("object {} delete_object: {:?}", path, e);
    Error::Unexpected(anyhow::Error::from(e))
  })
}

但是为了能签名这个请求,我们需要做非常多事情:

  • 从这个请求中构造出 CanonicalRequest
  • 基于 CanonicalRequest 来生成 StringToSign
  • 构造 SigningKey
  • 然后通过 SigningKeyStringToSign 计算出本次请求的 signature
  • 再按照要求把 signature 追加到请求中合适的位置

为了满足各种场景的需求,围绕着核心的签名计算逻辑,我们还需要做其他的工作

  • 同时支持通过 HTTP Header 和 HTTP Query 来签名
  • 支持从环境变量,配置文件,AWS STS 服务,AWS EC2 Metadata 服务等多种途径获取密钥
  • 支持 Security Token 这样会过期的认证信息,为此还需要搭配一套完整的过期自动更新机制

假设我们有一个库,能把上面的这些跟请求签名相关的工作全部搞定,让我们的 API 实现变成这样:

pub(crate) async fn delete_object(&self, path: &str) -> Result<hyper::Response<hyper::Body>> {
  let mut req =
    hyper::Request::delete(&format!("{}/{}/{}", self.endpoint, self.bucket, path))
        .body(hyper::Body::empty())
        .expect("must be valid request");

  self.signer.sign(&mut req).await.expect("sign must success");

  self.client.request(req).await.map_err(|e| {
    error!("object {} delete_object: {:?}", path, e);
    Error::Unexpected(anyhow::Error::from(e))
  })
}

一切是不是迎刃而解了呢?

实现

怀揣着这样的思路,我搓出来了 reqsign

它的目标就是让简单的 HTTP API 回归简单,专注于搞定请求签名这一件事情:

  • 传入一个 Request
  • reqsign 签名好并返回
  • 用户用自己的 Client 将它发出去

DONE!就是这么简单,没有多余的操作,没有复杂的抽象,仿佛本来就该如此。

reqsign 现在支持了 AWS SigV4,它能够自动的从环境变量,配置文件,AWS STS 服务中获取必要的签名信息,对用户的请求进行签名:

use reqsign::services::aws::v4::Signer;
use reqwest::{Client, Request, Url};
use anyhow::Result;

#[tokio::main]
async fn main() -> Result<()>{
    // Signer will load region and credentials from environment by default.
    let signer = Signer::builder().service("s3").build().await?;
    // Construct request
    let url = Url::parse( "https://s3.amazonaws.com/testbucket")?;
    let mut req = reqwest::Request::new(http::Method::GET, url);
    // Signing request with Signer
    signer.sign(&mut req).await?;
    // Sending already signed request.
    let resp = Client::new().execute(req).await?;
    println!("resp got status: {}", resp.status());
    Ok(())
}

reqsign 没有直接依赖任何 AWS 的库,而是选择参考 aws-sigv4 的代码自行实现了完整的签名认证逻辑,在测试中将签名结果与 aws-sigv4 直接对比,并通过构造请求发送到 AWS S3 和 minio 来确保自己的实现正确。

目前 opendal 已经彻底切换到了 reqsign 上,在 PR refactor: Say goodbye to aws-s3-sdk 中能看到 opendal 一口气删除了十个依赖:

aws-config = "0.8"
aws-endpoint = "0.8"
aws-http = "0.8"
aws-sdk-s3 = "0.8"
aws-sig-auth = "0.8"
aws-sigv4 = "0.8"
aws-smithy-client = "0.38"
aws-smithy-http = "0.38"
aws-smithy-http-tower = "0.38"
aws-types = { version = "0.8", features = ["hardcoded-credentials"] }

同时还删除了一大堆 AWS SDK Middleware 相关的代码,非常的痛快。

展望

社区的小伙伴正在 PR feat: Add support for azure storage 中尝试实现 Azure Storage 服务的支持,我也计划在 reqsign 中实现 OAuth2 的支持,并完善集成测试,保证 reqsign 生成的签名结果与官方的 SDK 相符。

欢迎大家一起来贡献和完善 reqsign,走过路过点个赞吧,哈哈~