最近做了一个很有趣的尝试,将 Apache OpenDAL 的用户文档彻底合并到代码中,不再区分单独的用户文档和 API 文档。经过几个版本的迭代,我发现这个实践效果确实很不错,今天这篇文章主要就是分享为什么要把文档视为代码,它的好处都有哪些以及在 Rust 项目中该如何执行。

问题

OpenDAL 是一个使用 Rust 开发的数据访问库,用户通过使用 OpenDAL 就能实现与各种存储服务的集成。为了能够更好的帮助用户快速上手,OpenDAL 在 API 文档之外还维护了一份用户文档,里面的内容包括概念介绍,样例,常见用法以及内部的一些实现介绍等等。OpenDAL 使用 mdbook 来构建这份用户文档,并使用 rustdoc 来生成 API 文档,通过同一个域名的不同路径对外提供服务:

但是这个方案存在着不少问题:

维护麻烦

在本地进行构建文档需要执行如下步骤:

cargo doc --no-deps --all-features
mdbook build
cp -r ./target/doc/* ./target/book

用户需要分别使用 cargo docmdbook 来构建两份文档,并手动复制其内容。

互相引用困难

API 文档和用户文档是分开构建的,因此互相引用内容十分困难。需要手动处理路径,并增加额外的 broken link 检查,无法复用 rustdoc 良好的 intradoc 机制。

在 OpenDAL 维护的过程中,曾经多次出现内部 API 发生变化,但是用户文档中没有及时更新导致引用失效的案例。

无法版本化

mdbook 本身没有提供版本化的机制,当新的版本更新后,用户就再也无法查看旧版本的用户文档,体验很差。

文档即代码

通过将文档直接放进代码中,使用 rustdoc 进行统一构建,我们能够一举解决上述所有问题:

  • 简化构建:开发者使用 cargo doc --open 就能轻松的在本地预览文档
  • 引用方便:文档中可以使用 [crate::Operator] 等语法方便的引用项目中所有的元素,同时 rustdoc 本身就提供 broken intradoc 的检查。
  • 原生版本化:所有推送到 Crates.io 的包都会自动构建文档并发布到 Docs.rs,每个版本都提供与其关联的文档

以 OpenDAL 为例,当用户访问 OpenDAL 0.27.0 时,内部所有互相引用的链接都指向当前版本,最大程度的避免让用户产生混淆。

实践

接下来我介绍一下实践文档即代码中的一些实用小技巧。

在实践之前需要先分析项目的具体情况,当且仅当项目的用户文档和 API 文档的读者群完全重叠时,才比较适合使用这种方案。如果项目本身交付的是二进制,通过 rustdoc 生成用户文档就不太好。

增加 docs feature

在项目中增加 docs feature 开关来控制是否编译文档,避免影响用户的编译速度。

Cargo.toml 增加 docs feature

# Build docs or not.
#
# This features is used to control whether or not to build opendal's docs.
# And doesn't have any other effects.
docs = []

在代码中启用 feature:

#[cfg(feature = "docs")]
pub mod docs;

引用已存在的 Markdown 文件

通过 #[doc = include_str!("xxx.md")] 可以将本地的 markdown 文件直接导入:

/// All features that provided by OpenDAL.
#[doc = include_str!("features.md")]
pub mod features {}

Markdown 的内容会原样展开后插入到这里,跟已有的注释相对位置是固定的。所以也可以在文档的中间插入:

/// # Compatible Services
#[doc = include_str!("compatible_services.md")]
///
/// # Other Notes
///
/// balabalba
#[derive(Default, Clone)]
pub struct S3Builder { ... }

注意这里插入文档中的代码块同样会被 rustdoc 解析,所以需要根据实际情况加上 ignore 或者 no_run 标记,或者对该模块彻底禁用 doctest:

/// Upgrade and migrate procedures while OpenDAL meets breaking changes.
#[doc = include_str!("upgrade.md")]
#[cfg(not(doctest))]
pub mod upgrade {}

插入图片

rustdoc 原生不提供插入图片的功能,但是可以使用 embed_doc_image 来实现:

use embed_doc_image::embed_doc_image;

#[embed_doc_image("myimagelabel", "images/foo.png")]
#[embed_doc_image("foobaring", "assets/foobaring.jpg")]
fn foobar() {}

不过我个人还是不推荐插入图片,毕竟 crate 存在大小限制,推荐使用 asciiflow 来插入好看的二维字符画。

docs.rs metadata

在上传之前需要修改 docs.rs metadata,保证 docs 都被正常编译:

[package.metadata.docs.rs]
all-features = true

总结

本文分享了 OpenDAL 的文档即代码实践,它主要有以下好处:

  • 简化构建
  • 引用方便
  • 原生版本化

如果你正在维护一个 rust lib,同时苦恼用户文档和 API 文档之间的分裂体验,推荐尝试一下~