ex.Terminator
is an embeddable object providing a destructor mechanism to your objects.
It provides two methods:
object.Defer
to defer closing the resources created by the current object (for examplefoo.Defer(foo.db.Close)
).object.Close
, once called, executes all of the deferred operations and returns errors if necessary.
Additionally, ex.Terminator
helps you find leaks by reporting errors if an object gets garbage-collected without having been closed.
ex.Terminator
has several benefits over maintaining your own Close
methods:
- Similarly to the
defer
keyword, it is easier to keep track of what is being closed or not, because both the open and close operations always goes together. Meanwhile, it is very easy to forget about it when writing or maintaining aClose
method. - Errors in manually maintained
Close
methods are often ignored, and handling it explicitly makes the readability worse.ex.Terminator
takes care of that for you, and includes helpful error messages. ex.Terminator
takes care of closing the resources in the right order, which is easy to get wrong when manually done.
https://go.dev/play/p/wFxNeCnYPLd
type FileRepository struct {
ex.Terminator
file *os.File
}
func NewFileRepository() (fileRepo *FileRepository, err error) {
fileRepo = &FileRepository{}
fileRepo.file, err = os.Create("data.json")
if err != nil {
return nil, err
}
fileRepo.Defer(fileRepo.file.Close)
fileRepo.Defer(func() error {
fmt.Println("closing FileRepository")
return nil
})
return fileRepo, nil
}
type DBRepository struct {
ex.Terminator
db *sql.DB
}
func NewDBRepository() (dbRepo *DBRepository, err error) {
dbRepo = &DBRepository{}
dbRepo.db, err = sql.Open("sqlite3", ":memory:")
if err != nil {
return nil, err
}
dbRepo.Defer(dbRepo.db.Close)
dbRepo.Defer(func() error {
fmt.Println("closing DBRepository")
return nil
})
return dbRepo, nil
}
type Service struct {
ex.Terminator
fileRepo *FileRepository
dbRepo *DBRepository
}
func NewService() (service *Service, err error) {
service = &Service{}
service.fileRepo, err = NewFileRepository()
if err != nil {
return nil, err
}
service.Defer(service.fileRepo.Close)
service.dbRepo, err = NewDBRepository()
if err != nil {
return nil, err
}
service.Defer(service.dbRepo.Close)
return service, nil
}
func main() {
service, err := NewService()
if err != nil {
panic(err)
}
defer service.Close()
// Output:
// "closing DBRepository"
// "closing FileRepository"
}
No. Resources are closed synchronously, meaning that the Close
methods still must be called, either via defer
, .Defer
or manually.
However, ex.Terminator
uses runtime.SetFinalizer
to help the developers find mistakes: an error message is printed in the console whenever a non-Closed object gets garbage-collected.
But this only used for this purpose. Closing the objects is never done in SetFinalizer
.
Yes! Although I only provided examples using the constructor (because it is the most common use case), you can use .Defer
in any method and any time of the life-cycle of your objects.
ex.Terminator
follows the same convention than the defer
keyword: the last deferred operation is executed first:
fileRepo.Defer(func() error { fmt.Println("closing A"); return nil })
fileRepo.Defer(func() error { fmt.Println("closing B"); return nil })
fileRepo.Defer(func() error { fmt.Println("closing C"); return nil })
Result:
closing C
closing B
closing A
Even in case of error, all of the deferred functions are always executed. The errors are then returned using errors.Join
.
No, but it has to be explicitly deferred instead.
ex.Terminator
is designed to keep the concerns strictly separated.
The best way to use it is to follow this rule: the code which creates a resource is always responsible for closing it.
In short, if main
calls NewFoo
which calls NewBar
, then main
should defer foo.Close
, and foo
should defer bar.Close
.
This way, you end-up with a hierarchy of Close
calls: main
calls foo.Close
, foo.Close
calls bar.Close
which in turn might close other resources it's responsible for.
While I recommend embedding it because I think that it is less error-prone and requires less boilerplate, it is very much possible to encapsulate it instead.
However, it is a trade off because of constraints inherent to the Go language:
- The benefit is that the
Defer
method will be encapsulated. - The drawback is that you will manually have to create a
Close
method to close the terminator.
type Foo struct {
terminator ex.Terminator
file *os.File
}
func NewFoo() *Foo {
var terminator ex.Terminator
file, _ := os.Create("data.json")
terminator.Defer(file.Close)
return &Foo{
terminator: terminator,
file: file,
}
}
func (foo *Foo) Close() error {
return foo.terminator.Close()
}