Stream DataInput e DataOutput in Java
Leggi e scrivi tipi primitivi Java in formato binario portabile con DataInputStream e DataOutputStream.
Finora in questa parte: byte (grezzi o con buffer) per dati binari arbitrari, caratteri per il testo. Esiste un terzo caso d'uso che i capitoli precedenti non coprono — scrivere un int, double o boolean Java su un file e rileggerlo come lo stesso tipo, in un formato con cui un'altra JVM (che gira su un OS diverso, con un ordine dei byte predefinito diverso) sarà d'accordo.
È per questo che esistono DataInputStream e DataOutputStream. Sono decoratori che si appoggiano sopra qualsiasi stream di byte e aggiungono metodi di lettura/scrittura tipizzati: writeInt, writeDouble, writeUTF, readInt, readDouble, readUTF. Il formato binario è documentato, fisso, big-endian e portabile su ogni JVM mai distribuita.
Ciò che scrivi è ciò che leggi
DataOutputStream espone un metodo per ogni tipo primitivo:
void writeBoolean(boolean v); // 1 byte (0 or 1)
void writeByte(int v); // 1 byte (low 8 bits)
void writeShort(int v); // 2 bytes, big-endian
void writeChar(int v); // 2 bytes, big-endian (UTF-16 code unit)
void writeInt(int v); // 4 bytes, big-endian
void writeLong(long v); // 8 bytes, big-endian
void writeFloat(float v); // 4 bytes, IEEE 754
void writeDouble(double v); // 8 bytes, IEEE 754
void writeUTF(String s); // modified UTF-8 with a 2-byte length prefixDataInputStream dispone dei corrispondenti readInt, readLong, readUTF e così via. Il contratto è simmetrico: scrivi un int con writeInt, rileggilo con readInt e ottieni lo stesso numero, ogni volta, su ogni JVM, su ogni sistema operativo.
Tre cose da interiorizzare:
-
Il formato non ha separatori di campo. Un file con
writeInt(42); writeUTF("alice"); writeDouble(3.14)è composto da4 + 2 + 5 + 8 = 19byte disposti senza marcatori tra loro. Devi leggere nello stesso ordine con gli stessi tipi. Non esiste schema, né auto-descrizione, né recupero se indovini male. -
writeUTFè UTF-8 modificato. Il prefisso è una lunghezza a 16 bit senza segno (quindi massimo 65.535 byte per string), eU+0000è codificato come due byte (0xC0 0x80) invece del singolo byte standard. Il formato è incompatibile con l'UTF-8 semplice — non puoi leggere una stringwriteUTFcon unReader. Usalo solo quando entrambi i lati sono Java. -
Big-endian, sempre. L'ordine nativo dei byte della macchina varia (x86 è little-endian, i protocolli di rete sono big-endian) ma
DataOutputStreamscrive sempre big-endian in modo incondizionato. Questo è ciò che rende il formato portabile. Se hai bisogno di little-endian per un protocollo che non controlli, usa invecejava.nio.ByteBuffer— ha un ordine dei byte configurabile.
Quando usare gli stream di dati
Due casi:
- Controlli entrambi i lati e vuoi un formato binario semplice, compatto e portabile tra linguaggi. Un "file di salvataggio" per un piccolo gioco Java, un file di fixture per un unit test, una cache che non deve sopravvivere alla versione della JVM. Il formato è semplice da scrivere e analizzare; non hai bisogno di una libreria di serializzazione.
- Stai leggendo un formato di file che usa il layout degli stream di dati Java. I file di classe (
.class), i record formattati conRandomAccessFile, alcuni file di indice.jar. Tutti questi sono stati scritti conDataOutputStreamperché il JDK costruisce il formato stesso.
Quando hai bisogno di interoperabilità tra linguaggi (Python, Go, JS), usa JSON, Protocol Buffers o MessagePack. Quando hai bisogno di versioning ed evoluzione dello schema, ObjectOutputStream è più vicino — ma è più pesante e ha le sue insidie.
La regola di fine file
Mentre InputStream.read() restituisce -1 alla fine dello stream, DataInputStream.readInt() (e simili) lancia EOFException. Non esiste un sentinel in-band — un int legale può essere qualsiasi valore a 32 bit, incluso -1, quindi l'unico modo per segnalare la fine dello stream è l'eccezione.
try (DataInputStream in = new DataInputStream(new BufferedInputStream(Files.newInputStream(path)))) {
try {
while (true) {
int x = in.readInt();
process(x);
}
} catch (EOFException e) {
// normal end of stream
}
}Quel try/catch per la terminazione normale è la forma idiomatica. È insolito per il JDK rendere un segnale di controllo del flusso un'eccezione, ma l'API di lettura tipizzata non ha altra scelta — non c'è nessun valore da restituire che non sia anche un int valido.
Per i file in cui controlli il formato, il pattern migliore è scrivere un prefisso di lunghezza all'inizio:
out.writeInt(n);
for (int i = 0; i < n; i++) out.writeInt(values[i]);Poi il lato di lettura esegue il ciclo n volte e non deve mai catturare EOFException per il controllo del flusso.
Usa il buffer prima di decorare
DataInputStream non esegue il buffering. Ogni readInt diventa una serie di chiamate read() sullo stream sottostante. Se quello stream è un FileInputStream, ogni readInt è quattro syscall. Avvolgi sempre con BufferedInputStream prima:
// Right
DataInputStream in = new DataInputStream(new BufferedInputStream(Files.newInputStream(path)));
DataOutputStream out = new DataOutputStream(new BufferedOutputStream(Files.newOutputStream(path)));Questo è lo stack standard a tre livelli: file → buffered → data. Lo stesso ordine si applica alla scrittura. Ometti il buffer e paghi il costo di syscall-per-byte del capitolo sugli stream con buffer, moltiplicato per il numero di byte per primitivo.
Un esempio pratico: un piccolo formato di record binario
Il programma seguente definisce un record binario minimale — un int id, un nome UTF, un double score, un boolean active — e scrive alcuni record su un file temporaneo con DataOutputStream. Li rilegge con DataInputStream usando sia il pattern count-prefix che il pattern EOFException, e infine mostra la modalità di errore di mancata corrispondenza del formato in cui il lettore e lo scrittore non sono d'accordo sui tipi di campo.
Cosa trarre dall'esecuzione:
- La dimensione del file è risultata esattamente quella che avresti previsto sommando le larghezze tipizzate: 4 (conteggio) + per record (4 + UTF con prefisso di lunghezza + 8 + 1). Nessun padding, nessun separatore. Un file data-stream è semplicemente i byte disposti, nient'altro.
- Entrambi i pattern di lettura hanno prodotto gli stessi tre record. Il pattern count-prefix è quello migliore quando stai progettando il formato; il pattern EOFException è quello a cui ricorri quando non puoi modificare lo scrittore e il formato è aperto.
- Il blocco di mancata corrispondenza del formato ha scritto due
inte letto unlong. I byte su disco (00 00 00 2A 00 00 00 63) erano validi per entrambe le interpretazioni —DataInputStreamnon ha modo di distinguere. Le due interpretazioni sono mutuamente coerenti byte per byte e mutuamente errate a livello semantico. Questo è il costo di un formato binario senza schema: la disciplina al confine è l'unica protezione. - Ogni stream era incapsulato
Files.newInputStream→BufferedInputStream→DataInputStream(e lo stesso sul lato di scrittura). Ometti il buffer ereadIntdiventa quattro syscall; il livello data-stream è puramente conversione di formato e non aggiunge alcun buffering proprio. writeUTFè stato usato per il nome. Il formato va bene per la comunicazione inter-Java ed è inutile per qualsiasi altra cosa — non sceglierlo per un file di configurazione che un giorno potresti leggere in Python. Per "solo Java e voglio che sia piccolo," è lo strumento giusto; per "qualcun altro potrebbe leggerlo," usa JSON o Protobuf.
Cosa c'è dopo
Gli stream di dati gestiscono un primitivo alla volta e richiedono che il lettore conosca il formato. Il prossimo capitolo, Java PrintWriter, torna al lato dei caratteri e copre il decoratore Writer che aggiunge print, println e printf — l'API che usi su System.out dal capitolo 1, finalmente come file-writer quale è sempre stato.