What the heck is an AutoMap?
Surely I can't be the only one who needs this, right?
You know how they say, “Those who don’t learn from the Design Patterns book are doomed to poorly reimplement those design patterns”? That happens to me all the time, and that might be happening to me again here. I’m writing this as a plea for help, someone tell me what the good version of the thing I implemented below is.
As previously indicated here, I wrote up my own blogging engine for this blog, the code for which is accessible here on my Forgejo. One of the things I do is watch the following files for changes:
- HTML templates in
templates/
- CSS and SCSS files in
styles/
- Markdown files with the post contents in
posts/
I deal with each of these situations in the following way:
- Read all the files in the correspnding folder
- Process each file and store the processed result in memory
- Watch the folder for changes
- Repeat step 2 every time a file changes
Step 2 is different for each folder, but steps 1, 3 and 4 are pretty much identical. I abstracted these into the following two Go interfaces:
// Updater is a key-value store which can be informed when to recompute values
// for a particular key. Updaters are normally also AutoMaps.
type Updater[K any] interface {
Fetch(K) error
Delete(K) error
}
// AutoMap is a key-value store where the values are automatically computed by
// the store itself, based on the key.
type AutoMap[K, V any] interface {
Get(K) (V, bool)
}
Here’s what a use for that might look like, using a hypothetical store of JSON files as an example, that I store indented:
type jsonStore struct {
jsons map[string]string
mu sync.Mutex
}
The store implements Updater[string]
like so:
func (js *jsonStore) Fetch(filename string) error {
js.mu.Lock()
defer js.mu.Unlock()
contents, err := os.ReadFile("jsons/" + filename)
if err != nil {
return err
}
var buf bytes.Buffer
err = json.Indent(&buf, contents, "", "\t")
if err != nil {
return err
}
js.jsons[filename] = buf.String()
return nil
}
func (js *jsonStore) Delete(filename string) error {
js.mu.Lock()
defer js.mu.Unlock()
delete(js.jsons, filename)
}
and I have a function called Watch(folder string, up Updater[string])
that uses the github.com/rjeczalik/notify package to watch the folder for filesystem events and call Fetch()
on file creations and changes and Delete()
on file deletes.
Then, for the consumers of the JSON, I implement AutoMap[string, V]
where V
is the type of the value I’m holding (in this case, also string
):
func (js *jsonStore) Get(filename string) (string, bool) {
js.mu.Lock()
defer js.mu.Unlock()
v, ok := js.jsons[filename]
return v, ok
}
And then consumers can create a JSON watcher using jsonstore.New()
:
package jsonstore
func New() (AutoMap[string, string], error) {
var js &jsonStore{}
files, err := os.ReadDir("jsons/")
if err != nil {
return nil, err
}
for _, f := range files {
filename := f.Name()
err = js.Fetch(filename)
if err != nil {
log.Printf("could not render %q, skipping: %v", filename, err)
}
}
go watcher.Watch("jsons/", pl)
return nil
}
and use it like so:
jsons, err := jsonstore.New()
if err != nil { log.Fatal(err) }
// later...
json, ok := jsons.Get("filename.json")
if !ok {
// filename.json does not exist on the filesystem right now.
} else {
// `json` holds most up-to-date contents of filename.json, pre-indented and ready for use.
}
This should mostly be fine and plausible, I hope. The only thing that irks is the type AutoMap
. Here’s the definition again:
// AutoMap is a key-value store where the values are automatically computed by
// the store itself, based on the key.
type AutoMap[K, V any] interface {
Get(K) (V, bool)
}
The comment makes it sound sorta bullshit, doesn’t it? What do you mean values are automatically computed by the store itself? Where does the value come from? What does it mean?
In my case, I’m caching the process of reading a file from the filesystem and processing it. But the caller does not care how the values are decided or kept up to date. All it knows is it can ask for a value corresponding to a key, and if one exists at that time, it will be provided.
I also have a variant that provides an iterator over its key-value pairs in a known order:
// OrderedAutoMap is an AutoMap that provides an iterator over its
// currently-existing keys in a known order.
type OrderedAutoMap[K, V any] interface {
AutoMap[K, V]
All() iter.Seq2[K, V]
}
The nice thing about this is that the known order could be based on the value, rather than the key—say, for example, the keys could be the URL slugs of blog posts and the values could be the post contents and metadata, and the sorting order could be the timestamp of the post.
This seems like a useful concept that I’ve accidentally reinvented—or more likely, a specialized sub-case of a more general, more useful concept that I’ve stumbled upon the edges of. If you know what it is, I would be happy to know.