DuckDB 插件开发实战:Mac 版 Everything–Go + Web UI——从持久化索引到实时文件监听
Go + Web UI——从持久化索引到实时文件监听
—— 当 SQL 遇上 HTTP
本文是《DuckDB 插件开发实战:Mac 版 Everything》系列的第 5 篇。前面 4 篇聚焦在 C++ 扩展本身,这篇往上走一层——怎么在 DuckDB 扩展之上构建一个完整的应用。
版本基线:本文所有 Go 代码、行数、API 端点、常量均基于
/Users/max/src/db/mac-everything仓库实测(go 1.21+);DuckDB 扩展来自duckdb-apfs仓库 v1.5.2 submodule。本文所有”实测”字段均为作者环境(M2 MacBook Pro 16GB)实测,读者环境会有细微差异。仅基本功能实现,实际运行体验仍与Windows版本有不小差距,源码见参考资料。
为什么要 Web UI?
apfs 扩展在 DuckDB CLI 里用得很开心,但有两个问题:
-
1. 不是每个人都会写 SQL——产品经理想找个文件,不会写 SELECT * FROM apfs_search -
2. CLI 没法实时搜索——每次搜索都要敲回车等结果,不像 Everything 那样边打字边出结果
所以 apfs 项目配了一个独立的 Web UI——Go 后端 + 单页 HTML 前端,提供类似 Everything 的搜索体验。第 1 篇展示的效果图(658 万文件索引、396ms 响应)就是这个 Web UI。
核心设计决策:Web UI 是独立项目(mac-everything),不是扩展的一部分。扩展可以完全不依赖 UI 独立工作。
架构总览
为什么用 CLI 子进程而不是 Go binding? 因为 apfs 扩展是 C++ 编写的,目前没有 Go binding 能直接加载自定义 C++ 扩展。通过 CLI 子进程调用是最简单的集成方式——虽然每次查询有约 0.09s 的进程启动开销(release 版),但对文件搜索场景完全可接受。
Go 服务端核心文件
|
|
|
|
main.go |
|
|
server.go |
|
|
watcher.go |
|
|
config.go |
|
|
config_watcher.go |
|
|
| 合计 | 2363 |
|
DuckDB 二进制自动检测
funcfindDuckDBBinary()string {
var candidates []string
// 1. 当前可执行文件同目录下查找(便于 make dist 打包分发)
if exe, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Join(filepath.Dir(exe), duckdbBinName()))
}
// 2. 当前工作目录
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(cwd, duckdbBinName()))
}
// 3. 用户主目录下常见路径 ~/.duckdb/cli/latest/duckdb
if home, _ := os.UserHomeDir(); home != "" {
candidates = append(candidates,
filepath.Join(home, ".duckdb", "cli", "latest", duckdbBinName()))
}
// 4. PATH 中查找
if pathBin, err := exec.LookPath("duckdb"); err == nil {
candidates = append(candidates, pathBin)
}
// 第一轮:优先选择带 apfs 扩展的
for _, bin := range candidates {
if hasApfsExtension(bin) { return bin }
}
// 第二轮:回退到任何可用的 DuckDB(会打印 WARNING)
// ...
}
hasApfsExtension 怎么检测?执行一条测试 SQL:
funchasApfsExtension(bin string)bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "-c",
"SELECT 1 FROM apfs_scan('/tmp', 'fts') LIMIT 0;")
return cmd.Run() == nil
}
8 个 API 端点
|
|
|
|
/ |
|
|
/api/stats |
|
|
/api/search |
|
|
/api/browse |
|
|
/api/files |
|
|
/api/scan |
|
|
/api/refresh |
|
|
/api/open |
|
|
SQL 执行方式
func(s *Server) runQueryOnDB(sql string, timeout time.Duration) ([]map[string]interface{}, error) {
s.dbMu.Lock() // 串行化所有数据库访问
defer s.dbMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, s.duckdbBin, s.dbPath, "-json", "-c", sql)
output, err := cmd.CombinedOutput()
// 解析 JSON 输出 → []map[string]interface{}
}
-json 参数让 DuckDB 输出 JSON 格式。dbMu 互斥锁保证同一时间只有一个进程访问 .duckdb 文件——DuckDB 的单写者模型要求这样。
持久化索引:首次扫描建表
func(s *Server) buildIndex() {
// 1. 创建 staging 表
"CREATE TABLE files_new (path VARCHAR, name VARCHAR, ...)"
// 2. 逐目录扫描
for _, dir := range getScanDirs() {
"INSERT INTO files_new SELECT * FROM apfs_scan(dir, 'fts')"
}
// 3. 验证不为空(防止空表替换)
count := "SELECT COUNT(*) FROM files_new"
if count == 0 { return } // 安全保护
// 4. 原子替换
"ALTER TABLE files RENAME TO files_old"
"ALTER TABLE files_new RENAME TO files"
"DROP TABLE files_old"
}
原子替换是关键——用户在索引重建期间搜索,看到的是旧的 files 表,不会出现”搜索返回空结果”的窗口。
防空表保护:如果扫描意外返回 0 行(比如权限问题),不替换现有索引。
搜索实现与 SQL 注入防护
func(s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
searchCondition := parseSearchQuery(q)
// "*.go" → "lower(extension) = 'go'"
// "config" → "name ILIKE '%config%' OR path ILIKE '%config%'"
sql := fmt.Sprintf("SELECT * FROM files %s %s LIMIT %d OFFSET %d",
whereClause, orderClause, limit, offset)
records, err := s.runQueryOnDB(sql, 60*time.Second)
}
// 白名单排序列——不是用户传什么就查什么
var allowedSortColumns = map[string]string{
"name": "varchar", "path": "varchar", "extension": "varchar",
"size": "numeric", "file_type": "varchar",
"modified_at": "timestamp", "created_at": "timestamp",
"accessed_at": "timestamp",
"depth": "numeric",
}
// 字符串值用单引号转义
funcescapeSQL(s string)string {
return strings.ReplaceAll(s, "'", "''")
}
搜索关键词通过 escapeSQL 处理单引号,排序列通过白名单(9 个字段)验证。非字符串列直接用 "col" 引用,字符串列额外包 encode("col") 避免排序时的 UTF-8 问题。
嵌入式前端
//go:embed static/index.html
var staticFS embed.FS
Go 1.16 的 embed 特性把 HTML 文件编译进二进制——单文件部署,不需要额外的静态文件目录。
前端是一个 ~1000 行的单文件 HTML,包含:
-
• 深色主题搜索界面(CSS 变量 + 系统字体) -
• 300ms debounce 即时搜索 -
• 6 种文件类型过滤(All / Files / Folders / Images / Documents / Code) -
• 服务端排序(点击列头) -
• 右键上下文菜单(打开文件、Finder 中显示、刷新目录、复制路径) -
• ⌘+K 快捷键聚焦搜索框
实时文件监听
问题:索引会过时
建完索引后,新创建或删除的文件不会自动反映在搜索结果中。用户必须手动点”刷新”才能看到最新状态。对于 Everything 这样的工具,实时性是基本体验。
解法:fsnotify + 增量 SQL
FileWatcher 核心结构体
type FileWatcher struct {
server *Server
watcher *fsnotify.Watcher
// Debounce:500ms 窗口批量处理
pending map[string]fsnotify.Op
debounce *time.Timer
// Deferred:全量索引期间暂存事件
deferred map[string]fsnotify.Op
}
两个 map 各有用途:pending 收集 500ms 内的事件然后批量处理,deferred 在全量索引期间暂存事件等索引完成后回放。
监听深度限制
const maxWatchDepth = 3
func(fw *FileWatcher) addDirRecursiveDepth(dir string, currentDepth int) error {
fw.watcher.Add(dir)
if currentDepth >= maxWatchDepth { returnnil }
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
fw.addDirRecursiveDepth(filepath.Join(dir, e.Name()), currentDepth+1)
}
}
returnnil
}
为什么限制深度为 3? macOS 的 kqueue 为每个被监听的目录打开一个文件描述符。深层递归会耗尽 fd 限制(macOS 默认 ulimit 约 256-10240)。3 层深度覆盖了绝大多数常用目录。
Debounce:500ms 窗口
文件操作经常是连续的——比如编辑器保存一个文件可能触发 Write → Write → Write 三个事件。不做 debounce 会导致频繁写数据库。
func(fw *FileWatcher) handleEvent(event fsnotify.Event) {
// 跳过隐藏文件和临时文件
if strings.HasPrefix(base, ".") { return }
if strings.HasSuffix(base, ".swp") || strings.HasSuffix(base, ".tmp") { return }
fw.pendingMu.Lock()
// 合并同一文件的多个事件(位或操作)
existing, ok := fw.pending[event.Name]
if ok {
fw.pending[event.Name] = existing | event.Op
} else {
fw.pending[event.Name] = event.Op
}
// 重置 500ms 定时器
if fw.debounce != nil { fw.debounce.Stop() }
fw.debounce = time.AfterFunc(500*time.Millisecond, fw.flushPending)
fw.pendingMu.Unlock()
}
Deferred:全量索引期间的事件暂存
如果用户正在做全量索引重建,这时候来的文件变更怎么办?不能丢,也不能立刻处理(会和全量重建冲突)。
func(fw *FileWatcher) flushPending() {
// 如果正在全量索引 → 暂存到 deferred
if indexing {
for path, op := range events {
fw.deferred[path] = existing | op
}
return
}
fw.processEvents(events)
}
// 全量索引完成后调用
func(fw *FileWatcher) FlushDeferred() {
events := fw.deferred
fw.deferred = make(map[string]fsnotify.Op)
fw.processEvents(events)
}
三种增量操作
func(fw *FileWatcher) processEvents(events map[string]fsnotify.Op) {
for path, op := range events {
if op&fsnotify.Remove != 0 || op&fsnotify.Rename != 0 {
fw.server.deleteFilePath(path)
}
if op&fsnotify.Create != 0 {
linfo, _ := os.Lstat(path)
if linfo.IsDir() {
fw.watcher.Add(path)
fw.server.refreshDir(path)
} else {
fw.insertSingleFile(path, info)
}
}
if op&fsnotify.Write != 0 && op&fsnotify.Create == 0 {
fw.updateFileMetadata(path, info)
}
}
}
insertSingleFile:完整元数据采集
func(fw *FileWatcher) insertSingleFile(path string, info os.FileInfo) error {
// 用 Lstat 检测 symlink(os.Stat 会跟随符号链接)
linfo, _ := os.Lstat(path)
fileType := "file"
if linfo.IsDir() { fileType = "directory" }
if linfo.Mode()&os.ModeSymlink != 0 { fileType = "symlink" }
// macOS 特有:从 syscall.Stat_t 获取创建时间和访问时间
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
createdAt = time.Unix(stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec)
accessedAt = time.Unix(stat.Atimespec.Sec, stat.Atimespec.Nsec)
}
sql := fmt.Sprintf("INSERT INTO files VALUES ('%s', '%s', ...)",
safePath, safeName, ...)
return fw.server.runCommandOnDB(sql, 10*time.Second)
}
配置热加载
Mac Everything 支持 TOML 配置文件(~/.mac-everything/config.toml),修改后无需重启:
配置文件支持指定扫描目录、排除目录(支持 **/.git 通配符)、排除文件名模式等。
总结
-
1. Go 服务端通过 CLI 子进程调用 DuckDB——简单但有效 -
2. 持久化索引存在 .duckdb文件中——搜索走表查询(毫秒级),不走实时扫描 -
3. 原子表替换 + 防空表保护——索引重建期间搜索不中断 -
4. SQL 注入防护用白名单 + 转义——不直接拼接用户输入 -
5. fsnotify 监听文件变更——500ms debounce 批量处理 -
6. Deferred 机制——全量索引期间暂存事件,完成后回放 -
7. 配置热加载——修改 TOML 配置自动生效
下一篇是收尾——测试、编译、分发,以及一份可带走的开发 Checklist。
参考资料
-
• mac-everything -
• duckdb-apfs -
• fsnotify/fsnotify — Go 文件系统通知库 -
• BurntSushi/toml — TOML 配置文件解析 -
• mac-everything 源码: server.go(HTTP API + 索引管理) -
• mac-everything 源码: watcher.go(文件系统监听 + 增量更新) -
• mac-everything 源码: config.go(配置解析 + 排除规则) -
• mac-everything 源码: server_test.go(API 和 Watcher 测试)
夜雨聆风