乐于分享
好东西不私藏

DuckDB 插件开发实战:Mac 版 Everything–Go + Web UI——从持久化索引到实时文件监听

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. 1. 不是每个人都会写 SQL——产品经理想找个文件,不会写 SELECT * FROM apfs_search
  2. 2. CLI 没法实时搜索——每次搜索都要敲回车等结果,不像 Everything 那样边打字边出结果

所以 apfs 项目配了一个独立的 Web UI——Go 后端 + 单页 HTML 前端,提供类似 Everything 的搜索体验。第 1 篇展示的效果图(658 万文件索引、396ms 响应)就是这个 Web UI。

核心设计决策:Web UI 是独立项目mac-everything),不是扩展的一部分。扩展可以完全不依赖 UI 独立工作。


架构总览

HTTP JSON

os/exec 调用DuckDB CLI

建索引

搜索

实时扫描

持久化

浏览器 index.html
Go 服务端
DuckDB CLI带 apfs 扩展
apfs_scan → INSERT INTO files
SELECT FROM files WHERE ILIKE
apfs_scan 直接返回
~/.mac-everything/files.duckdb

为什么用 CLI 子进程而不是 Go binding? 因为 apfs 扩展是 C++ 编写的,目前没有 Go binding 能直接加载自定义 C++ 扩展。通过 CLI 子进程调用是最简单的集成方式——虽然每次查询有约 0.09s 的进程启动开销(release 版),但对文件搜索场景完全可接受。


Go 服务端核心文件

文件
行数
职责
main.go
290
启动入口,DuckDB 二进制自动检测
server.go
1143
HTTP API + 索引管理 + SQL 执行
watcher.go
398
文件系统监听 + 增量更新
config.go
392
TOML 配置解析 + 排除规则
config_watcher.go
140
配置文件热加载
合计 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 端点

端点
方法
用途
/
GET
返回嵌入的 index.html
/api/stats
GET
索引状态(文件数、最后刷新时间、watcher 状态)
/api/search
GET
搜索文件(ILIKE 查持久化表)
/api/browse
GET
浏览文件列表(分页 + 排序)
/api/files
GET
按目录列出文件(用于树形视图)
/api/scan
GET
实时扫描(直接调 apfs_scan)
/api/refresh
POST
刷新索引(全量或定向目录)
/api/open
POST
用系统默认应用打开文件

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

文件系统变更事件
fsnotify.Watcherkqueue 监听
handleEventdebounce 500ms
processEvents 批量处理
Create → INSERT INTO files
Write → UPDATE files SET ...
Remove → DELETE FROM files WHERE ...
索引自动更新

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 interror {
    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),修改后无需重启:

语法错误/校验失败

有效

修改 config.toml
检测到文件变更1秒防抖
重新加载配置
配置是否有效?
保留旧配置控制台+界面提示错误
扫描范围有变化?
重建索引原子替换旧数据
仅更新排除规则

配置文件支持指定扫描目录、排除目录(支持 **/.git 通配符)、排除文件名模式等。


总结

  1. 1. Go 服务端通过 CLI 子进程调用 DuckDB——简单但有效
  2. 2. 持久化索引存在 .duckdb 文件中——搜索走表查询(毫秒级),不走实时扫描
  3. 3. 原子表替换 + 防空表保护——索引重建期间搜索不中断
  4. 4. SQL 注入防护用白名单 + 转义——不直接拼接用户输入
  5. 5. fsnotify 监听文件变更——500ms debounce 批量处理
  6. 6. Deferred 机制——全量索引期间暂存事件,完成后回放
  7. 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 测试)