尝试改进 OpenDAL PostgreSQL-Service (1)

尝试改进 OpenDAL PostgreSQL-Service (1)

Sunny

2025-05-10 发布295 浏览 · 2 点赞 · 1 收藏

不管对于多么细小的事情都要表达感谢,这是优先一切的大事,其中蕴含着巨大的力量。感谢是万能药,感谢不仅能把自己引向愉悦的境界,同时,也会让周围的人快乐起来。

——稻盛和夫《思维方式》

现在,通过前面一段时间的学习,我对 OpenDAL 建立了一个基本的理解,同时对于怎么展开进一步改进,也有了基本的思考,其成果体现在如下的两篇文章与 Issue 上面:

《使用 OpenDAL 连接 PostgreSQL》 (以用户视角阐述如何使用 OpenDAL 的 PostgreSQL Service 服务)

《测试 OpenDAL:以 PostgreSQL Service 为例》 (更进一步,以研发人员视角阐述如何研究 OpenDAL 并验证自己的修改行为)

New feature: PostgreSQL-Service Support copy & rename (计划为 OpenDAL 的 PostgreSQL Service 带来 rename 与 copy 功能,并阐述自己的设计思路)

而在提出构想的基础上,@XuanWo 建议:“I’m guessing we need to migrate PG away from the KV adapter before implementing this: #5739”(我认为在 OpenDAL 实现 #5739 号 Issue 之前,我们需要将 PostgreSQL Service 的实现代码从 KV Adapter 中抽离出来),因此,本文计划介绍一下 PostgreSQL Service 的实现思路,阐述我的改进思路,以及 KV Adapter 的有关内容,为下一步的具体改进工作提供参考(OpenDAL 项目的一个重大缺点,就是缺少很多 “面向圈外” 的材料,很多深度内容只有能够融入社区的人才能够了解,这种 “精英作派” 就我看来严重地限制了 OpenDAL 的发展,无论是现在还是将来都是,因为采取了类似发展模式的项目还有一个,就是 Emacs,如今该项目虽靠着历史名气保持着惯性发展,但是肉眼可见的,正在走下坡路...我希望 OpenDAL 不要犯同样的错误)。

PostgreSQL Service 简介及原理分析

PostgreSQL Service 是 OpenDAL 负责对接 PostgreSQL 的部分,依托了 sqlx 中有关 PostgreSQL 的部分 加以实现,理解 PostgreSQL Service 有助于我们更为深刻地掌握 OpenDAL,以及 Rust 中操作数据库的知识。

PostgreSQL Service 实现思路:本质上是用关系型数据表模拟存储文件数据

这里,我们沿用 《测试 OpenDAL:以 PostgreSQL Service 为例》 中的案例程序,并将其修改为如下的形式:

use anyhow::Result;
use opendal::services::Postgresql;
use opendal::Operator;

#[tokio::main]
async fn main() -> Result<()> {
    let builder = Postgresql::default()
        .root("/")
        /* 假定你的用户名为 user_name */
        .connection_string("postgresql://127.0.0.1:5432/postgres")
        /* 使用我们前面创建的 example 表  */
        .table("example")
        /* 存储文件名的数据列 */
        .key_field("filename")
        /* 存储文件数据的数据列 */
        .value_field("filedata");

    let op = Operator::new(builder)?.finish();

    /* 将 `Hello PostgreSQL` 写入到一个名为 `postgres.txt` 的文件中 */
    /* op.write("postgres.txt", "Hello PostgreSQL").await?; */
  
    // 这样我们就不需要关心数据的长度了
    let bs = op.read("postgres.txt").await?;
  
    /* 提取 Buffer 中的内容,即为文件内容 */
    // 这种方法可以支持中文输出
    println!("{:?}", String::from_utf8(bs.to_vec()).unwrap());

    Ok(())
}

之后,启动 psql 程序,清空 example 数据表的内容。

/* 使用 psql 连接本地的 PostgreSQL 数据库 */
psql -d postgres
/* 清空数据表的全部数据 */
DELETE FROM example;
/* 验证是否已经删除完毕? */
SELECT * FROM example;

clean-example.png

接下来,参照案例程序代码里面的内容,我们向数据表内插入一条这样的记录:

/* 插入的这条记录将会成为一个 '新文件' */
INSERT INTO example (filename, filedata) VALUES ('postgres.txt', '与伙伴一起改进 OpenDAL');
/* 验证修改 */
SELECT * FROM example;

verify-update.png

之后,我们开始运行修改后的 try_postgresql

1746743797912.png

1746743845608.png

可以发现,OpenDAL 的 “文件” 本质上就是一种对于各种存储后端数据记录的抽象(下图展现了这种对应关系)。

1746745468801.png

同时,阅读 PostgreSQL 有关的日志记录(需要你将日志记录的级别修订成 DEBUG,方式是找到 PostgreSQL 服务端依托运行的数据文件夹,在 postgresql.auto.conf 中添加一句 log_min_messages = debug 即可),可以发现 OpenDAL PostgreSQL-Service 对于“文件的读” 实际上就是一层对于 SQL 语言 SELECT 语句的封装。

1746745671146.png

这点也在 @XuanWo 的笔记中有所体现:

OpenDAL 旨在自由,无痛,高效的访问数据,其中自由意味着 OpenDAL 可以以相同的方式访问不同的存储服务。
为了实现这一点,OpenDAL 实现了不少服务的支持,比如 azblob,fs,ftp,gcp,hdfs,ipfs,s3 等等。
这些服务虽然接口和实现各有各的差异,但是他们共性是都有着类 POSIX 文件系统的抽象,OpenDAL 不需要额外的抽象层就能访问其中的数据。
—— @XuanWo 《2022-42: OpenDAL Key-Value Adapter》

在这个基础上,我们理解 OpenDAL 的 PostgreSQL-Service 实现,也就有了理论的依据,这就让我们可以用一个更深入的视角审视 Service.

PostgreSQL Service 工程结构分析

下图阐述了 PostgreSQL-Service 的目录结构以及各个文件的作用,从这里我们就可以看出一点:“OpenDAL 的 Service 应当被看作是一组小程序的集合,每一个小程序负责对接一种存储后端”(这就像是服务不同客户的一个个对接员一样)

service-dir.png

我们主要的精力应当放在对 PostgresqlConfig 这个结构和 backend.rs 这个文件的分析上面,其他的内容在理解他们以后,也就能够顺势理解了。

PostgresqlConfig 结构分析:存储了连接 PostgreSQL 数据表的有关数据

摘录并分析其代码如下所示:

pub struct PostgresqlConfig {
    /* 因为 PostgreSQL 的数据表抽象是逐行罗列记录,没有 “层次” 的概念 */
    /* 
        也就是说 file.txt 与 dir/file.txt 
        这两个 “文件” 在数据表内都只是同层次的记录而已
    */
    /* 所以这个 root 是没有任何意义的 */
    pub root: Option<String>,

    /* 连接 PostgreSQL 时所使用的字符串 */
    /* 对应的 PostgreSQL 文档:https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING */
    pub connection_string: Option<String>,
    
    /* 连接到 PostgreSQL 的哪一章数据表? */
    pub table: Option<String>,
    
    /* 对应数据表中的键,逻辑含义就是 “文件名” */
    pub key_field: Option<String>,
    
    /* 对应数据表中的数据,逻辑含义是 “文件数据” */
    pub value_field: Option<String>,
}

而对应的工程实现,便是向这个数据结构中存储内容,在这里,我们需要先对 OpenDAL 的 Builder 做做功课(在不了解设计理念的背景下阅读代码,只会知其然不知其所以然)。

PostgreSQL-Service Builder 简介

OpenDAL 有关 Builder 的部分中,可以发现这样一句话:“Builder is used to set up underlying services.”(Builder 被用于设置 OpenDAL 所依托的服务),换而言之,它实际上就是各个服务的私有数据以及配置接口的集合。

而在 PostgreSQL Service 中,为了对接好 PostgreSQL,OpenDAL 不仅仅设置了如上的 PostgresqlConfig,同时还实现了一个名为 PostgresqlBuilder 的 trait,负责承担 PostgreSQL-Service Builder 的落地任务,代码如下所示(作了简化):

impl PostgresqlBuilder {
    // ...
    // 设置 PostgreSQL Service 所需要对接的数据表
    pub fn table(mut self, table: &str) -> Self {
        if !table.is_empty() {
            self.config.table = Some(table.to_string());
        }
        self
    }
    // 其它的组件依据着同样逻辑,只是设置的对象不一样
    // ...
}

同时,我们注意到,在 PostgresqlBuilder 的基础上,OpenDAL 为 PostgreslBuilder 实现了对应的 Builder 通用接口(注:impl trait for type 是 Rust 里面的一种特别的语法,可以帮助我们实现面向某种数据类型的函数接口。在这个基础上,我们保持 trait 不变的基础上变更 type,进而可以使得我们所实现的这种函数接口在使用时不作任何改变的情况下,能够支持更多的数据类型,从而降低二次开发的成本,提升通用性)。

关于 impt trait for type 的更多知识,可以查看 Rust 语言文档的有关部分

言归正传,我们继续阅读 OpenDAL PostgreSQL-Service Builder 的有关部分(删除了逻辑类似的部分):

impl Builder for PostgresqlBuilder {
    const SCHEME: Scheme = Scheme::Postgresql;
    type Config = PostgresqlConfig;

    fn build(self) -> Result<impl Access> {
        // ...
        // 逻辑便是自 PostgresqlConfig 那个私有数据的结构体中提取数据
        // match ... Some ... None 的含义就是说:如果有数据则提取,否则报错
        let table = match self.config.table {
            Some(v) => v,
            None => {
                return Err(Error::new(ErrorKind::ConfigInvalid, "table is empty")
                    .with_context("service", Scheme::Postgresql))
            }
        };
        // ..
        // 最后返回时会构造一个 Adapter
        Ok(PostgresqlBackend::new(Adapter {
            pool: OnceCell::new(),
            config,
            table,
            key_field,
            value_field,
        })
        .with_normalized_root(root))
    }
}

KV Adapter 简介

关于 Adapter,在 @XuanWo 的文章 《OpenDAL Key-Value Adapter》 中指出:“Key-Value 服务的接口都是非常相似的,为了避免重复实现相似逻辑,OpenDAL 引入了 Adapter”,而在 《将 OpenDAL KV 性能提升 1000% 中,@XuanWo 在总结:“OpenDAL 在很久之前增加了 Kv Adapter:通过将 Key-Value 存储后端的常用 GET/SET 操作抽象出来以大幅度简化对接一个 kv service 的成本” 的基础上,指出:“OpenDAL 将把所有的内存后端迁移到 typed_kv::Adapter,并增加更多的 kv 后端支持”(这项修改的缘由在于:"现在的 Kv Adapter 只能存储字节流,无法存储额外的 metadata,使得其使用场景受到了不应该的限制",因此 OpenDAL 后面引入了一个更为通用的 Value 类型用以描述各种数据,进而打破了这项限制)。

简而言之,就像 Builder 为各个 OpenDAL Service 的配置提供了统一的规范接口一样,Adapter 为各个 OpenDAL Service 操作数据提供了统一的规范接口,而因为 OpenDAL 的一个主要用户有着缓存服务的需求,遂使得 OpenDAL 的这个接口以 Key-Value 为主要的抽象导向。

让我们同样地阅读一下实现的代码(经过简化):

pub trait Adapter: Send + Sync + Debug + Unpin + 'static {

    // Key-Value 数据库中的 Get 操作接口
    fn get(&self, path: &str) -> impl Future<Output = Result<Option<Value>>> + MaybeSend;

    // Key-Value 数据库中的 Set 操作接口
    fn set(&self, path: &str, value: Value) -> impl Future<Output = Result<()>> + MaybeSend;

    // Key-Value 数据库中的 Delette 操作接口
    fn delete(&self, path: &str) -> impl Future<Output = Result<()>> + MaybeSend;

    // 增强版本的 Key-Value Get 接口,会返回所有有着相同前缀的 Key-Value 记录
    fn scan(&self, path: &str) -> impl Future<Output = Result<Vec<String>>> + MaybeSend {
        let _ = path;

        ready(Err(Error::new(
            ErrorKind::Unsupported,
            "typed_kv adapter doesn't support this operation",
        )
        .with_operation("typed_kv::Adapter::scan")))
    }
}

而对应到具体的落地上面,继续参考 PostgreSQL Service 的实现(这里以 get 操作为例,对应我们开头的那个小实验):

impl kv::Adapter for Adapter {
    // ...
    async fn get(&self, path: &str) -> Result<Option<Buffer>> {
        let pool = self.get_client().await?;

        let value: Option<Vec<u8>> = sqlx::query_scalar(&format!(
            r#"SELECT "{}" FROM "{}" WHERE "{}" = $1 LIMIT 1"#,
            self.value_field, self.table, self.key_field
        ))
        .bind(path)
        .fetch_optional(pool)
        .await
        .map_err(parse_postgres_error)?;

        Ok(value.map(Buffer::from))
    }
    // ...
}

可以发现,PostgreSQL Service 依托了 sqlx 库(结合了连接池)从 PostgreSQL 中提取数据,并按照抽象接口的要求把数据返回,由此完成了一整套的在关系型数据库上面模拟文件系统模型的工作。

其它的接口的实现本质上也是对 UPDATEDELETE 等 SQL 语句的封装,我们这里不再展开叙述(因为内在逻辑是统一的)。

PostgreSQL Service 改进思路分析

现在,我们把目光继续回归到文章最开头的数据表上面,让我们来执行如下的一些操作:

/* 继续使用 psql 连接 PostgreSQL */
psql -d postgres
/* 清空数据表 */
DELETE FROM example;
/* 创建四个 “文件” */
INSERT INTO example VALUES ('postgres.txt', 'Hello PostgreSQL');
INSERT INTO example VALUES ('dir/postgres.txt', 'PostgreSQL Is Great!');
INSERT INTO example VALUES ('new_dir/postgres.txt', 'PostgreSQL Is Great!');
INSERT INTO example VALUES ('new_dir/opendal.txt', 'OpenDAL Is Good!');

模拟文件系统 ls 指令的思路

文件目录在这里实际上以一个前缀的形式体现出来了,因此我们其实可以通过 SQL 语言的字符串前缀匹配 LIKE 查询来展开实现。

SELECT * FROM example WHERE filename LIKE 'new_dir%';

ls.png

对应到 OpenDAL 上面,因为 PostgreSQL-Service 否决了实现 list 的想法,所以这里我们看看就好(目录在数据表里本质就是前缀字符串的功夫,而跳转工作目录呢?也是拼接字符串的功夫,比如我把我的工作目录设置为 dir 的话,以后我所有的提取数据操作都加一个 WHERE filename LIKE 'dir%' 就行)

模拟文件系统 rename 的思路

变更文件名只需要我们对 filename 做一个字符串名称替换就行:

/* 更新 postgres.txt 的文件名为 opendal.txt */
UPDATE example SET filename = 'opendal.txt' WHERE filename = 'postgres.txt';

rename.png

如果是有目录概念呢?还是字符串前缀的功夫。

rename-2.png

模拟文件系统 copy 的思路

本质上就是复制一份既有的记录,但是我们需要修订文件名(即 filename 域)。

copy.png

带目录的话,也是在字符串前缀后缀上面下功夫就行。

迁移 PostgreSQL-Service 出 KV Adapter

可以参考 B2 Service 的实现:

impl Access for B2Backend {
    // ...
    async fn delete(&self) -> Result<(RpDelete, Self::Deleter)> {
        Ok((
            RpDelete::default(),
            oio::OneShotDeleter::new(B2Deleter::new(self.core.clone())),
        ))
    }
    // ...
}

更改 impl 的挂靠 trait 即可以解决。

写在最后

感谢本科导师袁国铭老师,感谢中国 PG 分会魏波老师、王其达老师,感谢开放原子开源基金会张凯老师,感谢 NoSQL-CN Alex 与 @BitString 志士,感谢 @XuanWo 和 @尚卓燃(特别感谢他提醒我去阅读 OpenDAL 测试部分的有关代码),期待将来能够更加深入地学习数据库内核知识,贯彻稻盛和夫哲学,降低工业级数据库存储研发门槛!

感谢稻盛和夫的《思维方式》,启发良多,是一本好书。

请前往 登录/注册 即可发表您的看法…