首页
/ Moby 中 go-md2man 详解:Markdown 转 roff 的 man 页转换工具用法与实现原理

Moby 中 go-md2man 详解:Markdown 转 roff 的 man 页转换工具用法与实现原理

2026-09-06 15:28:08作者:卓艾滢Kingsley

go-md2man 是一个用 Go 编写的 Markdown 转 roff(man page)转换工具,Moby(Docker 项目核心仓库)将它的 v2 版本 vendored 在 man/vendor/ 目录下,用它把 man/ 中的 Markdown 源文件编译成 Docker 的 man 手册页。本文以 vendored 版本的 README 为主线,完整覆盖安装、运行与命令行参数用法,并结合 Moby 仓库中的 构建 Makefilego.mod 依赖声明 与 vendored 源码,深入解析从 Markdown 输入到 roff 输出的完整转换链路。

一、go-md2man 的定位:把 Markdown 转成 roff

vendored 版 README 开篇即说明了工具的定位:

Converts markdown into roff (man pages). Uses blackfriday to process markdown into man pages.

即:go-md2man 负责把标准 Markdown 文档转换为 roff 格式的 man 手册页,底层依赖 blackfriday 这个纯 Go 的 Markdown 解析器。"用纯 Go 实现、减少第三方运行时依赖"是它的设计取向,这也解释了为什么 Moby 能方便地把整个工具链 vendored 进仓库,在离线环境(GO111MODULE=auto)下直接构建。Moby 侧的依赖关系可以在 man/go.mod 中确认:

module github.com/docker/docker/man

go 1.19

require github.com/cpuguy83/go-md2man/v2 v2.0.7

require github.com/russross/blackfriday/v2 v2.1.0 // indirect

其中 blackfriday/v2 v2.1.0 以 indirect 依赖出现,印证了 README 中"使用 blackfriday 处理 Markdown"的描述——它并非直接调用,而是经由 go-md2man 的渲染层间接引入。

二、安装与运行方式(README 完整用法)

README 给出了两种安装/运行方式,均完整保留如下。

方式一:go install 安装全局命令(通用方式)

go install github.com/cpuguy83/go-md2man/v2@latest

go-md2man -in /path/to/markdownfile.md -out /manfile/output/path

先通过 go install 拉取最新版本并安装到 GOBIN,之后即可在任意目录以 go-md2man 命令调用,通过 -in 指定输入的 Markdown 文件、-out 指定输出的 man 页文件。

方式二:Go 1.24 及以上使用 go tool 免安装运行

README 同时说明,在 Go 1.24 及以上版本中,可以不安装全局命令,直接以"工具依赖"方式运行:

go get -tool github.com/cpuguy83/go-md2man/v2@latest
# it will be appended to `tool` directive in go.mod file

go tool go-md2man -in /path/to/markdownfile.md -out /manfile/output/path

go get -tool 会把该工具追加写入当前模块 go.modtool 指令,随后用 go tool go-md2man 在前端模块上下文中直接执行。这种方式适合"工具只服务于当前仓库构建"的场景——依赖声明收敛在仓库自己的 go.mod 中,不需要污染全局 PATH,也不需要手动维护二进制版本。

命令行参数的真实含义

README 只列出了 -in / -out 两个参数的用法,vendored 入口源码 md2man.go 则给出了精确的默认行为:

var (
	inFilePath  = flag.String("in", "", "Path to file to be processed (default: stdin)")
	outFilePath = flag.String("out", "", "Path to output processed file (default: stdout)")
)
  • 不传 -in 时,输入是 stdin不传 -out 时,输出是 stdout。因此工具天然支持 shell 管道重定向,这一点与随包附带的 man 页源文件 go-md2man.1.md 中 EXAMPLES 一节给出的两种等价写法一致:
# 管道重定向写法
go-md2man < go-md2man.1.md > go-md2man.1

# 命令行参数写法
go-md2man -in=go-md2man.1.md -out=go-md2man.1
  • 入口逻辑是"全量读取 → 渲染 → 全量写出":ioutil.ReadAll(inFile) 读入整份文档,交给 md2man.Render(doc) 渲染,再把结果写入输出文件或 stdout;任何一步出错都会打印错误并 os.Exit(1)。这意味着它处理的是完整文档而非流式片段,适合整页转换,不适合超大文件流式处理。

三、Moby 仓库如何把 go-md2man 织入构建链

在 Moby 中,go-md2man 不是随手安装的第三方命令,而是一套自包含的 man 页构建工具链。以下各节均以 man/README.md 的操作说明为主干,用仓库内文件逐一佐证。

3.1 依赖声明:tools.go 的"构建工具依赖"惯用法

man/tools.go 全文只有几行:

//go:build tools

package man

import (
	_ "github.com/cpuguy83/go-md2man/v2"
)

这是一个典型的"tools 依赖"文件://go:build tools 标签保证该文件不参与常规构建,空白导入 _ 则把 go-md2man/v2(及其 transitive 依赖 blackfriday)钉进 man/ 这个独立 Go module 的依赖图。它与 README 方式二中 tool 指令的思路一致,只是用了更早期、跨 Go 版本通用的写法。

3.2 构建 Makefile:从 vendored 源码编译本地二进制

man/Makefile 的核心逻辑分四步:

  1. 优先使用本地构建的 go-md2manGO_MD2MAN ?= .build/go-md2man。注释明确说明——默认情况下,man 页是用"从本目录 vendored 源码构建出的 go-md2man 副本"生成的;你也可以通过设置 GO_MD2MAN 变量指向一个已有的 go-md2man 二进制来覆盖该行为:

    .build/go-md2man: go.mod go.sum
    	GO111MODULE=auto go build -o $@ github.com/cpuguy83/go-md2man/v2
    

    GO111MODULE=auto 让它能在有 vendor 目录的前提下从 vendored 源码构建,不依赖网络拉包。

  2. 自动收集所有 man 页源文件ALL_PAGES := $(wildcard *.*.md),即只匹配 *.*.md 这种"标题.节号"命名的文件。Makefile 据此从文件名中提取手册节号(man_section 函数取最后一个点号之后的数字),并动态为每个节生成模式规则:

    define MANPAGE_template
    man$(1)/%.$(1): %.$(1).md $(if $(findstring file,$(origin GO_MD2MAN)),$(GO_MD2MAN)) | man$(1)
    	$(GO_MD2MAN) -in $$< -out $$@
    endef
    

    以节号 8 为例,就会得到规则 man8/dockerd.8: dockerd.8.md,正好对应 README 用法中 go-md2man -in dockerd.8.md -out man8/dockerd.8 的调用形态。注意规则的前置条件里用了 $(origin GO_MD2MAN) 判断:只有当 GO_MD2MAN 是文件路径(file 来源)时,才把该二进制本身列为构建前置依赖,避免用户用 GO_MD2MAN=go-md2man(可执行命令名)覆盖时误判。

  3. 生成与安装:默认目标 all 构建全部 man 页到 man<N>/ 子目录;install 目标按节循环执行 install -d $(DESTDIR)/usr/local/man/man$sec 并拷入生成的页面。man/README.md 对这一步的操作说明是:在 man/ 目录运行 make install,并支持 prefixmandirINSTALLINSTALL_DATADESTDIR 等 make 变量定制安装位置。

  4. 清理clean 目标移除 man* 目录与 .build

3.3 添加一个新 man 页的正确姿势

man/README.md 明确了新增流程:在本目录新建一个名为 TITLE.SECTION.md 的 Markdown 文件(如 dockerd.8.md),Makefile 的 *.*.md 通配会自动拾取。它同时说明 Makefile 会忽略不符合 *.*.md 通配的文件——这解释了为什么 man/README.md 这类非 man 页文档可以与 man 页源文件并存而不被误编译。

仓库中现成的实例是 man/dockerd.8.md,其首行是标准 man 页标题块:

% "DOCKERD" "8" "SEPTEMBER 2015" "Docker Community" "Docker User Manuals"

随后是 # NAME# SYNOPSIS(列出全部 dockerd 启动参数及默认值,如 --data-root[=/var/lib/docker]--iptables[=**true**]] 等)等章节。这份 Markdown 经 go-md2man 转换后即成为安装到 man8/dockerd.8` 的手册页。

四、转换管线:Markdown → blackfriday AST → roff 渲染

vendored 的渲染层源码 md2man/md2man.go 只有 24 行,完整展示了管线结构:

// Render converts a markdown document into a roff formatted document.
func Render(doc []byte) []byte {
	renderer := NewRoffRenderer()
	var r blackfriday.Renderer = renderer
	if v, _ := strconv.ParseBool(os.Getenv("MD2MAN_DEBUG")); v {
		r = &debugDecorator{Renderer: r}
	}

	return blackfriday.Run(doc,
		[]blackfriday.Option{
			blackfriday.WithRenderer(r),
			blackfriday.WithExtensions(renderer.GetExtensions()),
		}...)
}

从源码结构看,管线分为三层:

  1. 解析层blackfriday.Run 用 blackfriday/v2 把整份 Markdown 解析为 AST;
  2. 渲染层RoffRenderer(定义在同目录 roff.go)把 AST 节点翻译为 roff 指令,输出 .TH.SH.B 等 man 页所需的 macro;renderer.GetExtensions() 声明该渲染器需要启用的 blackfriday 扩展(如表格、定义列表等),这些扩展直接决定了 Markdown 源文件中哪些语法能被正确转换为 man 页元素——dockerd.8.md 里大量"加粗选项名 + 冒号缩进"的参数说明,正是依赖定义列表类扩展的渲染效果;
  3. 调试层:设置环境变量 MD2MAN_DEBUG=1 时,渲染器会被 debugDecoratordebug.go)装饰,用于在开发 man 页模板时观察渲染器收到的节点流。

此外,vendored 目录自带一套 DockerfileMakefile,说明该工具本身也支持多平台交叉构建(GOOS/GOARCH/TARGETVARIANT 透传,amd64 映射 GOAMD64、arm 映射 GOARM),并以 scratch 镜像产出无依赖的静态单二进制。

五、适用前提与边界

  • 本文所有构建/安装说明均针对 Moby 仓库当前状态:man/ 是独立 Go module(github.com/docker/docker/man,Go 1.19+),go-md2man 固定为 v2.0.7;README 中 go install ...@latestgo get -tool ...@latest 面向的是最新上游版本,与 vendored 的 v2.0.7 可能存在版本差异,仓库内构建始终以 vendored 源码为准。
  • go tool 方式要求 Go 1.24 及以上工具链;man/ 的 Makefile 流程对 Go 版本无此要求,它走的是 vendored 构建。
  • 输入为整文档一次性读入内存的模式决定了 go-md2man 适用于整页转换;若只想验证某一节的转换效果,可临时用管道方式 go-md2man < some.md | less 直接查看 roff 输出(仓库只读,以上仅为查看/运行方式说明)。

综上,go-md2man 在 Moby 中扮演的是"文档源到发行产物的编译器"角色:Markdown 源文件是可评审的真相来源,go-md2man 是可版本化的构建工具,man/ Makefile 则是把二者连接起来、支持按节号自动分派规则与定制安装的构建层。理解这条链路后,无论是阅读 dockerd.8.md 的编写约定,还是排查 man 页生成问题,都能追溯到具体的源文件与构建规则。

登录后查看全文
热门项目推荐
相关项目推荐