Series MapLesson 08 / 32
Build CoreOrdered learning track

Learn Java Io Modern Io Resource Boundaries Part 008 Binary Io Data Formats

15 min read2949 words
PrevNext
Lesson 0832 lesson track0718 Build Core

title: Learn Java IO, Modern IO, Streams, Buffers, Resources, Serialization & Data Boundaries - Part 008 description: Binary IO deep dive for Java engineers: DataInput/DataOutput, ByteBuffer byte order, endianness, length-prefixed framing, magic bytes, versioning, bounded reads, and binary protocol correctness. series: learn-java-io-modern-io-resource-boundaries seriesTitle: Learn Java IO, Modern IO, Streams, Buffers, Resources, Serialization & Data Boundaries order: 8 partTitle: Binary IO: DataInput, DataOutput, Endianness, Framing tags:

  • java
  • io
  • binary-io
  • datainput
  • dataoutput
  • bytebuffer
  • endianness
  • framing
  • protocol-design
  • series date: 2026-06-30

Part 008 — Binary IO: DataInput, DataOutput, Endianness, Framing

Target skill: mampu mendesain dan mereview binary IO Java yang eksplisit, portable, bounded, versioned, dan tahan partial read/write, endian mismatch, corrupt frame, resource exhaustion, serta compatibility drift.

Binary IO adalah tempat engineer sering merasa “lebih dekat ke mesin”, tetapi justru karena itu bug-nya sering lebih berbahaya. Text IO gagal dengan karakter aneh. Binary IO gagal dengan offset salah, length salah, endianness salah, allocation bomb, silent truncation, atau parser yang tetap menerima data corrupt.

Binary boundary harus diperlakukan seperti kontrak protokol.

Core invariant:

A binary format is not a byte array. It is a grammar over bytes.


1. Kaufman Deconstruction: Sub-skill yang Harus Dikuasai

Sub-skillMengapa pentingFailure jika lemah
Membedakan raw bytes vs structured fieldsBinary format harus punya grammarParser offset drift, data corrupt
Memahami primitive encodingint, long, float, double punya representasi byteData lintas bahasa/platform salah
Endianness eksplisitSistem/protokol bisa big-endian atau little-endianAngka berubah total
FramingStream tidak punya message boundary bawaanReader menggantung, over-read, under-read
Bounded allocationLength field dari luar tidak boleh dipercayaOOM / DoS
Partial read/writeIO tidak menjamin semua byte tersedia sekali bacaTruncation atau parsing frame setengah
VersioningFormat binary hidup lamaDeployment baru merusak data lama
Corruption detectionBinary sulit diinspeksi manualError terlambat masuk domain layer

Latihan deliberate practice untuk binary IO: tulis format kecil dari nol, baca kembali, fuzz length field, ubah endian, potong file di tengah, naikkan version, dan pastikan parser gagal dengan error yang benar.


2. Mental Model: Binary Format sebagai Grammar

Text format punya delimiter visual. Binary format tidak punya struktur kecuali kita mendesainnya.

Contoh grammar sederhana:

File        = Magic Version RecordCount Record*
Magic       = 4 bytes: 0x4A 0x49 0x4F 0x31  // "JIO1"
Version     = unsigned byte
RecordCount = int32 big-endian
Record      = Length Payload
Length      = int32 big-endian, max 1 MiB
Payload     = Length bytes, UTF-8 JSON or custom binary

Walaupun payload bisa apa pun, struktur luarnya tetap binary framing.

Representasi byte:

4A 49 4F 31 01 00 00 00 02 00 00 00 05 48 65 6C 6C 6F ...

Tanpa grammar, itu hanya hex dump. Dengan grammar, kita punya contract.


3. Java Binary IO API Landscape

APILevelCocok untuk
InputStream / OutputStreamraw byte streamportable byte transfer, wrappers
DataInput / DataOutputprimitive binary valuessimple binary format dengan Java primitive
DataInputStream / DataOutputStreamstream wrapperread/write primitive dari stream
RandomAccessFilefile random access legacyseek-based read/write sederhana
ByteBufferNIO byte containerbinary parsing, channels, endian control
FileChannelchannel-level file IOpositional IO, transfer, mmap foundation

Bagian ini fokus pada DataInput/DataOutput, DataInputStream/DataOutputStream, dan ByteBuffer untuk binary grammar. NIO channel dan file operations akan dibahas lebih dalam di Part 013–016.


4. DataInput dan DataOutput: Portable Primitive IO

DataOutput menulis primitive Java menjadi sequence of bytes. DataInput membaca sequence bytes kembali menjadi primitive.

Contoh:

import java.io.*;

byte[] encoded;

try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
     DataOutputStream out = new DataOutputStream(baos)) {
    out.writeInt(42);
    out.writeLong(1_700_000_000_000L);
    out.writeBoolean(true);
    encoded = baos.toByteArray();
}

try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(encoded))) {
    int id = in.readInt();
    long timestamp = in.readLong();
    boolean active = in.readBoolean();
}

Important properties:

  • DataInputStream membaca dari underlying InputStream.
  • DataOutputStream menulis ke underlying OutputStream.
  • Operasi primitive multi-byte punya urutan byte yang didefinisikan.
  • readFully berguna untuk membaca exact byte count.
  • EOFException menandakan input berakhir sebelum value lengkap.

Rule:

DataInputStream.read(byte[]) adalah stream read biasa; untuk binary field fixed length, gunakan readFully.


5. Partial Read: Bug Binary IO Paling Umum

InputStream.read(byte[]) tidak wajib mengisi seluruh buffer. Ia mengembalikan jumlah byte yang benar-benar dibaca, atau -1 untuk EOF.

Bug:

byte[] payload = new byte[length];
in.read(payload); // salah: bisa hanya sebagian
parse(payload);

Perbaikan dengan DataInputStream:

byte[] payload = new byte[length];
dataInput.readFully(payload);

Perbaikan generic:

public static void readFully(InputStream in, byte[] buffer, int offset, int length) throws IOException {
    int total = 0;
    while (total < length) {
        int n = in.read(buffer, offset + total, length - total);
        if (n == -1) {
            throw new EOFException("Expected " + length + " bytes, got " + total);
        }
        total += n;
    }
}

Rule:

Setiap binary parser harus mengasumsikan read bisa partial.


6. Partial Write: Jangan Asumsikan Semua Byte Langsung Persist

OutputStream.write(byte[]) pada banyak implementasi mencoba menulis seluruh array atau melempar exception. Namun saat masuk NIO channel, socket, non-blocking IO, atau custom sink, write bisa partial.

Untuk OutputStream, API write(byte[]) biasanya convenience yang memanggil write(byte[], 0, len), dan contract implementasi harus menangani sesuai spesifikasi. Tetapi mental model tetap penting:

  • write ke buffer bukan berarti durable,
  • flush bukan berarti fsync,
  • socket write bukan berarti peer sudah memproses,
  • non-blocking channel write bisa menulis sebagian.

Nanti di Part 015–016 kita akan membahas channel write loops.


7. Endianness: Big-endian, Little-endian, dan Network Order

Endianness menentukan urutan byte untuk angka multi-byte.

Angka 0x01020304:

EndianByte order
Big-endian01 02 03 04
Little-endian04 03 02 01

Jika parser salah endian, nilai berubah drastis.

Bytes: 00 00 01 00
Big-endian int:    256
Little-endian int: 65536

DataInputStream/DataOutputStream menggunakan format yang didefinisikan oleh Java data stream, yaitu high byte first untuk primitive multi-byte. Untuk format little-endian, gunakan ByteBuffer.order(ByteOrder.LITTLE_ENDIAN) atau encode manual.

ByteBuffer buffer = ByteBuffer.allocate(4);
buffer.order(ByteOrder.LITTLE_ENDIAN);
buffer.putInt(0x01020304);
byte[] bytes = buffer.array(); // 04 03 02 01

Default ByteBuffer adalah big-endian. Jangan mengandalkan default secara implisit pada protocol parser; set order eksplisit di boundary.

ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN);
int value = buffer.getInt();

Rule:

Binary contract harus menyebut endian untuk setiap multi-byte numeric field.


8. Signedness: Java Tidak Punya Unsigned Byte Primitive

Java byte signed: -128 sampai 127. Tetapi binary format sering memakai unsigned byte: 0 sampai 255.

Bug:

byte b = payload[0];
int value = b; // sign-extended, bisa negatif

Perbaikan:

int value = payload[0] & 0xFF;

Untuk unsigned short:

int unsignedShort = dataInput.readUnsignedShort();

Untuk unsigned int 32-bit:

long unsignedInt = Integer.toUnsignedLong(dataInput.readInt());

Rule:

Saat binary format menyebut unsigned, jangan pakai widening Java biasa tanpa masking/conversion eksplisit.


9. writeUTF Bukan General UTF-8 String Encoding

DataOutput.writeUTF(String) menulis string dalam format yang dikenal sebagai modified UTF-8, dengan length prefix tertentu. Ini berguna untuk data stream Java tertentu, tetapi bukan format UTF-8 umum untuk interoperabilitas lintas bahasa.

Untuk binary protocol yang ingin menyimpan UTF-8 biasa, gunakan:

byte[] utf8 = value.getBytes(StandardCharsets.UTF_8);
out.writeInt(utf8.length);
out.write(utf8);

Read side:

int length = in.readInt();
if (length < 0 || length > MAX_STRING_BYTES) {
    throw new IOException("Invalid string length: " + length);
}
byte[] utf8 = new byte[length];
in.readFully(utf8);
String value = new String(utf8, StandardCharsets.UTF_8);

Untuk strict UTF-8, gunakan CharsetDecoder seperti Part 007.

Rule:

Jangan gunakan writeUTF untuk format publik/interoperable kecuali contract secara eksplisit menyebut Java modified UTF-8.


10. Framing: Stream Tidak Punya Message Boundary

Byte stream adalah sequence kontinu. Ia tidak tahu “message 1 selesai di sini”. Jika kamu menulis dua payload berturut-turut, reader harus punya cara menentukan batas payload.

Strategi framing umum:

StrategyBentukCocok untukRisiko
Fixed lengthsetiap record N bytesformat sederhana, numeric tablewasteful, sulit evolve
Delimiterpayload diakhiri byte khusustext-ish protocolescaping, delimiter dalam payload
Length prefixlength + payloadbinary messagelength bomb jika tidak dibatasi
TLVtype + length + valueextensible binary formatparser lebih kompleks
Chunkedsequence chunk sampai terminatorstreaming large payloadstate machine lebih sulit

Untuk binary data, length-prefix adalah pilihan umum.

Frame = Magic Version Length Payload Crc32

11. Magic Bytes: Fail Fast terhadap Format Salah

Magic bytes adalah signature di awal file/message.

Contoh:

private static final int MAGIC = 0x4A494F31; // "JIO1"

Write:

out.writeInt(MAGIC);

Read:

int magic = in.readInt();
if (magic != MAGIC) {
    throw new IOException("Invalid magic: 0x" + Integer.toHexString(magic));
}

Manfaat:

  • mendeteksi file salah,
  • mendeteksi endian mismatch lebih cepat,
  • membantu debugging hex dump,
  • memberi boundary jelas untuk parser.

Rule:

Setiap binary file format non-trivial sebaiknya punya magic bytes.


12. Version Field: Format Akan Berevolusi

Binary format tanpa version field adalah format yang diam-diam menganggap tidak akan berubah.

Contoh header:

magic:   4 bytes
version: 1 byte
flags:   1 byte
length:  4 bytes

Read:

int version = in.readUnsignedByte();
if (version != 1) {
    throw new IOException("Unsupported version: " + version);
}

Versioning strategies:

StrategyCocok untukTrade-off
Single version byteformat kecilmajor changes saja
Major/minorcompatibility lebih halusparsing lebih kompleks
Feature flagsoptional capabilitiesbutuh unknown flag policy
TLV extension fieldsextensible recordsoverhead dan parser lebih rumit

Unknown flags policy harus jelas:

  • reject unknown critical flags,
  • ignore unknown non-critical flags,
  • preserve unknown fields jika melakukan read-modify-write.

13. Length Field: Validasi Sebelum Allocation

Length prefix adalah sumber vulnerability dan outage jika dipercaya mentah-mentah.

Bug:

int length = in.readInt();
byte[] payload = new byte[length]; // length bisa negatif atau sangat besar
in.readFully(payload);

Perbaikan:

private static final int MAX_PAYLOAD_BYTES = 1024 * 1024;

int length = in.readInt();
if (length < 0 || length > MAX_PAYLOAD_BYTES) {
    throw new IOException("Invalid payload length: " + length);
}

byte[] payload = new byte[length];
in.readFully(payload);

Rule:

Tidak ada allocation berdasarkan data eksternal tanpa batas maksimum.


14. CRC dan Integrity Check Ringan

Checksum seperti CRC32 bisa mendeteksi corruption accidental. Ini bukan security mechanism, bukan pengganti signature/MAC, tetapi berguna untuk file transfer, storage, dan framing.

Write:

import java.util.zip.CRC32;

CRC32 crc = new CRC32();
crc.update(payload);
long checksum = crc.getValue();
out.writeInt((int) checksum);

Read:

CRC32 crc = new CRC32();
crc.update(payload);
int actual = (int) crc.getValue();
int expected = in.readInt();

if (actual != expected) {
    throw new IOException("CRC mismatch");
}

Important:

  • CRC32 mendeteksi banyak corruption accidental.
  • CRC32 tidak mencegah malicious tampering.
  • Untuk security integrity gunakan mekanisme cryptographic yang dibahas di seri security, bukan diulang di sini.

15. Complete Example: Length-Prefixed Binary Record Codec

Kita buat format kecil:

Frame:
  magic       int32  big-endian  "JIO1"
  version     u8     currently 1
  flags       u8     currently 0
  type        u16    application-defined
  length      int32  payload length, 0..1MiB
  payload     bytes  opaque
  crc32       int32  CRC32 over payload

15.1 Model

public record BinaryFrame(int type, byte[] payload) {
    public BinaryFrame {
        if (type < 0 || type > 0xFFFF) {
            throw new IllegalArgumentException("type must fit unsigned 16-bit");
        }
        payload = payload.clone();
    }

    @Override
    public byte[] payload() {
        return payload.clone();
    }
}

15.2 Writer

import java.io.*;
import java.util.zip.CRC32;

public final class BinaryFrameWriter {
    private static final int MAGIC = 0x4A494F31; // JIO1
    private static final int VERSION = 1;
    private static final int FLAGS = 0;
    private static final int MAX_PAYLOAD_BYTES = 1024 * 1024;

    private final DataOutputStream out;

    public BinaryFrameWriter(OutputStream out) {
        this.out = new DataOutputStream(new BufferedOutputStream(out));
    }

    public void writeFrame(BinaryFrame frame) throws IOException {
        byte[] payload = frame.payload();
        if (payload.length > MAX_PAYLOAD_BYTES) {
            throw new IOException("Payload too large: " + payload.length);
        }

        out.writeInt(MAGIC);
        out.writeByte(VERSION);
        out.writeByte(FLAGS);
        out.writeShort(frame.type());
        out.writeInt(payload.length);
        out.write(payload);
        out.writeInt(crc32(payload));
    }

    public void flush() throws IOException {
        out.flush();
    }

    private static int crc32(byte[] bytes) {
        CRC32 crc = new CRC32();
        crc.update(bytes);
        return (int) crc.getValue();
    }
}

15.3 Reader

import java.io.*;
import java.util.zip.CRC32;

public final class BinaryFrameReader {
    private static final int MAGIC = 0x4A494F31; // JIO1
    private static final int SUPPORTED_VERSION = 1;
    private static final int MAX_PAYLOAD_BYTES = 1024 * 1024;

    private final DataInputStream in;

    public BinaryFrameReader(InputStream in) {
        this.in = new DataInputStream(new BufferedInputStream(in));
    }

    public BinaryFrame readFrame() throws IOException {
        int magic;
        try {
            magic = in.readInt();
        } catch (EOFException eof) {
            throw new EOFException("No complete frame header available");
        }

        if (magic != MAGIC) {
            throw new IOException("Invalid magic: 0x" + Integer.toHexString(magic));
        }

        int version = in.readUnsignedByte();
        if (version != SUPPORTED_VERSION) {
            throw new IOException("Unsupported frame version: " + version);
        }

        int flags = in.readUnsignedByte();
        if (flags != 0) {
            throw new IOException("Unsupported flags: 0x" + Integer.toHexString(flags));
        }

        int type = in.readUnsignedShort();
        int length = in.readInt();
        if (length < 0 || length > MAX_PAYLOAD_BYTES) {
            throw new IOException("Invalid payload length: " + length);
        }

        byte[] payload = new byte[length];
        in.readFully(payload);

        int expectedCrc = in.readInt();
        int actualCrc = crc32(payload);
        if (actualCrc != expectedCrc) {
            throw new IOException("CRC mismatch");
        }

        return new BinaryFrame(type, payload);
    }

    private static int crc32(byte[] bytes) {
        CRC32 crc = new CRC32();
        crc.update(bytes);
        return (int) crc.getValue();
    }
}

15.4 Why this is production-shaped

  • magic bytes detect wrong format,
  • version supports evolution,
  • flags create room for future capabilities,
  • type allows multiplexing record kinds,
  • length is bounded before allocation,
  • readFully handles partial read,
  • CRC detects accidental corruption,
  • buffered wrapper reduces syscall pressure,
  • payload is defensive-copied.

16. Streaming Multiple Frames

A file or socket may contain many frames.

while (true) {
    try {
        BinaryFrame frame = reader.readFrame();
        handle(frame);
    } catch (EOFException eof) {
        break;
    }
}

Namun ini terlalu kasar karena EOFException bisa berarti dua hal:

  1. clean EOF before next frame,
  2. truncated frame after partial header/payload.

Lebih baik desain reader yang membedakan:

public enum FrameReadStatus {
    FRAME,
    CLEAN_EOF,
    TRUNCATED
}

Atau gunakan method:

Optional<BinaryFrame> readNextFrame() throws IOException;

Tetapi Optional.empty() hanya boleh berarti clean EOF sebelum membaca byte pertama frame. Jika EOF terjadi setelah sebagian frame terbaca, itu harus error.

Rule:

Clean EOF dan truncated frame adalah dua kondisi berbeda.


17. ByteBuffer untuk Binary Parsing

ByteBuffer memberi API get/put primitive dengan posisi, limit, dan byte order.

ByteBuffer buffer = ByteBuffer.allocate(16)
        .order(ByteOrder.BIG_ENDIAN);

buffer.putInt(0x4A494F31);
buffer.put((byte) 1);
buffer.put((byte) 0);
buffer.putShort((short) 42);

buffer.flip();

int magic = buffer.getInt();
int version = buffer.get() & 0xFF;
int flags = buffer.get() & 0xFF;
int type = buffer.getShort() & 0xFFFF;

ByteBuffer lebih cocok saat:

  • kamu sudah punya byte array atau mapped region,
  • parsing header fixed-size,
  • bekerja dengan channels,
  • butuh endian control eksplisit,
  • ingin avoid banyak object wrapper.

DataInputStream lebih cocok saat:

  • stream sequential blocking,
  • format sederhana,
  • ingin API mudah untuk primitive,
  • tidak butuh random access atau NIO integration.

18. ByteBuffer Boundary Checks

ByteBuffer.getInt() akan gagal jika remaining bytes kurang dari 4. Tetapi error-nya adalah buffer exception, bukan domain-specific parse error.

Untuk parser production, cek remaining dan lempar error format yang jelas.

public static int getIntRequired(ByteBuffer buffer, String field) throws IOException {
    if (buffer.remaining() < Integer.BYTES) {
        throw new EOFException("Missing int32 field: " + field);
    }
    return buffer.getInt();
}

Untuk payload length:

int length = getIntRequired(buffer, "payloadLength");
if (length < 0 || length > MAX_PAYLOAD_BYTES) {
    throw new IOException("Invalid payload length: " + length);
}
if (buffer.remaining() < length) {
    throw new EOFException("Payload truncated: expected " + length + " bytes");
}

Rule:

Low-level buffer exception sebaiknya diterjemahkan menjadi format error yang bermakna di boundary parser.


19. TLV: Type-Length-Value untuk Evolvable Binary Format

TLV berguna saat record punya field opsional atau format perlu evolve.

Message = Field*
Field   = Type Length Value
Type    = u16
Length  = u32
Value   = bytes

Contoh type:

1 = userId UTF-8 string
2 = timestamp epoch millis int64
3 = status u8
1000..1999 = vendor extensions

Reader bisa:

  • parse known fields,
  • skip unknown non-critical fields,
  • reject duplicate required fields,
  • reject missing required fields,
  • enforce max field length.

Skeleton:

while (buffer.hasRemaining()) {
    int type = readUnsignedShort(buffer);
    int length = readBoundedLength(buffer, MAX_FIELD_BYTES);

    if (buffer.remaining() < length) {
        throw new EOFException("Truncated TLV field " + type);
    }

    ByteBuffer value = buffer.slice(buffer.position(), length);
    buffer.position(buffer.position() + length);

    switch (type) {
        case 1 -> userId = decodeUtf8(value);
        case 2 -> timestamp = readInt64(value);
        default -> handleUnknown(type, value);
    }
}

TLV trade-off:

  • lebih verbose,
  • parser lebih kompleks,
  • lebih mudah evolve,
  • lebih mudah skip unknown fields.

20. Varint: Compact Integer Encoding

Beberapa binary format memakai variable-length integer agar angka kecil memakai byte lebih sedikit. Contoh pendekatan umum: 7 bit data per byte, MSB sebagai continuation bit.

Write unsigned varint:

public static void writeUnsignedVarInt(OutputStream out, int value) throws IOException {
    while ((value & 0xFFFFFF80) != 0L) {
        out.write((value & 0x7F) | 0x80);
        value >>>= 7;
    }
    out.write(value & 0x7F);
}

Read unsigned varint dengan batas:

public static int readUnsignedVarInt(InputStream in) throws IOException {
    int value = 0;
    int position = 0;

    while (position < 32) {
        int b = in.read();
        if (b == -1) {
            throw new EOFException("Truncated varint");
        }

        value |= (b & 0x7F) << position;

        if ((b & 0x80) == 0) {
            return value;
        }

        position += 7;
    }

    throw new IOException("Varint too long");
}

Varint bagus untuk:

  • banyak angka kecil,
  • bandwidth sensitif,
  • format compact.

Varint buruk jika:

  • format perlu mudah dibaca hex dump,
  • random access fixed offset penting,
  • parser simplicity lebih penting dari beberapa byte hemat.

Rule:

Jangan memakai varint hanya karena terlihat sophisticated. Pakai saat compression/size benefit nyata.


21. Alignment dan Padding

Beberapa binary format memakai padding agar field dimulai pada offset tertentu. Ini umum di format low-level atau interop dengan native struct.

Contoh:

u8 type
u8 flags
u16 reserved
u32 length

Padding harus eksplisit dalam spec:

  • berapa byte,
  • harus nol atau boleh sembarang,
  • apakah reader harus reject jika non-zero,
  • apakah writer wajib mengisi nol.

Rule:

Reserved bytes harus ditulis nol dan biasanya direject jika non-zero kecuali spec mengizinkan.

Kenapa? Reserved bytes adalah ruang evolusi. Jika hari ini diabaikan tanpa aturan, besok compatibility sulit.


22. Binary Compatibility: Tambah Field Tanpa Merusak Reader Lama

Fixed-layout format:

V1: id:int32 amount:int64
V2: id:int32 amount:int64 currency:u16

Jika reader lama membaca V2 tanpa version/framing, ia bisa:

  • mengabaikan trailing bytes secara tidak sadar,
  • menganggap trailing bytes sebagai record berikutnya,
  • corrupt seluruh stream.

Lebih aman:

Record = version length payload

Reader lama yang melihat unsupported version bisa reject cleanly. Reader yang mendukung skip bisa melewati record berdasarkan length.

Evolution rules:

  • Tambah optional field hanya jika parser bisa skip unknown.
  • Jangan ubah meaning field existing tanpa major version.
  • Jangan ubah endian diam-diam.
  • Jangan ubah unit field tanpa version, misalnya seconds menjadi millis.
  • Jangan ubah signedness tanpa migration.

23. Error Taxonomy untuk Binary Parser

Jangan semua error menjadi IOException("bad file"). Boundary error harus actionable.

ErrorMeaningTypical action
InvalidMagicbukan format yang diharapkanreject file/message
UnsupportedVersionformat lebih baru/lamacompatibility handling
InvalidLengthlength negatif/terlalu besarreject, possible abuse
TruncatedFrameEOF sebelum frame lengkapretry transfer / mark corrupt
CrcMismatchpayload corruptreject / re-fetch
UnknownCriticalFlagfitur tidak didukungreject
DuplicateFieldTLV field invalidreject as corrupt
MissingRequiredFieldschema incompletereject

Di Java, kamu bisa mulai sederhana dengan custom exception:

public final class BinaryFormatException extends IOException {
    public BinaryFormatException(String message) {
        super(message);
    }

    public BinaryFormatException(String message, Throwable cause) {
        super(message, cause);
    }
}

Lalu gunakan message yang menyertakan field/offset jika memungkinkan.


24. Hex Dump untuk Debugging

Binary parser butuh tooling debugging. Minimal utility:

public static String hex(byte[] bytes, int max) {
    StringBuilder sb = new StringBuilder();
    int limit = Math.min(bytes.length, max);
    for (int i = 0; i < limit; i++) {
        if (i > 0) {
            sb.append(' ');
        }
        sb.append(String.format("%02X", bytes[i] & 0xFF));
    }
    if (bytes.length > max) {
        sb.append(" ...");
    }
    return sb.toString();
}

Jangan log seluruh payload besar atau sensitif. Log cukup:

  • magic,
  • version,
  • length,
  • offset,
  • first N bytes,
  • checksum,
  • correlation id.

25. Testing Binary IO

Binary IO test harus mencakup round-trip dan failure injection.

25.1 Round-trip

BinaryFrame original = new BinaryFrame(7, new byte[] {1, 2, 3});

ByteArrayOutputStream baos = new ByteArrayOutputStream();
BinaryFrameWriter writer = new BinaryFrameWriter(baos);
writer.writeFrame(original);
writer.flush();

BinaryFrameReader reader = new BinaryFrameReader(new ByteArrayInputStream(baos.toByteArray()));
BinaryFrame decoded = reader.readFrame();

assertEquals(original.type(), decoded.type());
assertArrayEquals(original.payload(), decoded.payload());

25.2 Invalid magic

Mutate first byte and expect failure.

bytes[0] = 0x00;
assertThrows(IOException.class, () -> reader(bytes).readFrame());

25.3 Truncated payload

Remove last byte.

byte[] truncated = Arrays.copyOf(bytes, bytes.length - 1);
assertThrows(EOFException.class, () -> reader(truncated).readFrame());

25.4 Length bomb

Set length to Integer.MAX_VALUE and ensure parser rejects before allocation.

// mutate bytes at payload length offset

Assertion penting:

Test harus memastikan parser tidak mencoba allocate payload besar sebelum validasi.

25.5 Endian mismatch

Encode little-endian, read big-endian, pastikan invalid magic/length terdeteksi.

25.6 Fuzz small mutations

Untuk format kecil, lakukan mutation random pada byte array dan pastikan parser:

  • tidak hang,
  • tidak OOM,
  • tidak uncaught runtime exception yang aneh,
  • gagal dengan IOException/BinaryFormatException.

26. Anti-patterns

Anti-pattern 1 — Allocation dari length eksternal

byte[] payload = new byte[in.readInt()];

Perbaikan: validate length dulu.

Anti-pattern 2 — read() sekali untuk payload

in.read(payload);

Perbaikan: readFully.

Anti-pattern 3 — Endian implicit

int value = ByteBuffer.wrap(bytes).getInt();

Perbaikan:

int value = ByteBuffer.wrap(bytes)
        .order(ByteOrder.BIG_ENDIAN)
        .getInt();

Anti-pattern 4 — writeUTF untuk public protocol

Perbaikan: length-prefixed normal UTF-8 bytes.

Anti-pattern 5 — No magic, no version

Masalah: file salah dan evolution sulit dideteksi.

Perbaikan: header dengan magic + version.

Anti-pattern 6 — Treat EOF as normal everywhere

Masalah: truncated frame dianggap clean EOF.

Perbaikan: bedakan EOF before frame vs EOF inside frame.


27. Binary Format Design Checklist

  • Apakah ada magic bytes?
  • Apakah ada version?
  • Apakah ada flags/reserved field untuk evolusi?
  • Apakah endian ditentukan eksplisit?

Length and bounds

  • Apakah semua length field divalidasi sebelum allocation?
  • Apakah ada max payload bytes?
  • Apakah max field bytes berbeda dari max message bytes jika perlu?
  • Apakah negative length direject?

Reading

  • Apakah fixed-size field dibaca dengan exact read?
  • Apakah clean EOF dibedakan dari truncated frame?
  • Apakah parser punya error taxonomy yang jelas?
  • Apakah unknown flags/fields punya policy?

Encoding

  • Apakah string memakai UTF-8 biasa atau modified UTF-8 secara sengaja?
  • Apakah signedness eksplisit?
  • Apakah unit numeric field jelas: bytes, millis, nanos, cents?
  • Apakah timestamp format jelas?

Evolution

  • Apakah tambah field bisa dilakukan tanpa merusak reader lama?
  • Apakah reserved bytes ditulis nol?
  • Apakah reader reject reserved non-zero jika perlu?
  • Apakah unsupported version gagal cleanly?

Integrity

  • Apakah corruption accidental perlu checksum?
  • Apakah security tampering butuh MAC/signature di layer lain?
  • Apakah checksum dihitung atas bytes yang benar?

28. Deliberate Practice

Exercise 1 — Build a binary frame codec

Implementasikan format:

magic: 4 bytes
version: 1 byte
type: 2 bytes
length: 4 bytes
payload: length bytes
crc32: 4 bytes

Requirement:

  • max payload 256 KiB,
  • big-endian eksplisit,
  • invalid magic error,
  • unsupported version error,
  • truncated frame error,
  • CRC mismatch error,
  • unit tests round-trip + mutations.

Exercise 2 — Add TLV extension section

Tambahkan optional metadata:

metadataLength: int32
metadata: repeated TLV

Requirement:

  • skip unknown non-critical fields,
  • reject duplicate field 1,
  • reject field length > 4 KiB,
  • decode field 1 as strict UTF-8.

Exercise 3 — Little-endian interop

Buat writer little-endian dengan ByteBuffer, lalu parser big-endian. Pastikan test menunjukkan failure yang jelas.

Exercise 4 — Fuzz parser

Generate valid frame, lalu mutate 1–5 random bytes. Parser tidak boleh:

  • hang,
  • OOM,
  • infinite loop,
  • throw unexpected ArrayIndexOutOfBoundsException,
  • accept obviously invalid length.

Exercise 5 — Hex dump diagnostics

Saat parse gagal, tampilkan:

  • offset logical,
  • field name,
  • first 32 bytes hex,
  • expected vs actual jika ada.

29. Key Takeaways

  1. Binary format adalah grammar atas byte, bukan byte array acak.
  2. Stream tidak punya message boundary; framing harus didesain.
  3. read(byte[]) bisa partial; gunakan readFully untuk exact binary fields.
  4. Endianness harus menjadi bagian eksplisit dari contract.
  5. Jangan allocate berdasarkan length field eksternal tanpa validasi maksimum.
  6. Magic bytes dan version field adalah investasi kecil untuk debugging dan evolution.
  7. writeUTF memakai modified UTF-8; jangan pakai untuk public protocol kecuali memang contract-nya demikian.
  8. Clean EOF dan truncated frame harus dibedakan.
  9. Binary parser perlu error taxonomy yang actionable.
  10. Test binary IO harus mencakup mutation, truncation, invalid length, endian mismatch, dan checksum mismatch.

30. Referensi

Lesson Recap

You just completed lesson 08 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.