C# - 实时控制台输出重定向

19

我正在开发一个C#应用程序,需要启动外部的控制台程序来执行一些任务(提取文件)。我需要做的是重定向控制台程序的输出。像这样的代码不起作用,因为它只在控制台程序中写入新行时才引发事件,但我使用的程序“更新”了控制台窗口中显示的内容,而没有写入任何新行。如何在每次更新控制台文本时引发事件?或者每X秒获取一次控制台程序的输出?谢谢!

5个回答

16

我遇到了一个非常类似(可能是完全相同的)你所描述问题:

  1. 我需要异步地接收控制台更新。
  2. 我需要检测更新,无论是否输入了换行符。

我最终做的事情如下:

  1. 开始一个“无限循环”,调用 StandardOutput.BaseStream.BeginRead
  2. BeginRead 的回调函数中,检查 EndRead 的返回值是否为 0;这意味着控制台进程已关闭其输出流(即不会再向标准输出写入任何内容)。
  3. 由于 BeginRead 强制您使用具有恒定长度的缓冲区,因此检查 EndRead 的返回值是否等于缓冲区大小。这意味着可能有更多的输出正在等待读取,而且可能需要(甚至是必要的)将此输出全部处理一次。我所做的是保留一个 StringBuilder 并将已经读取的输出附加到其中。每当读取输出但其长度小于缓冲区长度时,通知自己(我使用事件进行通知),告诉订阅者发送 StringBuilder 的内容,然后清除它。

然而,在我的情况下,我只是将更多的东西写到控制台的标准输出中。我不确定在你的情况下“更新”输出是什么意思。

更新:我刚意识到(解释你正在做什么是一次很好的学习体验吗?)上面概述的逻辑存在一个错位一的 bug:如果由 BeginRead 读取的输出长度恰好等于您的缓冲区长度,则该逻辑将把输出存储在 StringBuilder 中并在尝试查看是否有更多输出可附加时发生阻塞。当前的输出只有在/如果有更多的输出可用时作为较大字符串的一部分返回给您。

显然需要某种方法来防范这种情况(或者使用较大的缓冲区加信任自己的运气)才能以 100% 的正确性完成此操作。

更新 2(代码):

免责声明: 此代码不适用于生产环境。它是我快速编写的概念验证解决方案的结果。请不要将其原样用于您的生产应用程序中。如果此代码对您造成了可怕的影响,我将会假装别人写了它。

public class ConsoleInputReadEventArgs : EventArgs
{
    public ConsoleInputReadEventArgs(string input)
    {
        this.Input = input;
    }

    public string Input { get; private set; }
}

public interface IConsoleAutomator
{
    StreamWriter StandardInput { get; }

    event EventHandler<ConsoleInputReadEventArgs> StandardInputRead;
}

public abstract class ConsoleAutomatorBase : IConsoleAutomator
{
    protected readonly StringBuilder inputAccumulator = new StringBuilder();

    protected readonly byte[] buffer = new byte[256];

    protected volatile bool stopAutomation;

    public StreamWriter StandardInput { get; protected set; }

    protected StreamReader StandardOutput { get; set; }

    protected StreamReader StandardError { get; set; }

    public event EventHandler<ConsoleInputReadEventArgs> StandardInputRead;

    protected void BeginReadAsync()
    {
        if (!this.stopAutomation) {
            this.StandardOutput.BaseStream.BeginRead(this.buffer, 0, this.buffer.Length, this.ReadHappened, null);
        }
    }

    protected virtual void OnAutomationStopped()
    {
        this.stopAutomation = true;
        this.StandardOutput.DiscardBufferedData();
    }

    private void ReadHappened(IAsyncResult asyncResult)
    {
        var bytesRead = this.StandardOutput.BaseStream.EndRead(asyncResult);
        if (bytesRead == 0) {
            this.OnAutomationStopped();
            return;
        }

        var input = this.StandardOutput.CurrentEncoding.GetString(this.buffer, 0, bytesRead);
        this.inputAccumulator.Append(input);

        if (bytesRead < this.buffer.Length) {
            this.OnInputRead(this.inputAccumulator.ToString());
        }

        this.BeginReadAsync();
    }

    private void OnInputRead(string input)
    {
        var handler = this.StandardInputRead;
        if (handler == null) {
            return;
        }

        handler(this, new ConsoleInputReadEventArgs(input));
        this.inputAccumulator.Clear();
    }
}

public class ConsoleAutomator : ConsoleAutomatorBase, IConsoleAutomator
{
    public ConsoleAutomator(StreamWriter standardInput, StreamReader standardOutput)
    {
        this.StandardInput = standardInput;
        this.StandardOutput = standardOutput;
    }

    public void StartAutomate()
    {
        this.stopAutomation = false;
        this.BeginReadAsync();
    }

    public void StopAutomation()
    {
        this.OnAutomationStopped();
    }
}

使用方法如下:

var processStartInfo = new ProcessStartInfo
    {
        FileName = "myprocess.exe",
        RedirectStandardInput = true,
        RedirectStandardOutput = true,
        UseShellExecute = false,
    };

var process = Process.Start(processStartInfo);
var automator = new ConsoleAutomator(process.StandardInput, process.StandardOutput);

// AutomatorStandardInputRead is your event handler
automator.StandardInputRead += AutomatorStandardInputRead;
automator.StartAutomate();

// do whatever you want while that process is running
process.WaitForExit();
automator.StandardInputRead -= AutomatorStandardInputRead;
process.Close();

更新的意思是这样的:控制台程序有一行显示:“正在提取文件:1”。然后数字变成2、3、4等(不需要写入新行)。你能否也发布一下你的代码示例?(我不是专家,可能会感到困惑)。 - user197967
非常感谢!我会尽快尝试。 - user197967

12

或者,根据保持理智的原则,你可以阅读文档并正确地执行操作:

var startinfo = new ProcessStartInfo(@".\consoleapp.exe")
{
    CreateNoWindow = true,
    UseShellExecute = false,
    RedirectStandardOutput = true,
    RedirectStandardError = true,
};

var process = new Process { StartInfo = startinfo };
process.Start();

var reader = process.StandardOutput;
while (!reader.EndOfStream)
{
    // the point is that the stream does not end until the process has 
    // finished all of its output.
    var nextLine = reader.ReadLine();
}

process.WaitForExit();

3
问题在于ReadLine()、ReadToEnd()、Peek()等方法可能会被锁定,且不会按照文档中描述的返回。这些方法在处理文件流和内存流时可能有效,但是当你想要连接到控制台应用程序的流读取器时就不太好用了。如果你想从控制台中捕获原始数据,并获取所有换行符,那么就需要注意了。 - tofo
你丢失了换行符! - Xan-Kun Clark-Davis
更糟糕的是:ReadLine()有时会阻塞,有时如果没有数据则返回null。天哪! - Elmue
它应该被阻止! - satnhak
StandardOutput.EndOfStream 阻塞。 - Tom Charles Zhang

4
根据保持简单原则,我发布更紧凑的代码。
在我看来,在这种情况下,只需阅读即可。
    private delegate void DataRead(string data);
    private static event DataRead OnDataRead;

    static void Main(string[] args)
    {
        OnDataRead += data => Console.WriteLine(data != null ? data : "Program finished");
        Thread readingThread = new Thread(Read);
        ProcessStartInfo info = new ProcessStartInfo()
        {
            FileName = Environment.GetCommandLineArgs()[0],
            Arguments = "/arg1 arg2",
            RedirectStandardOutput = true,
            UseShellExecute = false,
        };
        using (Process process = Process.Start(info))
        {
            readingThread.Start(process);
            process.WaitForExit();
        }
        readingThread.Join();
    }

    private static void Read(object parameter)
    {
        Process process = parameter as Process;
        char[] buffer = new char[Console.BufferWidth];
        int read = 1;
        while (read > 0)
        {
            read = process.StandardOutput.Read(buffer, 0, buffer.Length);
            string data = read > 0 ? new string(buffer, 0, read) : null;
            if (OnDataRead != null) OnDataRead(data);
        }
    }

注意事项:

  • 更改读取缓冲区大小
  • 创建一个好的类
  • 创建更好的事件
  • 在另一个线程中启动进程(这样UI线程就不会被Process.WaitForExit阻塞)

1

斗争结束了

由于上述示例,我解决了使用StandardOutput和StandardError流阅读器时的阻塞和无法直接使用的问题。

微软在这里承认了锁定问题:system.io.stream.beginread

使用process.BeginOutputReadLine()和process.BeginErrorReadLine()订阅StandardOutput和StandardError事件,并订阅OutputDataReceived和ErrorDataReceived可以正常工作,但我错过了换行符并且无法模拟正在被监听的原始控制台上发生的情况。

此类接受StreamReader的引用,但从StreamReader.BaseStream中捕获控制台输出。 DataReceived事件将永远传递流数据,因为它到达。在外部控制台应用程序上测试时不会阻塞。

/// <summary>
/// Stream reader for StandardOutput and StandardError stream readers
/// Runs an eternal BeginRead loop on the underlaying stream bypassing the stream reader.
/// 
/// The TextReceived sends data received on the stream in non delimited chunks. Event subscriber can
/// then split on newline characters etc as desired.
/// </summary>
class AsyncStreamReader
{ 

    public delegate void EventHandler<args>(object sender, string Data);
    public event EventHandler<string> DataReceived;

    protected readonly byte[] buffer = new byte[4096];
    private StreamReader reader;


    /// <summary>
    ///  If AsyncStreamReader is active
    /// </summary>
    public bool Active { get; private set; }

    public void Start()
    {
        if (!Active)
        {
            Active = true;
            BeginReadAsync();
        }           
    }

    public void Stop()
    {
        Active=false;         
    }

    public AsyncStreamReader(StreamReader readerToBypass)
    {
        this.reader = readerToBypass;
        this.Active = false;
    }

    protected void BeginReadAsync()
    {
        if (this.Active)
        {
            reader.BaseStream.BeginRead(this.buffer, 0, this.buffer.Length, new AsyncCallback(ReadCallback), null);
        }
    }

    private void ReadCallback(IAsyncResult asyncResult)
    {
        var bytesRead = reader.BaseStream.EndRead(asyncResult);

        string data = null;

        //Terminate async processing if callback has no bytes
        if (bytesRead > 0)
        {
            data = reader.CurrentEncoding.GetString(this.buffer, 0, bytesRead);
        }
        else
        {
            //callback without data - stop async
            this.Active = false;                
        }

        //Send data to event subscriber - null if no longer active
        if (this.DataReceived != null)
        {
            this.DataReceived.Invoke(this, data);
        }

        //Wait for more data from stream
        this.BeginReadAsync();
    }

}

也许在AsyncCallback激活时提供一个明确的事件而不是发送空字符串会更好,但基本问题已经解决。
4096大小的缓冲区可以更小。回调将循环直到所有数据都被传递。
使用方法如下:
standardOutput = new AsyncStreamReader(process.StandardOutput);
standardError = new AsyncStreamReader(process.StandardError);

standardOutput.DataReceived += (sender, data) =>
{
    //Code here
};

standardError.DataReceived += (sender, data) =>
{
    //Code here
};

StandardOutput.Start();
StandardError.Start();

请问您能否更清楚地解释一下您所说的“但我错过了换行符”是什么意思? - Elmue
看看我发布的内容。使用二进制会更容易,对吧? - Sam Hobbs

0

Jon说:“我不确定在你的情况下“更新”输出意味着什么”,我也不知道他是什么意思。因此,我编写了一个程序,可以用于重定向其输出,以便我们可以清楚地定义要求。

可以使用Console.CursorLeft Property在控制台中移动光标。但是,当我使用它时,无法重定向输出,我收到了一个错误消息;大概是关于无效流的问题。然后我尝试了退格字符,就像已经建议的那样。因此,我正在使用以下程序来重定向输出。

class Program
{
    static readonly string[] Days = new [] {"Monday", "Tuesday", "Wednesday",
        "Thursday", "Friday", "Saturday", "Sunday"};
    static int lastlength = 0;
    static int pos = 0;

    static void Main(string[] args)
    {
        Console.Write("Status: ");
        pos = Console.CursorLeft;
        foreach (string Day in Days)
        {
            Update(Day);
        }
        Console.WriteLine("\r\nDone");
    }

    private static void Update(string day)
    {
        lastlength = Console.CursorLeft - pos;
        Console.Write(new string((char)8, lastlength));
        Console.Write(day.PadRight(lastlength));
        Thread.Sleep(1000);
    }
}

当我使用被接受的答案来重定向输出时,它能够正常工作。
我曾经在处理完全不同的东西时使用一些示例代码,它可以在标准输出可用时立即处理它,就像这个问题中一样。它将标准输出读取为二进制数据。因此,我尝试了这种方式,并提供了以下备选方案。
class Program
{
    static Stream BinaryStdOut = null;

    static void Main(string[] args)
    {
        const string TheProgram = @" ... ";
        ProcessStartInfo info = new ProcessStartInfo(TheProgram);
        info.RedirectStandardOutput = true;
        info.UseShellExecute = false;
        Process p = Process.Start(info);
        Console.WriteLine($"Started process {p.Id} {p.ProcessName}");
        BinaryStdOut = p.StandardOutput.BaseStream;
        string Message = null;
        while ((Message = GetMessage()) != null)
            Console.WriteLine(Message);
        p.WaitForExit();
        Console.WriteLine("Done");
    }

    static string GetMessage()
    {
        byte[] Buffer = new byte[80];
        int sizeread = BinaryStdOut.Read(Buffer, 0, Buffer.Length);
        if (sizeread == 0)
            return null;
        return Encoding.UTF8.GetString(Buffer);
    }
}

其实,这可能并不比marchewek的答案更好,但我想我还是会把它留在这里。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接