写一个「读 CSV、调个 API、输出统计」的小工具,需要几步?在传统 Scala 项目里,你要建目录结构、写 build.sbt、配 Scala 版本、加依赖、等 sbt 启动(几十秒)、再 run。而同样的事在 Python 里是一条 python x.py。这个「小工具的启动成本」长期是 Scala 的短板,Scala CLI 就是为了补上它而生的。
Scala CLI 的定位不是「替代 sbt」,而是把构建配置从文件降级成注释://> using dep ... 直接写在源码顶部,工具负责解析依赖、选择 Scala 版本、编译、运行、打包。这让脚本、原型、单文件工具、教学示例重新变得轻量。本文讲清它的能力边界,以及它和 sbt 各自该负责什么。
1. Scala CLI 的定位
Scala CLI 是一个单命令入口,把「依赖解析 + 编译 + 运行 + 测试 + 打包 + REPL」整合在一起。它的输入是 .scala 文件(或目录),配置来自源码顶部的指令注释(directives),而不是外部构建文件。
# 安装(各平台通用)
curl -sSLf https://scala-cli.virtuslab.org/get | sh
# 或通过包管理器
brew install scala-cli
# 或 coursier
cs install scala-cli
# 直接运行一个远程脚本(无需克隆仓库)
scala-cli run https://gist.githubusercontent.com/.../hello.scala
# 最简用法:单文件
cat > hello.scala <<'EOF'
println("hello, scala")
EOF
scala-cli run hello.scala
能力矩阵:
□ run 编译并运行(支持 main 方法或脚本顶层语句)
□ compile 只编译,用于 CI 快速校验
□ test 运行测试(识别 munit / scalatest / utest)
□ repl 启动 REPL(可带依赖)
□ package 打包成可执行 JAR / 原生镜像 / 库 JAR
□ fmt 格式化(内置 scalafmt 集成)
□ doc 生成 API 文档
□ publish 发布到 Maven 仓库(local 或远程)
工程要点:Scala CLI 的首次运行较慢(要下载 Scala 编译器与依赖),但依赖与编译产物会缓存到 ~/.cache/scala-cli(Linux)/ ~/Library/Caches/ScalaCli(macOS)。第二次运行同一脚本通常是亚秒级——这也是它比「sbt 启动几十秒」更适合作脚本工具的原因。
2. 指令声明与依赖
指令是写在源码顶部的注释行,以 //> 开头。它们既能在单文件里用,也能放在一个独立的 project.scala 里统一管理整个项目。
//> using scala 3.3.4
//> using dep com.lihaoyi::os-lib:0.10.7
//> using dep com.lihaoyi::upickle:4.0.2
//> using dep org.typelevel::cats-core:2.12.0
//> using test.dep org.scalameta::munit:1.0.2
//> using jvm 21
//> using options -deprecation -feature -Wunused:all
//> using resourceDir ./resources
import upickle.default.*
case class Row(name: String, score: Int) derives ReadWriter
@main def top(path: String): Unit =
val rows = os.read.lines(os.Path(path)).map(read[Row](_))
rows.sortBy(-_.score).take(10).foreach(r => println(s"${r.name}\t${r.score}"))
常用指令一览:
//> using scala <version> Scala 版本(2.13.x / 3.x)
//> using dep <org::name:version> 编译期与运行期依赖
//> using test.dep <...> 仅测试依赖
//> using jvm <version|system> JVM 版本
//> using options <...> 传给 scalac 的编译选项
//> using resourceDir <path> 资源目录
//> using mainClass <fqcn> 指定入口类
//> using platform jvm|scala-js|native 目标平台
//> using nativeImageOptions <...> 原生镜像参数
//> using python / using npm 跨语言依赖(可选)
# 临时加依赖(不写进源码)
scala-cli run tool.scala --dep com.lihaoyi::requests:0.9.0
# 覆盖版本(命令行优先于指令)
scala-cli run tool.scala --scala 3.5.2
# 查看最终生效的配置(依赖、版本、选项)
scala-cli run tool.scala --print-class-path
工程要点:指令的优先级是「命令行 > 源码指令 > 目录级 project.scala」,同名指令后者覆盖前者。这意味着同一个脚本在不同环境下可能解析出不同版本——CI 里务必显式固定 using scala 与所有依赖版本,别依赖「本地缓存里恰好是什么」。
3. 与 sbt 的职责边界
Scala CLI 与 sbt 不是替代关系。判断标准是**「项目的规模与生命周期」**:一次性脚本、教学示例、原型验证用 Scala CLI;多模块、多平台、需要自定义任务与插件生态的长期项目用 sbt。
| 维度 | Scala CLI | sbt |
|---|---|---|
| 配置形态 | 源码内注释 | build.sbt(Scala DSL) |
| 启动开销 | 低(缓存后亚秒) | 高(JVM + 构建定义加载) |
| 多模块 | 支持但较弱 | 强(project/ 与聚合) |
| 自定义任务 | 不支持 | 完整支持 |
| 插件生态 | 无 | 丰富(sbt-assembly、sbt-native-packager 等) |
| 跨平台 | JVM / JS / Native | JVM / JS / Native |
| 增量编译 | 有 | 有(更成熟) |
| 脚本化 | 极强 | 弱 |
| CI 适合度 | 单文件工具、快速校验 | 大型项目构建与发布 |
# 两者协作:sbt 项目里用 Scala CLI 跑一次性脚本
scala-cli run scripts/migrate.scala \
--dep com.lihaoyi::os-lib:0.10.7 \
--dep org.postgresql:postgresql:42.7.3
# 从 sbt 项目生成 Scala CLI 配置(辅助迁移评估)
scala-cli --power sbt setup
工程要点:最常见的合理组合是**「sbt 管主项目,Scala CLI 管辅助脚本」**——数据迁移、本地运维、压测发压、文档示例这类「不参与主构建、但需要项目依赖」的场景,Scala CLI 一条命令就能带上依赖跑,不必塞进 sbt 的自定义任务里。
4. 多文件项目与测试
当脚本长到需要拆文件时,Scala CLI 支持目录级项目:一个目录里的所有 .scala 文件属于同一个编译单元,project.scala 承载共享指令,test/ 子目录放测试。
my-tool/
├── project.scala # 共享指令(Scala 版本、公共依赖、编译选项)
├── src/
│ ├── Main.scala # @main 入口
│ ├── Parser.scala
│ └── Report.scala
├── test/
│ └── ParserSuite.scala # 测试代码(只在 test 时编译)
└── resources/
└── template.txt
// project.scala:整目录共享的指令
//> using scala 3.3.4
//> using dep com.lihaoyi::os-lib:0.10.7
//> using test.dep org.scalameta::munit:1.0.2
//> using resourceDir ./resources
// test/ParserSuite.scala
import munit.FunSuite
class ParserSuite extends FunSuite:
test("解析空行返回 None") {
assertEquals(Parser.parse(""), None)
}
test("解析正常行返回 Row") {
assertEquals(Parser.parse("a,1"), Some(Row("a", 1)))
}
# 运行整个目录
scala-cli run my-tool
# 只跑测试
scala-cli test my-tool
# 只编译(CI 快速校验,不执行)
scala-cli compile my-tool
# 监视模式(改动即重编译)
scala-cli compile --watch my-tool
工程要点:测试框架通过 test.dep 声明即可,Scala CLI 会自动识别 munit、scalatest、utest 并生成对应的运行器,不需要任何额外配置。但要注意测试文件必须在 test/ 目录下(或显式用 --test-only),放在 src/ 里会被打进主程序。
5. 打包与发布
Scala CLI 的打包能力覆盖了大多数发布形态:可执行 JAR(含依赖)、原生镜像(GraalVM)、库 JAR(供他人依赖)、以及容器镜像。
# 1) 胖 JAR:所有依赖打进去,java -jar 即可运行
scala-cli --power package my-tool --assembly -o tool.jar
java -jar tool.jar
# 2) 轻量启动脚本:只打包自己的类,依赖走 classpath
scala-cli --power package my-tool -o tool --standalone=false
# 3) GraalVM 原生镜像(启动毫秒级,需本机有 GraalVM)
scala-cli --power package my-tool --native-image -o tool-native -- \
--no-fallback -H:+ReportExceptionStackTraces
# 4) 库 JAR:给别的项目当依赖
scala-cli --power package my-tool --library -o my-tool.jar --publish
# 5) 发布到本地 Maven 仓库(测试下游消费)
scala-cli --power publish local my-tool --organization com.example --version 0.1.0
# 6) 容器镜像(需 docker 在 PATH 中)
scala-cli --power package my-tool --docker --docker-image-repository my-tool
打包形态的选择:
场景 方式 启动时间 体积
本地脚本工具 --assembly ~0.5s 中
对启动延迟敏感 --native-image ~10ms 大
库/共享代码 --library 不适用 小
容器部署 --docker ~0.5s 中
离线环境 全部 + 预拉依赖 同上 同上
工程要点:--power 前缀表示「实验性或需要显式确认」的选项,package 与 publish 都在其列。原生镜像打包对反射与动态代理敏感——用了反射的库(Jackson、部分日志框架)需要额外的 reflect-config.json,这是原生镜像最常见的失败原因。
6. CI 集成
Scala CLI 在 CI 里的优势是「无需构建定义加载」:直接 compile / test 即可,缓存依赖后通常比 sbt 启动更快。
# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
scala: ["3.3.4", "2.13.15"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- uses: VirtusLab/scala-cli-setup@v1
with:
power: true
- name: 缓存依赖与编译产物
uses: actions/cache@v4
with:
path: |
~/.cache/scalacli
~/.cache/coursier
key: scala-cli-${{ matrix.scala }}-${{ hashFiles('**/*.scala') }}
- run: scala-cli compile . --scala ${{ matrix.scala }}
- run: scala-cli test . --scala ${{ matrix.scala }}
# 本地复现 CI 的编译(与 CI 完全相同的命令)
scala-cli compile . --scala 3.3.4 --suppress-outdated-dependency-warning
# 输出机器可读的依赖树(审计用)
scala-cli --power dependency-outdated .
# 生成 IDE 用的 BSP 配置(VS Code / Metals)
scala-cli setup-ide .
CI 集成的要点:
□ 缓存 ~/.cache/coursier(依赖)与 scala-cli 的编译缓存
□ 显式固定 Scala 版本(矩阵或指令),别用 latest
□ compile 与 test 分两步:编译失败与测试失败的信号要分开
□ 用 setup-ide 生成 BSP 文件,让 IDE 与 CI 用同一套配置
□ 原生镜像构建要单独一个 job(需要 GraalVM 环境)
□ 离线环境:预热 coursier 缓存到镜像里
工程要点:缓存 key 要包含 Scala 版本。同一份源码在 Scala 3.3 与 2.13 下编译产物完全不同,若缓存 key 不含版本,交叉污染会导致「本地能编译、CI 报找不到符号」这类诡异问题。
7. 工具链迁移路径
从 sbt 迁到 Scala CLI 通常不是为了「替换」,而是为了给脚本类工作减负。迁移前先做一次评估:项目是否有自定义任务、是否有插件依赖、是否多模块。
迁移决策树:
1. 是单文件脚本 / 教学示例 / 一次性工具?
→ 直接迁,收益最大
2. 是小项目(< 10 个文件、无自定义任务、无插件)?
→ 可以迁,用 project.scala + test/ 目录
3. 是中型项目(多模块、有自定义任务)?
→ 保留 sbt,只把辅助脚本迁出
4. 是大型项目(插件生态、发布流程复杂)?
→ 不迁,Scala CLI 只做脚本与快速校验
从 Ammonite 迁移:
□ Ammonite 的 `import $ivy.` → `//> using dep`
□ 顶层语句与 @main 均可直接运行
□ `amm` 的 REPL 增强功能部分在 Scala CLI 的 repl 中缺失
# 让 IDE(Metals)认识 Scala CLI 项目
scala-cli setup-ide .
# 生成的 .bsp/ 目录让 Metals 用与命令行一致的编译配置
# 之后 VS Code 里的跳转、补全、诊断与 scala-cli 完全同步
# Scala 2 与 Scala 3 共存:用 --scala 切换
scala-cli run legacy.scala --scala 2.13.15
scala-cli run modern.scala --scala 3.3.4
工程要点:迁移过程中最大的风险是**「两套配置漂移」——project.scala 里的 Scala 版本与 CI 里 --scala 参数不一致,导致本地能跑、CI 报错。把版本只在一个地方**声明(要么全在 project.scala,要么全在 CI 矩阵),另一处显式引用同一变量。
8. 常见坑与规避
坑一:依赖解析走网络,离线环境失败
规避:预热 coursier 缓存;容器镜像里预置依赖
坑二:指令里的版本与实际解析版本不一致
规避:scala-cli --power dependency-outdated . 定期检查
CI 里固定版本,不用 latest / 范围版本
坑三:测试文件放错目录被当成主程序
规避:测试一律放 test/ 子目录
坑四:原生镜像构建时反射类缺失
规避:用 --native-image 的 -- 透传参数加 reflect-config
先本地跑一次再进 CI
坑五:package --assembly 后 main 类选错
规避:显式声明 //> using mainClass com.example.Main
坑六:资源文件找不到(工作目录与打包后不同)
规避:用 getClass.getResourceAsStream 读资源,别用相对路径
坑七:脚本里 @main 与顶层语句混用
规避:一个文件里二选一;顶层语句仅适合脚本形态
// 坑六的正确写法:资源从 classpath 读,不依赖当前工作目录
//> using resourceDir ./resources
object Report:
def template(): String =
val in = getClass.getResourceAsStream("/template.txt")
require(in != null, "template.txt 未被打包,检查 resourceDir 指令")
scala.io.Source.fromInputStream(in).mkString
工程要点:--assembly 打包后工作目录是启动进程的目录,与源码目录无关。所有资源访问都必须走 classpath(getResourceAsStream),凡是写 new File("resources/x.txt") 的代码在打包后必然失败。这一条同样适用于 sbt 项目,只是 Scala CLI 的「脚本能直接跑」会让人误以为路径规则一样。
9. 速查表与一句话记忆
| 问题 | 一句话答案 |
|---|---|
| 定位是什么 | 单命令工具链,配置从构建文件降级为源码注释 |
| 配置写在哪 | //> using ... 指令,或多文件项目的 project.scala |
| 指令优先级 | 命令行 > 源码指令 > 目录级 project.scala |
| 和 sbt 怎么分工 | 脚本与小工具用 CLI,多模块与插件生态用 sbt |
| 多文件项目布局 | project.scala + src/ + test/ + resources/ |
| 测试怎么跑 | scala-cli test .,自动识别 munit/scalatest/utest |
| 怎么打胖 JAR | scala-cli --power package . --assembly -o a.jar |
| 原生镜像 | --native-image,注意反射配置 |
| CI 缓存什么 | ~/.cache/coursier 与 scala-cli 编译缓存,key 含 Scala 版本 |
| 资源怎么读 | 走 classpath,绝不依赖工作目录相对路径 |
| 最大的坑 | 版本声明两处漂移、离线依赖缺失、资源路径失效 |
一句话记忆:Scala CLI = 单命令入口(run/compile/test/package/repl)+ 指令注释配置(//> using)+ 目录级项目(project.scala + test/)+ 一键打包(assembly/native-image/library/docker)——它替掉的是「为写十行脚本而配一个项目」的仪式感,不是 sbt;两者协作的正确姿势是 sbt 管主构建、CLI 管脚本与快速校验。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。