S3 兼容的分片上传
sj.aws 与 sj.aws.s3 实现了 AWS Signature Version 4(SigV4)请求签名和一个 S3 分片上传客户端,直接构建在 sj.net.NioAdapter 的 TLS 传输之上(sj.http.HttpClient 仅支持明文)。本教程覆盖凭据、TLS 传输的接线、四个分片调用、uploadFile 便捷包装,以及 — 放在最前面讲,因为它改变你实际使用的方式 — 它今天能到达与不能到达的边界。
开始之前:它到底能连上什么
connectTls 能连上真正的 AWS S3(#4224)。S3 协商的正是 SuperJ 提供的那一套套件,TLS_AES_128_GCM_SHA256 + X25519 + ECDSA-P256,而且它的证书链可用普通 CA bundle 验证。把信任锚定在 OS bundle 上,并指明你期望的主机名:
byte[] pem = /* read /etc/ssl/cert.pem, or your distro's ca-certificates.crt */;
TlsConfig tls = new TlsConfig()
.trustAnchors(Pem.certBundle(pem))
.expectHost("s3.ap-northeast-1.amazonaws.com");
对由你控制密钥的固定端点(下面的演示对它的 mock 服务器就是这么做的),改用 TlsConfig.pinnedPeerKey(...);对单个 CA 则用 trustAnchor(...)。
两件需要知道的事:
- 内置信任库是 Mozilla 的根列表,靠手工刷新。
SJ_HOME下的sdk/net/ca_bundle.pem保存完整的 Mozilla 集合(#5385),因此它可以支撑生产验证 — 但它的时效只到上一次刷新(tools/refresh_ca_bundle.sh),而且不携带部分不信任("distrust after")数据。 - 证书链算法。 链上各环可以是 ecdsa-with-SHA256(P-256 签发者)、ecdsa-with-SHA384(P-384 签发者,#5386)或 RSA(PKCS#1 SHA-256/384、PSS);RSA 服务器签名也会被检查(#3381/#3382)。P-521 与交叉配对(P-384 密钥配 SHA-256、P-256 密钥配 SHA-384)不支持。
临时凭据可用:AwsCredentials 携带可选的会话 token,签名器会正确覆盖它(#4232)。仍然要你自己做的是获取它 — IMDSv2 流程与到期驱动的刷新不在 SDK 里。见 凭据。
下面的演示在进程内立起一个最小的 mock S3 服务器,并通过真实的 loopback TLS 连接与它通信,因此它在没有凭据、没有网络的情况下就能运行,而其余每个细节 — 请求构建、SigV4 签名、响应解析 — 与真实 S3 兼容端点所见完全一致。自己跑一下:
superj compile "$SJ_HOME/demo/sj/demo/S3Demo.sj" --sdk-path "$SJ_HOME/sdk" --link --output s3demo
./s3demo
用 --sdk-source "$SJ_HOME/sdk/sj" 替代 --sdk-path 会以全程序方式构建同一个演示;它也能工作,但要在 clang 里花约 100 秒(#5279)。
凭据
import sj.aws.AwsCredentials;
// Long-term keys.
AwsCredentials creds = new AwsCredentials(accessKeyId, secretAccessKey);
// Temporary keys — STS, or an IMDSv2 instance role.
AwsCredentials creds = new AwsCredentials(accessKeyId, secretAccessKey, sessionToken);
// Or from the environment: AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, plus
// AWS_SESSION_TOKEN when it is set. Null if either key is unset.
AwsCredentials creds = AwsCredentials.fromEnvironment();
存在会话 token 时,每个请求都携带 x-amz-security-token,并且把它纳入签名 — SigV4 要求两者兼备,只做其中之一会以 SignatureDoesNotMatch 失败,而错误里没有任何信息说明缺的是哪一半。你不需要做任何事来安排这一切;S3Client 和 SigV4Signer 之间已经就此达成一致。
SDK 不做的是抓取或刷新这些凭据。IMDSv2 流程(PUT /latest/api/token,然后 GET /latest/meta-data/iam/security-credentials/<role>)以及在凭据到期前重读它们,是应用的任务 — 在它们轮换时构建新的 AwsCredentials 和新的 S3Client。
构建客户端
S3Client 通过构造函数注入接收它的 NioAdapter,而不是在内部自建 — 事件循环由你驱动,所以你在该 adapter 上做的任何其他事(另一条连接、一个服务器)在 S3Client 调用自身的有界 poll() 循环于其下运行时照常工作:
import sj.net.NioAdapter;
import sj.net.NioConfig;
import sj.net.TlsConfig;
import sj.aws.s3.S3Client;
NioAdapter adapter = new NioAdapter(new NioConfig(65536, 4096, 8, 65536, false, 16));
TlsConfig tls = new TlsConfig().pinnedPeerKey(pin); // or .trustAnchor(caDer)
S3Client client = new S3Client(adapter, host, port, region, bucket, creds, tls);
寻址是 path-style(/<bucket>/<key>),不是 virtual-hosted — 更简单,而且无论 DNS 如何设置都对任何 S3 兼容端点有效。
分片调用
string uploadId = client.createMultipartUpload("my/object.bin");
string etag1 = client.uploadPart("my/object.bin", uploadId, 1, part1, 0, part1.length);
string etag2 = client.uploadPart("my/object.bin", uploadId, 2, part2, 0, part2.length);
PartETag[] parts = new PartETag[2];
parts[0] = new PartETag(1, etag1);
parts[1] = new PartETag(2, etag2);
client.completeMultipartUpload("my/object.bin", uploadId, parts);
completeMultipartUpload 不是可选项 — 它才是真正完成上传的那个调用;在它成功之前,各分片一直处于未组装(并计费)状态。中途出问题时,用下面的调用清理:
client.abortMultipartUpload("my/object.bin", uploadId);
每个分片的 SHA-256 载荷哈希都预先算好(没有 chunked/streaming SigV4)— 反正整个分片本来就要在内存里才能写出。任何调用收到非 2xx 响应都会抛出 S3Exception,当响应带有 <Error><Code>/<Message> 时携带解析结果。
uploadFile:替你切分本地文件
string uploadId = client.uploadFile("/path/to/local/file", "my/object.bin");
它经 sj.io.MemoryMapped 把文件切成固定大小的分片(远高于 S3 的 5 MiB 下限),以每分片有界重试的方式顺序上传,然后调用 completeMultipartUpload — 或在某个分片重试耗尽、或完成本身失败时调用 abortMultipartUpload。
在长跑进程中上传:零分配重载
SuperJ 没有 GC,所以一个 string 会被保留到进程结束。上面的重载把上传 ID 作为一个 string 返回,每次调用成本约 48 字节 — 永久。对批处理作业这不算什么,对一个跑上几个月的归档器却是慢性泄漏。针对第二种情况有一个重载:
ByteArray uploadId = new ByteArray(256);
while (moreFiles()) {
uploadId.clear();
int n = client.uploadFile(path, key, uploadId); // retains NOTHING
// the ID is uploadId.array()[0..n), or uploadId.flip().toString() if you
// actually need it as text
}
如果你根本不想要这个 ID,给 uploadIdOut 传 null。装不下 ID 的缓冲区由 ByteArray 自己的边界报告,而不是静默截断。
上传路径上的其他一切已经什么都不保留 — 请求缓冲区、TLS 连接(跨请求复用)、SigV4 签名、CompleteMultipartUpload XML,以及分片 ETag(它们保存为字节区间而非 PartETag 对象,正是为了让每个分片不需要 string)。examples/test_4590_zero_alloc_upload.sj 以等式断言了这一点 — 200 次上传、600 个 HTTP 请求、保留零字节 — examples/test_4589_zero_alloc_request.sj 对单个请求做了同样的事。走到这一步靠的是 #4242、#4314、#4589 和 #4590;账目在 #4636,如果你想要明细。
读取对象
long size = client.headObject("my/object.bin"); // -1 if the server sent no Content-Length
byte[] buf = new byte[(int) size];
int n = client.getObject("my/object.bin", buf, 0, buf.length);
// Inclusive, as HTTP defines it — this reads 100 bytes.
int m = client.getObjectRange("my/object.bin", 0, 99, buf, 0, buf.length);
如果对象装不进目标缓冲区,getObject 会抛异常而不是返回一次短读 — 一个被当作完整报告的截断 body,正是你最终写出损坏文件还浑然不觉的方式。
所以对任何尺寸不由你控制的东西,用 downloadFile,即 uploadFile 在读取侧的镜像:
long written = client.downloadFile("my/object.bin", "/path/to/local/file");
long written = client.downloadFile("my/object.bin", "/path/to/local/file", 1 << 20); // 1 MiB chunks
它先 HEAD 对象,然后用区间 GET 逐段直接写到磁盘,因此无论对象多大,内存都保持在约 2 倍 chunk 大小。正是这一点让多 GB 的冷存储读取在这里成为可能 — 没有流式响应解析器,所以整对象的 getObject 确实需要把整个对象放进内存(瞬态地放两次)。
刻意不做的部分
- 没有凭据抓取 — 你可以携带会话 token 并用它签名,但产生它的 IMDSv2/STS 往返要你自己完成。
- 没有 chunked/streaming SigV4(
UNSIGNED-PAYLOAD或 AWS 的分块签名)。 - 没有单 PUT 对象上传、没有 bucket 列举、没有删除。
- 没有流式响应 body:对象整体到达你提供的数组。
downloadFile是绕开这一点的办法,适用于大对象。 - 没有通用 XML 解析器 —
sj.aws.s3.S3Xml只提取这些响应用到的少数标签,并构建CompleteMultipartUploadbody;仅此而已。
如果你需要,每一项都是合理的后续工作;对这个客户端为之构建的用例,没有一项是必需的。