db session mngmnt in go

This commit is contained in:
Wouter Groeneveld 2024-06-12 18:32:45 +02:00
parent 7969d45f28
commit f29a1268d9
2 changed files with 102 additions and 3 deletions

View File

@ -0,0 +1,99 @@
---
title: Database Session Management In Go
date: 2024-06-12T18:30:00+02:00
tags:
- go
categories:
- programming
---
For a Go project we're working on, the ever-increasing boilerplate code yet again started getting on my nerves. This seems to be a recurring story in enterprise software projects written in Go, but I digress. This time, the question is: how to do proper database transaction management, the idiomatic Go way? The thing we were trying to solve must have been solved more than a thousand times before, by others using Go, but also by others using classic object-oriented languages such as C# and Java.
In Java, we simply rely on Spring and JPA and Hibernate andWhatever-Its-Called-Now to conjure up a piece of middleware that automagically opens up and commits or rolls back the transaction. This is typically done using Aspect Oriented Programming. If the framework works---and that's usually the point of a frame _work_---all the programmer has to do is to add `@Transactional` on top of a method:
```java
@Transactional
public void SaveInvoice(Invoice invoice) {
if(!invoice.validate()) {
throw new InvoiceInvalidException();
}
invoiceRepo.persist(invoice);
loggingRepo.addNewLineFor(invoice);
}
```
If the method fails, the transaction is automatically rolled back. If `SaveInvoice()` is called upon multiple times in parallel, the behind-the-scenes transaction manager that the annotation `@Transactional` uses still works: it usually relies on a thread-static session that's unique for each incoming request.
This seems so simple and obvious, but let's still ask the question: how do you do this in Go? Use an unexported global variable? Create something new for each request? Rely on `context.Context`?
We rely on [GORM](https://gorm.io/docs/transactions.html), an ORM framework that could be compared to Hibernate except that it's much more lightweight---and as a result, leaves the transaction management up to you. The GORM way to open a transaction is perhaps weird: it returns a new pointer with the same type of `*gorm.DB`[^er]:
[^er]: Proper error handling in these pseudocode blocks is omitted for brevity.
```go
db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{})
tx := db.Begin()
tx.Model(&someStruct).Update("Property", 124)
tx.Commit()
```
That means you can't simply rely on dependency injection to pass in a GORM database pointer in your repository, as you'll likely want to have a transactional context across repositories unless you mutate the pointers or create repository structs ad-hoc. Both options are a big no-no in combination with a dependency injection system that for instance treats your repo as a singleton: mutating state means your next parallel request will be working on the wrong DB instance.
My first idea was to write a middleware system not unlike the Java AOP way, except there your transaction manager does what it says on the tin: it manages transactions across multiple requests and takes session pooling etc. into account. I'll have to write my own layer on top of GORM to do something like:
```go
func NewMiddleWare(ctx context.Context, db *gorm.DB, txManager transaction.Manager) {
id := commandIdBasedOnRequest(ctx)
tx := db.Begin()
txManager.AddContext(id, context.WithValue(ctx, infra.TxCtxKey{}, tx))
}
// ...
func Commit(ctx context.Context) {
id := commandIdBasedOnRequest(ctx)
tx := txManager.Context(id)
tx.Commit()
}
```
The transaction manager then is an ugly stateful singleton maintaining a `map[context.Context]*gorm.DB`-like map that keeps on growing and can't be efficiently shrunk in size after a while. On top of that, you'll have to pass in a `context.Context` for each repository function.
Additionally, [passing database transactions via context is an anti-pattern](https://www.reddit.com/r/golang/comments/1awp5af/is_passing_database_transactions_as_via_context/) ([seconded here](https://mariocarrion.com/2023/11/21/r-golang-transactions-in-context-values.html)). Whoops. You should not store anything in the context except for request-specific _data_. So what's the commonly used alternative?
After digging through these:
- https://threedots.tech/post/repository-pattern-in-go/
- https://github.com/gsdenys/gormet
- https://github.com/aklinkert/go-gorm-repository
And inspecting various context-specific framework code snippets, I came to the conclusion that the idiomatic Go way seems to be to pass in _something_ context-related as the first parameter---whether or not that's `context.Context` or `*gorm.DB` as the transaction doesn't really matter. The added benefit of that approach is easier testability and better enforcement of correct function usage.
The above Java snippet coming from a handler or service or whatever you call it might look like this in Go:
```go
func (c *invoiceCmd) Save(invoice Invoice) error {
// db is the initial GORM db pointer
c.db.Transaction(func(tx *gorm.DB) error {
err := invoice.Validate(); if err != nil {
return err
}
err = c.invoiceRepo.persist(tx, invoice)
if err != nil {
return err
}
err = c.loggingRepo.addNewLineFor(tx, invoice)
if err != nil {
return err
}
return nil // auto-commit
})
}
```
This allows us to use GORM's `Transaction()` convenience wrapper in true [Transaction Script](https://martinfowler.com/eaaCatalog/transactionScript.html) style as Martin Fowler likes to call it---or, if we really need to, manage transactions manually.
Still, it just bugs me that (1) this code is much more cluttered (thank you [Go error handling](/post/2024/03/error-handling-no-goes-in-go/)) and (2) you have to pass in `tx`, although that can be solved by creating your repositories within the `Transaction()` func. What about unit testability then? I suppose writing an integration test against an in-memory SQLite database is your only option then, as there's no way to inject other behaviour.
I can't remember where I once read that coding in Go is boring and dull, but it gets the job done without all the excessive fluff of other programming languages. At this point, I'd rather have my fluff, thank you very much.

View File

@ -15,8 +15,8 @@ There are ample articles from others exploring that thought but they all start i
Because of one big stinker: links. Default Markdown-style links I use to link this article with others in the form of `[blah](/post/yyyy/mm/other-article/)` include a lot of problematic substrings for Obsidian:
- The leading slash `/post` points to the root. This [isn't compatible with sub-vault inclusion](https://forum.obsidian.md/t/start-absolute-path-with-a-leading-slash/32501) systems in Obsidian, so root slashes aren't supported.
- The path itself starts at `post` but lives in `content` in the root of my Brain Baking repository. At what level are you creating an Obsidian Vault then?
- The leading slash `/post` points to the hosted website root. This [isn't compatible with sub-vault inclusion](https://forum.obsidian.md/t/start-absolute-path-with-a-leading-slash/32501) systems in Obsidian, so root slashes aren't supported.
- The path itself starts at `post` but lives in `content` in the root of my Brain Baking repository. At what level then are you creating an Obsidian Vault?
- The trailing slash `other-article/` confuses Obsidian into thinking it's a different file. The graph view points to a non-existing file.
Even though you were to disable `[[wikilinks]]` in Obsidian, you'll have to apply a bunch of fancy regexes before Obsidian is able to process and link the documents correctly. I came up with a quick and dirty regex `\[(.*)\]\(/post/[0-9]+/[0-9]+/(.+)/\)` to replace Markdown-links with `[[$2]]`, getting rid of the path entirely. I've seen others like [this hugo-wikilinks repository](https://github.com/milafrerichs/hugo-wikilinks) and [Quinn Casey](https://quinncasey.com/hugo-wikilink-support/) relying on the same dumb trick. Of course, that breaks the Hugo rendering pipeline, resulting in `<a/>` HTML tags that point to nowhere.
@ -61,4 +61,4 @@ After a painful `git reset`, I came up with a good-enough alternative: clean up
The whole linking system isn't as complex as [how I display series of posts](/post/2024/01/displaying-series-of-posts-in-hugo/) on my gaming blog that's also powered by Hugo---where admittedly the whole "series" system was introduced because tags started getting messy. Game platforms replace categories there which makes more sense. That blog is also much more focused while on Brain Baking I like to write about anything that comes to mind.
Obsidian visualized that I like linking articles together, even though while browsing the archives that perhaps doesn't come across as obvious. I'm still not too convinced. If you have a better idea about handling the big ball of mud called taxonomies, please drop me a line.
Obsidian's visualization revealed that I like linking articles together, even though while browsing the archives that perhaps doesn't come across as obvious. I'm still not too convinced. If you have a better idea about handling the big ball of mud called taxonomies, please drop me a line.