ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

零依赖POSIX Shell脚本实践:用TSV文件实现命令行任务管理工具

零依赖POSIX Shell脚本实践:用TSV文件实现命令行任务管理工具 做项目做了十几年我越来越烦一件事装个几十 KB 的小工具动不动就要拖家带口拉进来几百 MB 的依赖。前阵子在倒腾一个任务管理脚本顺手就想干脆回归一下“原始社会”——用最基础、最接近金属的 POSIX Shell写一个够用、不啰嗦、扔到任何 Unix 机器上都能直接跑的小工具。于是就有了这个项目名字就叫caveman。如果你也是一个追求“少即是多”、受够了现代软件层层抽象的人caveman 这个实验应该能给你一点启发。这篇文章我会把整个项目从理念到实现、从踩坑到实测全部摊开讲代码直接贴出来你可以照着抄也可以结合自己的需求改。适合折腾命令行、写脚本、以及所有想“去依赖化”的人参考。1. 为什么我非要写一个叫 caveman 的极简工具先说一个让我彻底破防的场景。之前想用一个特别简单的 TODO 管理工具官网写着“轻量”结果按照说明装完好家伙一个 Node 运行时、一个全局包管理器、一个守护进程、再加上六七个传递依赖磁盘占用轻松突破 300 MB。我就想记一条“下楼买牛奶”至于吗这个场景其实反映了一个非常普遍的问题**现代工具链把自己养得太肥了。**大多数日常需求真正用到的东西可能不到整个系统能力的 10%但为了这 10%你被迫接受那 90% 的维护负担、安全风险和更新折腾。所以 caveman 这个名字不是随便起的。我就是要学穴居人——就地取材、工具原始、但皮实耐用。穴居人拿石头能砸开坚果我不需要一台挖掘机去剥一颗花生。1.1 不做什么比做什么更重要在动手之前我先给 caveman 划了一条清晰的边界不依赖任何第三方库只用系统自带的 Shell 命令不做守护进程不常驻后台执行完就退不用数据库数据存在普通文本文件里不支持插件系统功能多了我自己都维护不动不强求跨平台瞄准 macOS 自带的 shell 和 Linux 的 bash/dash说白了这款工具从设计之初就主动放弃了绝大多数“现代化”特性。这一点很重要因为很多项目死于“什么都要”很少死于“什么都有”。1.2 穴居人式的使用体验是什么caveman 的使用体验我是这样设想的你在终端敲一条命令它立刻给你一个结果然后闭嘴。没有启动动画没有 TUI 界面没有“正在加载”的临时反馈。这套体验有点像一个老派的 Unix 工具。数据存在文本文件里意味着你可以用cat、grep、sed直接操纵数据想备份就cp一下想同步就用 rsync。所谓“穴居”其实就是把数据的主动权牢牢攥在自己手里。2. 从第一印象到第一版caveman 的定位与功能边界caveman 到底干嘛的一句话**一个零依赖的命令行任务管理脚本。**我把它设计成三个核心操作再加两个辅助操作。命令作用示例cv add新增一条任务cv add 写季度报告 今天cv list列出任务cv list 今天/cv list allcv done完成某条任务cv done 3cv stats看统计cv statscv archive把已完成任务归档cv archive我故意没设计编辑、拖拽优先级、日历视图、通知提醒这类功能。原因很简单真需要这些的请去用带图形界面的专业工具caveman 服务的是“快速记一笔”和“快速看一眼”这两个场景。你在开会、写代码、打电话的时候想记一件事敲命令比打开 App 快得多。2.1 存储格式的选择我就是故意用 TSV 的存储格式是决定这个项目灵魂的地方。我对比了几个方案JSON现代工具最爱但要解析就得依赖 jq 或者 Python违背零依赖原则SQLite功能强但引入了二进制依赖备份复杂Markdown人读友好但计算机解析容易出错TSV足够简单Shell 里用cut就能拆分字段用grep就能筛选而且文本文件丢到任何机器上都能打开我选了 TSVTab 分隔值。每行一条任务四个字段任务ID 创建时间 完成状态 任务描述实际长这样1 2025-01-12 10:23 0 写季度报告 2 2025-01-12 10:25 0 回复王工的邮件 3 2025-01-12 10:26 1 下楼买牛奶你可能会说这连个文件扩展名都没有太土了吧是的就是土。但土有土的好处**40 年后你翻到这个文件依然能看懂里面写了什么。**这年头多少软件的数据因为格式私有、数据库损坏、厂商倒闭而彻底失踪了数据能存多久应该比软件能跑多快更重要。2.2 目录结构怎么摆整个项目的目录干净得不像一个现代项目caveman/ ├── bin/ │ └── cv # 主脚本单文件 └── README.md数据文件则放在用户主目录下的隐藏目录~/.caveman/ └── tasks.tsv我没有搞配置文件、日志目录、插件目录。一个二进制脚本、一个数据文件这就是全部家当。要卸载删掉这两个路径就行不留一点垃圾。3. 原型实现一个只有两三百行的 Shell 脚本是如何工作的很多人一说“写工具”就想到用 Go 或 Rust我倒觉得可以先从纯 Shell 搞起来。Shell 的好处是可以快速验证想法坏了也不心疼改个函数几秒钟就完事。下面是我第一版的完整思路拆解不是贴全部代码那太长了而是把每个关键环节拿来讲透。3.1 命令分发没有参数解析库的硬核土办法现代工具的参数解析多半靠文档生成器和库。我只有一只case#!/bin/sh cmd$1 shift case $cmd in add) cmd_add $ ;; list) cmd_list $ ;; done) cmd_done $ ;; stats) cmd_stats ;; archive) cmd_archive ;; help|--help|-h|) cmd_help ;; *) echo caveman: 未知命令 $cmd exit 1 ;; esac这没什么技术含量但很管用。要注意一个细节我用了shift之后的$把剩余参数传给子命令。所有入口参数都加了引号避免带空格的字符串被切开。3.2 add 功能的实现把一行文本变成一条记录add是使用频率最高的功能它的实现逻辑不复杂读取当前最大任务 ID新任务 ID 最大 ID 1取当前时间把ID\t时间\t0\t描述追加到 tasks.tsv其中读取最大 ID我用了这样一段cmd_add() { desc$* [ -z $desc ] echo caveman: 任务描述不能为空 return 1 data_file${CAVEMAN_DATA:-$HOME/.caveman/tasks.tsv} # 取当前最大 ID1 if [ -f $data_file ]; then last_id$(tail -n 1 $data_file | cut -f1) new_id$((last_id 1)) else new_id1 fi ts$(date %Y-%m-%d %H:%M) # 追加写入 printf %d\t%s\t%d\t%s\n $new_id $ts 0 $desc $data_file echo 已添加任务 #$new_id: $desc }注意几个关键点$*把参数合并成一个字符串这样cv add 写 报告和cv add 写 报告效果一样都对新手更友好tail -n 1 | cut -f1取 ID简单粗暴但不会出错支持CAVEMAN_DATA环境变量覆盖数据文件路径方便测试这个小设计在实际用的时候救了我好多次3.3 list 功能的实现纯文本的好处体现出来了list要支持按时间筛选比如cv list 今天。核心就是 grep 条件cmd_list() { filter$1 data_file${CAVEMAN_DATA:-$HOME/.caveman/tasks.tsv} if [ ! -f $data_file ]; then echo 还没有任何任务用 cv add 添加一条吧 return 0 fi case $filter in |all) # 全部任务未完成优先 echo ID 创建时间 状态 描述 awk -F\t BEGIN{OFS\t} {print $1, $2, ($30 ? 待办 : 完成), $4} $data_file ;; 今天|today) today$(date %Y-%m-%d) awk -F\t -v t$today $2 ~ t {print $1, $2, ($30 ? 待办 : 完成), $4} $data_file ;; *) # 按关键字过滤 grep $filter $data_file | awk -F\t {print $1, $2, ($30 ? 待办 : 完成), $4} ;; esac }awk 在这里承担了一个小“视图”的角色把 0/1 转成 待办/完成。TSV 配 awk是 Shell 世界里最顺手的一对组合比学一门新语言去处理 JSON 快多了。3.4 done 功能与 ID 定位标记完成我选择按 ID 定位而不是按描述内容匹配。为什么因为描述可能重复但 ID 是唯一的。cmd_done() { id$1 data_file${CAVEMAN_DATA:-$HOME/.caveman/tasks.tsv} # 用 awk 按 ID 找行保存到一个临时文件 awk -F\t -v id$id BEGIN{OFS\t} { if ($1 id) { if ($3 1) { print caveman: 任务 # id 已经是完成状态; exit 1 } $3 1 } print } $data_file $data_file.tmp if [ -s $data_file.tmp ]; then mv $data_file.tmp $data_file echo 已完成任务 #$id else rm -f $data_file.tmp echo caveman: 找不到任务 #$id fi }这段也是最容易踩坑的地方一会儿在第四章专门讲。3.5 统计与归档stats就三行 awk数一数总共有多少条、完成了多少条顺便算个完成率。而archive是把完成状态的记录挪到一个叫done.tsv的文件里去保持主文件干净。这些实现都很直白不需要展开说了。4. 大量踩坑记录Shell 脚本看着简单坑全在细节里这个项目代码量不大但我调试的时间远超预期。这里分享几个印象最深的坑也是如果你要复制这个方案最值得提前知道的地方。4.1 POSIX Shell 没有数组这改变了很多设计在 bash 里你可以写tasks(a b) echo ${tasks[1]}但在#!/bin/sh很多系统指向 dash下面这套文件完全跑不了。POSIX Shell 不支持数组是的我知道某些 shell 做了扩展但你要写可移植的 sh就得绕开它。我的解决方案就是用文本流替代“内存列表”。每一条任务不需要存进数组直接从文件一行一行处理处理完输出就完事。这种做法在数据量小的时候性能完全可以接受而且代码简洁。4.2 read 循环里别用管道这是老生常谈但总是犯我一开始写了一个函数要遍历任务文件给每一行前面加上序号cat $data_file | while read line; do ... done这段代码在 bash 里能跑但有一个隐性问题**管道右边的 while 是在子 shell 里执行的循环内部对变量的修改循环外面读不到。**如果你在循环里累计一个计数器最后发现它永远是 0那不是逻辑写错了是子 shell 的锅。我后面改用重定向while IFS read -r line; do ... done $data_file这样 while 就在当前 shell 里执行变量改动没问题。4.3 原子写救了我一命done那个功能一开始我是直接对原文件做原地修改用的是sed -i。在 Linux 上跑得好好的拿到 macOS 上一试sed -i的语法居然不一样而且就算能用也是直接改原文件。后来我想明白了凡是要修改文件一律采用“写临时文件 mv 覆盖”的原子写模式。好处有三个写一半断电也不会破坏原文件临时文件名带.tmp一眼能识别mv 在同一文件系统内是原子操作瞬间完成这是个看起来笨、实际很稳的做法。我在写脚本的时候顺手做了个约定处理任何数据文件绝不直接改先写.tmp再mv。4.4 date 命令的跨平台差异date %Y-%m-%d %H:%M在 macOS 的 BSD date 和 Linux 的 GNU date 上都支持。但你敢加一个-d参数试试Linux 的date -d 昨天在 macOS 上就是另一个含义的选项。所以我在代码里就只用最基础的时间格式化绝不碰-d这类扩展。提示写 Shell 脚本要克制住“用更高级参数”的冲动。每多用一种平台特有的扩展你的脚本就少了一批能跑的环境。4.5 空文件名和其他边界情况我遇到的另一个问题是数据文件不存在时grep、awk都会报错还会把错误信息打到屏幕上很难看。所以我在每个函数入口都做了存在性检查不存在就直接提示“还没有任务”。还有一点任务描述里如果包含特殊字符比如$、反引号、双引号在 Shell 里很容易出问题。我全部用单引号包裹用户输入并且在写入时用printf格式化参数而不是直接拼接字符串。这一点老实说是写得多了才养成的条件反射。5. 实测对比一个“原始”工具到底能跑多快理论吹得再响不如跑一次实际测试。我拿 caveman 和另外两个主流任务管理工具笔笔划划在同一台机器上做了个简单测试。测试环境一台 2021 年款的 MacBook ProApple M1 Pro终端为 zsh数据量是向 tasks.tsv 里塞了 1000 条任务。结果如下操作caveman工具ANode 生态工具B全量客户端启动耗时含加载依赖约 15ms约 420ms约 1200ms新增一条任务约 20ms约 180ms约 300ms列出所有任务约 25ms约 250ms约 800ms磁盘占用4KB脚本 16KB数据约 280MB约 1.2GB依赖数量080无法统计这个表里的数据是我在本机实测的大致结果不同机器、不同版本差异很大但量级的差别基本能说明问题。caveman 不是因为它优化得好才快而是它什么都没干所以当然快。没有框架启动没有日志系统没有网络检查没有自动更新它只是一个脚本在跑能不快吗另外我想强调一个很多人忽略的点启动速度。图形界面工具动辄一两秒的启动时间单个看好像没什么但每天用二三十次一年下来你浪费在等启动上的时间就很可观了。工具的使用频率越高启动速度越重要。6. 为什么最终的版本里没有数据库也没有守护进程我注意到不少朋友看这类项目第一反应是这怎么不用 SQLite这怎么不搞个服务常驻我想认真回答这个问题因为它关系到工具设计的根本取向。6.1 SQLite 很好但不适合“穴居人”SQLite 我天天用确实是嵌入式数据库的典范。但假如我在 caveman 里用了 SQLite我得保证系统里装了 sqlite3 命令行工具数据不透明了想看一条任务得先写 SQL备份变得必须用工具导出不能直接拷贝当然SQLite 也有一个我没有预料到的优势并发安全。我的 TSV 方案如果同时跑两个cv add理论上会丢数据。要解决就得加一个文件锁flock不过单用户场景遇到并发写入的概率实在太低我觉得不值得为这个场景引入一个数据库。6.2 守护进程这个东西是“重武器”让 caveman 常驻后台实时监控文件变化再提供个 HTTP 接口——这当然可以做但是你想过没有常驻后台意味着系统又多了一个进程要管内存被白占安全面被扩大一旦崩溃你的任务数据可能还在内存里来不及落盘穴居人的工具应该是什么样需要的时候就存在不需要的时候就消失。毫不拖泥带水。这个理念其实和原来的 Unix 哲学完全一脉相承。7. 用了一段时间之后我发现了哪些真正的边界诚实地说caveman 不是万金油。用了一个多月之后我摸清了它真正擅长和真正不适合的场景。它非常适合个人快速记事、记录 TODO尤其是终端党需要把数据随手拉到文本处理流水线里的场景服务器上、SSH 会话里没法安装图形界面工具的地方想给孩子演示“程序 处理数据”这种本质理念的人它真的不适合团队协作、多人同时读写同一份任务列表并发会有问题需要复杂的查询、标签、项目分组的场景需要移动端同步、推送通知的场景需要权限管理、审计日志的场景边界就是边界我很清楚。caveman 的定位不是要替代 Todoist 或 Things它更像是你口袋里的一张纸和一支笔。高级笔记本写小说很好但贴便利贴的时候你不会掏出笔记本。8. 这个项目后续还可以往哪些方向走如果你看完也手痒想在自己的场景里用类似思路做点东西我给你几个扩展方向的建议。8.1 增加优先级和标签在 TSV 的第五、第六列加上优先级和标签实现起来非常简单# 给第5列写优先级 awk -F\t BEGIN{OFS\t} { $5 P1; print } $data_file tmp mv tmp $data_file然后 list 的时候按第五列排序或者加一个参数显示标签过滤。8.2 增加提醒能力虽然在终端里做闹钟很反直觉但你可以用一个 cron 任务每天上午九点自动跑cv list 今天把输出通过邮件或系统通知发给自己。caveman 不负责通知只负责把数据准备好剩下的交给操作系统原生的能力。8.3 接入 Git 做时间旅行既然数据是纯文本那给数据目录做 Git 版本管理就成了天然选项cd ~/.caveman git init git add tasks.tsv git commit -m daily backup配个 cron 自动提交你就有了一份带完整历史的任务日志。这个能力是 JSON 存数据库的方案很难轻易获得的。8.4 用一个大文件还是拆成多个文件目前所有任务都塞在 tasks.tsv 里。如果有人想按项目拆分可以把数据文件的路径作为第一参数传进去cv --project blog add 写一篇关于 caveman 的文章不过加这个功能要克制因为项目多起来筛选逻辑就会变复杂。我个人的建议是先别加等真需要的时候再加而且一次只加一个功能。9. 给想复刻这个方案的人的三条真话写到这里我总结三条最想对你说的话。第一**零依赖和跨平台是两回事。**你可以在 macOS 上用 zsh 写一个漂亮的脚本但一旦要跑在嵌入式设备或老系统的 dash 上很多语法就得改写。如果你真的在意可移植性从一开始就只用 POSIX 特性别给自己留回旋余地。第二**Shell 脚本的“快”不代表一切。**它启动快是因为它每次重新读文件、重新解析。数据量一旦达到几十万行awk 也会开始吃力。到那时候你不是去优化 Shell而是应该换语言重写。但问题是绝大多数个人工具的数据量根本到不了那个级别。你先跑起来再说别提前优化。第三**工具单身主义是会传染的。**自从写了 caveman我开始看什么都想能不能用纯 Shell 简化一下。实际上后来我还用它处理过博客的发布流程就是几个命令来回组合居然把原本依赖构建工具链的部署脚本也换成了纯 Shell。这种“往回走”的感觉反而让系统变清爽了。我在实际使用中最大的体会是工具越简单你对数据的掌控感就越强。出问题时没有黑盒想看数据就直接打开文件。这种放心感是很多华丽的现代化软件给不了你的。如果你也想试试不必完整复刻我这个。拿一份任务文件自己定义一个格式写一个几十行的脚本去处理它。跑上一天你会发现自己对“软件到底是怎么工作的”这件事突然理解得比以前更透了。
返回列表