乐于分享
好东西不私藏

DuckDB 插件开发实战:Mac 版 Everything-从 0 到 1 构建 apfs ——完整实现与官方对比

DuckDB 插件开发实战:Mac 版 Everything-从 0 到 1 构建 apfs ——完整实现与官方对比

从 0 到 1 构建 apfs 插件——完整实现与官方插件对比

—— 如果从零开始,怎么一步步构建出当前的样子

本文是《DuckDB 插件开发实战:Mac 版 Everything》系列的第 2 篇。上一篇体验了 apfs 扩展的效果和项目结构,这篇以”如果从零开始”为主线,完整走一遍 apfs 插件的构建决策过程,并横向对比 DuckDB 官方核心插件。

版本基线:本文所有代码、行数、API 均基于 duckdb-apfs 仓库 duckdb/ submodule 锁定的 DuckDB v1.5.2、Apple clang 16.0.0 编译;文中提到的所有行数均为作者环境实测,读者在不同提交、不同平台下会有细微差异。仅基本功能实现,实际运行体验仍与Windows版本有不小差距,源码见参考资料。


从 extension-template 到 apfs——搭骨架

本文讲解的 duckdb-apfs 仓库就是从 duckdb/extension-template clone 下来、按下文 Step 2-5 演进而成的。阅读本文时可对照 duckdb-apfs 源码。

Step 1:clone 模板,改名

git clone --recurse-submodules https://github.com/duckdb/extension-template.git duckdb-apfscd duckdb-apfs

模板默认叫 “quack”,自带一个 quack('world') → 'Quack world 🐥' 的标量函数。

Step 2:配置 extension_config.cmake

# This file is included by DuckDB's build system. It specifies which extension to loadduckdb_extension_load(apfs    SOURCE_DIR ${CMAKE_CURRENT_LIST_DIR}    LOAD_TESTS)

把 quack 改成 apfs——extension_config.cmake 完整文件 7 行(含 header 注释 + 空行),核心声明 5 行。这告诉 DuckDB 构建系统:”我有个叫 apfs 的扩展,源码在当前目录,测试也要加载。”

Step 3:配置 CMakeLists.txt

apfs 扩展比模板多了一步——链接 macOS 框架。下面是 CMakeLists.txt(完整 38 行)中最关键的片段,省略了固定模板生成的 cmake_minimum_required / project / include_directories / install 等样板:

set(EXTENSION_SOURCES    src/apfs_extension.cpp    src/apfs_fts_scanner.cpp    src/apfs_spotlight_scanner.cpp    src/apfs_has_changes.cpp    src/apfs_utils.cpp)build_static_extension(${TARGET_NAME}${EXTENSION_SOURCES})build_loadable_extension(${TARGET_NAME}${PARAMETERS}${EXTENSION_SOURCES})# 这是 apfs 特有的——Spotlight API 在这两个框架里if(APPLE)find_library(CORE_FOUNDATION_LIB CoreFoundation)find_library(CORE_SERVICES_LIB CoreServices)if(CORE_FOUNDATION_LIB AND CORE_SERVICES_LIB)target_link_libraries(${EXTENSION_NAME}${CORE_FOUNDATION_LIB}${CORE_SERVICES_LIB})target_link_libraries(${LOADABLE_EXTENSION_NAME}${CORE_FOUNDATION_LIB}${CORE_SERVICES_LIB})endif()endif()

对比 json 扩展:json 扩展的 CMakeLists.txt 不需要链接系统框架,但它引入了 yyjson 第三方库。不同扩展的外部依赖不同,但 build_static_extension / build_loadable_extension 这两个宏是绝大多数 C++ 扩展都会用到的(delta 这类带 vcpkg 依赖的扩展有额外步骤)。

Step 4:定义 Extension 类

// src/include/apfs_extension.hpp(22 行)classApfsExtension : public Extension {public:voidLoad(ExtensionLoader &loader)override;std::string Name()override;std::string Version()constoverride;};

三个方法,每个扩展都一样。Load() 是唯一需要写逻辑的——注册你的函数。

Step 5:写 LoadInternal——总入口

LoadInternal 函数本身是 27 行(src/apfs_extension.cpp),剩余 153 行是 include、Helper 函数(DefineApfsColumns / ApfsScanBindInternal / ApfsScanBindOneArg / ApfsScanBindTwoArgs / ApfsScanInit / ApfsScanFunction 等)和 C 入口宏。完整 LoadInternal

staticvoidLoadInternal(ExtensionLoader &loader){// 1. 注册 apfs_scan:TableFunctionSet 含两个重载TableFunctionSet scan_set("apfs_scan");// 重载 1: apfs_scan(path) → 默认 fts 模式TableFunction scan_one_arg("apfs_scan", {LogicalType::VARCHAR},        ApfsScanFunction, ApfsScanBindOneArg, ApfsScanInit);    scan_one_arg.named_parameters["since"] = LogicalType::TIMESTAMP;    scan_set.AddFunction(scan_one_arg);// 重载 2: apfs_scan(path, mode) → 显式指定模式TableFunction scan_two_args("apfs_scan",        {LogicalType::VARCHAR, LogicalType::VARCHAR},        ApfsScanFunction, ApfsScanBindTwoArgs, ApfsScanInit);    scan_two_args.named_parameters["since"] = LogicalType::TIMESTAMP;    scan_set.AddFunction(scan_two_args);    loader.RegisterFunction(scan_set);// 2. 注册 apfs_search(单个 TableFunction)TableFunction search_func("apfs_search", {LogicalType::VARCHAR},        ApfsSearchFunction, ApfsSearchBindWrapper, ApfsSearchInit);    loader.RegisterFunction(search_func);// 3. 注册 apfs_has_changes(ScalarFunction)    loader.RegisterFunction(CreateApfsHasChangesFunction());}

named_parameters["since"] 的注册至关重要——省略后写 apfs_scan('/', 'spotlight', since=TIMESTAMP '2026-04-01') 会报 Named parameter 'since' not found。位置参数和命名参数的混合设计让 path 必传、mode 通过重载实现可选(单参时默认 fts)、since 作为可选命名参数。

对比 json 扩展的 LoadInternalduckdb/extension/json/json_extension.cpp,103 行):

staticvoidLoadInternal(ExtensionLoader &loader){// 1. 注册 JSON 类型    loader.RegisterType(LogicalType::JSON_TYPE_NAME, LogicalType::JSON());// 2. 注册类型转换(cast)    JSONFunctions::RegisterSimpleCastFunctions(loader);    JSONFunctions::RegisterJSONCreateCastFunctions(loader);// 3. 注册标量函数(json_extract, json_type, ...)for (auto &fun : JSONFunctions::GetScalarFunctions()) {        loader.RegisterFunction(fun);    }// 4. 注册表函数(read_json, read_json_auto, ...)for (auto &fun : JSONFunctions::GetTableFunctions()) {        loader.RegisterFunction(fun);    }// 5. 注册 pragma 函数// 6. 注册替换扫描(replacement scan)// 7. 注册 copy 函数(COPY TO ... FORMAT JSON)// 8. 注册宏(json_group_array, json_group_object, ...)}

json 扩展注册了类型 + cast + 标量函数 + 表函数 + pragma + copy + macro——几乎用到了 DuckDB 所有的注册 API。apfs 只用了其中两种(表函数 + 标量函数),是最常见的组合。

对比 tpch 扩展的 LoadInternalduckdb/extension/tpch/tpch_extension.cpp,227 行):

staticvoidLoadInternal(ExtensionLoader &loader){// 1. 注册 dbgen 表函数(生成 TPC-H 测试数据)TableFunction dbgen_func("dbgen", {}, DbgenFunction, DbgenBind);    dbgen_func.named_parameters["sf"] = LogicalType::DOUBLE;    dbgen_func.named_parameters["overwrite"] = LogicalType::BOOLEAN;// ... 更多命名参数    loader.RegisterFunction(dbgen_func);// 2. 注册 tpch pragma(运行 TPC-H 查询)auto tpch_func = PragmaFunction::PragmaCall("tpch", PragmaTpchQuery, {LogicalType::BIGINT});    loader.RegisterFunction(tpch_func);// 3. 注册 tpch_queries 表函数(返回 22 条查询)// 4. 注册 tpch_answers 表函数(返回查询答案)}

tpch 扩展的结构最清晰——表函数 + pragma,没有复杂的类型系统。apfs 和它最像。


设计 Schema——11 列是怎么定下来的

staticvoidDefineApfsColumns(vector<LogicalType> &return_types, vector<string> &names){    names.emplace_back("path");        return_types.emplace_back(LogicalType::VARCHAR);    names.emplace_back("name");        return_types.emplace_back(LogicalType::VARCHAR);    names.emplace_back("extension");   return_types.emplace_back(LogicalType::VARCHAR);    names.emplace_back("size");        return_types.emplace_back(LogicalType::BIGINT);    names.emplace_back("file_type");   return_types.emplace_back(LogicalType::VARCHAR);    names.emplace_back("modified_at"); return_types.emplace_back(LogicalType::TIMESTAMP);    names.emplace_back("created_at");  return_types.emplace_back(LogicalType::TIMESTAMP);    names.emplace_back("accessed_at"); return_types.emplace_back(LogicalType::TIMESTAMP);    names.emplace_back("permissions"); return_types.emplace_back(LogicalType::VARCHAR);    names.emplace_back("owner");       return_types.emplace_back(LogicalType::VARCHAR);    names.emplace_back("depth");       return_types.emplace_back(LogicalType::INTEGER);}

为什么是这 11 列?来自两个数据源的交集:

fts 模式(struct stat)
Spotlight 模式(MDItem)
path
entry->fts_path kMDItemPath
name
entry->fts_name kMDItemFSName
extension
从 name 解析
从 name 解析
size
st->st_size kMDItemFSSize
file_type
entry->fts_info kMDItemContentType
modified_at
st->st_mtimespec kMDItemContentModificationDate
created_at
st->st_birthtimespec kMDItemContentCreationDate
accessed_at
st->st_atimespec kMDItemLastUsedDate
permissions
st->st_mode
额外 lstat() 补充
owner
getpwuid(st->st_uid)
额外 lstat() 补充
depth
entry->fts_level
从 path 计算

设计原则:两种模式返回相同的 schema,用户不需要关心底层用的哪个引擎。permissions 和 owner 在 Spotlight 模式下需要额外的 lstat() 调用——Spotlight 索引不存储这两个字段。

对比 tpch 的 schema:tpch_queries 只有 2 列(query_nr INTEGER, query VARCHAR),因为它返回的是固定的 22 条 SQL 查询文本。schema 设计完全取决于你的数据源。


实现三个函数——从简单到复杂

函数 1:apfs_has_changes——标量函数,最简单

// src/apfs_has_changes.cpp(133 行)ScalarFunction CreateApfsHasChangesFunction(){ScalarFunction func("apfs_has_changes",        {LogicalType::VARCHAR, LogicalType::TIMESTAMP},        LogicalType::BOOLEAN,        ApfsHasChangesFunction);    func.stability = FunctionStability::VOLATILE;return func;}

标量函数不需要 Bind/Init/Execute 三板斧,只要一个执行函数。输入 (path, since_timestamp),输出 BOOLEAN

对比 demo_capi 的 add_numbers_togetherduckdb/extension/demo_capi/add_numbers.cpp,64 行):

// 纯 C API 写法——不用 C++ 类,直接操作底层指针staticvoidAddNumbersTogether(duckdb_function_info info,    duckdb_data_chunk input, duckdb_vector output) {idx_t input_size = duckdb_data_chunk_get_size(input);    duckdb_vector a = duckdb_data_chunk_get_vector(input, 0);    duckdb_vector b = duckdb_data_chunk_get_vector(input, 1);auto a_data = (int64_t *)duckdb_vector_get_data(a);auto b_data = (int64_t *)duckdb_vector_get_data(b);auto result_data = (int64_t *)duckdb_vector_get_data(output);for (idx_t row = 0; row < input_size; row++) {        result_data[row] = a_data[row] + b_data[row];    }}

demo_capi 用的是 DuckDB 的 C 稳定 APIduckdb_extension.h),直接操作底层数据指针,性能更高但代码更底层。apfs 用的是 C++ 内部 APIScalarFunction + Value),更简洁但与 DuckDB 版本强绑定。

函数 2:apfs_scan——表函数,双模式分发

apfs_scan 是最复杂的函数——用 TableFunctionSet 注册两个重载:

// 重载 1: apfs_scan(path) → 默认 fts 模式TableFunction scan_one_arg("apfs_scan",    {LogicalType::VARCHAR},    ApfsScanFunction, ApfsScanBindOneArg, ApfsScanInit);// 重载 2: apfs_scan(path, mode) → 显式指定模式TableFunction scan_two_args("apfs_scan",    {LogicalType::VARCHAR, LogicalType::VARCHAR},    ApfsScanFunction, ApfsScanBindTwoArgs, ApfsScanInit);

DuckDB 通过参数个数自动选择重载。Bind 层根据 mode 参数创建不同类型的 BindData,Init/Execute 层通过 dynamic_cast 分发到对应引擎。

对比 tpch 的 dbgen 表函数:tpch 没有重载,全部用命名参数:

TableFunction dbgen_func("dbgen", {}, DbgenFunction, DbgenBind);dbgen_func.named_parameters["sf"] = LogicalType::DOUBLE;dbgen_func.named_parameters["overwrite"] = LogicalType::BOOLEAN;dbgen_func.named_parameters["catalog"] = LogicalType::VARCHAR;

dbgen 的参数列表是空的 {}——所有参数都是命名参数。apfs_scan 选择了位置参数 + 命名参数的混合方式:path 和 mode 是位置参数(必须传),since 是命名参数(可选)。

函数 3:apfs_search——表函数,Spotlight 专用

TableFunction search_func("apfs_search",    {LogicalType::VARCHAR},    ApfsSearchFunction, ApfsSearchBindWrapper, ApfsSearchInit);

apfs_search 只走 Spotlight,不支持 fts 模式。它的 Bind 函数复用了 DefineApfsColumns 定义相同的 11 列 schema,但 Init 和 Execute 是独立的实现。


5 个源文件的职责划分

apfs 扩展源码结构
apfs_extension.cpp180 行入口 + 统一 Bind/Init/Execute + Schema
apfs_fts_scanner.cpp184 行fts(3) 引擎
apfs_spotlight_scanner.cpp385 行Spotlight 引擎 + apfs_search
apfs_has_changes.cpp133 行标量函数
apfs_utils.cpp129 行UTF-8 / 权限 / owner
文件
行数
职责
apfs_extension.cpp
180
入口 + 统一 Bind/Init/Execute 分发 + Schema 定义
apfs_fts_scanner.cpp
184
fts(3) 引擎:Init 打开句柄,Execute 逐条读取
apfs_spotlight_scanner.cpp
385
Spotlight 引擎 + apfs_search:MDQuery 创建/执行/提取
apfs_has_changes.cpp
133
标量函数:轻量 MDQuery 检测变更
apfs_utils.cpp
129
工具函数:UTF-8 清洗、权限转换、owner 查找

Spotlight 引擎比 fts 引擎大一倍——因为要处理 Core Foundation 的内存管理和类型转换(每个 MDItemCopyAttribute 返回值都要 CFRelease)。

对比 json 扩展的文件组织:json 扩展有 14+ 源文件,按功能域拆分(json_scan.cppjson_reader.cppjson_functions.cpp 等)。apfs 的 5 个文件按”引擎”拆分——入口、fts 引擎、spotlight 引擎、标量函数、工具函数。

对比 parquet 扩展的文件组织:parquet 扩展有 20+ 源文件,按 reader/writer/decoder 分层。这是因为 Parquet 格式本身就很复杂(列式存储、嵌套类型、压缩编码)。apfs 的数据模型简单(11 列扁平结构),不需要这么多层次。


DuckDB 官方核心插件巡览

DuckDB v1.5.2 源码 extension/ 目录下共 10 个内置扩展目录(外加一个非扩展的 loader/ 辅助目录,不计入)。以下是它们的对比:

插件
类型
注册内容
特点
core_functions
内置
标量函数 + 聚合函数
DuckDB 的”标准库”,每次构建必含
parquet
内置
表函数 + copy + 替换扫描
最复杂的内置扩展,读写 Parquet 格式
json
内置
类型 + cast + 标量/表/pragma/copy + macro
注册种类最全,几乎用到所有注册 API
icu
内置
标量函数(日期/时间/时区)
国际化支持,依赖 ICU 第三方库
tpch
内置
表函数 + pragma
基准测试数据生成
tpcds
内置
表函数 + pragma
类似 tpch,更复杂的基准测试
demo_capi
内置
标量函数(纯 C API)
唯一的 C API 示例
delta
内置
表函数
读取 Delta Lake 格式,C++ 扩展代码 + Rust delta-kernel-rs 库
autocomplete
内置
PRAGMA / 标量函数
CLI 的 SQL 自动补全支持
jemalloc
内置
内存分配器替换
仅 Linux x86_64 默认启用;其他平台作为可选项

DuckDB 的 extension/extension_config.cmake 默认至少启用 2 个扩展,另有 1 个按平台条件启用:

# 每次构建都会包含duckdb_extension_load(core_functions)duckdb_extension_load(parquet)# 仅 Linux x86_64 默认启用(Apple Silicon / Windows 等不启用)if(CMAKE_SIZEOF_VOID_P EQUAL8AND ... AND OS_NAME STREQUAL"linux" ...)    duckdb_extension_load(jemalloc)endif()

其他扩展(json、icu、tpch、autocomplete 等)需要通过 DUCKDB_EXTENSIONS 变量或配置文件显式启用。

三种扩展 API 风格

混合
Rust 核心 + C++ 胶水层
delta
C 稳定 API
DUCKDB_EXTENSION_ENTRYPOINT+ duckdb_extension.h
demo_capi
C++ 内部 API
DUCKDB_CPP_EXTENSION_ENTRY+ ExtensionLoader
apfs / json / tpch / icu
API 风格
入口宏
优点
缺点
适合
C++ 内部
DUCKDB_CPP_EXTENSION_ENTRY
最简洁,直接用 DuckDB C++ 类
与 DuckDB 版本强绑定
大多数扩展
C 稳定
DUCKDB_EXTENSION_ENTRYPOINT
ABI 稳定,跨版本兼容
代码更底层,手动管理内存
需要跨版本分发
混合
自定义
可用 Rust/Go 等语言
构建复杂
已有非 C++ 代码库

apfs 选择了 C++ 内部 API——因为它是和特定版本的 DuckDB 一起编译分发的(通过 git submodule 锁定版本),不需要跨版本兼容。

C++ 内部 API「版本绑定」的真实代价:DuckDB 的 C++ 内部 API 会在跨版本演进时重命名/移除符号。本文作者在 v1.5.2 编译时就曾遇到:某些历史教程写作 FlatVector::GetDataMutable<T>(result) 以获取非 const 可写指针,但 v1.5.2 已将这个模板方法整合回 FlatVector::GetData<T>(Vector &) 的非 const 重载(src/include/duckdb/common/types/vector.hpp:370)。使用旧写法会直接编译失败:

error: no member named 'GetDataMutable' in 'duckdb::FlatVector'

这就是 C++ 内部 API 的脆弱性——社区扩展只能通过 duckdb/ submodule 锁死版本解决。参见官方的 Extension Versioning。如果你的扩展想要跨多个 DuckDB 版本分发,只能选 C 稳定 API。


apfs 插件的设计决策复盘

为什么选双引擎(fts + spotlight)?

不是”二选一”而是”互补”:

场景
fts
spotlight
扫描 /tmp
❌(Spotlight 不索引)
扫描 .git/ 内容
❌(Spotlight 排除)
全盘快速分析
❌(太慢)
✅(1-3 秒)
增量扫描(since 参数)
❌(不支持)

为什么用 TableFunctionSet 重载而不是全用命名参数?

apfs_scan('/tmp') 比 apfs_scan(path='/tmp') 更自然。path 是必须的,mode 有默认值(fts),since 是可选的——位置参数 + 命名参数的混合方式最符合使用习惯。

为什么 apfs_has_changes 是标量函数而不是表函数?

因为它只返回一个 boolean 值——”有没有变更”。不需要返回变更的文件列表。标量函数可以在 WHERE 子句中使用:

-- 只在有变更时才重新扫描SELECT*FROM apfs_scan('/tmp''fts')WHERE apfs_has_changes('/tmp'TIMESTAMP'2026-04-01');

和向量扩展组合——做本地 RAG

apfs 扩展本身只做「文件元数据扫描」,但 DuckDB 生态里还有 vss(向量相似搜索)、fts(全文搜索)等扩展。把 apfs 作为数据采集层,三者组合就能在本地做一个轻量 RAG 管道:

-- 1. 用 apfs 扫描:拿到路径 + 修改时间 + 大小CREATE TABLE files ASSELECT*FROM apfs_scan('/Users/me/docs');-- 2. 只读文本类文件(交给外部脚本/扩展做内容抽取)-- 3. 在 files 表上建 FTS(用 DuckDB 的 fts 扩展)或 vss 向量索引PRAGMA create_fts_index('files''path''name');-- 4. 增量更新:只重新 embed 变更过的文件SELECT path FROM filesWHERE apfs_has_changes(path, last_embed_time);

11 列 schema 的 AI 用途

可用于
场景
path

 / name / extension
直接 embed 成向量
语义文件名搜索
size
数值特征
过滤掉异常大/小的文件
modified_at

 / created_at
时间衰减权重
最近修改的文件优先
file_type
分类过滤
只检索 public.pdf 一类
owner

 / permissions
访问控制
多用户本地 RAG 的 ACL

这也是 apfs 和 apfs_has_changes 为什么要标量化的理由之一——可以在 SQL 的 WHERE 里做增量 Embedding 更新,这是 RAG 管道的高频需求。


总结

从 0 到 1 构建一个 DuckDB 插件,核心步骤:

  1. 1. 从 extension-template 开始——改名 + 配置 CMake
  2. 2. 设计 Schema——根据数据源能提供什么来定义输出列
  3. 3. 实现函数——标量函数最简单,表函数需要 Bind/Init/Execute 三板斧
  4. 4. 写 LoadInternal——注册所有函数的总入口
  5. 5. 文件组织——按引擎/功能域拆分,入口文件只做注册和分发

apfs 扩展的代码量(~1200 行 C++)在 DuckDB 扩展生态中属于中等偏小。json 扩展有数千行,parquet 扩展更多。但核心模式是一样的——LoadInternal 注册函数,Bind/Init/Execute 处理数据

下一篇拆解 DuckDB 表函数的 Bind / Init / Execute 三板斧——apfs 插件的每个函数是怎么注册和执行的。


参考资料

  • • mac-everything
  • • duckdb-apfs
  • • DuckDB Extension Template
  • • DuckDB 官方文档 – Extensions
  • • DuckDB 源码:extension/README.md(扩展类型和构建方式说明)
  • • DuckDB 源码:extension/json/json_extension.cpp(103 行,json 扩展入口)
  • • DuckDB 源码:extension/tpch/tpch_extension.cpp(227 行,tpch 扩展入口)
  • • DuckDB 源码:extension/demo_capi/add_numbers.cpp(64 行,C API 标量函数示例)
  • • DuckDB 源码:extension/demo_capi/capi_demo.cpp(23 行,C API 扩展入口)
  • • DuckDB 源码:extension/extension_config.cmake(默认启用 core_functions + parquet)
  • • duckdb-apfs 源码:src/apfs_extension.cpp(180 行,完整入口代码)