首页 >  Golang >  Go教程

Go io.MultiWriter 如何同时写文件和摘要哈希

来源:17golang原创

时间:2026-09-12 09:44:20 215浏览 收藏

需要把下载流保存到文件,同时计算 SHA-256 摘要时,Go 不必先把全部内容读进内存。把文件和哈希对象交给 io.MultiWriter,再用 io.Copy 消费同一个 io.Reader 即可。不过它只负责“按顺序转发写入”,不负责回滚:某个 Writer 失败后,后面的 Writer 不会再收到当前这块数据。

要点速览
  • io.MultiWriter(file, hash) 会先写文件,再写摘要;顺序就是失败边界。
  • 返回的 nerr 必须一起判断,短写会被报告为 io.ErrShortWrite
  • 要避免半截正式文件,先写临时文件,复制成功并关闭后再 Rename

io.MultiWriter 为什么能同时写文件和摘要

io.MultiWriter 接收多个 io.Writer,返回一个新的 Writer。每次调用它的 Write,内部都会把同一个字节切片依次交给列表中的 Writer。它不是并行广播器,也不是事务容器;传入顺序会影响失败后的状态。

Go io.MultiWriter 将 Reader 的同一字节流连接到文件 Writer 与 SHA-256 Hash 的静态关系图
图1:看清 Reader、io.MultiWriter、文件 Writer 和 SHA-256 Hash 的静态连接关系。

例如文件 Writer 放在前面时,文件写入成功后才会更新哈希;文件写失败,当前这块数据不会到达哈希。若哈希放在前面,则文件失败时摘要可能已经前进。两种顺序都不能撤销已经成功的写入,所以生产代码还要有临时文件或失败清理策略。

最小实现:一份输入写入文件并计算 SHA-256

下面的函数适合已经接受“失败时删除目标文件或由调用方处理半成品”的场景。文件放在第一位,意味着文件成功接收当前数据后,摘要才接收同一块数据。

package main

import (
    "crypto/sha256"
    "fmt"
    "io"
    "os"
)

// saveAndDigest 将同一个 Reader 写入文件,同时返回完整数据的 SHA-256。
func saveAndDigest(src io.Reader, path string) (string, error) {
    // O_CREATE|O_TRUNC 确保旧文件不会和本次内容拼接;权限按业务需要调整。
    file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
    if err != nil {
        return "", fmt.Errorf("打开目标文件: %w", err)
    }
    defer file.Close() // 关闭错误在临时文件版本中单独检查。

    digest := sha256.New()
    writer := io.MultiWriter(file, digest)

    // io.Copy 返回已写入字节数和遇到的第一个错误,不能只检查字节数。
    written, err := io.Copy(writer, src)
    if err != nil {
        return "", fmt.Errorf("复制 %d 字节后失败: %w", written, err)
    }
    return fmt.Sprintf("%x", digest.Sum(nil)), nil
}

这里的“同时”是同一次复制过程中的双路写入,不代表两个目标并行执行。sha256.Hash.Write 通常不会返回错误,因此文件 Writer 的错误最常见;但自定义 Writer 仍可能短写或返回错误,不能假设所有目标都可靠。

失败时怎么判断文件和摘要是否还能配对

先看返回值,再看输出状态。io.Writer 约定当返回的 n 小于输入长度时必须同时返回非空错误;io.MultiWriter 对没有报错却短写的 Writer 会返回 io.ErrShortWrite。一旦任一 Writer 报错,当前写入停止,后面的目标不会补写。

结果应如何处理
err == nil复制完整;此时再读取 digest.Sum(nil)
err == io.ErrShortWrite视为输出不完整,删除或隔离半成品
文件写入错误摘要可能只覆盖此前已成功转发的数据,不能当作完整文件摘要
关闭文件失败仍应判定提交失败,尤其是网络盘或带缓存的文件系统

所以不要在 io.Copy 返回错误时继续把摘要写入数据库,也不要只依据文件已经存在就认为保存成功。MultiWriter 没有跨 Writer 的撤销接口。

Go 文件写入、SHA-256 摘要、关闭检查和 Rename 提交边界的静态结构图
图2:把临时文件写入、摘要计算、关闭检查与正式文件提交分成清晰边界。

生产写法:临时文件成功后再替换正式文件

如果正式路径不能出现半截内容,把 io.MultiWriter 的文件目标改成同目录临时文件。复制成功后先关闭,再调用 os.Rename;任一步失败都清理临时文件。摘要只在提交前返回,调用方就不会拿到一份未落盘完成的校验值。

func saveAtomically(src io.Reader, finalPath string) (string, error) {
    // 临时文件放在目标目录,便于 Rename 保持在同一文件系统内完成。
    dir := filepath.Dir(finalPath)
    tmp, err := os.CreateTemp(dir, ".payload-*")
    if err != nil {
        return "", fmt.Errorf("创建临时文件: %w", err)
    }
    tmpName := tmp.Name()
    committed := false
    defer func() {
        if !committed {
            _ = os.Remove(tmpName) // 失败路径只清理本次临时文件。
        }
    }()

    digest := sha256.New()
    written, err := io.Copy(io.MultiWriter(tmp, digest), src)
    if err != nil {
        _ = tmp.Close()
        return "", fmt.Errorf("写入 %d 字节后失败: %w", written, err)
    }
    // Close 失败也不能提交,避免把缓存或元数据错误隐藏起来。
    if err := tmp.Close(); err != nil {
        return "", fmt.Errorf("关闭临时文件: %w", err)
    }
    if err := os.Rename(tmpName, finalPath); err != nil {
        return "", fmt.Errorf("提交文件: %w", err)
    }
    committed = true
    return fmt.Sprintf("%x", digest.Sum(nil)), nil
}

示例还需要在 import 中加入 path/filepath。同一目录是为了降低跨文件系统重命名失败的概率;若业务还要求断电后的持久性,可在提交策略中加入 Sync,但这属于存储可靠性设计,不是 MultiWriter 自动提供的能力。

上线前的检查清单

  • 确认输入 Reader 只消费一次,文件 Writer 与哈希 Writer 接收的是同一份字节流。
  • 同时判断 writtenerr,并把短写当成失败。
  • 失败后删除临时文件;不要把失败时的中间摘要当成最终摘要。
  • 提交前检查 Close,提交后再持久化摘要和文件元数据。
  • 并发写同一目标时,在外层增加锁或版本控制; MultiWriter 本身不提供业务级并发协调。

常见问题

io.MultiWriter 会并行写多个目标吗?

不会。标准库按传入顺序逐个调用 Writer;需要并行时必须自行设计并发、背压和错误汇总。

文件成功但哈希失败会怎样?

文件可能已经包含当前数据,而摘要没有同步更新。遇到这种自定义哈希 Writer 错误,应把本次结果视为失败并清理或隔离文件。

能不能用 bytes.Buffer 先缓存来避免半文件?

小数据可以,但大文件会增加内存占用。更稳妥的通用方案是临时文件写入完成后再重命名。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>