Day 39 — io interfaces
Day 39 — io interfaces
Stage V · ~3h (composition-heavy)
Goal: Internalize Go’s streaming I/O model—minimal interfaces, composition, and correct error/EOF handling—then build a transforming Reader you can plug into io.Copy.
Stage V is stdlib that pays rent. Almost every network, file, and encoding path you touch for the rest of this volume is an io.Reader or io.Writer in disguise.
Why this day exists
fmt.Println and os.ReadFile hide the real model. Production Go streams bytes:
- Files, sockets, HTTP bodies, compressors, encoders
- Pipes between processes
- In-memory buffers that still speak the same interfaces
If you only ever load whole files into []byte, you will write fragile services when payloads grow or when you must chain transforms.
Theory 1 — The minimal contracts
From io (simplified):
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}
type ReadCloser interface {
Reader
Closer
}
type WriteCloser interface {
Writer
Closer
}Rules that prevent bugs
Readmay returnn > 0anderr != nilin the same call. Process the bytes first, then the error.
io.EOFmeans “no more data,” not failure. Callers that treat EOF as fatal are wrong.
Writemust report short writes. Ifn < len(p)anderr == nil, that is a contract violation by the writer;io.ErrShortWriteexists for helpers that detect it.
- Interfaces are satisfied implicitly. Your type only needs the methods.
Why small interfaces win
Anything that can fill a buffer is a Reader. You can:
io.Copy(dst, src) // works for files, net.Conn, bytes.Buffer, http.Response.Body, ...without caring what src or dst really are.
Theory 2 — Copy, Limit, Multi, Tee
io.Copy
// Copy copies from src to dst until EOF or error.
// Returns bytes written and the first error encountered (EOF is not an error).
n, err := io.Copy(dst, src)Internally it uses a buffer (or WriterTo / ReaderFrom fast paths when available). Prefer Copy over hand-rolled loops unless you need special control.
Useful wrappers
| Helper | Role |
|---|---|
io.LimitReader(r, n) |
At most n bytes from r |
io.MultiReader(r1, r2, …) |
Concatenate readers |
io.MultiWriter(w1, w2, …) |
Fan-out writes |
io.TeeReader(r, w) |
Read from r while writing a copy to w |
io.NopCloser(r) |
Add a no-op Close to a bare Reader |
io.SectionReader |
Window into an io.ReaderAt |
Example — cap HTTP body size
const maxBody = 1 << 20 // 1 MiB
limited := io.LimitReader(r.Body, maxBody+1)
data, err := io.ReadAll(limited)
if err != nil {
return err
}
if len(data) > maxBody {
return fmt.Errorf("body too large")
}Theory 3 — io.Pipe and backpressure
pr, pw := io.Pipe()- Writes to
pwblock until someone reads frompr(buffer is small).
Close/CloseWithErrorunblocks the other side.
- Ideal for connecting a producer that wants a
Writerto a consumer that wants aReaderwithout buffering everything.
Pattern
pr, pw := io.Pipe()
go func() {
defer pw.Close()
// encode JSON, gzip, etc. into pw
_ = json.NewEncoder(pw).Encode(payload)
}()
// pass pr to http.Request.Body or io.CopyAlways handle goroutine lifecycle and errors (CloseWithError).
Theory 4 — Building a custom Reader
A transforming reader holds an inner io.Reader and rewrites bytes as they flow.
Contract checklist for custom Read:
- Copy at most
len(p)bytes intop.
- Return
(n, err)withn= bytes placed inp.
- Preserve EOF semantics of the inner reader.
- Do not retain
pafter return (caller owns the buffer).
Worked example — rot13 stream
package main
import (
"fmt"
"io"
"os"
"strings"
)
type rot13Reader struct {
r io.Reader
}
func rot13(b byte) byte {
switch {
case b >= 'A' && b <= 'Z':
return 'A' + (b-'A'+13)%26
case b >= 'a' && b <= 'z':
return 'a' + (b-'a'+13)%26
default:
return b
}
}
func (r rot13Reader) Read(p []byte) (int, error) {
n, err := r.r.Read(p)
for i := 0; i < n; i++ {
p[i] = rot13(p[i])
}
return n, err
}
func main() {
src := strings.NewReader("Hello, Stage V!")
var out strings.Builder
if _, err := io.Copy(&out, rot13Reader{r: src}); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(out.String())
}Run, then reverse by wrapping again (rot13 is involutive).
Theory 5 — ReaderFrom / WriterTo (efficiency awareness)
Optional interfaces:
type WriterTo interface {
WriteTo(w Writer) (n int64, err error)
}
type ReaderFrom interface {
ReadFrom(r Reader) (n int64, err error)
}io.Copy checks for these and skips intermediate buffering when both sides support a fast path (e.g. *os.File ↔︎ net.TCPConn on some platforms via sendfile-like paths). You rarely implement these on day one—but knowing why Copy is preferred over naive loops matters.
Theory 6 — Discard and black-hole patterns
io.Copy(io.Discard, resp.Body) // drain body so connection can be reused
resp.Body.Close()Leaving HTTP response bodies unread can stall connection reuse. Closing without draining is often wrong for keep-alive clients (Day 46 deepens this).
Lab 1 — Workspace + baseline Copy
mkdir -p ~/lab/90daysofx/01-go/day39
cd ~/lab/90daysofx/01-go/day39
go mod init example.com/day39Create copyfile.go:
package main
import (
"fmt"
"io"
"os"
)
func copyFile(dstPath, srcPath string) (int64, error) {
src, err := os.Open(srcPath)
if err != nil {
return 0, err
}
defer src.Close()
dst, err := os.Create(dstPath)
if err != nil {
return 0, err
}
defer dst.Close()
return io.Copy(dst, src)
}
func main() {
if len(os.Args) != 3 {
fmt.Fprintf(os.Stderr, "usage: %s <src> <dst>\n", os.Args[0])
os.Exit(2)
}
n, err := copyFile(os.Args[2], os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("copied %d bytes\n", n)
}echo 'stream me' > in.txt
go run . in.txt out.txt
cmp in.txt out.txt && echo OKLab 2 — Custom transforming Reader
Implement upperReader that uppercases ASCII letters as data is read (reuse the rot13 pattern).
Requirements:
type upperReader struct { r io.Reader }withReadmethod.
- Unit test using
strings.NewReader+io.ReadAll.
- Composition:
io.LimitReader(upperReader{r: src}, 5)and assert only 5 bytes emerge, already uppercased.
// upper_test.go
package main
import (
"io"
"strings"
"testing"
)
func TestUpperReader(t *testing.T) {
r := upperReader{r: strings.NewReader("abC12")}
got, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if string(got) != "ABC12" {
t.Fatalf("got %q", got)
}
}
func TestUpperLimited(t *testing.T) {
r := io.LimitReader(upperReader{r: strings.NewReader("hello world")}, 5)
got, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if string(got) != "HELLO" {
t.Fatalf("got %q", got)
}
}go test ./...Lab 3 — Tee + MultiWriter journal
Build a small tool that reads stdin, writes a copy to a log file and stdout:
mw := io.MultiWriter(os.Stdout, logFile)
if _, err := io.Copy(mw, os.Stdin); err != nil {
// handle
}Or use io.TeeReader(os.Stdin, logFile) then io.Copy(os.Stdout, tee).
Document which approach buffers less for your OS (both stream; difference is API shape).
Lab 4 — Pipe encode (stretch)
In a goroutine, write a multi-line payload through json.NewEncoder(pw). In main, io.Copy(os.Stdout, pr). Ensure:
- Producer calls
pw.Close()orCloseWithError
- Consumer sees clean EOF
- Injecting an encode error surfaces via
CloseWithError
Common gotchas
| Gotcha | Fix |
|---|---|
Ignoring n when err != nil on Read |
Always process p[:n] first |
Treating io.EOF as hard failure |
EOF ends the stream successfully for Copy |
| Buffering multi-GB files in memory | Stream with Copy / limited readers |
Forgetting Close on ReadCloser |
defer resp.Body.Close() (and drain when needed) |
Retaining the Read buffer |
Copy out data you need; never store p |
| Pipe without consumer | Deadlock—always have a reader side |
Assuming Write always writes all |
Check n and err; use io.Copy helpers |
Mental model diagram (text)
[source] --Read--> [transform Reader] --Read--> [LimitReader] --Copy--> [Writer]
^ composition: each layer only implements Read
Checkpoint
- Explain
Readreturning(n>0, err)in one sentence
- List five concrete types that implement
io.Reader
- Custom transforming
Readertested withgo test
- Demonstrated
LimitReadercomposition
- Used
io.Copyinstead of a hand-rolled full-buffer loop
- Can explain when to use
io.Pipe
Commit
git add .
git commit -m "day39: io interfaces, composition, transforming Reader"Write three personal gotchas before continuing.
Tomorrow
Day 40 — bufio / bytes / strings: buffering for efficient line/word scanning, bytes.Buffer, and strings.Builder for a streaming record parser.