对个人项目的管理应该结合自身需求,比如我:
以上三点促使我不断优化自己的项目管理方式。以我为自己开发词典应用时遇到的问题举例:
我用 Rust 编写,为了让笔记本反应迅速,除了代码高亮我没有启用任何编辑器智能提示插件,这就导致了我在开发过程中需要不断调用 cargo clippy 做代码检查。我的源码目录和编译目录是分离的,这又导致我每次运行检查之前,需要先把源码 复制 -> 粘贴 到编译目录中。编译目录经常会膨胀到几十个 G,随时可能被清理,而且我使用的是离线词典数据,也好几百兆,程序和数据只能分离,所以编译出的程序还要 复制 -> 粘贴 到另一个有词典数据包的目录以便调试和平时使用。
所以我的工作流是:
写代码 -> 复制 -> 粘贴 -> cargo clippy -> cargo test -> 改代码 -> 复制 -> 粘贴 -> cargo clippy -> cargo test -> … -> cargo build -> 复制 -> 粘贴 -> 启动程序 -> 试用 -> 改代码 -> … -> 平时使用 -> 有了新点子或需求 -> 写代码 …
如果那将几个 ...
展开,将会看到少量的普通字(创造)被淹没在茫茫多蓝色字(简单重复)之中。
即便启用智能提示,编译和移动也是很无聊的一件事,所以我需要一个解决方案。
首先我得把鼠标 选中 -> 复制 -> 粘贴 的流程改成用命令执行,即便我使用各种快捷键切换界面和窗口操作得飞起,也不如在命令行中按 上 -> 回车 来得快。最初我也是这么做的,我几乎在我每个项目里都会写下几个脚本:复制的、代码检查的、编译的、启动的。这样我开发的时候可把那堆蓝字部分变成敲几个字母按下回车就行了,终端窗口还有输入记忆,之后按 上 下 选就可以了。
刚开始的时候这样工作得不错,启动新项目时老脚本代码只需要简单改改目录位置什么的基本都能复用,但随着项目增多,Windows 的批处理脚本或者 Linux 的 Shell 脚本的弊端就显现出来了:
这俩脚本语言的语法奇特,数据类型约等于只有字符串,很难把功能优雅地抽象成函数,更别说模块化了。所以我每个项目都得复制好几个脚本,即便只需要修改里面几个跟路径有关的变量。如果我改了硬盘整体的目录结构,就得到所有项目的所有脚本里挨个修改路径,每次修改都心惊肉跳,心智负担很重,而且一个一个手工改又是一项简单重复性劳动。
命令行脚本的好处是调用命令方便,而其他编程语言方便模块化和工程化。那我可以选用一个其他语言来抽象功能,最后组合成几个文本命令来执行,市面上已经有很多此类运维工具,各种语言都有。当选项很多时,要思考的是“要避免什么”。
如果是个人使用,我认为运维工具恰恰要避免过度工程化。很多工具在配置层完全脱离了终端命令,创造出各种“新方案”,不让直接用图灵完备的“编程语言”,可是灵活的逻辑判断怎么提供呢?只好再造一个可编程却不完备的“配置语言”,然后配置层“特殊约定”满天飞,不仅复杂到需要学很久,心智负担一点没减轻,还不是什么都能干,你只能做它“允许”你做的事。最后这个工具本身成为了另一个需要 配置 -> 运维 -> 调试 的、包含了很多与你个人无关的“特殊情况”的大型项目。这种工具之所以能防止有人脑子不清醒干出蠢事,恰巧是因为它自己足够蠢(当然这对多人协作是必须的)。
各种基于 YAML 和 JSON 的运维工具,都完美地满足了上述缺点。排除。
重点还是本节第一句,不要离原始命令太远,且代码能模块化复用。如果加上能解释执行,那么基于 Python 开发的工具肯定是首选方案。
Python 也有自己的问题,比如包管理,如果不想搞乱全局依赖,需要配置虚拟环境。再比如打开终端执行几句命令,本应是极迅速的操作,但用 Python 你无法确定它冷启动时要不要卡那么一下,因为你不能把控你所依赖的包在运行时背后又依赖了多少包。如果想避免前述问题,只能自己精心挑选所依赖的包,写一个工具出来。
其他主流语言还不如 Python——用 Go 需要编译;用 JavaScript 不仅会进入黑洞级的依赖地狱,V8 还会吃你电脑的内存和电量;用 Java 倒是不用怀疑启动速度,因为它一定会卡那么一下,也吃内存和电量。
还是得自己动手,丰衣足食。
基于上述思考,我又加了一个条件,这个工具要资源占用极低,这样才能启动无感知或者可以一直挂着也不影响笔记本的续航。
为了达到上述目的,这个工具要有明确的功能边界,不是限制它的编程能力,而是有一个“尽管什么都能做但主做什么”的指导思想。最终,我总结出几个要点:
如果功能范围明确如上述最后一条,就不需要用 Python 这个包超多的方案,对于特殊需求我可以单写一个程序然后用脚本调用它。那么给 Lua 绑定一些操作函数用就成了更好的选择。
如果要满足上述“要点 2”,我需要一个机制来实现“随意跳转当前路径又不影响脚本的查找和加载”。Lua 有 package
,且足够轻量,我的方案是启动的时候先跳到固定目录里加载所有脚本,然后跳转到工作目录或返回启动时所在的目录。所以它应该需要两个启动参数:data_dir
和 work_dir
。
Lua 的标准库很小,想要方便使用需要扩充一下,我喜欢用 Rust,所以我决定把 Rust 的标准库绑定给 Lua,主要就是 env
fs
path
string
process
ffi
里的一部分。为了降低我的记忆负担,绑定后在 Lua 端调用也应该尽可能像 Rust 标准库。
这个实现过程有点曲折,因为两种语言的数据模型不同,像 Rust 有 Option<T>
和 Result<T>
类型,而 Lua 用 nil
和多返回值。Rust 还有所有权的概念,不是所有类型都能 Clone
。我花了很长时间设计了一个方案,写了一个能把 Rust 的各种 Option<T>
和 Result<T>
映射成多返回值的宏,又写了一个把不能 Clone
的类型装到 RefCell<T>
里绑定给 Lua 的宏,还写了一个把即不能 Clone
也没有 Default
的类型套进 Option<RefCell<T>>
的宏。
在经过与宏系统艰苦卓绝地斗争、熟练地掌握了宏套娃技能后,我终于能把 Rust 的所有类型都绑定给 Lua 了,完成了第一个运行时的绑定。
我希望这个工具的脚本能同时工作在 Windows 和 Linux 上,所以我需要它能抹平平台差异,或者判断当前系统执行不同的命令。
比如复制,我写了一个接受 from
和 to
的函数,它根据当前操作系统输出一条肯定不报错的 XCOPY
或 cp
命令,如在 Windows 下它会先判断 from
是文件还是目录,而 Linux 不需要。大部分情形下很方便,遇到特殊情况我可以单写一个函数再调用它。后期经过实践我又套了一个带 force
的函数,它会判断 to
是否存在,根据不同的系统在复制函数生成的命令前直接加 echo y |
或 echo f |
或 echo d |
,这俩函数几乎覆盖了全部复制场景。
我还花了不少功夫设计了一个命令解析逻辑,因为不可能每个脚本只执行一个命令,得能带参数:
key
和 value
,且不会截断带引号的字符串。key
的 value
,如 -a b -a c -a d
,则 -a
的值是 { b, c, d }
。key
和 flag
,也就是没有 value
的 key
,如 -a -b -c d
,则 -a
和 -b
是 flags。args
),如 -a b c d
,则 kv
是 { -a = b }
,args
是 { c, d }
。我写了很多测试用例,把如 a -b c --dd "e \" f" -b a -c -a -g h
的字符串解析成类似:
{
args: ['a', 'a'],
kv: {
'-b': ['c', 'a'],
'--dd': 'e " f',
'-g': 'h',
}
flags: ['-c', '-a'],
}
我定义所有脚本都要返回一个表,至少包含三个字段 name
help
task
,前两个用于输出帮助信息,免得时间长了我忘了这个脚本都能执行哪些命令。
之后我开始实现这个工具的主体逻辑,全部用 Lua 实现,并把它跑了起来。后期结合实践加了一些人性化功能,比如等待输入时显示当前目录,不需要调用 dir
或 ls
;输出实际执行的每一条命令并用颜色高亮关键命令,方便调试翻阅等。
最终我愉快地把所有命令行脚本都迁移到这个为编写工具而编写的工具上来了,在我电脑里跑了两年多,配合我完成了一打小项目,满足了我所有的需求:简单迅速、符合直觉、高度可扩展、占用极低。
随着时间推移,几个新的痛点浮现,超出了修改能解决的范围,所以我又经历了四次全部推倒重写,脚本层换过 JavaScript(用 QuickJS),实现过 GUI 版(用 FLTK),甚至自己实现了一个缝合 Rust 类型模型和 Lisp 的脚本语言(这段经验终于让我重新思考出了一个相对优雅的 Lua 绑定方式并验证且重写了 Lua 版)。但直到现在,都没有脱离本文的设计思路。