在java程序中截获控制台输出
内容:
一、java管道流
1.1 注意事项一
1.2 注意事项二
1.3 注意事项三
1.4 解决问题
二、捕获java控制台输出
三、捕获其他程序的控制台输出
参考资料
关于作者
俞良松 (javaman@163.net)
软件工程师,独立顾问和自由撰稿人
在java开发中,控制台输出仍是一个重要的工具,但默认的控制台输出有着各种各样的局限。本文介绍如何用java管道流截取控制台输出,分析管道流应用中应该注意的问题,提供了截取java程序和非java程序控制台输出的实例。
即使在图形用户界面占统治地位的今天,控制台输出仍旧在java程序中占有重要地位。控制台不仅是java程序默认的堆栈跟踪和错误信息输出窗口,而且还是一种实用的调试工具(特别是对习惯于使用println()的人来说)。然而,控制台窗口有着许多局限。例如在windows 9x平台上,dos控制台只能容纳50行输出。如果java程序一次性向控制台输出大量内容,要查看这些内容就很困难了。
对于使用javaw这个启动程序的开发者来说,控制台窗口尤其宝贵。因为用javaw启动java程序时,根本不会有控制台窗口出现。如果程序遇到了问题并抛出异常,根本无法查看java运行时环境写入到system.out或system.err的调用堆栈跟踪信息。为了捕获堆栈信息,一些人采取了用try/catch()块封装main()的方式,但这种方式不一定总是有效,在java运行时的某些时刻,一些描述性错误信息会在抛出异常之前被写入system.out和system.err;除非能够监测这两个控制台流,否则这些信息就无法看到。
因此,有些时候检查java运行时环境(或第三方程序)写入到控制台流的数据并采取合适的操作是十分必要的。本文讨论的主题之一就是创建这样一个输入流,从这个输入流中可以读入以前写入java控制台流(或任何其他程序的输出流)的数据。我们可以想象写入到输出流的数据立即以输入的形式“回流”到了java程序。
本文的目标是设计一个基于swing的文本窗口显示控制台输出。在此期间,我们还将讨论一些和java管道流(pipedinputstream和pipedoutputstream)有关的重要注意事项。图一显示了用来截取和显示控制台文本输出的java程序,用户界面的核心是一个jtextarea。最后,我们还要创建一个能够捕获和显示其他程序(可以是非java的程序)控制台输出的简单程序。
图一:多线程的控制台输出截取程序
一、java管道流
要在文本框中显示控制台输出,我们必须用某种方法“截取”控制台流。换句话说,我们要有一种高效地读取写入到system.out和system.err所有内容的方法。如果你熟悉java的管道流pipedinputstream和pipedoutputstream,就会相信我们已经拥有最有效的工具。
写入到pipedoutputstream输出流的数据可以从对应的pipedinputstream输入流读取。java的管道流极大地方便了我们截取控制台输出。listing 1显示了一种非常简单的截取控制台输出方案。
【listing 1:用管道流截取控制台输出】
pipedinputstream pipedis = new pipedinputstream();
pipedoutputstream pipedos = new pipedoutputstream();
try {
pipedos.connect(pipedis);
}
catch(ioexception e) {
system.err.println("连接失败");
system.exit(1);
}
printstream ps = new printstream(pipedos);
system.setout(ps);
system.seterr(ps);
可以看到,这里的代码极其简单。我们只是建立了一个pipedinputstream,把它设置为所有写入控制台流的数据的最终目的地。所有写入到控制台流的数据都被转到pipedoutputstream,这样,从相应的pipedinputstream读取就可以迅速地截获所有写入控制台流的数据。接下来的事情似乎只剩下在swing jtextarea中显示从pipedis流读取的数据,得到一个能够在文本框中显示控制台输出的程序。遗憾的是,在使用java管道流时有一些重要的注意事项。只有认真对待所有这些注意事项才能保证listing 1的代码稳定地运行。下面我们来看第一个注意事项。
1.1 注意事项一
pipedinputstream运用的是一个1024字节固定大小的循环缓冲区。写入pipedoutputstream的数据实际上保存到对应的pipedinputstream的内部缓冲区。从pipedinputstream执行读操作时,读取的数据实际上来自这个内部缓冲区。如果对应的pipedinputstream输入缓冲区已满,任何企图写入pipedoutputstream的线程都将被阻塞。而且这个写操作线程将一直阻塞,直至出现读取pipedinputstream的操作从缓冲区删除数据。
这意味着,向pipedoutputstream写数据的线程不应该是负责从对应pipedinputstream读取数据的唯一线程。从图二可以清楚地看出这里的问题所在:假设线程t是负责从pipedinputstream读取数据的唯一线程;另外,假定t企图在一次对pipedoutputstream的write()方法的调用中向对应的pipedoutputstream写入2000字节的数据。在t线程阻塞之前,它最多能够写入1024字节的数据(pipedinputstream内部缓冲区的大小)。然而,一旦t被阻塞,读取pipedinputstream的操作就再也不会出现,因为t是唯一读取pipedinputstream的线程。这样,t线程已经完全被阻塞,同时,所有其他试图向pipedoutputstream写入数据的线程也将遇到同样的情形。
图二:管道流工作过程
这并不意味着在一次write()调用中不能写入多于1024字节的数据。但应当保证,在写入数据的同时,有另一个线程从pipedinputstream读取数据。
listing 2示范了这个问题。这个程序用一个线程交替地读取pipedinputstream和写入pipedoutputstream。每次调用write()向pipedinputstream的缓冲区写入20字节,每次调用read()只从缓冲区读取并删除10个字节。内部缓冲区最终会被写满,导致写操作阻塞。由于我们用同一个线程执行读、写操作,一旦写操作被阻塞,就不能再从pipedinputstream读取数据。
【listing 2:用同一个线程执行读/写操作导致线程阻塞】
import java.io.*;
public class listing2 {
static pipedinputstream pipedis = new pipedinputstream();
static pipedoutputstream pipedos =
new pipedoutputstream();
public static void main(string[] a){
try {
pipedis.connect(pipedos);
}
catch(ioexception e) {
system.err.println("连接失败");
system.exit(1);
}
byte[] inarray = new byte[10];
byte[] outarray = new byte[20];
int bytesread = 0;
try {
// 向pipedos发送20字节数据
pipedos.write(outarray, 0, 20);
system.out.println(" 已发送20字节...");
// 在每一次循环迭代中,读入10字节
// 发送20字节
bytesread = pipedis.read(inarray, 0, 10);
int i=0;
while(bytesread != -1) {
pipedos.write(outarray, 0, 20);
system.out.println(" 已发送20字节..."+i);
i++;
bytesread = pipedis.read(inarray, 0, 10);
}
}
catch(ioexception e) {
system.err.println("读取pipedis时出现错误: " + e);
system.exit(1);
}
} // main()
}
只要把读/写操作分开到不同的线程,listing 2的问题就可以轻松地解决。listing 3是listing 2经过修改后的版本,它在一个单独的线程中执行写入pipedoutputstream的操作(和读取线程不同的线程)。为证明一次写入的数据可以超过1024字节,我们让写操作线程每次调用pipedoutputstream的write()方法时写入2000字节。那么,在startwriterthread()方法中创建的线程是否会阻塞呢?按照java运行时线程调度机制,它当然会阻塞。写操作在阻塞之前实际上最多只能写入1024字节的有效载荷(即pipedinputstream缓冲区的大小)。但这并不会成为问题,因为主线程(main)很快就会从pipedinputstream的循环缓冲区读取数据,空出缓冲区空间。最终,写操作线程会从上一次中止的地方重新开始,写入2000字节有效载荷中的剩余部分。
【listing 3:把读/写操作分开到不同的线程】
import java.io.*;
public class listing3 {
static pipedinputstream pipedis =
new pipedinputstream();
static pipedoutputstream pipedos =
new pipedoutputstream();
public static void main(string[] args) {
try {
pipedis.connect(pipedos);
}
catch(ioexception e) {
system.err.println("连接失败");
system.exit(1);
}
byte[] inarray = new byte[10];
int bytesread = 0;
// 启动写操作线程
startwriterthread();
try {
bytesread = pipedis.read(inarray, 0, 10);
while(bytesread != -1) {
system.out.println("已经读取" +
bytesread + "字节...");
bytesread = pipedis.read(inarray, 0, 10);
}
}
catch(ioexception e) {
system.err.println("读取输入错误.");
system.exit(1);
}
} // main()
// 创建一个独立的线程
// 执行写入pipedoutputstream的操作
private static void startwriterthread() {
new thread(new runnable() {
public void run() {
byte[] outarray = new byte[2000];
while(true) { // 无终止条件的循环
try {
// 在该线程阻塞之前,有最多1024字节的数据被写入
pipedos.write(outarray, 0, 2000);
}
catch(ioexception e) {
system.err.println("写操作错误");
system.exit(1);
}
system.out.println(" 已经发送2000字节...");
}
}
}).start();
} // startwriterthread()
} // listing3
也许我们不能说这个问题是java管道流设计上的缺陷,但在应用管道流时,它是一个必须密切注意的问题。下面我们来看看第二个更重要(更危险的)问题。
1.2 注意事项二
从pipedinputstream读取数据时,如果符合下面三个条件,就会出现ioexception异常:
试图从pipedinputstream读取数据,
pipedinputstream的缓冲区为“空”(即不存在可读取的数据),
最后一个向pipedoutputstream写数据的线程不再活动(通过thread.isalive()检测)。
这是一个很微妙的时刻,同时也是一个极其重要的时刻。假定有一个线程w向pipedoutputstream写入数据;另一个线程r从对应的pipedinputstream读取数据。下面一系列的事件将导致r线程在试图读取pipedinputstream时遇到ioexception异常:
w向pipedoutputstream写入数据。
w结束(w.isalive()返回false)。
r从pipedinputstream读取w写入的数据,清空pipedinputstream的缓冲区。
r试图再次从pipedinputstream读取数据。这时pipedinputstream的缓冲区已经为空,而且w已经结束,从而导致在读操作执行时出现ioexception异常。
构造一个程序示范这个问题并不困难,只需从listing 3的startwriterthread()方法中,删除while(true)条件。这个改动阻止了执行写操作的方法循环执行,使得执行写操作的方法在一次写入操作之后就结束运行。如前所述,此时主线程试图读取pipedinputstraem时,就会遇到一个ioexception异常。
这是一种比较少见的情况,而且不存在直接修正它的方法。请不要通过从管道流派生子类的方法修正该问题??在这里使用继承是完全不合适的。而且,如果sun以后改变了管道流的实现方法,现在所作的修改将不再有效。
最后一个问题和第二个问题很相似,不同之处在于,它在读线程(而不是写线程)结束时产生ioexception异常。
1.3 注意事项三
如果一个写操作在pipedoutputstream上执行,同时最近从对应pipedinputstream读取的线程已经不再活动(通过thread.isalive()检测),则写操作将抛出一个ioexception异常。假定有两个线程w和r,w向pipedoutputstream写入数据,而r则从对应的pipedinputstream读取。下面一系列的事件将导致w线程在试图写入pipedoutputstream时遇到ioexception异常:
写操作线程w已经创建,但r线程还不存在。
w向pipedoutputstream写入数据。
读线程r被创建,并从pipedinputstream读取数据。
r线程结束。
w企图向pipedoutputstream写入数据,发现r已经结束,抛出ioexception异常。
实际上,这个问题不象第二个问题那样棘手。和多个读线程/单个写线程的情况相比,也许在应用中有一个读线程(作为响应请求的服务器)和多个写线程(发出请求)的情况更为常见。
1.4 解决问题
要防止管道流前两个局限所带来的问题,方法之一是用一个bytearrayoutputstream作为代理或替代pipedoutputstream。listing 4显示了一个loopedstreams类,它用一个bytearrayoutputstream提供和java管道流类似的功能,但不会出现死锁和ioexception异常。这个类的内部仍旧使用管道流,但隔离了本文介绍的前两个问题。我们先来看看这个类的公用方法(参见图3)。构造函数很简单,它连接管道流,然后调用startbytearrayreaderthread()方法(稍后再讨论该方法)。getoutputstream()方法返回一个outputstream(具体地说,是一个bytearrayoutputstream)用以替代pipedoutputstream。写入该outputstream的数据最终将在getinputstream()方法返回的流中作为输入出现。和使用pipedoutputstream的情形不同,向bytearrayoutputstream写入数据的线程的激活、写数据、结束不会带来负面效果。
图三:bytearrayoutputstream原理
【listing 4:防止管道流应用中出现的常见问题】
import java.io.*;
public class loopedstreams {
private pipedoutputstream pipedos =
new pipedoutputstream();
private boolean keeprunning = true;
private bytearrayoutputstream bytearrayos =
new bytearrayoutputstream() {
public void close() {
keeprunning = false;
try {
super.close();
pipedos.close();
}
catch(ioexception e) {
// 记录错误或其他处理
// 为简单计,此处我们直接结束
system.exit(1);
}
}
};
private pipedinputstream pipedis = new pipedinputstream() {
public void close() {
keeprunning = false;
try {
super.close();
}
catch(ioexception e) {
// 记录错误或其他处理
// 为简单计,此处我们直接结束
system.exit(1);
}
}
};
public loopedstreams() throws ioexception {
pipedos.connect(pipedis);
startbytearrayreaderthread();
} // loopedstreams()
public inputstream getinputstream() {
return pipedis;
} // getinputstream()
public outputstream getoutputstream() {
return bytearrayos;
} // getoutputstream()
private void startbytearrayreaderthread() {
new thread(new runnable() {
public void run() {
while(keeprunning) {
// 检查流里面的字节数
if(bytearrayos.size() > 0) {
byte[] buffer = null;
synchronized(bytearrayos) {
buffer = bytearrayos.tobytearray();
bytearrayos.reset(); // 清除缓冲区
}
try {
// 把提取到的数据发送给pipedoutputstream
pipedos.write(buffer, 0, buffer.length);
}
catch(ioexception e) {
// 记录错误或其他处理
// 为简单计,此处我们直接结束
system.exit(1);
}
}
else // 没有数据可用,线程进入睡眠状态
try {
// 每隔1秒查看bytearrayoutputstream检查新数据
thread.sleep(1000);
}
catch(interruptedexception e) {}
}
}
}).start();
} // startbytearrayreaderthread()
} // loopedstreams
startbytearrayreaderthread()方法是整个类真正的关键所在。这个方法的目标很简单,就是创建一个定期地检查bytearrayoutputstream缓冲区的线程。缓冲区中找到的所有数据都被提取到一个byte数组,然后写入到pipedoutputstream。由于pipedoutputstream对应的pipedinputstream由getinputstream()返回,从该输入流读取数据的线程都将读取到原先发送给bytearrayoutputstream的数据。前面提到,loopedstreams类解决了管道流存在的前二个问题,我们来看看这是如何实现的。
bytearrayoutputstream具有根据需要扩展其内部缓冲区的能力。由于存在“完全缓冲”,线程向getoutputstream()返回的流写入数据时不会被阻塞。因而,第一个问题不会再给我们带来麻烦。另外还要顺便说一句,bytearrayoutputstream的缓冲区永远不会缩减。例如,假设在能够提取数据之前,有一块500 k的数据被写入到流,缓冲区将永远保持至少500 k的容量。如果这个类有一个方法能够在数据被提取之后修正缓冲区的大小,它就会更完善。
第二个问题得以解决的原因在于,实际上任何时候只有一个线程向pipedoutputstream写入数据,这个线程就是由startbytearrayreaderthread()创建的线程。由于这个线程完全由loopedstreams类控制,我们不必担心它会产生ioexception异常。
loopedstreams类还有一些细节值得提及。首先,我们可以看到bytearrayos和pipedis实际上分别是bytearrayoutputstream和pipedinputstream的派生类的实例,也即在它们的close()方法中加入了特殊的行为。如果一个loopedstreams对象的用户关闭了输入或输出流,在startbytearrayreaderthread()中创建的线程必须关闭。覆盖后的close()方法把keeprunning标记设置成false以关闭线程。另外,请注意startbytearrayreaderthread()中的同步块。要确保在tobytearray()调用和reset()调用之间bytearrayoutputstream缓冲区不被写入流的线程修改,这是必不可少的。由于bytearrayoutputstream的write()方法的所有版本都在该流上同步,我们保证了bytearrayoutputstream的内部缓冲区不被意外地修改。
注意loopedstreams类并不涉及管道流的第三个问题。该类的getinputstream()方法返回pipedinputstream。如果一个线程从该流读取,一段时间后终止,下次数据从bytearrayoutputstream缓冲区传输到pipedoutputstream时就会出现ioexception异常。
二、捕获java控制台输出
listing 5的consoletextarea类扩展swing jtextarea捕获控制台输出。不要对这个类有这么多代码感到惊讶,必须指出的是,consoletextarea类有超过50%的代码用来进行测试。
【listing 5:截获java控制台输出】
import java.io.*;
import java.util.*;
import javax.swing.*;
import javax.swing.text.*;
public class consoletextarea extends jtextarea {
public consoletextarea(inputstream[] instreams) {
for(int i = 0; i < instreams.length; ++i)
startconsolereaderthread(instreams[i]);
} // consoletextarea()
public consoletextarea() throws ioexception {
final loopedstreams ls = new loopedstreams();
// 重定向system.out和system.err
printstream ps = new printstream(ls.getoutputstream());
system.setout(ps);
system.seterr(ps);
startconsolereaderthread(ls.getinputstream());
} // consoletextarea()
private void startconsolereaderthread(
inputstream instream) {
final bufferedreader br =
new bufferedreader(new inputstreamreader(instream));
new thread(new runnable() {
public void run() {
stringbuffer sb = new stringbuffer();
try {
string s;
document doc = getdocument();
while((s = br.readline()) != null) {
boolean caretatend = false;
caretatend = getcaretposition() == doc.getlength() ?
true : false;
sb.setlength(0);
append(sb.append(s).append(′ ′).tostring());
if(caretatend)
setcaretposition(doc.getlength());
}
}
catch(ioexception e) {
joptionpane.showmessagedialog(null,
"从bufferedreader读取错误:" + e);
system.exit(1);
}
}
}).start();
} // startconsolereaderthread()
// 该类剩余部分的功能是进行测试
public static void main(string[] args) {
jframe f = new jframe("consoletextarea测试");
consoletextarea consoletextarea = null;
try {
consoletextarea = new consoletextarea();
}
catch(ioexception e) {
system.err.println(
"不能创建loopedstreams:" + e);
system.exit(1);
}
consoletextarea.setfont(java.awt.font.decode("monospaced"));
f.getcontentpane().add(new jscrollpane(consoletextarea),
java.awt.borderlayout.center);
f.setbounds(50, 50, 300, 300);
f.setvisible(true);
f.addwindowlistener(new java.awt.event.windowadapter() {
public void windowclosing(
java.awt.event.windowevent evt) {
system.exit(0);
}
});
// 启动几个写操作线程向
// system.out和system.err输出
startwritertestthread(
"写操作线程 #1", system.err, 920, 50);
startwritertestthread(
"写操作线程 #2", system.out, 500, 50);
startwritertestthread(
"写操作线程 #3", system.out, 200, 50);
startwritertestthread(
"写操作线程 #4", system.out, 1000, 50);
startwritertestthread(
"写操作线程 #5", system.err, 850, 50);
} // main()
private static void startwritertestthread(
final string name, final printstream ps,
final int delay, final int count) {
new thread(new runnable() {
public void run() {
for(int i = 1; i <= count; ++i) {
ps.println("***" + name + ", hello !, i=" + i);
try {
thread.sleep(delay);
}
catch(interruptedexception e) {}
}
}
}).start();
} // startwritertestthread()
} // consoletextarea
main()方法创建了一个jframe,jframe包含一个consoletextarea的实例。这些代码并没有什么特别之处。frame显示出来之后,main()方法启动一系列的写操作线程,写操作线程向控制台流输出大量信息。consoletextarea捕获并显示这些信息,如图一所示。
consoletextarea提供了两个构造函数。没有参数的构造函数用来捕获和显示所有写入到控制台流的数据,有一个inputstream[]参数的构造函数转发所有从各个数组元素读取的数据到jtextarea。稍后将有一个例子显示这个构造函数的用处。首先我们来看看没有参数的consoletextarea构造函数。这个函数首先创建一个loopedstreams对象;然后请求java运行时环境把控制台输出转发到loopedstreams提供的outputstream;最后,构造函数调用startconsolereaderthread(),创建一个不断地把文本行追加到jtextarea的线程。注意,把文本追加到jtextarea之后,程序小心地保证了插入点的正确位置。
一般来说,swing部件的更新不应该在awt事件分派线程(awt event dispatch thread,aedt)之外进行。对于本例来说,这意味着所有把文本追加到jtextarea的操作应该在aedt中进行,而不是在startconsolereaderthread()方法创建的线程中进行。然而,事实上在swing中向jtextarea追加文本是一个线程安全的操作。读取一行文本之后,我们只需调用jtext.append()就可以把文本追加到jtextarea的末尾。
三、捕获其他程序的控制台输出
在jtextarea中捕获java程序自己的控制台输出是一回事,去捕获其他程序(甚至包括一些非java程序)的控制台数据又是另一回事。consoletextarea提供了捕获其他应用的输出时需要的基础功能,listing 6的appoutputcapture利用consoletextarea,截取其他应用的输出信息然后显示在consoletextarea中。
【listing 6:截获其他程序的控制台输出】
import java.awt.*;
import java.awt.event.*;
import java.io.*;
import javax.swing.*;
public class appoutputcapture {
private static process process;
public static void main(string[] args) {
if(args.length == 0) {
system.err.println("用法:java appoutputcapture " +
"<程序名字> {参数1 参数2 ...}");
system.exit(0);
}
try {
// 启动命令行指定程序的新进程
process = runtime.getruntime().exec(args);
}
catch(ioexception e) {
system.err.println("创建进程时出错... " + e);
system.exit(1);
}
// 获得新进程所写入的流
inputstream[] instreams =
new inputstream[] {
process.getinputstream(),process.geterrorstream()};
consoletextarea cta = new
consoletextarea(instreams);
cta.setfont(java.awt.font.decode("monospaced"));
jframe frame = new jframe(args[0] +
"控制台输出");
frame.getcontentpane().add(new jscrollpane(cta),
borderlayout.center);
frame.setbounds(50, 50, 400, 400);
frame.setvisible(true);
frame.addwindowlistener(new windowadapter() {
public void windowclosing(windowevent evt) {
process.destroy();
try {
process.waitfor(); // 在win98下可能被挂起
}
catch(interruptedexception e) {}
system.exit(0);
}
});
} // main()
} // appoutputcapture
appoutputcapture的工作过程如下:首先利用runtime.exec()方法启动指定程序的一个新进程。启动新进程之后,从结果process对象得到它的控制台流。之后,把这些控制台流传入consoletextarea(inputstream[])构造函数(这就是带参数consoletextarea构造函数的用处)。使用appoutputcapture时,在命令行上指定待截取其输出的程序名字。例如,如果在windows 2000下执行javaw.exe appoutputcapture ping.exe www.yahoo.com,则结果如图四所示。
使用appoutputcapture时应该注意,被截取输出的应用程序最初输出的一些文本可能无法截取。因为在调用runtime.exec()和consoletextarea初始化完成之间存在一小段时间差。在这个时间差内,应用程序输出的文本会丢失。当appoutputcapture窗口被关闭,process.destory()调用试图关闭java程序开始时创建的进程。测试结果显示出,destroy()方法不一定总是有效(至少在windows 98上是这样的)。似乎当待关闭的进程启动了额外的进程时,则那些进程不会被关闭。此外,在这种情况下appoutputcapture程序看起来未能正常结束。但在windows nt下,一切正常。如果用jdk v1.1.x运行appoutputcapture,关闭窗口时会出现一个nullpointerexception。这是一个jdk的bug,jdk 1.2.x和jdk 1.3.x下就不会出现问题。
请从这里下载本文完整代码:javaconsoleoutput_code.zip
参考:
java 技巧 14:在 java 中对标准流进行重定向
java 技巧 33:再谈对流进行重定向
编写多线程的java 应用程序 如何避免当前编程中最常见的问题
java 程序中的多线程
关于作者
俞良松,软件工程师,独立顾问和自由撰稿人。最初从事pb和oracle开发,现主要兴趣在于internet开发。您可以通过 javaman@163.net 和我联系。
闽公网安备 35060202000074号