Capturing Application System.out With jQuery
Bear Giles | March 19, 2014One of my first tasks at my new job has involved cleaning up an existing data preprocessing application. The legacy version had to be run via ant(!) with a very nasty dependency tree. After a bottle or two of Tylenol I reduced that to a standalone jar and two configuration files.
Which GUI is best?
That’s a good first step but it still requires the user to manually edit the configuration files. Could we do better?
The old school thought is that we can use X/Motif. No, wait, not that old old school. Swing. We can use swing!
Ugh. I’m not sure that’s much of an improvement.
The new school thought is that we should be able to use our standard browsers. More people can do more things more quickly leveraging the standard javascript frameworks than using swing. It’s not even close – from a management perspective (how hard to find qualified people? how hard to maintain?) it’s easily a 100:1 benefit. Don’t be mistaken – there are times when there’s no substitute for swing or a comparable framework. But an internal application that will only be used by us and a Professional Services Group? Browser with javascript, without a doubt.
Embedded Jetty and AWT Desktop class
Our first step is to launch an embedded jetty server and the associated browser. This is a very easy process:
- public class WebWrapper {
- private Server server = new Server(8080);
- /**
- * Set up embedded Jetty server. We don't need a full webapp, typically
- * just a handler for static content (html, css, javascript) and maybe
- * 6-10 handlers for REST calls.
- */
- public void startWebServer() throws Exception {
- // set up Jetty handlers...
- final HandlerWrapper handler1 = ...
- final HandlerWrapper handler2 = ...
- final HandlerWrapper handler3 = ...
- // register handlers.
- final HandlerList handlers = new HandlerList();
- handlers.setHandlers(new Handler[] { handler1, handler2, handler3, new DefaultHandler() });
- server.setHandler(handlers);
- // start server, wait for it to be fully launched.
- server.start();
- server.join();
- }
- /**
- * Main application - start web server then launch user's default browser
- */
- public static void main(String[] args) throws Exception {
- final WebWrapper w = new WebWrapper();
- if (Desktop.isDesktopSupported()) {
- w.startWebServer();
- final URI uri = URI.create("http://localhost:8080/");
- Desktop.getDesktop().browse(uri);
- }
- }
- }
public class WebWrapper { private Server server = new Server(8080); /** * Set up embedded Jetty server. We don't need a full webapp, typically * just a handler for static content (html, css, javascript) and maybe * 6-10 handlers for REST calls. */ public void startWebServer() throws Exception { // set up Jetty handlers... final HandlerWrapper handler1 = ... final HandlerWrapper handler2 = ... final HandlerWrapper handler3 = ... // register handlers. final HandlerList handlers = new HandlerList(); handlers.setHandlers(new Handler[] { handler1, handler2, handler3, new DefaultHandler() }); server.setHandler(handlers); // start server, wait for it to be fully launched. server.start(); server.join(); } /** * Main application - start web server then launch user's default browser */ public static void main(String[] args) throws Exception { final WebWrapper w = new WebWrapper(); if (Desktop.isDesktopSupported()) { w.startWebServer(); final URI uri = URI.create("http://localhost:8080/"); Desktop.getDesktop().browse(uri); } } }
It’s important to remember that this is the frontend to a desktop application, not a general-purpose webapp, and we can take advantage of that. For instance we don’t need to worry about network latency. IMPORTANT: we still need to code defensively unless we can guarantee that nobody else will access the Jetty instance! Fortunately it is not hard to transparently add authentication information as we launch the browser.
Sidenote: we don’t have to use a full webapp with embedded Jetty but it is an option – this could be a convenient midway point between running an application traditionally and running it remotely as a classic webapp.
We now need a Jetty handler to launch our application. I’m demonstrating the case where we’re running the application asynchronously – the AJAX call returns immediately and we’ll pick up the results later.
- public class ApplicationHandler extends HandlerWrapper {
- private WrappedPrintStream writer;
- /**
- * Application handler.
- *
- * @param target
- * @param baseRequest
- * @param request
- * @param response
- * @throws IOException
- * @throws ServletException
- */
- public void handle(String target, final Request baseRequest, HttpServletRequest request,
- HttpServletResponse response) throws IOException, ServletException {
- final String path = request.getPathInfo();
- if ("POST".equals(request.getMethod())) {
- if ("/rest/doIt".equals(path)) {
- doIt(target, baseRequest, request, response);
- baseRequest.setHandled(true);
- } else if ("/rest/poll".equals(path)) {
- poll(target, baseRequest, request, response);
- baseRequest.setHandled(true);
- }
- }
- }
- /**
- * "Do it" - launch long-lived application in separate thread.
- *
- * @param target
- * @param baseRequest
- * @param request
- * @param response
- * @throws IOException
- * @throws ServletException
- */
- public void doIt(String target, final Request baseRequest, HttpServletRequest request,
- HttpServletResponse response) throws IOException, ServletException {
- response.setContentType("text/plain;charset=utf-8");
- // read json payload
- String json = null;
- final Scanner s = null;
- try {
- s = new Scanner(request.getInputStream());
- s.useDelimiter("^Z");
- if (s.hasNext()) {
- json = s.next();
- }
- } finally {
- if (s != null) {
- s.close();
- }
- }
- // return an error response if there's no payload
- if (s == null) {
- response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
- return;
- }
- // create Jackson JSON mapper.
- final ObjectMapper mapper = new ObjectMapper();
- final JsonNode root = mapper.readTree(json);
- // wrap System.out so we write to both command shell and browser.
- final WrappedPrintStream wps = WrappedPrintStream.newInstance(System.out);
- try {
- Thread t = new Thread(new Runnable() {
- public void run() {
- try {
- // reset System.out so it points to our wrapper.
- System.setOut(wps);
- // create the application, run it.
- final DoItApplication app = new DoItApplication(root);
- app.init(root);
- app.execute();
- } catch (IOException e) {
- e.printStackTrace(wps);
- } finally {
- // mark the wrapper closed.
- wps.setClosed(true);
- // reset System.out
- System.setOut(wps.getOut());
- }
- }
- });
- // cache the writer
- writer = wps;
- // start the thread
- t.start();
- // a typical response might be the threadId of the new process.
- response.setStatus(HttpServletResponse.SC_OK);
- response.setContentType("text/plain;charset=utf-8");
- final PrintWriter pw = response.getWriter();
- pw.printf("{ \"threadId\": \"%s\" }\n", t.getId());
- } catch (Exception e) {
- // oops - something went wrong.
- response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
- response.setContentType("text/plain;charset=utf-8");
- final PrintWriter pw = response.getWriter();
- e.printStackTrace(pw);
- e.printStackTrace();
- }
- }
- ...
public class ApplicationHandler extends HandlerWrapper { private WrappedPrintStream writer; /** * Application handler. * * @param target * @param baseRequest * @param request * @param response * @throws IOException * @throws ServletException */ public void handle(String target, final Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { final String path = request.getPathInfo(); if ("POST".equals(request.getMethod())) { if ("/rest/doIt".equals(path)) { doIt(target, baseRequest, request, response); baseRequest.setHandled(true); } else if ("/rest/poll".equals(path)) { poll(target, baseRequest, request, response); baseRequest.setHandled(true); } } } /** * "Do it" - launch long-lived application in separate thread. * * @param target * @param baseRequest * @param request * @param response * @throws IOException * @throws ServletException */ public void doIt(String target, final Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { response.setContentType("text/plain;charset=utf-8"); // read json payload String json = null; final Scanner s = null; try { s = new Scanner(request.getInputStream()); s.useDelimiter("^Z"); if (s.hasNext()) { json = s.next(); } } finally { if (s != null) { s.close(); } } // return an error response if there's no payload if (s == null) { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); return; } // create Jackson JSON mapper. final ObjectMapper mapper = new ObjectMapper(); final JsonNode root = mapper.readTree(json); // wrap System.out so we write to both command shell and browser. final WrappedPrintStream wps = WrappedPrintStream.newInstance(System.out); try { Thread t = new Thread(new Runnable() { public void run() { try { // reset System.out so it points to our wrapper. System.setOut(wps); // create the application, run it. final DoItApplication app = new DoItApplication(root); app.init(root); app.execute(); } catch (IOException e) { e.printStackTrace(wps); } finally { // mark the wrapper closed. wps.setClosed(true); // reset System.out System.setOut(wps.getOut()); } } }); // cache the writer writer = wps; // start the thread t.start(); // a typical response might be the threadId of the new process. response.setStatus(HttpServletResponse.SC_OK); response.setContentType("text/plain;charset=utf-8"); final PrintWriter pw = response.getWriter(); pw.printf("{ \"threadId\": \"%s\" }\n", t.getId()); } catch (Exception e) { // oops - something went wrong. response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); response.setContentType("text/plain;charset=utf-8"); final PrintWriter pw = response.getWriter(); e.printStackTrace(pw); e.printStackTrace(); } } ...
We also need a handler that returns anything recently written to System.out. The handler needs to be able to distinguish between “no data” and “no more data ever”, and a more robust solution would probably limit the amount of data transferred in any single call. It doesn’t really matter when you’re running the app and browser on the same machine but it will probably not take long for users to realize that they can access the application remotely unless you write your handlers to explicitly check the ‘remote ip’ of the caller.
- /**
- * Poll for new content.
- *
- * @param target
- * @param baseRequest
- * @param request
- * @param response
- * @throws IOException
- * @throws ServletException
- */
- public void poll(String target, final Request baseRequest, HttpServletRequest request, HttpServletResponse response)
- throws IOException, ServletException {
- response.setContentType("text/plain;charset=utf-8");
- if (writer != null) {
- final String s = writer.poll();
- if (s != null) {
- response.setStatus(HttpServletResponse.SC_OK);
- response.getWriter().print(s);
- } else {
- response.setStatus(HttpServletResponse.SC_NOT_FOUND);
- }
- } else {
- response.setStatus(HttpServletResponse.SC_NOT_FOUND);
- }
- }
- }
/** * Poll for new content. * * @param target * @param baseRequest * @param request * @param response * @throws IOException * @throws ServletException */ public void poll(String target, final Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { response.setContentType("text/plain;charset=utf-8"); if (writer != null) { final String s = writer.poll(); if (s != null) { response.setStatus(HttpServletResponse.SC_OK); response.getWriter().print(s); } else { response.setStatus(HttpServletResponse.SC_NOT_FOUND); } } else { response.setStatus(HttpServletResponse.SC_NOT_FOUND); } } }
Finally we need a way to capture the contents of our output stream. Again this should be familiar to any Java developer.
- public class WrappedPrintStream extends PrintStream {
- private final PrintStream ps;
- private final ByteArrayOutputStream baos;
- private final Lock lock = new ReentrantLock();
- private boolean closed = false;
- /**
- * Public factory method. We need to do this because of need to call super
- * constructor below.
- */
- public static WrappedPrintStream newInstance(PrintStream ps) {
- return new WrappedPrintStream(ps, new ByteArrayOutputStream());
- }
- /**
- * Private constructor.
- *
- * @param pw
- */
- private WrappedPrintStream(PrintStream ps, ByteArrayOutputStream baos) {
- super(baos);
- this.baos = baos;
- this.ps = ps;
- }
- /**
- * Has this writer been marked closed?
- *
- * @return
- */
- public boolean isClosed() {
- return closed;
- }
- /**
- * Set this writer closed.
- *
- * @param closed
- */
- public void setClosed(boolean closed) {
- this.closed = closed;
- }
- /**
- * Get original output stream.
- */
- public PrintStream getOut() {
- return ps;
- }
- /**
- * One of many methods...
- */
- @Override
- public void println(String str) {
- lock.lock();
- try {
- baos.write(str.getBytes());
- baos.write("\n".getBytes());
- super.println(str);
- } finally {
- lock.unlock();
- }
- }
- /**
- * Flush the output stream.
- */
- @Override
- public void flush() {
- lock.lock();
- try {
- super.flush();
- } finally {
- lock.unlock();
- }
- }
- /**
- * Poll writer for new content since last call.
- *
- * @return
- * @throws RuntimeException
- */
- public String poll() throws RuntimeException, IOException {
- String s = "";
- lock.lock();
- try {
- s = new String(baos.toByteArray());
- baos.reset();
- } finally {
- lock.unlock();
- }
- // write copy to prior printstream, typically system.out.
- ps.print(s);
- ps.flush();
- if (isClosed() && s.isEmpty()) {
- return null;
- }
- return s;
- }
- }
public class WrappedPrintStream extends PrintStream { private final PrintStream ps; private final ByteArrayOutputStream baos; private final Lock lock = new ReentrantLock(); private boolean closed = false; /** * Public factory method. We need to do this because of need to call super * constructor below. */ public static WrappedPrintStream newInstance(PrintStream ps) { return new WrappedPrintStream(ps, new ByteArrayOutputStream()); } /** * Private constructor. * * @param pw */ private WrappedPrintStream(PrintStream ps, ByteArrayOutputStream baos) { super(baos); this.baos = baos; this.ps = ps; } /** * Has this writer been marked closed? * * @return */ public boolean isClosed() { return closed; } /** * Set this writer closed. * * @param closed */ public void setClosed(boolean closed) { this.closed = closed; } /** * Get original output stream. */ public PrintStream getOut() { return ps; } /** * One of many methods... */ @Override public void println(String str) { lock.lock(); try { baos.write(str.getBytes()); baos.write("\n".getBytes()); super.println(str); } finally { lock.unlock(); } } /** * Flush the output stream. */ @Override public void flush() { lock.lock(); try { super.flush(); } finally { lock.unlock(); } } /** * Poll writer for new content since last call. * * @return * @throws RuntimeException */ public String poll() throws RuntimeException, IOException { String s = ""; lock.lock(); try { s = new String(baos.toByteArray()); baos.reset(); } finally { lock.unlock(); } // write copy to prior printstream, typically system.out. ps.print(s); ps.flush(); if (isClosed() && s.isEmpty()) { return null; } return s; } }
I’m using an explicit lock as per Effective Java.
As a practical matter it will probably be enough to only wrap println(), println(String), print(String), and printf(String, Object…). On the other hand the best way to guarantee that your class will be reused in unexpected ways is to cut corners!
In the browser
For the sake of presentation I am assuming that we already have a working browser-enabled app and we only need to add a “console” that shows the contents of the application’s System.out.
We start with a one-line addition to our HTML:
- <div id="content"/>
<div id="content"/>
with a bit of CSS chrome:
- #console {
- height: 360px;
- overflow: auto;
- font-family: monospace;
- background-position: center center;
- //background-image: url('/images/ajax_loader_red_256.gif');
- background-repeat: no-repeat;
- }
#console { height: 360px; overflow: auto; font-family: monospace; background-position: center center; //background-image: url('/images/ajax_loader_red_256.gif'); background-repeat: no-repeat; }
The background image is a spinner that shows when the browser is waiting for the application. We don’t need to set the actual image here – we’ll do that with jQuery later – but it’s a nice reminder if someone is tempted to add their own background image.
We also need to add a bit of javascript to our pages. In this case I’m also overriding the standard javascript console.log() method but this isn’t required.
- $(document).ready(function() {
- // override console so it appears on the website.
- console.log = function(str) {
- $('#console').append(str);
- $('#console').append('<br/>');
- }
- console.logSuccess = function(str) {
- $('#console').append('<font color="green">' + str + '</font><br/>');
- }
- console.logApp = function(str) {
- $('#console').append('<font color="blue">' + str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br/>') + '</font>');
- }
- console.logError = function(str) {
- $('#console').append('<font color="red">' + str + '</font><br/>');
- }
- }
$(document).ready(function() { // override console so it appears on the website. console.log = function(str) { $('#console').append(str); $('#console').append('<br/>'); } console.logSuccess = function(str) { $('#console').append('<font color="green">' + str + '</font><br/>'); } console.logApp = function(str) { $('#console').append('<font color="blue">' + str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br/>') + '</font>'); } console.logError = function(str) { $('#console').append('<font color="red">' + str + '</font><br/>'); } }
Updating the AJAX call
Finally we need to modify our AJAX calls back to the application. Our original code probably looks something like this:
- function doIt(json) {
- $.ajax({
- url : '/rest/doIt',
- processData : false,
- type : 'POST',
- contentType : 'application/json',
- data : json,
- success : function(response, statusText, xhr) {
- console.log("> doit: " + statusText);
- },
- error : function(xhr, statusText, exception) {
- console.log("> doit: " + statusText + ' (' + exception + ')');
- }
- });
- }
function doIt(json) { $.ajax({ url : '/rest/doIt', processData : false, type : 'POST', contentType : 'application/json', data : json, success : function(response, statusText, xhr) { console.log("> doit: " + statusText); }, error : function(xhr, statusText, exception) { console.log("> doit: " + statusText + ' (' + exception + ')'); } }); }
We want to add a beforeSend function that sets the background spinner and starts polling the application for updates.
- //
- // Poll the application's system.out, write it to console.
- //
- function pollSystemOut() {
- $.ajax({
- url : '/rest/poll',
- type : 'POST',
- success : function(response, statusText, xhr) {
- // update console, schedule next call.
- console.logApp(response);
- setTimeout(pollSystemOut, 250);
- },
- error : function(xhr, statusText, exception) {
- // turn off spinner
- $('#console').css('background-image', '');
- }
- });
- }
- //
- // Our existing function adds a 'beforeSend' function.
- //
- function doIt(json) {
- $.ajax({
- url : '/rest/buildDictionary',
- processData : false,
- type : 'POST',
- contentType : 'application/json',
- data : json,
- beforeSend : function() {
- // show spinner
- $('#console').css('background-image', "url('/images/ajax_loader_red_256.gif')");
- // initial delay in case the app takes a moment to start up.
- setTimeout(pollSystemOut, 500);
- return true;
- },
- success : function(response, statusText, xhr) {
- console.logSuccess("> doit: " + statusText);
- },
- error : function(xhr, statusText, exception) {
- console.logError("> doit: " + statusText + ' (' + exception + ')');
- $('#console').css('background-image', '');
- }
- });
- }
// // Poll the application's system.out, write it to console. // function pollSystemOut() { $.ajax({ url : '/rest/poll', type : 'POST', success : function(response, statusText, xhr) { // update console, schedule next call. console.logApp(response); setTimeout(pollSystemOut, 250); }, error : function(xhr, statusText, exception) { // turn off spinner $('#console').css('background-image', ''); } }); } // // Our existing function adds a 'beforeSend' function. // function doIt(json) { $.ajax({ url : '/rest/buildDictionary', processData : false, type : 'POST', contentType : 'application/json', data : json, beforeSend : function() { // show spinner $('#console').css('background-image', "url('/images/ajax_loader_red_256.gif')"); // initial delay in case the app takes a moment to start up. setTimeout(pollSystemOut, 500); return true; }, success : function(response, statusText, xhr) { console.logSuccess("> doit: " + statusText); }, error : function(xhr, statusText, exception) { console.logError("> doit: " + statusText + ' (' + exception + ')'); $('#console').css('background-image', ''); } }); }
Conclusion
This is not the only way to capture an application’s System.out. It may not even be the best way. But it’s probably a Good Enough balance between cost and performance for most uses, esp. with a few obvious enhancements.
Update: 4/10/14
A little Google goes a long way. A simple change will keep the page scrolled to the bottom. This may work a little too well since a user won’t be able to scroll back while the data is still updating but that often won’t be a problem.
- $(document).ready(function() {
- // override console so it appears on the website.
- console.log = function(str) {
- $('#console').append(str);
- $('#console').append('<br/>');
- $('#console').scrollTop($('#console')[0].scrollHeight);
- }
- console.logSuccess = function(str) {
- $('#console').append('<font color="green">' + str + '</font><br/>');
- $('#console').scrollTop($('#console')[0].scrollHeight);
- }
- console.logApp = function(str) {
- $('#console').append('<font color="blue">' + str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br/>') + '</font>');
- $('#console').scrollTop($('#console')[0].scrollHeight);
- }
- console.logError = function(str) {
- $('#console').append('<font color="red">' + str + '</font><br/>');
- $('#console').scrollTop($('#console')[0].scrollHeight);
- }
- }
$(document).ready(function() { // override console so it appears on the website. console.log = function(str) { $('#console').append(str); $('#console').append('<br/>'); $('#console').scrollTop($('#console')[0].scrollHeight); } console.logSuccess = function(str) { $('#console').append('<font color="green">' + str + '</font><br/>'); $('#console').scrollTop($('#console')[0].scrollHeight); } console.logApp = function(str) { $('#console').append('<font color="blue">' + str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br/>') + '</font>'); $('#console').scrollTop($('#console')[0].scrollHeight); } console.logError = function(str) { $('#console').append('<font color="red">' + str + '</font><br/>'); $('#console').scrollTop($('#console')[0].scrollHeight); } }