写 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.Stdin、os.Stdout、os.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.Stdin、os.Stdout、os.Stderr,测试环境用 strings.Reader 和 bytes.Buffer。
为什么要这样做
方便测试
如果业务代码里到处直接写 fmt.Println、fmt.Scanln、fmt.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.ReadFile、fmt.Println 也可以 |
| 普通可复用函数 | 优先依赖 io.Reader / io.Writer |
| 需要测试 | 强烈建议依赖接口 |
| 正式 CLI 工具 | 推荐 IOStreams + NewRootCmd(ioStreams) |
| HTTP、文件、CLI 共用一段逻辑 | 必须把 IO 从业务逻辑里拆出来 |
| 有 JSON、静默模式、错误输出 | CLI 里必须区分 Out 和 ErrOut |
一句话总结:
Go 里的 IO 不一定要全局统一,但最好在入口处接入具体资源,在业务层依赖 io.Reader / io.Writer 这类接口。CLI 只是其中一个典型场景。