A buffered writer is so ubiquitous that we do not usually consider it as a pattern, but sometimes we reinvent it or even do it in an inferior way. Let us look at a real use case first.
Batch processor
What would you do to improve the throughput of a service? The answer is short: batching.
By processing and sending in a batch of multiple items instead of a single item at a time, you are amortizing the network overhead from the request-response round trip among all the items in the batch.
Then how would you design a client interface to do that batching?
How about this?
type BatchProcessor interface {
Process(items []Item) error
}
It looks like a straightforward solution, but in reality, it introduces unnecessary complexity in both business logic and error handling.
The processing is often composed of multiple steps working on the items, e.g. transformations and encoding.
items -> transformations -> encoding -> bytes
With a batch processor interface like above, each step has to loop around the items, and each step has to deal with the errors from multiple items. Not only there is more complexity but also less flexibility. What if the client would like to send the rest of the items, even if some of the items return errors? What if the client instead would like to discard the whole batch if any one of them is erroneous?
There must be a better way.
End-to-end principle
“Smart terminals, dumb network”. The end-to-end (e2e) principle, articulated in the field of computer network, basically says any smart features should reside in the communicating end nodes, rather than in intermediary nodes.
In our use case, the smart feature is batching. By e2e, we make sure each step should only process a single item, and only the initial sender and the final receiver knows about the batching.
There are various examples In Go’s standard packages that already do this, e.g. bufio.Writer. The basic idea is an interface similar to below:
type BufferedWriter interface {
Write(item Item) error
Flush() error
}
The caller issues multiple writes to make a batch and a flush to mark the end of the batch. The writer chains the transformation and encoding steps of an item in a single write method and returns the error for the item. When the flush method is called, the writer flushes the whole batch and completes the batch.
Stateless vs Stateful
On the surface, BatchProcessor
is stateless while BufferedWriter
is stateful, but the former only pushes to its caller the responsibility of aggregating a batch, which is a stateful operation. On the other hand, the final step of the processing - the underlying driver regardless it is of file or network IO - is stateful too. So BufferedWriter
does not add additional burden to its caller for managing a stateful interface.
Rather, BufferedWriter
not only simplifies the chain of processing within it, but also simplifies the batching logic on its caller side.
Concurrency
A BufferedWriter
can become concurrently safe by locking both Write
and Flush
methods. However, the ideal way of calling a BufferedWriter
is from a single goroutine so that the caller is able to control exactly what are in the batch, and we can get rid of the overhead of the lock.
If multiple goroutines must share a single underlying writer and at the same time want to control its own batches, then we could return an object instead of flushing, as below:
type Builder interface {
Write(item Item) error
Bytes() []byte // return bytes
Object() Batch // or a batch object
}
In fact, it becomes the Builder Pattern. Each goroutine has its own builder, building its own batches, and then sending those batches to a shared driver.
In addition, we could even have various write methods, each for its own item type.
Transaction
If the caller needs to discard a batch, we could extend it with a rollback method, similar to sql.Tx:
type TxWriter interface {
Write(item Item) error
Commit() error
Rollback() error
}
Then it becomes the Unit of Work Pattern.
Conclusion
Whenever we want to process and send multiple items, consider this Buffered Writer Pattern and its variants and see if it can better suit our needs.