首页 / 资讯中心 / 文章详情

回归极简:用Go标准库打造文件存储+Git同步的自托管套件

回归极简:用Go标准库打造文件存储+Git同步的自托管套件 ★ FEATURED ARTICLE
我最初想做这个项目纯粹是被眼前这些“现代化”的应用折腾烦了。随便一个自托管软件动辄几百 MB 的镜像、十几个环境变量、一套完整的前后端分离架构再加上数据库、消息队列、对象存储就为了记几条待办事项。我寻思着咱们怎么就从程序员变成了运维天天伺候这些庞然大物。所以我想反着来做一个真正回归原始的极简工具集合名字就叫caveman。它不是某个大平台的附属项目就是一个个人维护的、不带任何花活的轻量级自托管套件核心哲学是三句话能用文件存就不用数据库能跑标准库就不用框架能一个进程解决就绝不拆成微服务。这个项目解决的实际问题很简单我需要的不是更多功能而是更少负担。国内服务器带宽和内存都是钱一台 1 核 1G 的小机器跑一堆容器经常捉襟见肘而 caveman 把整个套件压到了不到 50MB 内存这体验完全不一样。适合谁参考如果你也受够了那些为了显示技术能力而堆砌复杂度的小玩意喜欢自己掌控数据、追求长期可维护性那这篇文章值得你花几分钟看看。我会把设计思路、核心模块的实现细节、部署过程以及我踩过的那些坑一五一十地写出来。1. 项目整体设计与思路拆解1.1 为什么要叫 caveman回到“刚好够用”的状态起名 caveman 并没有指望它多优雅而是要时刻提醒自己一件事——不要过度设计。穴居人用石头砸开坚果他不会去研发一台坚果破壳机。现在的软件生态恰恰相反工具本身成了负担一个文本编辑器要吃掉几个 G 内存一个记账软件要让你创建在线账户。caveman 想做的是把优雅和效率让位给简单和直接。整个项目的形态最初是一个单二进制文件Go 写的里面打包了三个小工具TODO 清单、书签收藏、以及一个基于 Git 的文件同步接口。后来我在实际使用中又把它们合并成了一个大杂烩服务共用同一个端口、同一个数据目录、同一套启动参数。说实话这种做法在工程上有点反模式服务边界完全模糊掉了。但从最终目的来看它是合理的因为我根本不需要这些模块单独扩容、单独部署它们的负载连零头都算不上硬拆成微服务纯属自找麻烦。这种事我干过太多次了年轻人总觉得架构越复杂越显得专业实际上能用crontab解决的问题千万不要上 K8s。1.2 技术选型背后的取舍清单技术选型这部分我一开始就列了个禁令列表然后所有的决定都是为了配合这条禁令。列表很简单不引入外部数据库除非必须不使用任何 Web 框架只走 Go 标准库不提供 Docker 镜像作为主推安装方式直接给二进制不搞前后端分离页面就是嵌入二进制里的几个 HTML 模板这样选型的理由非常直白为了十年后还能跑。依赖越少随时间腐烂的速度就越慢。我见过太多项目三年前还能跑今天拉下来因为某个传递依赖版本冲突直接编译失败。Go 标准库在这件事上优势极大它的net/http虽然写起来啰嗦但稳定到可怕而且自带的html/template和embed包完全够我这种轻度需求用。数据存储方面我最终选择了用纯文本文件加 JSON 序列化而不是 SQLite。原因也很朴素这套工具的写入频率极低多用户并发写的情况基本不存在既然永远不需要处理并发写冲突引入一个数据库引擎就是纯浪费。1.3 功能范围的边界控制确定功能边界这件事比写代码难十倍。我在设计 caveman 时候给自己划了一条红线只做“卡片盒”式的记录工具不做“工作流引擎”。什么意思TODO 就只提供清单的增删改查和完成状态切换不做番茄钟、不做日历联动、不做优先级矩阵书签就只保存 URL、标题和标签不做全文搜索、不做页面正文抓取、不搞收藏夹分类树。这些功能听着都很有用但每一个都会让代码规模翻倍让维护成本上升让数据格式变复杂。我在项目里留了个设计注释大意是如果你发现自己在加一个功能之前需要先写一个配置文件去控制它的开关那你其实应该把这个功能拿掉。这个原则帮我挡掉了至少十几个需求。比如有段时间我很想加个 RSS 订阅功能后来想通了RSS 的解析和更新策略完全是个独立项目塞进 caveman 只会毁掉它的纯粹性。这也是我特别想对所有搞自托管的人说的一句话你需要的不是功能最多那个而是你愿意一直维护那个。2. 核心功能细节解析与实现要点2.1 极简 TODO用文件目录结构代替数据库TODO 模块是我最先写的也是使用频率最高的。它的数据存储很有意思不搞什么任务 ID 自增直接用一个 tasks 目录每条任务就是一个以时间戳命名的.json文件。比如tasks/1693489201.json。一开始有人觉得这不专业但实际用下来好处多得惊人——备份就是拷贝目录查看一条任务的具体内容就是cat一个文件甚至你可以直接用grep在命令行里做全局搜索完全绕过了我的 API。每条任务的 JSON 结构我设计得非常克制只有四个字段{ id: 1693489201, title: 给服务器换块 SSD, done: false, created_at: 2023-08-31T10:20:0008:00 }你可能会发现里面没有截止日期、没有优先级、没有标签。我是故意的。TODO 工具存在的目的是让你别忘记有事要干而不是让你沉浸在给任务分类排级的仪式感里。一旦加上截止日期和优先级你就不得不处理排序、提醒、过期判断这些逻辑紧接着又会想加日历视图。边界就是这么一点一点失守的。写入逻辑我用的是最简单粗暴的方式每次增删改都重写整个 tasks 目录索引。没错就是遍历目录下的所有 JSON 文件生成一个索引数组再序列化写入一个index.json。这个方案在任务达到一万条之前完全够用而正常人一辈子大概也存不了一万条待办。操作接口我提供了 REST 风格的简单路由三个动作搞定POST /api/todo创建、PUT /api/todo/{id}切换状态、DELETE /api/todo/{id}删除。前端页面是一张极简的无色表格用原生 JS 发 fetch 请求没有构建过程没有打包工具就是直接读取嵌入 HTML 里的那些脚本。2.2 书签收藏一个 HTML 文件当数据库用书签模块是我个人最喜欢的设计。我见过很多书签工具用 SQLite然后提供各种花哨的浏览器插件同步方案用起来确实方便但一旦你想写个脚本批量导出自定义格式就会发现数据被困在软件里。caveman 的书签存储方案原始到离谱一个bookmarks.html文件每一条书签就是一个A标签外加tags属性。这样搞有个意外的好处这个文件本身就是 Netscape 书签格式的超集任何浏览器都能直接导入。也就是说就算 caveman 项目明天不维护了我的书签数据依然是裸的、可读的、可迁移的。数据只要能被任何文本编辑器打开就没有被绑架的感觉。写入过程走 APIPOST /api/bookmark接收 URL 和标题服务端会做一次轻量的请求去抓取页面标题抓不到就用用户提交的标题兜底。这个抓取动作我用了一个 3 秒超时的http.Client只解析head里的title标签用golang.org/x/net/html做流式解析不会把整个页面读入内存。当然这套逻辑后来也给我惹了麻烦某些响应慢的网站会阻塞这个请求后来我加了超时设置和并发度控制。细节后面在问题排查部分慢慢说。2.3 极简同步方案Git 做后端不做 cosas 花哨同步这个功能是我在设计 caveman 时最谨慎的部分。很多自托管应用都栽在同步上冲突处理、增量同步、离线操作、多端一致性每一个词都是无底洞。我当时的想法特别简单我已经把所有数据存成了纯文本文件那同步不就是现成的吗直接调用git不就行了于是 caveman 的“同步”不是一个真正的同步引擎而是一个 Git 封装器。它会在数据目录里自动初始化一个裸仓库每次数据变更后自动 commitcommit message 是带毫秒时间戳的一句auto commit。用户可以通过POST /api/sync/push和POST /api/sync/pull触发远程仓库的推送和拉取。数据目录本身不是 Git 仓库它是个工作目录远程地址通过sync.remote配置项指定。这个方案听着粗暴但实际用起来是极其可靠的。Git 的冲突处理能力经过全世界开发者十几年的锤炼比我闭门造车造一个同步引擎高明得多。如果两台机器同时改了同一个 JSON 文件pull 的时候 Git 会报告冲突但因为我们每条任务是独立文件实际冲突概率微乎其微大部分情况是无人值守的自动 merge 成功。我把这个设计写进 README 的时候特意加了一句这不是通用同步工具它只适用于低频率、小数据量、个人使用的场景。2.4 嵌入式的纯手工界面写完 API 和数据层之后我面临一个选择要不要配一个像样的管理界面答案显而易见不要上 React、Vue 那一套。倒不是我不会写而是觉得为了五个页面引入一个前端构建链路实在太蠢了。caveman 的界面是三个.html文件放在web/templates/目录里用的是script typetext/javascript原生写法。渲染用的模板引擎是 Go 标准库的html/template好处是自动转义不会因为用户提交了个恶意标题就把我自己的后台变成 XSS 攻击目标。整个界面没有路由跳转就是三个页面首页展示 TODO 和书签的统计信息todo 页面管清单bookmark 页面管收藏。CSS 就一个style标签白底黑字加一点灰没有任何现代前端那种动效和渐变。说实话第一次打开的时候我自己都觉得有点寒酸但用了一周之后就习惯了已经大半年没有想起过换主题的事。3. 实操过程与核心环节实现3.1 从零初始化项目结构我建议想复刻这个项目的朋友按我的目录结构来组织因为后面所有脚本和工具都建立在这个结构上。整个项目的核心目录树非常简单caveman/ ├── main.go ├── config.example.toml ├── scripts/ │ ├── install.sh │ └── backup.sh ├── web/ │ └── templates/ │ ├── index.html │ ├── todo.html │ └── bookmark.html └── data/ ├── tasks/ ├── bookmarks.html └── index.jsonmain.go是整个程序的唯一入口初始版本一上来就写路由和静态文件服务。要做到零第三方依赖有几个关键点你必须知道配置文件解析这块如果你不想引入 TOML 库最简单的办法是用环境变量加命令行 flag。我自己后来干脆放弃了配置文件转为-port、-data、-sync-remote三个启动参数真的够用了。你要明白多数自托管服务挨个设置环境变量的本质就是把换名字的配置项填一遍而我们这种只有三个参数的工具根本不需要那个抽象层。初始化数据目录的逻辑也极简单启动时判断目录是否存在不存在就os.MkdirAll创建并写入空的bookmarks.html模板。这个操作必须在服务启动前完成否则后续请求处理时会疯狂报错。这本是最基础的健壮性问题但我见过好多项目在这上面翻车就是那种你跑起来访问首页白屏日志里却全是file not found的情况。3.2 核心实现TODO 列表的增删改查TODO 的 API 实现我把最有价值的部分贴出来。这段代码看似简单但隐藏了几个关键的设计决定一是全程只用标准库二是用os.ReadDir而不是filepath.Walk来遍历任务效率足够且简单三是排序方式直接用文件名的时间戳字符串排序省掉了解析时间戳再排序的步骤。type Task struct { ID int64 json:id Title string json:title Done bool json:done CreatedAt string json:created_at } func loadTasks(dataDir string) ([]Task, error) { taskDir : filepath.Join(dataDir, tasks) entries, err : os.ReadDir(taskDir) if err ! nil { if os.IsNotExist(err) { return []Task{}, nil } return nil, err } tasks : make([]Task, 0, len(entries)) for _, e : range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), .json) { continue } raw, err : os.ReadFile(filepath.Join(taskDir, e.Name())) if err ! nil { continue } var t Task if json.Unmarshal(raw, t) nil { tasks append(tasks, t) } } sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID tasks[j].ID }) return tasks, nil }这段代码的粗糙之处在于每次请求都会把磁盘上所有任务读一遍但实际体验是没有问题的。个人场景下的待办任务数极少超过 500 条一次全量读取耗时在一毫秒以内比查数据库还快。唯一需要注意的是文件权限如果你用www-data用户跑服务一定要保证data目录对它有写权限否则会出现“接口返回 500但日志里只有一行permission denied”这种经典问题。3.3 把服务装进 systemd开机自启与崩溃恢复代码写完后部署环节才是真正让人想砸键盘的地方。我先讲明白我的部署场景一台 1 核 1G 的云服务器Ubuntu 22.04前面挂了个 Nginx 做 HTTPS 终结caveman 监听 127.0.0.1 的某个端口。为了让服务在掉线重启后能自己起来我写了系统服务文件。这一步是所有人都绕不过去的因为裸跑二进制一旦进程崩了你就得 SSH 登录重启挂两次就会烦得想摔笔记本。[Unit] Descriptioncaveman - minimal self-hosted suite Afternetwork.target [Service] Typesimple Usercaveman Groupcaveman WorkingDirectory/opt/caveman ExecStart/opt/caveman/caveman -data /opt/caveman/data -port 8080 Restarton-failure RestartSec5 EnvironmentTZAsia/Shanghai [Install] WantedBymulti-user.target这里有几个我踩过坑细节想特别强调。第一务必创建独立用户caveman来运行服务绝对不要用 root。我一开始图省事直接 root 跑结果后来调试别的程序时误删了/opt/caveman里的一个目录就是因为权限太宽根本没有心理负担。第二Restarton-failure看着没问题但如果你程序启动时因为端口被占用崩溃systemd 会每 5 秒重启一次反复刷日志。这种情况要把RestartSec调大一点。第三启动之前先手动跑一次caveman -port 8080确认没有报错再让 systemd 接管别看 systemd 日志一脸懵。3.4 反代与 HTTPS没有证书的现代软件是不完整的现在哪还有网站敢裸奔 HTTP反正我是不敢。配置 Nginx 反代的过程非常简单但有几个想当然的地方必须小心。第一是proxy_pass后面的 URI 处理如果你把location /todo反代到http://127.0.0.1:8080注意不要在proxy_pass后面加多余的路径否则你的静态文件可能会全部 404。第二是 WebSocket 的问题如果以后你计划在页面里做实时刷新一定要加proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection upgrade;这两行现在不用以后也得用。我最终用的核心配置是这样一段server { listen 443 ssl http2; server_name caveman.example.com; ssl_certificate /etc/nginx/ssl/example.crt; ssl_certificate_key /etc/nginx/ssl/example.key; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }证书我用的是自签名还是 Let’s Encrypt我个人强烈建议用 acme.sh 签发免费的受信证书不要自签。原因很简单自签证书会让你手机上的浏览器一直弹出红色警告每次都要点继续时间久了你会开始无视所有安全警告这是相当危险的肌肉记忆。别贪图省事装个客户端自动续期这才是正规做法。3.5 备份与恢复把数据当作最珍贵的资产写代码花了三天配置备份花了半天。因为 caveman 的数据全部是文件备份实际脚本化到令人愉悦。我的思路是每天凌晨用tar把整个 data 目录打包再通过scp传到另一台机器上原理上这就是异地容灾。完整的脚本长这样#!/bin/bash BACKUP_DIR/backups/caveman TIMESTAMP$(date %Y%m%d_%H%M%S) mkdir -p $BACKUP_DIR tar czf $BACKUP_DIR/caveman_$TIMESTAMP.tar.gz -C /opt/caveman data find $BACKUP_DIR -name caveman_*.tar.gz -mtime 30 -delete这里有个比较隐蔽的坑我一开始吃过大亏。tar是在备份一个正在写入的文件目录如果你在打包的时候恰好有请求在写index.json打包出来的文件可能是半截状态。对于纯 JSON 文本半截状态的后果是恢复时解析失败。你当然可以引入文件锁或停机快照但个人项目没必要那么重。我采取的方案是备份前先调用一次POST /api/sync/push让 Git 的 commit 先运行完再执行 tar这样至少保证 Git 元数据是完整同步过的。配合的文件恢复校验也极其简单解包后直接跑一遍caveman看能否正常启动启动成功基本就算数据健康。4. 常见问题与排查技巧实录4.1 端口被占 / 权限不足最基础也是最容易翻车的我先说一个最常见的启动后访问不了查看日志发现是bind: address already in use。八成是服务已经在后台运行了只是你想再开一个。解决办法很简单先ss -tlnp | grep 8080看看 PID然后用kill干掉它。可过了两天你重启服务器后发现端口还是被占那就是有别的程序偷偷监听了我遇到过 Nginx 自己把默认站点监听了 8080跟我的服务抢端口。建议统一改到 8787 之类不常用的端口减少冲突概率。权限方面最典型的就是data目录归属不对。如果你用sudo -u caveman跑程序那么整个/opt/caveman目录的所有者必须是caveman。我因为在/opt下手动mkdir时用了 root导致服务进程完全没有写权限。解决方法是chown -R caveman:caveman /opt/caveman。这类问题排查方式不用想复杂先看进程能不能读写数据文件再看目录权限一个ls -lah就解决的事。4.2 并发写导致 index.json 损坏旧方案的血泪教训早期版本我在每次写index.json时直接os.WriteFile这个文件是任务的全局索引。后来发现一个高频卡死 bug用户刚创建完一条任务又瞬间勾选另一条完成两个请求同时触发loadTasks和WriteFile导致读到的内容是一个写了一半的 JSON直接解析报错。我当时的反思是其实连index.json都不应该存在重建索引的开销本身就是一次目录遍历。后来我毅然把索引文件整个删掉了所有列表渲染都直接os.ReadDir实时生成。这个改动让我悟到一个很朴素的道理与其加强一致性的处理还不如消灭掉这个一致性问题。如果你也有类似的个人项目我建议你重新审视一下“索引”“缓存”是不是真的需要很多时候它们只是让无状态变成了有状态平白无故多了无数矛盾。4.3 Git 自动提交失败远程仓库身份认证的难题caveman 的同步依赖 Git而 Git 的远程认证问题绝对是我被坑得最惨的一次。第一次配置远程仓库时我用了 HTTPS 的远程地址里面带了 token比如httpd...这种形式。但问题是 data 目录的.git/config会把远程地址里的 token 以明文存下来。虽然我没打算把 data 目录推到 GitHub但这仍然是个潜在的泄露点。后来我改用 SSH 密钥认证在部署机器的/home/caveman/.ssh/下生成了专门的密钥对把公钥加到 Gitea 的 SSH 设置里。这之后就不再需要任何密码或 token。配置过程要记住一点不要去设全局 Gituser.email直接在/opt/caveman/data/.git/config里本地设置cd /opt/caveman/data git config user.name caveman-backup git config user.email cavemanlocalhost如果不设这个第一次自动 commit 时 Git 会用系统主机名生成一个奇怪的用户信息导致 commit 记录张冠李戴看起来像不同的人在提交。4.4 抓取网页标题超时别让外网拖垮本地服务书签的标题抓取功能是我写完后第一次被生产环境打脸的模块。一开始我是同步地逐条发起 HTTP 请求遇到响应慢的网站时前端创建书签的请求要等 10 秒甚至 30 秒体验极其糟糕。更严重的是Go 的http.Get默认没有超时某些网络黑洞会导致连接永远悬挂占满 goroutine。修复思路很简单设置客户端超时并把抓标题的请求从主链路里拿出来改为异步执行。创建书签时先用本地提供的标题返回 201后台 goroutine 去抓取真实标题抓到了再更新数据文件。这个改动让我明白一个事个人工具也可以有后台任务但千万别让它们阻塞主要操作。这类线上故障排查起来非常痛表面现象是你的服务响应越来越慢实际看 goroutine 发现几十个都在等net/http的底层连接。如果你用 Go 做 Web 服务http.Client{Timeout: 3 * time.Second}是所有外呼请求的标配。4.5 数据恢复演练备份永远要在灾难前试一次我承认这个标题有点危言耸听但真的发生过我自信满满地以为备份脚本运转正常直到某天磁盘损坏才发现备份文件因为 tar 命令执行到一半被 OOM 杀掉只有一个残缺的包。从那次之后我的备份策略里多了一条死规矩每个季度必须实际执行一次恢复演练。不是看看备份文件存在而是真实把它们解压到一个临时的/tmp/caveman_restore目录启动一次服务访问几个接口确认数据完整。演练过程也很简单几行命令mkdir -p /tmp/caveman_restore tar xzf /backups/caveman/最新包.tar.gz -C /tmp/caveman_restore /opt/caveman/caveman -data /tmp/caveman_restore/data -port 9999然后浏览器打开http://127.0.0.1:9999看看页面列表是否完整。只要这一步过了你心里就有底了。说句实在话大部分自托管用户从没演练过恢复出了事故才后悔。数据这东西丢了就是真丢了补不回来。每天花两分钟看一眼备份日志是一切自托管的体面。4.6 内存占用异常波动Goroutine 泄漏的症状与对策caveman 跑了一段时间后我观察到一个现象内存占用从刚启动的 20MB慢慢涨到 100MB、200MB这在一个极简工具身上是不正常的。查了一圈最终定位在标题抓取模块的http.Client上。没有超时的连接如果一直悬挂底层的 transport 会持续创建新的 TCP 连接占用文件描述符和内存而且连接池还会缓存这些坏连接。修复方案是把http.Client提升为全局单例统一配置超时和连接池而不是每次创建书签时新建一个var httpClient http.Client{ Timeout: 5 * time.Second, Transport: http.Transport{ MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second, DisableCompression: false, }, }这种问题值得每个 Go 写服务的人警惕。不是说 standard library 写不出内存泄漏而是你的资源生命周期管理一旦疏忽goroutine 和连接就会像漏水的龙头一样越积越多。排查我用的工具也比较原始直接curl localhost:8080/debug/pprof/goroutine?debug2看看哪个函数的 goroutine 数量异常增长很快就能锁定凶手。5. 经验总结与最终心得5.1 所有设计决策最终都指向同一个目标如果让我一句话总结 caveman 这个项目我会说它的每个设计决策最终都指向同一个目标——降低长期维护的心理负担。技术上它不前沿甚至连数据库都没有而正因如此我可以很有底气地说三年后我依然愿意打开这个项目改代码因为它不欠任何人技术债。有时候我会觉得做独立开发或自托管的人都或多或少被“现代最佳实践”绑架了。容器、编排、可观测性每一个词背后都是学习成本和运维负担。不是说那些东西不好而是它们该用在值得用的地方。你写一个给自己记账的工具它就是该用最笨的办法把数据存在一个你能理解的地方。caveman 教会我的不是怎么写出更优雅的代码而是怎么更容易地把一个东西维护下去。5.2 原始方法不一定差只是需要想清楚为什么现在你再让我评价 caveman 这种“原始风”的实现方式我的态度还是那么坚定它不一定优雅但它够用够你把它放在一个角落里十年不用操大心。我在实际维护中发现很多所谓的“技术选型”其实是在为不存在的规模提前买单。1 核 1G 的机器上跑 K8s说自己是为了横向扩展这种笑话在社区里太多了。所以如果你想复刻一个类似的项目我的第一条建议永远都是先想清楚你要它服务几个人、解决什么问题、你能接受多少维护时间然后再决定技术栈。如果最后得出的结论是“我只需要一个能跑十年的小工具”那 caveman 的这个思路值得你参考。5.3 下一步我能想到的玩法明明说了不搞扩展但最后我还是忍不住想了两个方向。如果想给 caveman 加功能我唯一会考虑的是加一个 Markdown 笔记模块因为笔记也是低频写入、纯文本能搞定的场景数据可以全部落在.md文件里和现有架构完全兼容。另一个方向是加一个简单的移动端适配不是逼我做 App只是把现有页面的meta nameviewport和几个表单的点击区域优化一下让手机浏览器用起来不至于太难受。不过说归说我大概率不会真的去做。因为我现在用 caveman 用得顺风顺水让我给一个稳定的工具动刀我没有这个动力。维护一个项目最理想的状态就是它象一块石头那样安静地待在那里。caveman 这个名字恰好就是它最好的注脚。
阅读完成 · 觉得有帮助?
咨询建站