随着 S3 等服务的广泛流行,HDFS 已经不再时髦。但是仍然有很多用户在使用 HDFS 作为存储底座,因此 Databend 向 OpenDAL 提出了支持 HDFS 的需求。OpenDAL 旨在成为链接所有存储服务的 Open Data Access Layer,HDFS 显然是需要支持的服务之一,(而且 Databend 是 OpenDAL 最大的用户)。
实现方案
HDFS 是用 Java 开发的服务,目前社区提供了如下对接方案:
- Java API: 引入 hdfs 发行版中提供的 Jar 包即可,这也是绝大多数 HDFS 用户使用的方式
- C API libhdfs: HDFS 基于 JNI 提供了 C binding,用户链接
libhdfs.so
即可反向调用 Java 函数 - WebHDFS REST API: HDFS 基于 HTTP 封装一套 API 接口,用户只需要 HTTP Client 即可访问
- webhdfs-rs: 维护质量相对较好
- libhdfs3:Pivotal 基于 HDFS RPC 接口开发的 C/CPP Client,后来贡献给了 Clickhouse 社区。
- datafusion-hdfs-native:没有集成测试
直接对接 Java API 需要使用 JNI,Rust 社区的选择包括 jni-rs 与 j4rs。在尝试一个下午之后发现基于 JNI 对接相比于使用 libhdfs 没有什么明显的好处,反而带来了不少维护上的麻烦(有不少 conversion 都要自己处理,还要自行解决启动 JVM 之类的问题),所以放弃了这个方案。
WebHDFS 的问题在于要求 HDFS 集群启用 dfs.webhdfs.enabled
配置,而且通过 HTTP 传输大于 10MB 的文件时存在着一些已知的性能问题:WebHDFS vs Native Performance。
libhdfs3 被 ClickHouse 与 datafusion 采用,是目前应用较为广泛的方式,但是这个库要求使用 RPC 来访问 HDFS,引入了一套额外的依赖,更严重的是它会破坏一些用户的预期:因为有些用户业务使用 hdfs 的方式都是提供一个自己的 Jar 包,在里面做了一些封装和逻辑供外部的客户端加载,显式地调用 RPC 接口可能会让他们的一些内部逻辑失效。
因此 OpenDAL 计划采用原生的 C API libhdfs 来对接 HDFS,但是社区提供的选择我都不太满意,我想要:
- 完整的 API 支持:兼容所有 HDFS API 版本,使得 OpenDAL 对接 HDFS 时不需要操心兼容性问题
- 良好的抽象分层:将 C bindings 与 Rust API 剥离开,当 Rust API 无法满足需求时用户能够自行封装而不需要重新造轮子
- 完善的集成测试:C bindings 需要与各个主流 HDFS 版本进行链接并测试 API,而 Rust API 更需要完整的集成测试确保 API 正常且不出现内存泄露
- 易用的接口:屏蔽 HDFS 的内部细节,对外暴露更符合 Rust 习惯的
File
和io::Read
等接口,降低用户学习成本
既然社区没有满足我需求的实现,那就自己来造一个吧~
设计
C bindings 与 Rust API 分开是 Rust 中比较通用的做法,比如:
因此我计划拆分出 hdfs-sys 与 hdrs 两个部分,其中:
- hdfs-sys 负责暴露 HDFS C API 的接口并链接 libhdfs
- hdrs 则负责在 hdfs-sys unsafe API 基础上封装更符合 Rust 风格的 safe API
hdfs-sys 实现
最简单的方案是使用 bindgen,只需要有 hdfs.h
就能自动生成出所有的 Rust 接口。但是 HDFS 是一个开发跨度长达十数年的服务,部署什么版本的用户都有可能,我们在实现 hdfs-sys 的时候需要将 API 的兼容性考虑在内,因此我模仿 clang-sys 实现了一套 API 的兼容逻辑。
HDFS 的 C API 都是向前兼容的,不会移除或者修改函数,每次新增函数时都会更新 minor 版本号。基于这一核心原则,我从 hadoop 代码库中 checkout 出来从 hadoop 2.2 至 hadoop 3.3 这 13 个版本的 hdfs.h
头文件,以 hadoop 2.2 作为基准,逐个版本的对比差异,并为每个版本赋予一个 feature flag:
#[cfg(feature = "hdfs_2_2")]
mod hdfs_2_2;
#[cfg(feature = "hdfs_2_2")]
pub use hdfs_2_2::*;
#[cfg(feature = "hdfs_2_3")]
mod hdfs_2_3;
#[cfg(feature = "hdfs_2_3")]
pub use hdfs_2_3::*;
#[cfg(feature = "hdfs_2_4")]
mod hdfs_2_4;
#[cfg(feature = "hdfs_2_4")]
pub use hdfs_2_4::*;
换言之,当用户启用 hdfs_2_2
时,他就可以使用 2.2 版本暴露的 API,当用户启用 hdfs_2_3
时,他就可以使用 2.2 和 2.3 版本所有的 API。测试集的覆盖也使用了一样的原则:
#[test]
#[cfg(feature = "hdfs_2_3")]
fn test_hdfs_abi_2_3() {
test_hdfs_abi_2_2();
let _ = hadoopRzOptionsAlloc;
let _ = hadoopRzOptionsSetSkipChecksum;
let _ = hadoopRzOptionsSetByteBufferPool;
let _ = hadoopRzOptionsFree;
let _ = hadoopReadZero;
let _ = hadoopRzBufferLength;
let _ = hadoopRzBufferGet;
let _ = hadoopRzBufferFree;
}
考虑到实际情况,hdfs-sys 并没有去支持所有的 hadoop 版本:
- hadoop 1 实际采用量很低且不再维护新的 patch 版本,所以不予支持
- hadoop 2.2 是 hadoop 2 首个 stable release,因此 2.1 (只有 beta) 和 2.0(只有 alpha)都不予支持
- hadoop 2.2 理论上能够兼容现行的所有 hadoop 环境,因此将其作为默认的 feature
有了 hdfs-sys 之后,我们就可以放心使用 API 而不需要担心版本的问题了~
hdrs 实现
就像 hdfs-sys 说的那样:Work with these bindings directly is boring and error proven
。直接使用 hdfs-sys
意味着大量的 unsafe 代码,需要跟 raw pointer 打交道,我们需要一层更加高级的封装,比如:
pub fn stat(&self, path: &str) -> io::Result<Metadata> {
let hfi = unsafe {
let p = CString::new(path)?;
hdfsGetPathInfo(self.fs, p.as_ptr())
};
if hfi.is_null() {
return Err(io::Error::last_os_error());
}
// Safety: hfi must be valid
let fi = unsafe { Metadata::from(*hfi) };
// Make sure hfi has been freed.
unsafe { hdfsFreeFileInfo(hfi, 1) };
Ok(fi)
}
这样用户可以避免:
- 处理
String
到CString
的转换 - 跟 unsafe 打交道
- 处理 raw pointer
- 处理动态分配结构体的手动 free
- 从 errno 到
io::Error
的转换
hdrs 为绝大多数常用的接口进行了此类的封装并暴露了与 std::fs
类似的接口。
除此以外,hdrs 还通过 Github Action 进行了与 hadoop 2.10.1,3.2.3,3.3.2 等版本的集成测试,保证 hdrs 的核心 API 在真实的用户环境中也能正常工作。
总结
回顾一下之前聊到的需求:
- 完整的 API 支持
- 良好的抽象分层
- 完善的集成测试
- 易用的接口
hdfs-sys 支持了从 hadoop 2.2 开始到最新版本所有的 API 接口,满足了完整的 API 支持的需求。而 hdfs-sys 与 hdrs 的抽象分层使得用户可以根据自己的需求选择依赖 hdfs-sys 或者 hdrs。同时 hdfs-sys 和 hdrs 都通过 Github Actions 进行了集成测试,确保核心的逻辑工作正常以及不会出现意外的 break。hdrs 在 hdfs-sys 的基础上封装了类似 std::fs
的接口,尽可能降低用户的学习成本。
接下来 OpenDAL 将会基于 hdrs 展开 HDFS 的支持工作,并通过真实的需求来进一步完善 hdfs-sys 与 hdrs 这两个的库~