Skip to Content
Course content

119: Wrapping Readers and Writers for Composability

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you first see io.Reader and io.Writer, they seem almost too simple. They're just interfaces with one method. But the real magic of Go's I/O isn't in the interfaces themselves; it's in how we wrap them. I call this "the onion pattern." You start with a base source of data and keep wrapping it in layers that add specific behaviors.

Why can't I just write a function that handles the decompression and reading in one go?

You could, but you'd be painting yourself into a corner. If you write a function called ReadCompressedFile(path string), that function only works for files. What happens when your boss tells you that the compressed data is now coming from an S3 bucket, or a network socket, or an in-memory buffer for your unit tests?

By wrapping readers, you decouple where the data comes from from what you do to the data. The gzip.NewReader function doesn't care if it's reading from a file or a TCP connection; it just needs something that satisfies io.Reader. This composability is why Go's standard library is so powerful. You build a pipeline of small, single-purpose tools rather than one giant, rigid function.

How do I actually chain multiple wrappers together?

It's a bit like nesting Russian dolls. You pass the result of one wrapper as the input to the next. Let's look at a real-world scenario: you need to read a gzipped log file, but you want to limit the read to the first 10KB to avoid loading a massive file into memory, and you want it buffered for performance.


package main

import (
	"bufio"
	"compress/gzip"
	"fmt"
	"io"
	"os"
)

func main() {
	// 1. The base: a raw file reader
	f, err := os.Open("logs.txt.gz")
	if err != nil {
		panic(err)
	}
	defer f.Close()

	// 2. Wrap it to handle decompression
	gzReader, err := gzip.NewReader(f)
	if err != nil {
		panic(err)
	}
	defer gzReader.Close()

	// 3. Wrap it to limit the number of bytes read (protection against "zip bombs")
	limitedReader := io.LimitReader(gzReader, 10240) // 10KB limit

	// 4. Wrap it in a buffer to make reads efficient
	bufferedReader := bufio.NewReader(limitedReader)

	// Now we just use the final wrapper. 
	// It doesn't know (or care) about the gzip or the limit.
	line, _ := bufferedReader.ReadString('\n')
	fmt.Println(line)
}

See what happened there? Each layer adds a "capability." The bufio.Reader thinks it's reading from a normal stream, but every time it asks for data, it triggers a chain reaction: it asks the LimitReader, which asks the gzip.Reader, which finally asks the os.File.

Is there a performance cost to adding all these layers?

Technically, yes. Every layer adds a function call to the stack. However, in 99% of Go applications, this is negligible compared to the cost of the actual I/O (like reading from a disk or network). In fact, wrapping is often how you improve performance.

Take bufio as the prime example. Reading one byte at a time from a disk is painfully slow because of system call overhead. By wrapping your reader in a bufio.Reader, you're telling Go to grab a large chunk of data from the disk once and keep it in memory, serving your small reads from that buffer. I've seen developers avoid wrapping because they fear "overhead," only to find their app is 10x slower because they weren't buffering their I/O.

What happens to the original reader or writer when I'm done?

This is where you have to be careful. Not all wrappers are created equal. Some wrappers just pass through the Read or Write calls, but others—like gzip.Reader or bufio.Writer—maintain internal state or buffers that need to be explicitly closed or flushed.

  • If a wrapper provides a Close() method, you should call it.
  • If you're using a bufio.Writer, you must call Flush() before closing the underlying writer, otherwise, you'll likely lose the last few KB of data still sitting in the buffer.

My rule of thumb: always defer the Close() of the base resource (the file or socket) and the Close() of any wrapper that requires it, in the reverse order they were created.




📋 Practical Task

Build a "Counting Writer" Wrapper

Your task is to create a custom wrapper for io.Writer that tracks how many total bytes have been written to the underlying stream. This is a common pattern for implementing progress bars or telemetry in CLI tools.

Requirements:

  • Define a struct called ByteCounter that embeds an io.Writer and has an int64 field to track the total bytes.
  • Implement the Write([]byte) (int, error) method for ByteCounter so that it increments the counter by the number of bytes successfully written by the underlying writer.
  • In your main function, wrap an os.Stdout (or a file) with your ByteCounter.
  • Write several strings to the wrapped writer and then print the final count of bytes written to the console.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.