androidhttpnanohttpd

NanoHTTPD - write to a socket instead of copy a string to deliver pages


This question is not about how NanoHTTPD can deliver streaming content, or how it can leave the HTTP socket connection open after serving a page.

I generate HTML very responsibly, with HTML.java, by passing in a Writer that assembles all the content into a String.

Then my code copies that string and drops it into newFixedLengthResponse() which sends the HTML to a client.

This means, the entire time my HTML generator writes into the Writer stringStream, a real stream - the socket to the web browser - is open and doing nothing. While my stringStream does too much - buffering more and more memory...

Can't I just find that socket itself, and drop it into my HTML generator? That way when I evaluate html.div(), the "<div" part actually goes out the wire, and into the browser (it's nearby) while we render the rest of the page.

I am aware that most web servers don't do this, and they all buffer huge strings in memory instead of efficiently streaming them out the wire...

for my next magical trick I will get HTTPS working C-;


Solution

  • Even in the age of virtual memory and terabyte RAM, streams are more efficient than strings. When I originally posted this question I accidentally didn't notice the HTTPSession object already had a outputStream member. So the first step is to escalate it. Add this to IHTTPSession:

    OutputStream getOutputStream();
    

    Now add this to HTTPSession:

    public OutputStream getOutputStream() {
        return outputStream;
    }
    

    And add this method to Response:

    public final void sender(@NonNull OutputStream outputStream, @NonNull Runnable run) {
        SimpleDateFormat gmtFrmt = new SimpleDateFormat("E, d MMM yyyy HH:mm:ss 'GMT'", Locale.US);
        gmtFrmt.setTimeZone(TimeZone.getTimeZone("GMT"));
    
        try {
            if (status == null) {
                throw new Error("sendResponse(): Status can't be null.");
            }
            PrintWriter pw = new PrintWriter(new BufferedWriter(new OutputStreamWriter(outputStream, new ContentType(mimeType).getEncoding())), false);
            pw.append("HTTP/1.1 ").append(status.getDescription()).append(" \r\n");
            if (mimeType != null) {
                printHeader(pw, "Content-Type", mimeType);
            }
            if (getHeader("date") == null) {
                printHeader(pw, "Date", gmtFrmt.format(new Date()));
            }
            for (Entry<String, String> entry : header.entrySet()) {
                printHeader(pw, entry.getKey(), entry.getValue());
            }
            for (String cookieHeader : cookieHeaders) {
                printHeader(pw, "Set-Cookie", cookieHeader);
            }
            if (getHeader("connection") == null) {
                printHeader(pw, "Connection", (keepAlive ? "keep-alive" : "close"));
            }
            long pending = data != null ? contentLength : 0;
            if (requestMethod != Method.HEAD && chunkedTransfer) {
                printHeader(pw, "Transfer-Encoding", "chunked");
            }
            pw.append("\r\n");
            pw.flush();
    
            run.run(); // <-- your streaming happens here
    
            outputStream.flush();
            NanoHTTPD.safeClose(data);
        } catch (IOException ioe) {
            NanoHTTPD.LOG.log(Level.SEVERE, "Could not send response to the client", ioe);
        }
    }
    

    Note we could chop out even more unused stuff. For example, the browser does not deserve to know the Content-Length; it will just have to pull the page and see what it gets.

    Now the app overrides .serve() and makes it look like this:

        public final Response serve(IHTTPSession session) {
            OutputStream outputStream = session.getOutputStream();
    
            newFixedLengthResponse("").sender(outputStream, () -> {
                new OutputStreamWriter(outputStream).write("Yo, World!");
            } ;
    
            return null;
        }
    

    Finally, to prevent NanoHTTPD from having a snit-fit over that null, get inside HTTPSession and replace this throw with a return:

    //            throw new ResponseException(Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: Serve() returned a null response.");
                return;
    

    Further cleanups are obviously possible, but the root principle remains that because my app uses streaming (specifically, com.googlecode.jatl.HTML) to build its pages, the web browser can be painting the top of our page while we are still generating the bottom part of the page.