File Viewer, Part 2

One of many things Fred Brooks is famous for saying is, "plan to throw one away; you will anyhow.[*] Now that we've got filter streams in hand, I'm ready to throw out the monolithic design for the FileDumper program used in Chapter 4. I'm going to rewrite it using a more flexible, object-oriented approach that relies on multiple chained filters. This allows us to extend the system to handle new formats without rewriting all the old classes. (It also makes some of the examples in subsequent chapters smaller, since I won't have to repeat all of the code each time.) The basic idea is to make each interpretation of the data a filter input stream. Bytes from the underlying stream move into the filter; the filter converts the bytes into strings. Since more bytes generally come out of the filter than go into it (for instance, the single byte 32 is replaced by the four bytes "0", "3", "2", and " " in decimal dump format), the filter streams buffer the data as necessary.

[*] Frederick P. Brooks, The Mythical Man-Month, 20th Anniversary Edition (Reading: Addison-Wesley), 115.

The architecture revolves around the abstract DumpFilter class shown in Example 6-8. The public interface of this class is identical to that of FilterInputStream. Internally, a buffer holds the string interpretation of each byte as an array of bytes. The read( ) method returns bytes from this array as long as possible. An index field tracks the next available byte. When index reaches the length of the array, the abstract fill( ) method is invoked to read from the underlying stream and place data in the buffer.

By changing how the fill( ) method translates the bytes it reads into the bytes in the buffer, you can change how the data is interpreted.

Example 6-8. DumpFilter

package com.elharo.io; import java.io.*; public abstract class DumpFilter extends FilterInputStream { // This is really an array of unsigned bytes. private int[] buf = new int[0]; private int index = 0; public DumpFilter(InputStream in) { super(in); } public int read( ) throws IOException { int result; if (index < buf.length) { result = buf[index]; index++; } // end if else { try { this.fill( ); // fill is required to put at least one byte // in the buffer or throw an EOF or IOException. result = buf[0]; index = 1; } catch (EOFException ex) { result = -1; } } // end else return result; } protected abstract void fill( ) throws IOException; public int read(byte[] data, int offset, int length) throws IOException { if (data == null) { throw new NullPointerException( ); } else if ((offset < 0) || (offset > data.length) || (length < 0) || ((offset + length) > data.length) || ((offset + length) < 0)) { throw new ArrayIndexOutOfBoundsException( ); } else if (length == 0) { return 0; } // Check for end of stream. int datum = this.read( ); if (datum == -1) { return -1; } data[offset] = (byte) datum; int bytesRead = 1; try { for (; bytesRead < length ; bytesRead++) { datum = this.read( ); // In case of end of stream, return as much as we've got, // then wait for the next call to read to return -1. if (datum == -1) break; data[offset + bytesRead] = (byte) datum; } } catch (IOException ex) { // Return what's already in the data array. } return bytesRead; } public int available( ) throws IOException { return buf.length - index; } public long skip(long bytesToSkip) throws IOException { long bytesSkipped = 0; for (; bytesSkipped < bytesToSkip; bytesSkipped++) { int c = this.read( ); if (c == -1) break; } return bytesSkipped; } public void mark(int readlimit) {} public void reset( ) throws IOException { throw new IOException("marking not supported"); } public boolean markSupported( ) { return false; } }

The FilterInputStream class tacitly assumes that the number of bytes of input read from the underlying stream is the same as the number of bytes read from the filter stream. Somtimes this isn't true, as is the case here. For instance, the HexFilter will provide three bytes of data for every byte read from the underlying stream. The DecimalFilter will provide four. Therefore, we also have to override skip( ) and available( ). The skip( ) method reads as many bytes as possible, then returns. The available( ) method returns the number of bytes remaining in the buffer. For the uses we're putting these classes to, these methods aren't all that important, so I haven't bothered to provide optimal implementations. You can do better in subclasses, if you like.

The same problem applies to the mark( ) and reset( ) methods. These will mark and reset the underlying stream, but what we really desire is to mark and reset this stream. The easiest solution here is to deliberately not support marking and resetting. If marking and resetting is necessary, it's easy to chain this stream to a buffered stream as long as the buffered stream follows the dump filter in the chain rather than preceding it.

Concrete subclasses need to implement only a constructor or two and the fill( ) method. Example 6-9 shows the DecimalFilter class. Example 6-10 shows the HexFilter class. These two classes are very similar; each implements fill( ) and overrides available( ) (the latter mainly because it's straightforward to do). The algorithms used by the fill( ) methods for converting bytes to decimal and hexadecimal strings are essentially the same as those used by the dumpDecimal( ) and dumpHex( ) methods back in Chapter 4's FileDumper program.

Example 6-9. DecimalFilter

package com.elharo.io; import java.io.*; public class DecimalFilter extends DumpFilter { private int numRead = 0; private int breakAfter = 15; private int ratio = 4; // number of bytes of output per byte of input public DecimalFilter(InputStream in) { super(in); } protected void fill( ) throws IOException { buf = new int[ratio]; int datum = in.read( ); this.numRead++; if (datum == -1) { // Let read( ) handle end of stream. throw new EOFException( ); } String dec = Integer.toString(datum); if (datum < 10) { // Add two leading zeros. dec = "00" + dec; } else if (datum < 100) { // Add leading zero. dec = '0' + dec; } for (int i = 0; i < dec.length( ); i++) { buf[i] = dec.charAt(i); } if (numRead < breakAfter) { buf[buf.length - 1] = ' '; } else { buf[buf.length - 1] = ' '; numRead = 0; } } public int available( ) throws IOException { return (buf.length - index) + ratio * in.available( ); } }

Example 6-10. HexFilter

package com.elharo.io; import java.io.*; public class HexFilter extends DumpFilter { private int numRead = 0; private int breakAfter = 24; private int ratio = 3; // Number of bytes of output per byte of input. public HexFilter(InputStream in) { super(in); } protected void fill( ) throws IOException { buf = new int[ratio]; int datum = in.read( ); this.numRead++; if (datum == -1) { // Let read( ) handle end of stream. throw new EOFException( ); } String hex = Integer.toHexString(datum); if (datum < 16) { // Add a leading zero. hex = '0' + hex; } for (int i = 0; i < hex.length( ); i++) { buf[i] = hex.charAt(i); } if (numRead < breakAfter) { buf[buf.length - 1] = ' '; } else { buf[buf.length - 1] = ' '; numRead = 0; } } public int available( ) throws IOException { return (buf.length - index) + ratio * in.available( ); } }

The main( ) method and class in Example 6-11 are similar to what we've seen before. However, rather than selecting a method to dump the file, we select a dump filter to use. This allows multiple filters to be used in sequencea feature that will be important when we want to decompress, decrypt, or perform other transformations on the data, in addition to interpreting it. The program is also easier to read and understand when split across the three classes.

Example 6-11. FileDumper2

import java.io.*; import com.elharo.io.*; public class FileDumper2 { public static final int ASC = 0; public static final int DEC = 1; public static final int HEX = 2; public static void main(String[] args) { if (args.length < 1) { System.err.println("Usage: java FileDumper2 [-ahd] file1 file2..."); return; } int firstArg = 0; int mode = ASC; if (args[0].startsWith("-")) { firstArg = 1; if (args[0].equals("-h")) mode = HEX; else if (args[0].equals("-d")) mode = DEC; } for (int i = firstArg; i < args.length; i++) { try { InputStream in = new FileInputStream(args[i]); dump(in, System.out, mode); if (i < args.length-1) { // more files to dump System.out.println( ); System.out.println("--------------------------------------"); System.out.println( ); } } catch (IOException ex) { System.err.println(ex); } } } public static void dump(InputStream in, OutputStream out, int mode) throws IOException { // The reference variable in may point to several different objects // within the space of the next few lines. We can attach // more filters here to do decompression, decryption, and more. if (mode == ASC) ; // no filter needed, just copy raw bytes else if (mode == HEX) in = new HexFilter(in); else if (mode == DEC) in = new DecimalFilter(in); BufferedStreamCopier.copy(in, out); in.close( ); } }

The main( ) method is responsible for choosing the file and format to be dumped. The dump( ) method translates an input stream onto an output stream using a particular filter. This allows the dump( ) method to be used by other classes as a more general translation service for streams. An alternative pattern would pass the filter as an argument to dump( ) rather than as an integer mode. This might make the program more flexible but would not allow us to easily chain several filters together, as we'll do in upcoming chapters.

Категории