如何在 Go 中与外部交互式命令进行实时双向通信

本文介绍如何在 go 程序中启动外部交互式进程(如 `rm -i`),并实时读取其提示信息、写入用户响应,实现真正的终端级交互,而非仅捕获一次性输出。核心在于正确管理标准输入/输出管道、避免使用阻塞式 `combinedoutput`,并灵活处理非换行终止的提示文本。

在 Go 中调用外部命令时,exec.Command 默认提供的是单向、批处理式交互(如 Output() 或 CombinedOutput()),适用于无需用户干预的场景。但当目标程序(如 rm -i、gpg --sign、ssh 交互式会话等)需要实时响应输入(例如确认提示 "Remove file 'somefile.txt'?")时,必须建立双向流式管道(stdin/stderr),并手动控制读写时序。

✅ 正确做法:显式管理 StdinPipe 和 StderrPipe

rm -i 将提示信息输出到 stderr(而非 stdout),因此需调用 cmd.StderrPipe() 获取读取端;同时通过 cmd.StdinPipe() 获取写入端,向进程发送响应(如 "y\n")。关键点如下:

  • ❌ 禁用 CombinedOutput():它内部自动等待进程结束并一次性读取全部输出,无法在运行中写入 stdin;
  • ✅ 必须调用 cmd.Start() 启动进程(非 cmd.Run()),才能在子进程运行期间持续读写;
  • ✅ 使用 bufio.NewReader 或自定义 bufio.Scanner.SplitFunc 处理无 \n 结尾的提示(如 rm 的问号提示常不带换行,或末尾无 \n)。

? 示例一:基础 ReadLine() 方案(适合简单换行提示)

package main

import (
    "bufio"
    "log"
    "os/exec"
)

func main() {
    cmd := exec.Command("rm", "-i", "somefile.txt")

    // rm 的提示写入 stderr
    stderr, err := cmd.StderrPipe()
    if err != nil {
        log.Fatal("获取 stderr 管道失败:", err)
    }
    reader := bufio.NewReader(stderr)

    stdin, err := cmd.StdinPipe()
    if err != nil {
        log.Fatal("获取 stdin 管道失败:", err)
    }
    defer stdin.Close()

    // 启动进程(非 Run!)
    if err := cmd.Start(); err != nil {
        log.Fatal("启动 rm 失败:", err)
    }

    // 逐行读取提示(注意:ReadLine 不保证以 \n 结尾,需检查 isPrefix)
    for {
        line, isPrefix, err := reader.ReadLine()
        if err != nil {
            break // EOF 或其他错误
        }
        if isPrefix {
            // 行太长被截断,需继续读取(实际中 rm 提示通常很短)
            continue
        }
        prompt := string(line)
        if prompt == "Remove file 'somefile.txt'?" || 
           prompt == "rm: remove regular empty file ‘somefile.txt’" {
            stdin.Write([]byte("y\n"))
        }
    }

    // 等待进程退出
    if err := cmd.Wait(); err != nil {
        log.Printf("rm 执行异常: %v", err)
    }
}

⚙️ 示例二:自定义分隔符扫描器(精准匹配 ? 提示)

某些交互程序(如 rm 在不同 locale 下)可能输出无换行的提示,或以 ? 结尾但无 \n。此时需自定义 bufio.Scanner.SplitFunc,将 ? 也视为行结束符:

package main

import (
    "bytes"
    "bufio"
    "log"
    "os/exec"
)

// 自定义分隔符:支持 \n 和 ? 作为行边界
func scanOnQuestion(data []byte, atEOF bool) (advance int, token []byte, err error) {
    if atEOF && len(data) == 0 {
        return 0, nil, nil
    }
    if i := bytes.IndexByte(data, '\n'); i >= 0 {
        return i + 1, data[:i], nil
    }
    if i := bytes.IndexByte(data, '?'); i >= 0 {
        return i + 1, data[:i], nil
    }
    if atEOF {
        return len(data), data, nil
    }
    return 0, nil, nil
}

func main() {
    cmd := exec.Command("rm", "-i", "somefile.txt")

    stderr, _ := cmd.StderrPipe()
    scanner := bufio.NewScanner(stderr)
    scanner.Split(scanOnQuestion) // 注册自定义分割函数

    stdin, _ := cmd.StdinPipe()
    defer stdin.Close()

    if err := cmd.Start(); err != nil {
        log.Fatal("启动失败:", err)
    }

    for scanner.Scan() {
        line := scanner.Text()
        // 注意:不同系统/语言环境下提示文本可能不同,建议日志调试确认
        if line == "rm: remove regular empty file ‘somefile.txt’" ||
           line == "Remove file 'somefile.txt'" {
            stdin.Write([]byte("y\n"))
        }
    }

    if err := scanner.Err(); err != nil {
        log.Fatal("扫描 stderr 出错:", err)
    }

    if err := cmd.Wait(); err != nil {
        log.Printf("rm 退出异常: %v", err)
    }
}

⚠️ 重要注意事项

  • 环境依赖性:rm -i 的提示文本随系统 locale、GNU coreutils 版本变化(如英文 "remove regular empty file" vs 中文 "是否删除普通空文件"),生产环境应结合 LC_ALL=C rm -i 固定输出,或使用正则模糊匹配。
  • 竞态风险:若进程快速输出多条提示,需确保读写顺序严格同步;复杂场景建议引入 sync.Mutex 或使用 io.MultiReader/io.Pipe 增强控制。
  • 资源清理:务必调用 stdin.Close()(防止子进程因管道未关闭而挂起),并在 cmd.Wait() 后检查退出状态。
  • 替代方案:对高度复杂的交互(如 SSH、TUI 应用),推荐使用专用库如 github.com/creack/pty 创建伪终端(PTY),获得真正等价于手动操作的体验。

掌握管道的显式控制与流式解析,即可让 Go 程序无缝集成各类交互式 CLI 工具,大幅提升自动化脚本的健壮性与适用范围。