一位 OCaml 开发者的基础:Dune
刚接触 OCaml 或其他编程语言,首先要面对代码的构建、运行和测试。好在有强大的构建系统 `dune`,它应用广泛,能让项目设置和编译简单直接。理解 `dune` 工作原理是在 OCaml 生态系统高效开发的关键一步。本文将带你了解如何用 `dune` 构建库、可执行文件和测试,以及管理项目结构。无论编写首个 OCaml 程序,还是处理基于 dune 的新代码库,这篇指南都能助你快速上手。我们坚信,从零开始是学习全新技术主题的关键 —— 今天的主题也不例外。任何在探索新代码库时感到迷茫的人都知道,简单示例往往是培养直觉的最佳方式。
参考资源
本文在最新的 [Opam 103:使用 opam 启动一个新的 OCaml 项目](https://ocamlpro.com/blog/2025_04_29_opam_103_starting_new_project/) 背景下撰写。那篇文章介绍了 OCaml 开发者用 `opam` 构建 OCaml 项目的方法。今天聚焦 OCaml 项目结构的另一个关键因素:构建系统。目标是展示 `opam` 和 `dune` 工作流程如何协同,同时介绍 dune 基础知识。我们用 [同一个示例项目](https://gitlab.ocamlpro.com/raja/opam_bps_examples/-/tree/dune-minimal/opam-103) `helloer` 作基础。它简单且范围明确,结构符合 opam 和 dune 习惯用法,适合在不增加不必要复杂性的情况下说明基础知识。需注意,`helloer` 并非用本文末尾介绍的 `dune init` 创建。首先了解 Dune 底层工作原理很重要,这样能知道它生成了什么、如何自信修改,以及如何融入整体构建流程。建议查阅 [Dune 的官方参考手册](https://dune.readthedocs.io/en/latest/reference/index.html) 或访问 [OCaml 官方讨论论坛](https://discuss.ocaml.org/) 与 OCaml 社区交流。
项目元数据和构建规范文件
dune-project
每个由 Dune 驱动的项目根目录下都应有 `dune-project` 文件。它是项目入口点,内容是项目元数据,Dune 据此了解项目结构。元数据包括:所用 `dune` 版本;项目生命周期中的重要 URL;可选设置,如依赖项许可、文档等;甚至包括自动生成 opam 文件的配置。更多内容参考 [Opam 103](https://ocamlpro.com/blog/2025_04_29_opam_103_starting_new_project)。这些信息不仅指导 Dune,还助 opam 等工具了解如何构建、分发和记录项目。
$ cat dune-project
(lang dune 3.15)
(package (name helloer))
(cram enable)
注意:第一行必须是 `(lang dune X.Y)`,不能有注释或多余空格,这行决定 dune 能识别的功能和语法。可在 [官方文档](https://dune.readthedocs.io/en/latest/reference/dune-project/index.html) 中找到所有补充信息。
dune 文件
`dune` 文件是构建规范文件,告诉 Dune 如何编译特定目录中的 OCaml 代码。通常每个子目录都有 `dune` 文件,描述该目录内容 —— 库、可执行文件或测试。示例项目 `helloer` 结构扁平,将此文件放 [项目根目录](https://gitlab.ocamlpro.com/raja/opam_bps_examples/-/tree/dune-minimal/opam-103) 下。
$ cat dune
(library
(name helloer_lib)
(modules helloer_lib)
)
(executable
(public_name helloer)
(name helloer)
(libraries cmdliner helloer_lib)
(modules helloer)
)
(test
(name test)
(libraries alcotest helloer_lib)
(modules test)
)
实际上,这告诉 `dune`:如何构建该目录中的 OCaml 文件;库、可执行文件和测试目标如何定义。
关键节(stanzas)
在 Dune 里,[节(stanza)](https://dune.readthedocs.io/en/stable/overview.html#term-stanza) 指配置块,告诉构建系统要定义的工件类型 —— 可以是库、可执行文件、测试、文档别名,甚至可安装的二进制文件。每个节在 `dune` 文件中,遵循结构化声明性语法。它们通常按用途分组,每种类型有预期字段。每个节都值得深入研究,这里先快速概述。
`library` 节
(library
(name helloer_lib)
(modules helloer_lib)
)
`library` 节告诉 Dune 如何将一组模块编译成可重用包。此节用途:定义名为 `helloer_lib` 的库;该库由模块 `helloer_lib.ml` 构建(默认每个 `.ml` 文件定义同名模块);只列公开模块,即作为库公共 API 一部分,可供项目其他部分或外部代码使用的模块。OCaml 模块名应与文件名匹配,所以 `helloer_lib.ml` 文件应在该目录中。
`executable` 节
(executable
(public_name helloer)
(name helloer)
(libraries cmdliner helloer_lib)
(modules helloer)
)
`executable` 节说明如何将代码打包成可运行二进制文件。用途:`name`:构建名为 `helloer` 的可执行文件;需要依赖库:外部 `cmdliner`(用于 CLI 解析)和内部 `helloer_lib`(自己的库);`public_name helloer`:使该可执行文件可公开使用,如在 `opam` 文件中可用 `dune install helloer` 安装。可在最新的 Opam 103 博客文章 [中了解如何在 `opam` 中查找和安装 `cmdliner`](https://ocamlpro.com/blog/2025_04_29_opam_103_starting_new_project/#clitooling),那里还有 [一个简单的 `opam` 文件分解](https://ocamlpro.com/blog/2025_04_29_opam_103_starting_new_project/#minimalopamfile)。
`test` 节
(test
(name test)
(libraries alcotest helloer_lib)
(modules test)
)
作用:声明名为 `test` 的测试目标,定义在 `test.ml` 文件中。`test` 节将该可执行文件注册为 `runtest` 规则别名一部分,调用 `dune runtest`(或其别名 `dune test`)时,它将被编译并自动运行;使用 `alcotest` 测试库;也用 `helloer_lib` 测试其功能。
构建并运行你的项目
`dune build`
`dune build @all` 命令将构建 `dune` 文件中定义的所有目标,这是 `dune build` 命令默认行为。
$ tree
.
├── dune
├── dune-project
├── helloer_lib.ml
├── helloer.ml
├── helloer.opam
└── test.ml
$ dune build @all
$ tree -L 2
.
├── _build
│ ├── default
│ │ ├── helloer.exe // 可执行文件在其构建目录中
│ │ ├── helloer_lib.cmxs // 已构建的库
│ │ ├── test.exe // 测试可执行文件
│ │ └── [...]
│ ├── install
│ └── log
├── dune
├── dune-project
├── helloer_lib.ml
├── helloer.ml
├── helloer.opam
└── test.ml
解释:`@all` 是别名,包含 `dune` 文件中定义的所有可构建目标:可执行文件、库、测试、文档等;适合完整构建,确保所有内容编译通过。也可使用自定义别名(如 `@doc`、`@runtest` 等),或在 `dune` 文件中定义自己的别名。
`dune build @doc`
代码能成功构建且项目有合适的 `dune-project` 文件,可用以下命令生成文档:
$ dune build @doc
作用:幕后用 `odoc` 从 OCaml 代码构建 API 文档,使用此功能须安装 `odoc`,执行 `opam install odoc` 即可;在 `_build/default/_doc/_html/` 目录下生成 HTML 文件。确保 `dune-project` 文件含 `(package ...)` 节,且库用 OCaml 注释 `(** 注释 *)` 适当文档化。可在 [这里](https://github.com/OCamlPro/opam_bp_examples/commit/5ec8dd28115f72df44fd9f1b4de4379d2bf54d5f) 查看示例项目的文档生成情况。可在 [官方文档](https://ocaml.github.io/odoc/odoc/odoc_for_authors.html) 中找到所有补充信息。构建完成后,可查看生成的文档:
$ open _build/default/_doc/_html/index.html
这对检查模块接口或在线发布文档很有用。
`dune exec --`
此命令用于运行项目中定义的可执行文件。例如:
$ dune exec -- ./helloer.exe
Hello OCamlers!!
$ dune exec -- ./helloer.exe --gentle
Welcome my dear OCamlers.
这告诉 dune 必要时构建可执行文件,然后运行它。`--` 分隔 dune 选项和可执行文件及其参数,`--` 后第一项是要运行的可执行文件。可以是:指向已构建目标的相对路径,如:`dune exec -- ./path/to/executable`;已安装可执行文件的公共名称,即:`dune exec -- ./helloer`。可执行文件名称后所有额外参数(如 `--gentle`)都会传递给可执行文件本身。本质上,`dune exec -- COMMAND` 行为与先调用 `dune install` 然后再调用 `COMMAND` 相同。若想将可执行文件复制到项目根目录(`_build/` 之外),可在可执行文件节中添加 `(promote (until-clean))`。
用 Dune 测试你的项目
Cram 测试
在 `helloer` 项目中,内部 `helloer_lib` 使用 `alcotest` 库,很常见。借助 Cram 测试,可在不依赖外部工具的情况下测试可执行文件本身。Dune 支持 Cram 测试,灵感源于原始的 [Cram](https://bitheap.org/cram/),用于检查命令行示例是否产生预期输出。“预期输出” 指 shell 会话本身,测试运行时,可执行文件输出会与 Cram 文件中写入的预期输出对比。创建 Cram 测试,只需编写 `.t` 文件,包含一系列类似 shell 的会话,用空行分隔,如下:
$ helloer
Hello OCamlers!!
$ helloer --gentle
Welcome my dear OCamlers.
工作原理:运行 `.t` 文件中的命令;将二进制文件输出到 `stdout` 的内容与 Cram 文件中写入的预期输出比较;输出不同则测试失败。对二进制文件输出到 `stdout` 的内容更改时,可用 `dune promote` 命令将所有失败测试替换为新输出。可在 [这里](https://github.com/OCamlPro/opam_bp_examples/commit/8415437d2a3d13c890af4eb7406f0803a185d6a6) 测试它。
`dune runtest`
可用以下命令运行所有测试:
$ dune runtest
构建项目中定义的 `test` 目标;查找以 `.t` 结尾的文件或标记为测试的 `.ml` 文件;执行测试,通常用 _expect_ 风格测试(如 `ppx_expect` 或 `alcotest`)。很简单:有 `inline_tests` 节或 `expect` 测试,它会运行并告知是否有失败情况。例如,有效 Cram 测试输出如下:
$ dune runtest
Testing `Tests'.
This run has ID `N39NJ5ZE'.
[OK] messages 0 normal.
[OK] messages 1 gentle.
Full test results in `~/ocamler/dev/helloer/_build/default/_build/_tests/Tests'.
Test Successful in 0.000s. 2 tests run.
若一个测试失败,会看到类似以下输出:
$ dune runtest
File "test.t", line 1, characters 0-0:
diff --git a/_build/.sandbox/e6d6dcfb864b62e42104889af2a44f23/default/test.t b/_build/.sandbox/e6d6dcfb864b62e42104889af2a44f23/default/test.t.corrected
index f79b63c..70c7a17 100644
--- a/_build/.sandbox/e6d6dcfb864b62e42104889af2a44f23/default/test.t
+++ b/_build/.sandbox/e6d6dcfb864b62e42104889af2a44f23/default/test.t.corrected
@@ -3,7 +3,7 @@ Default behaviour
Hello OCamlers!!
Gentle behaviour
$ helloer --gentle
- Welcome my deer OCamlers.
+ Welcome my dear OCamlers.
Unknown behaviour
$ helloer --unknown
helloer: unknown option '--unknown'.
File "dune", line 16, characters 7-11:
16 | (name test)
^^^^
Testing `Tests'.
This run has ID `1OS0H3WP'.
[OK] messages 0 normal.
> [FAIL] messages 1 gentle.