为什么我要选择 mise

公司的项目基本都是 Monorepo,不同的子项目有不同的工具链版本要求:JDK 8、JDK 17、Node.js 14、Node.js 16、Node.js 18……

在以往的工作流中,每当我们拿到一个不熟悉的项目,第一件事往往不是阅读代码,而是先找到对应的开发人员,问几个问题:

  • 这个项目要用哪个版本的 JDK 或 Node.js?
  • 应该进入哪个目录?
  • 使用什么命令启动?

这些问题单独看都不复杂,但它们会反复发生。

尤其是公司项目之间的人员流动比较频繁,经常有人被临时借到另一个项目中。在开发过程中,前端人员需要启动后端服务对接口,后端人员也需要启动前端项目看效果。对项目不熟悉的人越来越多,原本依赖口口相传的开发方式也越来越难以维持。

版本不对、目录进错,或者少了某个参数,最后都会表现为一个看起来非常复杂的错误。

说的就是你,Node.js。

之前我也尝试过把工具版本和项目的运行方式维护在文档中,但效果并不好。这年头会看文档的人真的是屈指可数!!!!很多人都大概看一下,然后在群里 @ 我:

我编译报错了,怎么办啊!!!

我去,你 Node.js 版本都不对,那不是肯定报错吗。

为了拯救我的青春,我选择了 mise

为什么选择 mise

mise 在这个项目里主要干两件事:

管理工具链版本,提供统一的任务入口。

它可以在项目配置中固定 Node.js、Python、JDK 等工具的版本,也可以把构建、测试、运行和部署任务放在同一份配置附近。

市面上并不缺工具链版本管理工具。

Node.js 有 nvm 和 fnm,Java 有 SDKMAN! 和 jenv,Python 有 pyenv。它们在各自的语言生态中都很好用。问题是,当一个项目同时使用这三种语言时,我就需要同时维护三套工具、三套配置和三套使用习惯。

最后,每一种语言都得到了一套优雅的解决方案;而同时使用三种语言的人,先得到了三套优雅的解决方案,然后又需要第四套方案把前三套统一起来。

是的,又是经典的 XKCD 92: Standards
是的,又是经典的 XKCD 92: Standards

mise 对我最大的吸引力就是它可以在同一份配置中管理多种语言的工具链,而且跨平台支持做得比较完整。

这里必须大力夸赞一下:在我的实际使用中,mise 对 Windows 的支持确实比较省心。

公司里的开发人员基本都使用 Windows 电脑。引入 mise 之后,我们没有因为 mise 本身遇到明显的兼容问题。它提供了正式的 Windows 安装方式,也为 Windows 下的路径、Shell 和任务执行提供了专门的配置能力。

对于一个长期生活在“此命令仅适用于 Linux 和 macOS”阴影中的 Windows 用户来说,这一点很重要。

固定工具链版本

公司开发中最让人头疼的问题之一,就是一个项目在我的电脑上能够编译成功,到了同事的电脑上却无法编译。

研究半天报错之后,最后发现:

  • 项目要求 Node.js 16,本地安装的是 Node.js 18;
  • 项目使用 JDK 8,本地环境变量指向了 JDK 17;
  • Maven 版本不同,某个插件的行为也不一样。

这些都不是什么困难的问题,但它们非常浪费时间。

固定工具链版本并不能把所有人的电脑变成完全相同的环境。操作系统、系统依赖和外部服务还是可能不同,但最容易发生漂移、也最容易把人骗进构建日志里的工具版本,至少可以先固定下来。

相比要求每个人记得手动切换版本,交给工具还有个更直接的好处:它会替你记。

不会再研究半天构建日志,最后才发现自己只是忘了把 Node.js 16 切换成 Node.js 18。

也不会再经历那种“问题解决了,但我不愿意承认它是怎么解决的”的时刻。

跨平台的任务入口

说到任务执行,很多人首先想到的可能是 Makefile。

Makefile 在 Unix 环境里很好用,但在公司这套 Windows 开发环境中并不省心。想让它顺畅地跑起来,经常还要额外安装 make、MinGW,或者补上一套 Unix 风格的工具链。

然后你原本只是想运行一条构建命令,最后开始研究 Windows、POSIX 和 Shell 之间横跨几十年的恩怨。

跨平台任务中还有一个非常典型的问题:环境变量。

例如,我们经常需要在 package.json 中设置环境变量,但是 Windows 和 Linux 设置环境变量的方式并不一样:

shell
# Linux / macOS
NODE_ENV=production npm run build

# Windows CMD
set NODE_ENV=production && npm run build

这个问题甚至催生了一个专门的 npm 包:cross-env,用来帮助 npm 脚本跨平台设置环境变量。

在 mise 中,我们可以直接把环境变量写在任务配置里,而不需要关心当前使用的是哪一种 Shell:

toml
[tasks."build:web"]
description = "构建前端项目"
dir = "web"
env.NODE_ENV = "production"
run = "npm run build"

对于确实需要在 Windows 和 Unix 下执行不同命令的任务,mise 还提供了 run_windows

toml
[tasks.clean]
description = "清理前端构建产物"

# Linux / macOS
run = "rm -rf web/dist"

# Windows
run_windows = '''
powershell -NoProfile -Command "Remove-Item -Recurse -Force web/dist"
'''

绝大多数任务可以共用一份配置,只有确实存在系统差异的地方,再补一个 run_windows

这真的救了我的老命。

它不像 Makefile,要在 Windows 上顺畅地跑起来,往往还要补上一整套工具链。mise 让作为 Windows 用户的我体会到了家的温暖,我们二等公民也终于站起来了(×)。

Monorepo:把不同语言的入口收在一起

在单语言项目中,我大概不会特意引入 mise。

Node.js 有 npm,Java 有 Maven,Rust 有 Cargo。这些工具负责真正的构建,mise 没必要再造一遍。到了 Monorepo 里,缺的不是另一个构建系统,而是一个能把这些构建系统收在一起的入口。

假设我们的代码库结构如下:

shell
company-project/
├── apps/
   ├── web/              # Node.js 18
   └── legacy-web/       # Node.js 14
├── services/
   └── server/           # JDK 17
├── scripts/
   ├── deploy-dev.sh
   └── deploy-dev.ps1
└── mise.toml

没有统一入口时,开发人员可能需要记住:

bash
cd apps/web
npm install
npm run build

或者:

bash
cd services/server
mvn clean package -DskipTests

这些命令单独看都不复杂。

但当一个仓库里有十几个子项目,每个项目使用不同的语言、版本和启动参数时,问题就不再是某一条命令难不难记,而是为什么每个人都要重新记一遍。

引入 mise 后,可以把入口统一成:

bash
mise build:web
mise build:legacy-web
mise build:server

如果需要构建整个项目,则只需要:

bash
mise build

当任务名称不与 mise 自身的命令冲突时,可以直接使用 mise <task>;始终通用的写法则是:

shell
mise run <task> 

mise 也可以从不同子目录加载配置,把子项目的任务组织到统一的任务命名空间中。

前端人员不需要知道后端具体使用了什么 Maven 参数,后端人员也不必先进入前端目录研究 package.json。他们只需要知道:我要构建前端,所以运行 mise build:web

这已经足够了。

不要在 mise 中写复杂脚本

我比较推荐把 mise.toml 当成一个简单的任务包装层,而不要在其中编写过于复杂的脚本。

在我的实践中,有一个把服务部署到开发环境的任务。这个任务包括:

  • 编译和打包项目;
  • 上传构建产物;
  • 停止旧服务;
  • 替换部署文件;
  • 重新启动服务;
  • 检查服务是否启动成功。

如果把这些逻辑全部塞进 mise.toml,配置文件很快就会变成一大段难以阅读、难以调试的内嵌脚本。

刚开始可能只有十几行。

后来加一个重试,再加一个健康检查,再处理一个 Windows 路径问题,最后打开 mise.toml 时,看到的就不再是任务配置,而是一坨迷(晕)人的 shit。

因此,我把具体逻辑封装到了 PowerShell 和 Shell 脚本中,在 mise 中只保留简单的调用入口:

toml
[tasks."deploy:dev"]
description = "构建项目并部署到开发环境"
depends = ["build"]

run = "./scripts/deploy-dev.sh"
run_windows = '''
powershell -ExecutionPolicy Bypass -File scripts/deploy-dev.ps1
'''

把逻辑拆出去后,PowerShell 和 Shell 脚本可以单独运行和调试,也能获得 IDE 的语法高亮、格式化和静态检查支持。

与此同时,mise.toml 重新变回了一张任务清单。开发者打开文件,就能看到项目提供了哪些任务,而不需要先穿过几百行部署逻辑。

所以我只在 mise 中保留任务入口,真正的实现仍然放在对应的构建系统或者独立脚本里。

mise 是一层薄包装。它越薄,反而越容易长期维护。

可执行的文档

mise 用了一段时间后,我发现它很巧妙地解决了另一个问题:项目文档经常失效。

以前我们也会在 README 或内部文档里写清楚:

  • 应该安装哪个版本的 JDK;
  • 应该安装哪个版本的 Node.js;
  • 前端项目如何构建;
  • 后端项目如何运行;
  • 开发环境应该如何部署。

问题是,这些说明和实际执行过程之间没有任何联系。

README 里的命令需要人复制出来执行。命令已经改了,只要没有人想起来更新文档,旧命令就会继续留在那里。

mise.toml 里的工具版本、执行目录、任务依赖和构建命令,会直接参与项目的构建和运行。

配置和项目不一致时,通常很快就会暴露出来;文档写错了,却可能几个月都没有人发现。

这和 Cyrille Martraire 在《Living Documentation》中讨论的思路很接近:文档不应该是软件开发完成之后,再由人手动补充的一份静态产物。它应该尽可能与软件一起变化,并与实际系统共享同一个事实来源。

放到这个项目里,mise.toml 就是那个“活文档”:

文档里描述的东西,同时也是项目真正执行的东西。

一个合适的 mise.toml,就是一种非常简单的可执行文档。

它会告诉新的开发者:

  • 项目有哪些子模块;
  • 每个子模块使用什么工具链;
  • 各个模块应该如何编译;
  • 测试应该如何运行;
  • 开发环境应该如何部署;
  • 完整构建依赖哪些任务。

值得一提的是,与 package.json 使用的标准 JSON 不同,TOML 允许添加注释。除了任务名称和 description,我们还可以直接在配置文件中解释某段配置为什么存在,以及一些看起来奇怪的参数到底是在解决什么问题。

最后落到仓库里,配置大致如下:

toml
# mise.toml

# 构建主前端项目
[tasks."build:web"]
description = "使用 Node.js 18 构建主前端项目"
dir = "apps/web"
tools.node = "18"
env.NODE_ENV = "production"
run = [
    "npm ci",
    "npm run build",
]

# 构建遗留前端项目
[tasks."build:legacy-web"]
description = "使用 Node.js 14 构建遗留前端项目"
dir = "apps/legacy-web"
tools.node = "14"
env.NODE_ENV = "production"
run = [
    "npm ci",
    "npm run build",
]

# 构建后端服务
[tasks."build:server"]
description = "使用 JDK 17 构建后端服务"
dir = "services/server"
tools.java = "temurin-17"
tools.maven = "3.9"
run = "mvn clean package -DskipTests"

# 构建整个 Monorepo
[tasks.build]
description = "构建所有前端项目和后端服务"
depends = [
    "build:web",
    "build:legacy-web",
    "build:server",
]

# 部署到开发环境
# 复杂逻辑放在独立脚本中,mise 只负责提供统一入口
[tasks."deploy:dev"]
description = "构建整个项目并部署到开发环境"
depends = ["build"]

run = "./scripts/deploy-dev.sh"
run_windows = '''
powershell -ExecutionPolicy Bypass -File scripts/deploy-dev.ps1
'''

新的开发者即使没有阅读其他文档,只要先运行:

bash
mise tasks

再打开 mise.toml 看一遍,基本就能知道项目有哪些模块、分别使用什么版本,以及常用操作从哪里进入。

总结

我更愿意把 mise 看成 Monorepo 的项目入口,而不是一个新的构建系统。

引入 mise 之后,那些反复确认版本、目录和启动方式的沟通少了许多。它不会消除所有环境差异,mise.toml 也仍然需要维护,但至少把最常见的答案放回了仓库里。

一个新的开发者拿到项目后,可以先运行:

bash
mise tasks

而不是先在群里问:

这个项目用哪个版本的 JDK? 前端要进入哪个目录? 后端到底应该怎么启动?

对我来说,这已经足够有用了。

评论

读完之后,可以在这里继续这段讨论。

正在读取评论


© 纪华裕 2024 - 2026