EN

输入与输出

理解 stdin、stdout 与 stderr 的职责,正确处理管道、诊断信息、退出码和自动化测试。

更新于

CLI 不只是在终端里打印文字。它通常处在 shell 管道中,上一个程序的输出可能成为它的输入,它的结果也可能交给文件、另一个程序或测试代码。stdin、stdout 和 stderr 是常见的三个标准数据流。

数据流方向func 默认值主要职责
stdin外部 → CLIprocess.stdin管道数据、重定向文件或交互输入。
stdoutCLI → 外部process.stdout命令的正常结果,以及可供后续程序消费的数据。
stderrCLI → 外部process.stderr错误、警告、进度和其他诊断信息。

位置参数与 stdin 也不是同一种输入。ship show alice 中的 alice 是较短、参与命令语法的位置参数,通过 @Args().inputs 读取;cat users.json | ship import 中的 JSON 是内容流,通过 Context.io.stdin 读取。

区分 stdout 与 stderr

如果最终命令结果中需要包含的内容,应该进入 stdout,帮助用户理解执行过程、但不属于结果本身的写入 stderr。这只是约定,不是绝对的,命令行框架不会阻止你以其他方式写入数据。

内容推荐数据流原因
文本结果、表格、JSON、生成内容stdout可以安全地重定向到文件或交给下一个程序。
错误详情、警告、废弃提示stderr不会污染供机器读取的正常结果。
spinner、下载进度、调试诊断stderr属于执行过程,不应成为管道数据。
提示符与交互反馈stderr保持 stdout 可用于最终结果;具体库可能另有约定。

例如下方正确分流后,警告和进度不会破坏 JSON 输出。

  • ship inspect --json > result.json 只把 stdout 写入文件,stderr 仍显示在终端;
  • ship inspect --json 2> diagnostic.log 则单独保存诊断信息。
  • ship inspect --json | jq '.status' 只会把 stdout 送给 jq

写入 stderr 不会自动让调用失败,写入 stdout 也不代表一定成功。命令是否成功由 Context.exitCode 和错误处理结果表达:默认退出码为 0,未处理异常默认为 1,应用也可以显式设置 0255 的整数。

为什么不直接使用 console.log

在默认进程环境中,console.log() 最终通常也会写入 process.stdout,所以手动运行时看起来没有区别。问题在于它绕过了 func 为本次调用配置的 IO:

  • 副作用:app.bootstrap()invoker.invoke() 可以为一次调用替换 stdout 和 stderr,console.log() 仍使用全局 console。
  • 测试兼容:func/testing 捕获的是调用上下文中的 streams,直接调用 console.log() 会把内容写到测试进程,而不是 result.stdout
  • 输出稳定:console.log() 会格式化多个参数并自动追加换行;Writable.write() 输出的内容更明确,适合稳定的 CLI 文本或 JSON 协议。
  • 污染:依赖注入让业务代码不必绑定到单例进程,因此同一应用更容易嵌入其他 Node.js 程序或在内存中重复调用。

同理,业务命令也应通过注入的 stderr 代替全局 console.error()。底层库如果只返回数据或抛出错误,而把最终输出留给 Command,通常更容易复用和测试。

直接注入标准流

Command 只需要单个标准流时,使用 @Stdin()@Stdout()@Stderr() 注入本次调用的 Node.js stream:

src/commands/status.command.ts
import { Command, Handler, Stderr, Stdout } from 'func'
import type { Writable } from 'node:stream'

@Command('status')
export class StatusCommand {
  @Handler()
  run(
    @Stdout() stdout: Writable,
    @Stderr() stderr: Writable,
  ) {
    stdout.write('{"status":"ready"}\n')
    stderr.write('Cache is warming up.\n')
  }
}

这些装饰器可用于 DI 管理的构造函数和 Handler 参数。func 默认直接提供本次 invocation 配置的 stream;仅在 JSON 等调用模式需要抑制 stdout 时,才提供只管理写入的 stream。func 不会关闭底层 stream 或自动换行;需要换行时显式写入 \n,也不要对注入的 stream 调用 end()。对于少量 CLI 输出可以直接 write();持续写入大量内容时,应遵循 Node.js stream 的背压规则,或使用 stream pipeline。

持续写入大量内容

Writable.write() 返回 true 时可以继续写入;返回 false 表示内部缓冲区已达到阈值,应暂停生产数据并等待 drain。忽略这个返回值会让待写数据继续堆积在内存中。

src/shared/write-rows.ts
import { once } from 'node:events'
import type { Writable } from 'node:stream'

const writeRows = async (
  stdout: Writable,
  rows: AsyncIterable<unknown>,
) => {
  for await (const row of rows) {
    const chunk = `${JSON.stringify(row)}\n`

    if (!stdout.write(chunk)) await once(stdout, 'drain')
  }
}

逐块生成结果时使用这种写法,不要用 Promise.all() 并发写入所有 chunk。它既能限制内存占用,也能保持输出顺序;循环结束后直接返回,不要调用 stdout.end()

如果数据已经是 Readable,或者需要串联压缩、编码等 transform,优先使用 pipeline(),让 Node.js 统一处理背压和错误。不过 pipeline() 会管理传给它的 stream 生命周期:正常完成时会结束末端,失败时可能销毁参与管道的 stream。因此不要把注入的 stdout 或 stderr 直接作为管道末端,而应增加一个只转发写入、但不转发 end()destroy() 的适配层:

src/commands/download.command.ts
import { createReadStream } from 'node:fs'
import { Writable, type Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { Command, Handler, Stdout } from 'func'

const createWriteOnlyTarget = (destination: Writable) =>
  new Writable({
    write(chunk, encoding, callback) {
      destination.write(chunk, encoding, callback)
    },
  })

const pipeToStdout = async (source: Readable, stdout: Writable) => {
  await pipeline(source, createWriteOnlyTarget(stdout))
}

@Command('download')
export class DownloadCommand {
  @Handler()
  async run(@Stdout() stdout: Writable) {
    const source = createReadStream('large-result.ndjson')
    await pipeToStdout(source, stdout)
  }
}

pipeline() 可以安全地结束或销毁这个临时 Writable,底层注入流仍由 invocation 宿主拥有。适配层通过底层 write() 的 callback 推进上游,因此仍会施加背压,并把异步写入错误交给 pipeline()

如果 Command、Module 或 Provider 同时需要输入、输出和其他运行信息,直接注入 Context,再使用 context.io.stdoutcontext.io.stderr

从 stdin 读取内容

@Stdin()Context.io.stdin 暴露同一个本次调用配置的 Node.js Readable。可以用异步迭代逐块读取:

src/commands/import.command.ts
import { Command, Handler, Stdin, Stdout } from 'func'
import type { Readable, Writable } from 'node:stream'

@Command('import')
export class ImportCommand {
  @Handler()
  async run(
    @Stdin() stdin: Readable,
    @Stdout() stdout: Writable,
  ) {
    const chunks: Buffer[] = []
    for await (const chunk of stdin) {
      chunks.push(Buffer.from(chunk))
    }

    const payload = Buffer.concat(chunks).toString('utf8')
    stdout.write(`Imported ${payload.length} bytes.\n`)
  }
}

上面的实现会一直读取到输入结束,适合 cat data.json | ship importship import < data.json。大文件不应先全部拼接到内存;应逐块解析或把 stdin 接入支持 stream 的解析器。Prompt 如何绑定渲染目标、避免非 TTY 环境等待,见交互式应用

在应用边界替换 IO

app.bootstrap(options)invoker.invoke(argv, options) 接受 stdinstdoutstderr。未传入时使用当前进程的标准流;嵌入应用、编写适配器或测试时,可以传入其他 ReadableWritable

这些 streams 由调用方拥有。func 只读取或写入它们,不负责关闭。一次调用中的 Context.io@Stdin()@Stdout()@Stderr() 始终指向同一组对象,因此 Command 与 Provider 可以遵循一致的 IO 边界。

测试输入与输出

func/testing 会为每次 invoke() 创建隔离 IO。通过 stdin 提供输入字符串,并直接断言返回结果中的 stdoutstderr

tests/io.test.ts
import { CommandMajor, Context, Handler, Module } from 'func'
import { createTestingApp } from 'func/testing'

@CommandMajor()
class EchoCommand {
  constructor(private readonly context: Context) {}

  @Handler()
  async run() {
    let input = ''
    for await (const chunk of this.context.io.stdin) {
      input += chunk.toString()
    }

    this.context.io.stdout.write(input.toUpperCase())
  }
}

@Module({ commands: [EchoCommand] })
class AppModule {}

const result = await createTestingApp(AppModule).invoke([], { stdin: 'hello' })

expect(result.stdout).toBe('HELLO')
expect(result.stderr).toBe('')
expect(result.exitCode).toBe(0)

测试时应至少覆盖正常结果、诊断信息和失败退出码。对于 JSON 输出,可以先断言 stderr 为空,再对 result.stdout 使用 JSON.parse();这样能同时验证输出格式没有被警告或调试文本污染。组件的作用域、错误响应和 ANSI 处理详见 JSON 输出

如果测试看到了终端日志,但 result.stdout 为空,通常表示代码仍在调用全局 console.log()。把 IO 改为 @Stdin()@Stdout()@Stderr()Context.io 后,生产环境替换和测试捕获才会使用同一条路径。完整测试应用和 Provider override 方式见测试