Go 里的 IO 应该怎么管理

写 Go 代码时,很容易随手把具体 IO 写进业务逻辑里:

go 复制代码
fmt.Println("hello")
fmt.Fprintln(os.Stderr, "something went wrong")
fmt.Scanln(&name)

或者:

go 复制代码
data, err := os.ReadFile("input.json")
if err != nil {
    return err
}

return os.WriteFile("output.json", data, 0644)

小脚本这样写没什么问题。但只要代码开始变得正式一点,比如要测试、要复用、要换输入来源、要把输出写到 HTTP response、文件、buffer 或标准输出,这些散落在各处的具体 IO 调用就会慢慢变成负担。

更推荐的做法是:在入口处统一接入具体 IO,业务层只依赖 io.Reader / io.Writer 这类接口,不要在业务逻辑里直接硬编码 os.Stdinos.Stdoutos.Stderr 或某个具体文件路径。


先依赖接口

Go 标准库已经把 IO 抽象得很好了。很多时候,函数不需要知道数据来自哪里,也不需要知道结果要写到哪里。

它只需要一个 reader 和一个 writer:

go 复制代码
func Convert(r io.Reader, w io.Writer) error {
    data, err := io.ReadAll(r)
    if err != nil {
        return err
    }

    _, err = fmt.Fprintf(w, "converted: %s\n", data)
    return err
}

调用方可以把任何实现了接口的对象传进去:

go 复制代码
Convert(os.Stdin, os.Stdout)
Convert(file, os.Stdout)
Convert(strings.NewReader("hello"), &bytes.Buffer{})
Convert(req.Body, resp)

这就是 Go 里很自然的依赖注入:不用引入框架,也不用抽象出很重的 service,只是让函数依赖它真正需要的能力。


CLI 是一个典型场景

在 CLI 里,通常会有三类 IO:

go 复制代码
In     io.Reader // 输入,比如 os.Stdin
Out    io.Writer // 正常输出,比如 os.Stdout
ErrOut io.Writer // 错误输出,比如 os.Stderr

可以把它们封装成一个结构:

go 复制代码
type IOStreams struct {
    In     io.Reader
    Out    io.Writer
    ErrOut io.Writer
}

然后提供一个默认实例:

go 复制代码
func DefaultIOStreams() IOStreams {
    return IOStreams{
        In:     os.Stdin,
        Out:    os.Stdout,
        ErrOut: os.Stderr,
    }
}

注意这里不是全局变量,而是一个函数。每次创建命令时,都可以显式传入一组 IO。


Cobra 里怎么用

如果使用 Cobra,可以让 NewRootCmd 接收 IOStreams

go 复制代码
func NewRootCmd(ioStreams IOStreams) *cobra.Command {
    cmd := &cobra.Command{
        Use: "mycli",
        RunE: func(cmd *cobra.Command, args []string) error {
            fmt.Fprintln(ioStreams.Out, "hello cli")
            return nil
        },
    }

    return cmd
}

入口里只负责传入真实的标准输入输出:

go 复制代码
func main() {
    cmd := NewRootCmd(DefaultIOStreams())

    if err := cmd.Execute(); err != nil {
        os.Exit(1)
    }
}

这样一来,命令的构造逻辑和具体的运行环境就解耦了。生产环境用 os.Stdinos.Stdoutos.Stderr,测试环境用 strings.Readerbytes.Buffer


为什么要这样做

方便测试

如果业务代码里到处直接写 fmt.Printlnfmt.Scanlnfmt.Fprintln(os.Stderr, ...),测试会很麻烦。

你很难捕获输出,也很难模拟输入。

统一注入之后,测试可以这样写:

go 复制代码
func TestRootCmd(t *testing.T) {
    in := strings.NewReader("yes\n")
    out := &bytes.Buffer{}
    errOut := &bytes.Buffer{}

    cmd := NewRootCmd(IOStreams{
        In:     in,
        Out:    out,
        ErrOut: errOut,
    })

    if err := cmd.Execute(); err != nil {
        t.Fatal(err)
    }

    if !strings.Contains(out.String(), "hello") {
        t.Fatal("expected hello")
    }
}

这时候 CLI 的输入输出都变成了普通对象,测试可以直接断言。

如果命令需要读取用户输入,也不要直接读 os.Stdin

go 复制代码
scanner := bufio.NewScanner(ioStreams.In)
if scanner.Scan() {
    name := scanner.Text()
    fmt.Fprintf(ioStreams.Out, "hello %s\n", name)
}

测试时只要换一个 reader 就行。

同样的思路也适用于普通业务函数:

go 复制代码
func TestConvert(t *testing.T) {
    in := strings.NewReader("hello")
    out := &bytes.Buffer{}

    if err := Convert(in, out); err != nil {
        t.Fatal(err)
    }

    if !strings.Contains(out.String(), "converted: hello") {
        t.Fatal("expected converted output")
    }
}

这里没有临时文件,没有真实终端,也不需要改全局变量。

方便区分 stdout 和 stderr

命令行工具应该尊重 stdout 和 stderr 的语义。

正常结果走 stdout:

go 复制代码
fmt.Fprintln(ioStreams.Out, "正常结果")

错误信息走 stderr:

go 复制代码
fmt.Fprintln(ioStreams.ErrOut, "错误信息")

这样用户才能正确重定向:

sh 复制代码
mycli list > result.txt
mycli list 2> error.log

如果所有内容都用 fmt.Println,就会把正常结果、警告、错误日志混在一起。对人类用户不友好,对脚本也不友好。

方便复用和扩展

当 IO 被抽象成接口之后,同一段逻辑可以被放到更多地方:

  • CLI 命令
  • HTTP handler
  • 后台任务
  • 单元测试
  • 文件导入导出
  • 内存 buffer

比如导出逻辑可以这样写:

go 复制代码
func ExportUsers(ctx context.Context, w io.Writer, users []User) error {
    enc := json.NewEncoder(w)
    enc.SetIndent("", "  ")
    return enc.Encode(users)
}

CLI 里写到 stdout:

go 复制代码
return ExportUsers(cmd.Context(), ioStreams.Out, users)

HTTP 里写到 response:

go 复制代码
w.Header().Set("Content-Type", "application/json")
return ExportUsers(r.Context(), w, users)

测试里写到 buffer:

go 复制代码
buf := &bytes.Buffer{}
err := ExportUsers(context.Background(), buf, users)

业务函数不需要知道自己运行在 CLI、HTTP 还是测试里。

另外,CLI 后面经常会加一些输出相关的能力:

  • --json
  • --quiet
  • --verbose
  • --no-color
  • 输出到文件
  • 交互式确认

如果 IO 调用散落在各处,每加一个选项都要到处改。

统一成 IOStreams 以后,可以把输出策略集中起来。命令和业务代码只需要知道"往哪里写",而不用关心最终是写到终端、文件、buffer,还是被上层包装过的 writer。


不建议做成全局变量

虽然我们希望统一管理 IO,但不太建议这样写:

go 复制代码
var Stdout = os.Stdout
var Stderr = os.Stderr
var Stdin = os.Stdin

也不太建议这样:

go 复制代码
var IO = IOStreams{
    In:     os.Stdin,
    Out:    os.Stdout,
    ErrOut: os.Stderr,
}

全局变量看起来方便,但会带来几个问题:

  • 测试之间可能互相影响
  • 并发执行多个命令实例时不清晰
  • 调用方很难知道命令到底依赖了哪些外部资源
  • 后续做嵌入式调用、集成测试、批量执行时会更别扭

更好的方式是显式依赖。CLI 里可以这样:

go 复制代码
func NewRootCmd(ioStreams IOStreams) *cobra.Command

普通业务函数里可以这样:

go 复制代码
func ImportUsers(ctx context.Context, r io.Reader) ([]User, error)
func ExportUsers(ctx context.Context, w io.Writer, users []User) error

谁调用这段逻辑,谁决定这次要接入哪一组 IO。

这也是依赖注入在 Go 里的一个很自然的用法:不是为了搞复杂架构,而是为了让依赖关系清楚、可替换、可测试。


业务层只依赖接口

再往下一层,业务代码也不应该直接依赖 Cobra。

比如一个导出函数,可以这样设计:

go 复制代码
type ExportOptions struct {
    Format string
}

func RunExport(ctx context.Context, out io.Writer, opts ExportOptions) error {
    switch opts.Format {
    case "json":
        return json.NewEncoder(out).Encode(loadData(ctx))
    default:
        _, err := fmt.Fprintln(out, "plain text result")
        return err
    }
}

Cobra 命令只负责解析参数,然后把 writer 传进去:

go 复制代码
func NewExportCmd(ioStreams IOStreams) *cobra.Command {
    var format string

    cmd := &cobra.Command{
        Use: "export",
        RunE: func(cmd *cobra.Command, args []string) error {
            return RunExport(cmd.Context(), ioStreams.Out, ExportOptions{
                Format: format,
            })
        },
    }

    cmd.Flags().StringVar(&format, "format", "text", "输出格式")
    return cmd
}

这样业务层没有 os.Stdout,也没有 *cobra.Command。它只知道自己要把结果写到一个 io.Writer 里。

这类代码通常更好测,也更容易复用。

同理,业务层也尽量不要随手打开固定路径:

go 复制代码
func LoadConfig(r io.Reader) (Config, error) {
    var cfg Config
    err := json.NewDecoder(r).Decode(&cfg)
    return cfg, err
}

文件路径可以留给入口层处理:

go 复制代码
func main() {
    f, err := os.Open("config.json")
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close()

    cfg, err := LoadConfig(f)
    if err != nil {
        log.Fatal(err)
    }

    _ = cfg
}

这样 LoadConfig 就既能读文件,也能读内存字符串、HTTP body、embed 文件,或者测试里的 buffer。


一个简单判断标准

可以按这个尺度判断要不要抽象 IO:

场景 建议
很小的一次性脚本 直接用 os.ReadFilefmt.Println 也可以
普通可复用函数 优先依赖 io.Reader / io.Writer
需要测试 强烈建议依赖接口
正式 CLI 工具 推荐 IOStreams + NewRootCmd(ioStreams)
HTTP、文件、CLI 共用一段逻辑 必须把 IO 从业务逻辑里拆出来
有 JSON、静默模式、错误输出 CLI 里必须区分 OutErrOut

一句话总结:

Go 里的 IO 不一定要全局统一,但最好在入口处接入具体资源,在业务层依赖 io.Reader / io.Writer 这类接口。CLI 只是其中一个典型场景。

相关推荐
喵个咪1 小时前
Go-Wind HTTP 服务器从入门到精通
后端·http·go
喵个咪1 小时前
Go-Wind gRPC 服务器从入门到精通
后端·go·grpc
知恒3 小时前
Go环境搭建与入门
go
用户6757049885021 天前
你知道 Go 结构体和结构体指针调用的区别吗?一文带你彻底搞懂!
后端·go
唐青枫1 天前
别把泛型写复杂了:Go generic 从类型参数到实战封装
go
GetcharZp2 天前
告别OOM!用Go+libvips实现30000×50000超大图片的流式瓦片服务
后端·go
妙码生花5 天前
从 PHP 到 AI + Golang,程序员自救转型手记(八):设计管理员模型、热重载配置
前端·后端·go
tyung6 天前
Go 手写 Wait-Free MPSC 无界队列:SwapPointer 实现多生产者无锁入队
后端·go
陈明勇6 天前
Go 1.26 新特性回顾:语言增强、工具升级与 Green Tea GC 默认启用
后端·go