第一章:Go模块依赖源码溯源术的底层逻辑与问题本质
Go模块依赖的源码溯源并非简单的路径查找,而是由go.mod文件、模块缓存($GOPATH/pkg/mod)、代理服务(如proxy.golang.org)与本地vendor机制共同构成的多层解析系统。其核心在于go list -m -f '{{.Dir}}' <module>命令所触发的模块解析链:Go工具链首先检查当前模块的replace和exclude指令,再依据require语句中声明的版本号,在模块缓存中定位对应校验和匹配的只读快照目录。
模块解析的三大关键锚点
- go.sum 文件:记录每个模块版本的SHA-256校验和,用于验证源码完整性;缺失或不匹配将导致
go build失败并提示checksum mismatch - modcache 目录结构:遵循
$GOMODCACHE/<module>@<version>/路径规范,例如github.com/sirupsen/logrus@v1.9.0/,所有go get下载的模块均以不可变快照形式存放于此 - GOPROXY 优先级策略:默认启用
https://proxy.golang.org,direct,当代理返回404时自动回退至直接拉取,但若设置为GOPROXY=direct则完全绕过代理,直连模块源仓库
溯源失败的典型场景与诊断步骤
执行以下命令可快速定位问题根源:
# 1. 查看当前模块依赖树及解析路径
go list -m -u -f '{{.Path}} {{.Version}} {{.Dir}}' all | head -10
# 2. 强制刷新模块缓存并验证校验和(需网络)
go clean -modcache && go mod download && go mod verify
# 3. 检查特定模块是否被replace覆盖
go list -m -f '{{if .Replace}}{{.Replace.Path}}@{{.Replace.Version}}{{else}}(none){{end}}' github.com/gorilla/mux
| 现象 | 根本原因 | 解决方向 |
|---|---|---|
cannot find module |
GOPROXY阻断或私有模块未配置 | 配置GOPRIVATE或添加replace |
checksum mismatch |
modcache损坏或上游篡改 | 清理缓存+重新go mod download |
build constraints |
构建标签与GOOS/GOARCH不匹配 | 检查// +build注释与环境变量 |
模块溯源的本质,是Go构建系统在确定性、安全性和可复现性之间建立的契约:每一次go build都必须能唯一映射到经go.sum锁定的、物理存储于modcache中的源码快照。任何偏离该契约的行为——无论是手动修改go.mod、篡改缓存文件,还是忽略校验和验证——都将破坏构建的可重现性根基。
第二章:解析v0.0.0-00010101000000-000000000000语义的三大核心机制
2.1 Go伪版本(Pseudo-version)生成规则的源码级推演(go mod tidy源码追踪)
伪版本形如 v0.0.0-20230415123456-abcdef123456,由三段构成:基础前缀、时间戳、提交哈希。
生成时机
当模块未打语义化标签时,go mod tidy 调用 module.PseudoVersion(位于 cmd/go/internal/mvs/repo.go)动态构造。
// cmd/go/internal/mvs/repo.go#L127
func PseudoVersion(time time.Time, hash string) string {
base := "v0.0.0"
ts := time.Format("20060102150405") // UTC时间,精确到秒
return fmt.Sprintf("%s-%s-%s", base, ts, strings.ToLower(hash[:12]))
}
time来自 Git 提交的committer date(非author date);hash截取git rev-parse HEAD的前12位小写十六进制字符,确保可重现且短于完整 SHA-1。
时间戳约束
| 字段 | 来源 | 格式要求 |
|---|---|---|
| 基础前缀 | 固定字符串 | "v0.0.0" |
| 时间戳 | Git committer timestamp | YYYYMMDDHHMMSS,UTC,无时区偏移 |
| 提交哈希 | git rev-parse --short=12 HEAD |
小写,12位 |
graph TD
A[go mod tidy] --> B[resolveMissingModule]
B --> C[repo.PseudoVersion]
C --> D[Git committer time]
C --> E[Git short hash]
D & E --> F[v0.0.0-YmdHMS-hash]
2.2 go.sum文件中校验和与commit哈希的双向映射实践(基于crypto/sha256与module/graph实现)
Go 模块校验机制依赖 go.sum 中的 SHA-256 校验和,而 module/graph 内部通过 crypto/sha256 实现模块内容指纹与 Git commit 哈希的隐式关联。
校验和生成逻辑
// 伪代码:go mod download 后计算 module zip 内容哈希
h := sha256.New()
io.WriteString(h, "github.com/example/lib v1.2.3\n")
io.WriteString(h, "h1:abc123...") // 实际为 zip 文件解压后 go.mod + .go 文件的归一化内容哈希
sum := hex.EncodeToString(h.Sum(nil))
该哈希不直接包含 commit ID,但因 Go 工具链强制要求 go.mod 文件在每次 commit 中更新 // indirect 或版本号,使 sum 与 commit 形成弱一致性映射。
双向映射验证流程
graph TD
A[go.sum 中 h1:xxx] --> B[解包 module zip]
B --> C[提取 go.mod + 所有 .go 文件]
C --> D[归一化格式:trim space, sort imports]
D --> E[sha256.Sum256]
E --> F[比对校验和]
| 映射方向 | 依据 | 可靠性 |
|---|---|---|
| commit → sum | go mod download 时固化 |
强 |
| sum → commit | 需结合 git log -S 搜索 |
弱 |
go mod verify仅校验sum,不反查 commit;- 真实双向需配合
vcs.Repo接口调用RevByHash(需.git元数据)。
2.3 GOPROXY协议下fetcher如何解析并还原未打tag分支的真实commit(proxy/cache/fetch.go实操分析)
核心挑战:无tag分支的commit定位
GOPROXY在响应/@v/list或/@v/{version}.info时,对未打tag的分支(如v0.0.0-20240515123456-abcdef123456)仅返回伪版本号,真实commit需从/@v/{version}.mod或/@v/{version}.info反向解析。
解析流程关键步骤
- 请求
/@v/v0.0.0-20240515123456-abcdef123456.info获取JSON元数据 - 提取
Version字段中的commit哈希(末尾12位或完整40位) - 验证
Time字段与vcs.Repo的commit时间一致性
// proxy/cache/fetch.go#L187-L195
func parsePseudoVersion(v string) (commit string, time time.Time, ok bool) {
if !strings.HasPrefix(v, "v0.0.0-") {
return "", time.Time{}, false
}
parts := strings.Split(v, "-")
if len(parts) < 3 { return }
commit = parts[2] // e.g., "abcdef123456"
t, _ := time.Parse("20060102150405", parts[1])
return commit, t, true
}
parts[2]即伪版本中嵌入的短commit(可能截断),parts[1]为ISO8601格式时间戳;实际fetcher会调用vcs.Repo.ResolveRevision(commit)校验其是否存在于远程仓库。
数据同步机制
| 输入伪版本 | 提取commit | 远程验证 | 最终commit |
|---|---|---|---|
v0.0.0-20240515123456-abc123 |
abc123 |
✅ 存在且时间匹配 | abc123def456... |
graph TD
A[收到伪版本 v0.0.0-TIME-COMMIT] --> B[拆分TIME/CMT]
B --> C[构造commit ref]
C --> D[调用vcs.Repo.ResolveRevision]
D --> E[返回完整40位SHA]
2.4 go list -m -json与go mod download输出差异背后的module.Graph构建逻辑(modload/load.go关键路径剖析)
go list -m -json 仅构建模块元数据图(module.Graph 的轻量视图),跳过依赖下载与校验,直接从 go.mod 和本地缓存读取 Module 结构体并序列化。
go mod download 则触发完整 module.Graph 构建流程:调用 modload.LoadGraph → loadFromRoots → loadAllPackages,递归解析 require、replace、exclude 并校验 checksum。
关键路径对比
| 阶段 | go list -m -json |
go mod download |
|---|---|---|
| 图构建入口 | modload.LoadModFile(仅解析) |
modload.LoadGraph(全量加载) |
| 网络请求 | ❌ 无 | ✅ fetch + verify |
module.Graph 状态 |
graph.nodes 仅含声明模块 |
graph.nodes 含 transitive closure |
// modload/load.go 核心分支逻辑(简化)
func LoadGraph() (*module.Graph, error) {
g := new(module.Graph)
// ↓ 此处决定是否递归 resolve
if !flagSkipDeps { // go mod download 设为 false
loadAllPackages(g, roots) // 触发 module.Tree walk
} else { // go list -m -json 实际走此路径
loadDeclaredModules(g) // 仅填充 go.mod 中显式声明项
}
return g, nil
}
该分支由
modload.skipDeps控制,其值取决于命令上下文(listvsdownload)及-mod=模式。
graph TD
A[命令入口] --> B{isListMJson?}
B -->|Yes| C[LoadModFile → Graph.nodes = declared]
B -->|No| D[LoadGraph → loadAllPackages → full resolve]
C --> E[JSON 输出: Name, Version, Replace]
D --> F[Download: zip + sumdb verify]
2.5 本地vendor与replace指令对伪版本解析路径的干扰验证(vendor/modules.txt与replace语句的优先级实验)
Go 模块解析时,vendor/ 目录与 replace 指令可能产生冲突。关键在于二者生效顺序:replace 优先于 vendor/modules.txt 中记录的伪版本。
实验环境准备
# 初始化模块并 vendor 依赖
go mod init example.com/test
go get github.com/gorilla/mux@v1.8.0
go mod vendor
# 此时 vendor/modules.txt 记录伪版本 v1.8.0.00000000000000
replace 覆盖行为验证
// go.mod 中添加:
replace github.com/gorilla/mux => ./local-mux
replace在go build前即介入模块图构建,完全绕过 vendor 目录及其中的伪版本解析逻辑。vendor/modules.txt仅用于go build -mod=vendor场景,而replace始终生效。
优先级对比表
| 机制 | 生效阶段 | 是否影响伪版本解析 | 是否受 -mod=vendor 影响 |
|---|---|---|---|
replace |
go list 阶段 |
✅ 跳过版本计算 | ❌ 不受影响 |
vendor/modules.txt |
go build -mod=vendor 时 |
❌ 仅记录已 vendored 版本 | ✅ 仅在此模式下启用 |
解析路径决策流程
graph TD
A[解析 import path] --> B{replace 存在?}
B -->|是| C[直接映射到 replacement target]
B -->|否| D{go build -mod=vendor?}
D -->|是| E[读取 vendor/modules.txt]
D -->|否| F[按 go.sum + proxy 解析伪版本]
第三章:三步定位法的工程化落地与边界条件应对
3.1 第一步:从go.mod提取module path与pseudo-version的正则解析与结构化解析(modfile.Read+semver.Parse)
Go 模块元数据解析需兼顾语义正确性与格式鲁棒性。modfile.Read 提供 AST 级别解析能力,而 semver.Parse 负责校验并结构化伪版本(如 v0.0.0-20230101120000-deadbeef1234)。
解析流程概览
data, _ := os.ReadFile("go.mod")
f, _ := modfile.Parse("go.mod", data, nil)
modPath := f.Module.Mod.Path // module path(如 github.com/example/lib)
version := f.Require[0].Mod.Version // pseudo-version 字符串
parsed, _ := semver.Parse(version) // 结构化为 semver.Version
modfile.Read 返回 *modfile.File,其 Module.Mod.Path 是声明的模块路径;Require[i].Mod.Version 可能为语义版本或伪版本;semver.Parse 自动识别并拆解 Version, Prerelease, Timestamp, Commit 等字段。
伪版本结构对照表
| 字段 | 示例值 | 说明 |
|---|---|---|
| Version | v0.0.0 |
固定前缀,非真实语义版本 |
| Prerelease | 20230101120000-deadbeef1234 |
时间戳+提交哈希 |
| Timestamp | 2023-01-01T12:00:00Z |
由 semver.Parse 推导生成 |
graph TD
A[Read go.mod] --> B[modfile.Parse]
B --> C[Extract module path]
B --> D[Extract require version]
D --> E[semver.Parse]
E --> F{Is pseudo-version?}
F -->|Yes| G[Split timestamp/commit]
F -->|No| H[Standard semver handling]
3.2 第二步:通过git remote与go proxy双通道反向查询commit hash(cmd/go/internal/modfetch/proxy.go调试实战)
Go 模块解析需在无 go.mod 时回溯版本源头,proxy.go 中 RevInfo 调用链启用双通道策略:
// cmd/go/internal/modfetch/proxy.go#L127
func (p *proxy) Stat(ctx context.Context, path, version string) (*RevInfo, error) {
// 通道1:go proxy HTTP 接口(快但可能无 commit hash)
info, err := p.proxyStat(ctx, path, version)
if err == nil && info.Version != "" {
return info, nil
}
// 通道2:fallback 到 git remote ls-remote(精确但慢)
return p.gitStat(ctx, path, version)
}
逻辑分析:version 可为语义化标签(如 v1.2.3)或 latest;proxyStat 优先查 /@v/list 和 /@v/v1.2.3.info;若缺失 Rev 字段,则触发 gitStat 执行 git ls-remote --refs <repo> refs/tags/v1.2.3。
数据同步机制
| 通道 | 响应延迟 | 精确性 | 依赖条件 |
|---|---|---|---|
| Go Proxy | ~50ms | ⚠️ 可能映射错误 | proxy 服务缓存一致性 |
| Git Remote | ~300ms | ✅ 原始 commit hash | 网络可达 + git 协议支持 |
调试关键路径
- 设置
GOPROXY=https://proxy.golang.org,direct强制双通道 - 在
p.gitStat中插入log.Printf("git query: %s %s", repo, rev)观察 fallback 触发时机
3.3 第三步:利用go mod verify与git cat-file -p交叉验证源码一致性(modfetch/verify.go与git plumbing命令联动)
Go 模块校验链的可信锚点在于双重哈希比对:go mod verify 计算模块 ZIP 内容的 sum.golang.org 签名哈希,而 git cat-file -p 提取 commit 对象的原始树结构。
验证流程示意
graph TD
A[go mod download] --> B[生成 go.sum 条目]
B --> C[go mod verify 校验 checksum]
C --> D[解析 module.zip 中 go.mod 的 vcs info]
D --> E[git cat-file -p <commit> | grep tree]
关键命令联动
# 从 go.sum 提取 commit hash(如 github.com/foo/bar@v1.2.3 => h1:abc123...)
go mod download -json github.com/foo/bar@v1.2.3 | jq -r '.Version'
# 获取对应 commit 的 tree hash
git cat-file -p abc123... | head -n1 | cut -d' ' -f2
该命令提取 Git 对象中 tree 行的 SHA-1 值,与 modfetch/verify.go 中 vcs.Repo.TreeHash() 返回值比对,确保归档 ZIP 解压后目录结构与 Git 树完全一致。
校验字段对照表
| 字段来源 | 字段名 | 用途 |
|---|---|---|
go.sum |
h1:<hash> |
ZIP 内容 SHA256 校验和 |
git cat-file -p |
tree <hash> |
源码树根节点 SHA1 |
verify.go |
ModFile.Hash |
go.mod 文件独立哈希 |
第四章:典型场景下的溯源失效归因与修复策略
4.1 私有仓库无GOPROXY支持时的git ls-remote fallback方案(modfetch/zip.go中fetchByHash的定制改造)
当私有 Git 仓库未被 GOPROXY 缓存且不支持 go mod download 直接拉取时,Go 的模块获取逻辑会退回到 git ls-remote 探测 commit hash,再触发 fetchByHash 下载 ZIP 包。
fallback 触发条件
GOPROXY=off或私有域名不在代理白名单中go list -m -json无法解析版本 → 进入modfetch/zip.go#fetchByHash
关键代码改造点
// modfetch/zip.go 修改片段(fetchByHash)
func (f *Fetcher) fetchByHash(ctx context.Context, mod module.Version, hash string) (io.ReadCloser, error) {
cmd := exec.CommandContext(ctx, "git", "ls-remote", f.repoURL, hash)
stdout, err := cmd.Output() // ← 原逻辑仅校验 exit code,现增强解析 stdout 提取真实 ref
if err != nil || len(stdout) == 0 {
return nil, fmt.Errorf("no matching ref for %s", hash)
}
ref := strings.Fields(string(stdout))[0] // 提取 SHA
return f.fetchZip(ctx, mod, ref) // 改为用解析出的 ref 构造 /archive/ URL
}
逻辑分析:原生
fetchByHash直接拼接/archive/{hash}.zip,但私有 Git(如 Gitea/GitLab)常需先通过ls-remote确认 ref 是否存在并标准化(如refs/heads/main→main),否则返回 404。此改造将探测与下载解耦,提升容错性。
支持的私有 Git 类型对比
| 平台 | 原生 /archive/{hash} |
ls-remote fallback 后 |
|---|---|---|
| GitHub | ✅ | ✅ |
| GitLab | ❌(需 refs/heads/ 前缀) |
✅ |
| Gitea | ❌(仅支持 tag/branch 名) | ✅ |
graph TD
A[fetchByHash called] --> B{git ls-remote succeeds?}
B -->|Yes| C[Parse SHA from stdout]
B -->|No| D[Return explicit error]
C --> E[Construct /archive/{sha}.zip]
E --> F[HTTP GET with auth]
4.2 submodule嵌套导致的commit hash歧义问题(gitmodules解析与go mod graph递归依赖图可视化)
当 submodule A 嵌套引用 submodule B,而 B 又被主仓库直接声明时,同一 commit hash 可能对应不同语义上下文——源于 .gitmodules 中路径解析的相对性与 git rev-parse 作用域隔离。
gitmodules 解析歧义示例
# .gitmodules 中的嵌套声明(相对路径易混淆)
[submodule "vendor/libB"]
path = vendor/libB
url = https://github.com/org/libB.git
# 注意:此路径在 submodule A 内解析时,实际为 A/vendor/libB
该路径未绑定 owner repo,git submodule update --init 在不同父模块中解析出不同工作树位置,导致 git log -1 输出相同 hash 却指向不同 tree。
go mod graph 的递归依赖可视化
graph TD
Main -->|v1.2.0| SubA
SubA -->|v0.5.0| SubB
Main -->|v0.4.9| SubB
| 场景 | commit hash | 实际归属 | 风险 |
|---|---|---|---|
| 主仓库直引 SubB | a1b2c3d |
main→SubB |
构建可复现 |
| SubA 内嵌 SubB | a1b2c3d |
SubA→SubB |
潜在 patch 差异 |
使用 go mod graph | grep SubB 可定位多路径引入,辅以 git ls-tree -r <hash> 校验 tree id 一致性。
4.3 go.work多模块工作区中伪版本跨workspace解析异常(workload.Load与modload.InitModules协同机制)
问题根源:伪版本解析时的模块路径隔离
go.work 定义的多模块工作区中,v0.0.0-yyyymmddhhmmss-commit 类伪版本在跨 workspace 模块引用时,因 modload.InitModules 未同步加载 workload.Load 所构建的 *WorkFile 上下文,导致 module.Version 解析失败。
协同机制断点
// modload/init.go 中关键逻辑片段
func InitModules() {
if !workload.IsLoaded() { // ❌ 未检查 workfile 是否已初始化
loadFromGoWork() // 实际应由 workload.Load 驱动
}
// 后续调用 modfetch.GetMod 时,baseDir 仍为单模块根目录
}
此处
InitModules独立执行,忽略workload.Load已注册的dir → modulePath映射表,致使伪版本解析使用错误GOPATH或默认GOROOT路径。
修复路径依赖关系
| 阶段 | 调用方 | 依赖状态 | 影响 |
|---|---|---|---|
workload.Load() |
go run / go list 入口 |
✅ 注册 workspace 模块树 | 提供 WorkModule 列表 |
modload.InitModules() |
loadPackage 前置 |
❌ 未获取 workload.Modules() |
伪版本 resolve 失败 |
核心流程修复示意
graph TD
A[workload.Load] --> B[注册 workfile.Modules]
B --> C[modload.InitModules]
C --> D{IsWorkMode?}
D -->|Yes| E[use workload.Modules for lookup]
D -->|No| F[fallback to single-module logic]
4.4 CI环境缓存污染引发的commit误判(GOCACHE与GOPATH/pkg/mod/cache/vcs状态清理脚本编写)
CI流水线中,GOCACHE 和 GOPATH/pkg/mod/cache/vcs 的残留状态常导致 go build 或 go mod download 误判本地变更,触发冗余构建或跳过必要编译。
缓存污染典型表现
go list -m all输出包含未提交的 dirty commit hashgit status干净但go mod graph显示非官方版本
清理脚本核心逻辑
#!/bin/bash
# 清理Go构建缓存与VCS元数据,保留模块下载包(避免重复拉取)
rm -rf "$GOCACHE"
rm -rf "$GOPATH/pkg/mod/cache/vcs" # 注意:不删 pkg/mod 下的modules,仅清vcs工作区
GOCACHE存储编译中间产物(.a文件、汇编缓存),污染后会复用旧符号表;vcs目录缓存Git clone镜像及HEAD引用,若CI worker复用容器,其内部vcs/xxx/info/refs可能指向已失效分支。
推荐CI执行策略
| 步骤 | 命令 | 说明 |
|---|---|---|
| 1. 清理 | go clean -cache -modcache |
安全但较慢,会清空整个pkg/mod |
| 2. 精准清理 | rm -rf $GOPATH/pkg/mod/cache/vcs |
更快,仅重置VCS状态,保留依赖包 |
graph TD
A[CI Job Start] --> B{vcs cache exists?}
B -->|Yes| C[Read stale HEAD ref]
C --> D[go mod download picks wrong rev]
B -->|No| E[Clone fresh repo]
第五章:从溯源到治理——构建可持续的Go模块依赖可信体系
依赖溯源:从 go.sum 到 SBOM 的完整证据链
Go 模块系统天然通过 go.sum 文件记录每个依赖模块的校验和,但该文件仅验证完整性,不提供作者身份、构建环境或供应链上下文。在某金融级 API 网关项目中,团队扩展了 go mod graph 与自研工具 gomod-provenance,自动提取每个 v0.12.3+incompatible 模块的 Git commit hash、签名证书(来自 Sigstore Fulcio)、CI 构建日志 URL,并聚合生成 SPDX 2.3 格式 SBOM。该 SBOM 被嵌入容器镜像 OCI 注解,供 Clair 和 Trivy 扫描器实时校验。
自动化依赖准入:基于策略的 CI 拦截机制
以下为某电商中台在 GitHub Actions 中启用的依赖准入检查逻辑片段:
- name: Enforce module provenance policy
run: |
go run ./cmd/dep-check \
--policy ./policies/strict.yaml \
--sbom ./dist/sbom.spdx.json \
--fail-on=untrusted-author,missing-signature,transitive-vuln-cvss>7.0
策略文件 strict.yaml 明确禁止使用 github.com/*/*-dev 类非正式发布分支,且要求所有直接依赖必须由 CNCF 签名服务认证。
依赖拓扑可视化与风险热力图
使用 Mermaid 渲染核心服务的模块依赖网络,突出高风险路径:
graph LR
A[auth-service] --> B[golang.org/x/crypto@v0.17.0]
A --> C[github.com/gorilla/mux@v1.8.0]
C --> D[github.com/gorilla/context@v1.1.1]:::deprecated
B --> E[golang.org/x/sys@v0.15.0]:::critical
classDef deprecated fill:#ffebee,stroke:#f44336;
classDef critical fill:#fff3cd,stroke:#ffc107;
该图集成至内部 DevOps 门户,点击节点可跳转至 Snyk 报告与补丁建议。
持续治理:模块版本冻结与灰度升级流水线
团队建立“三周冻结期”机制:新引入模块需经安全团队人工复核并签署 MODULE_APPROVAL.md,方可进入 go.mod。升级流程强制走灰度通道——先在非生产流量网关(处理 5% 支付回调)中部署 72 小时,监控 p99 延迟波动 >15ms 或 panic 率突增即自动回滚。历史数据显示,该机制使因 golang.org/x/net 升级引发的 DNS 解析超时事故下降 100%。
可信仓库镜像联邦体系
| 公司内部部署 Go Proxy 集群,采用分层缓存架构: | 层级 | 地址 | 同步源 | 审计频率 |
|---|---|---|---|---|
| L1(全球) | proxy.global.corp | proxy.golang.org + verified-mirror.cn | 实时哈希比对 | |
| L2(区域) | proxy.shanghai.corp | L1 + 本地白名单仓库 | 每 4 小时全量校验 | |
| L3(离线) | proxy.airgap.corp | 离线 USB 同步包 + 签名验证 | 每次发布前人工触发 |
所有代理节点均启用 GOPROXY=proxy.shanghai.corp,direct 并强制 GOSUMDB=sum.golang.org+local,确保校验数据库不可绕过。
开发者自助合规看板
前端看板实时展示每位开发者提交的 go.mod 变更中:未签名模块数量、CVSS≥9.0 的间接依赖路径数、超过 18 个月未更新的模块列表。2024 年 Q2 数据显示,87% 的 PR 在提交后 22 分钟内收到自动化合规建议,平均修复耗时缩短至 4.3 小时。
