├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── event.go ├── event_test.go ├── go.mod ├── hook.go ├── hook_test.go ├── hooks_test.go ├── logger.go └── logger_test.go /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: 1.18 20 | 21 | - uses: actions/cache@v2 22 | with: 23 | path: | 24 | ~/.cache/go-build 25 | ~/go/pkg/mod 26 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 27 | restore-keys: | 28 | ${{ runner.os }}-go- 29 | 30 | - name: Test 31 | run: go test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mike Stefanello 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hooks 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/mikestefanello/hooks)](https://goreportcard.com/report/github.com/mikestefanello/hooks) 4 | [![Test](https://github.com/mikestefanello/hooks/actions/workflows/test.yml/badge.svg)](https://github.com/mikestefanello/hooks/actions/workflows/test.yml) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 6 | [![Go Reference](https://pkg.go.dev/badge/github.com/mikestefanello/hooks.svg)](https://pkg.go.dev/github.com/mikestefanello/hooks) 7 | [![GoT](https://img.shields.io/badge/Made%20with-Go-1f425f.svg)](https://go.dev) 8 | 9 | ## Overview 10 | 11 | _Hooks_ provides a simple, **type-safe** hook system to enable easier modularization of your Go code. A _hook_ allows various parts of your codebase to tap into events and operations happening elsewhere which prevents direct coupling between the producer and the consumers/listeners. For example, a _user_ package/module in your code may dispatch a _hook_ when a user is created, allowing your _notification_ package to send the user an email, and a _history_ package to record the activity without the _user_ module having to call these components directly. A hook can also be used to allow other modules to alter and extend data before it is processed. 12 | 13 | Hooks can be very beneficial especially in a monolithic application both for overall organization as well as in preparation for the splitting of modules into separate synchronous or asynchronous services. 14 | 15 | ## Installation 16 | 17 | `go get github.com/mikestefanello/hooks` 18 | 19 | ## Usage 20 | 21 | 1) Start by declaring a new hook which requires specifying the _type_ of data that it will dispatch as well as a name. This can be done in a number of different way such as a global variable or exported field on a _struct_: 22 | 23 | ```go 24 | package user 25 | 26 | type User struct { 27 | ID int 28 | Name string 29 | Email string 30 | Password string 31 | } 32 | 33 | var HookUserInsert = hooks.NewHook[User]("user.insert") 34 | ``` 35 | 36 | 2) Listen to a hook: 37 | 38 | ```go 39 | package greeter 40 | 41 | func init() { 42 | user.HookUserInsert.Listen(func(e hooks.Event[user.User]) { 43 | sendEmail(e.Msg.Email) 44 | }) 45 | } 46 | ``` 47 | 48 | 3) Dispatch the data to the hook _listeners_: 49 | 50 | ```go 51 | func (u *User) Insert() { 52 | db.Insert("INSERT INTO users ...") 53 | 54 | HookUserInsert.Dispatch(&u) 55 | } 56 | ``` 57 | 58 | Or, dispatch all listeners asynchronously with `HookUserInsert.DispatchAsync(u)`. 59 | 60 | ### Things to know 61 | 62 | - The `Listen()` callback does not have to be an anonymous function. You can also do: 63 | 64 | ```go 65 | package greeter 66 | 67 | func init() { 68 | user.HookUserInsert.Listen(onUserInsert) 69 | } 70 | 71 | func onUserInsert(e hooks.Event[user.User]) { 72 | sendEmail(e.Msg.Email) 73 | } 74 | ``` 75 | 76 | - If you are using `init()` to register your hook listeners and your package isn't being imported elsewhere, you need to import it in order for that to be executed. You can simply include something like `import _ "myapp/greeter"` in your `main` package. 77 | - The `hooks.Event[T]` parameter contains the data that was passed in at `Event.Msg` and the hook at `Event.Hook`. Having the hook available in the listener means you can use a single listener for multiple hooks, ie: 78 | 79 | ```go 80 | HookOne.Listen(listener) 81 | HookTwo.Listen(listener) 82 | 83 | func listener(e hooks.Event[SomeType]) { 84 | switch e.Hook { 85 | case HookOne: 86 | case HookTwo: 87 | } 88 | } 89 | ``` 90 | 91 | - If the `Msg` is provided as a _pointer_, a hook can modify the the data which can be useful to allow for modifications prior to saving a user, for example. 92 | - You do not have to use `init()` to listen to hooks. For example, another pattern for this example could be: 93 | 94 | ```go 95 | package greeter 96 | 97 | type Greeter struct { 98 | emailClient email.Client 99 | } 100 | 101 | func NewGreeter(client email.Client) *Greeter { 102 | g := &Greeter{emailClient: client} 103 | 104 | user.HookUserInsert.Listen(func (e hooks.Event[user.User]) { 105 | g.sendEmail(e.Msg.Email) 106 | }) 107 | 108 | return g 109 | } 110 | ``` 111 | 112 | - Following the previous example, hooks can be provided as part of exported _structs_ rather than just global variables, for example: 113 | 114 | ```go 115 | package greeter 116 | 117 | type Greeter struct { 118 | HookSendEmail *hooks.Hook[Email] 119 | emailClient email.Client 120 | } 121 | 122 | func NewGreeter(client email.Client) *Greeter { 123 | g := &Greeter{emailClient: client} 124 | 125 | user.HookUserInsert.Listen(func (e hooks.Event[user.User]) { 126 | g.sendEmail(e.Msg.Email) 127 | }) 128 | 129 | return g 130 | } 131 | 132 | func (g *Greeter) sendEmail(email string) error { 133 | e := Email{To: email} 134 | if err := g.emailClient.Send(e); err != nil { 135 | return err 136 | } 137 | 138 | g.HookSendEmail.Dispatch(e) 139 | } 140 | ``` 141 | 142 | ## More examples 143 | 144 | While event-driven usage as shown above is the most common use-case of hooks, they can also be used to extend functionality and logic or the process in which components are built. Here are some more examples. 145 | 146 | ### Router construction 147 | 148 | If you're building a web service, it could be useful to separate the registration of each of your module's endpoints. Using [Echo](https://github.com/labstack/echo) as an example: 149 | 150 | ```go 151 | package main 152 | 153 | import ( 154 | "github.com/labstack/echo/v4" 155 | "github.com/myapp/router" 156 | 157 | // Modules 158 | _ "github.com/myapp/modules/todo" 159 | _ "github.com/myapp/modules/user" 160 | ) 161 | 162 | func main() { 163 | e := echo.New() 164 | router.BuildRouter(e) 165 | e.Start("localhost:9000") 166 | } 167 | ``` 168 | 169 | ```go 170 | package router 171 | 172 | import ( 173 | "net/http" 174 | 175 | "github.com/labstack/echo/v4" 176 | "github.com/labstack/echo/v4/middleware" 177 | "github.com/mikestefanello/hooks" 178 | ) 179 | 180 | var HookBuildRouter = hooks.NewHook[echo.Echo]("router.build") 181 | 182 | func BuildRouter(e *echo.Echo) { 183 | e.Use( 184 | middleware.RequestID(), 185 | middleware.Logger(), 186 | ) 187 | 188 | e.GET("/", func(ctx echo.Context) error { 189 | return ctx.String(http.StatusOK, "hello world") 190 | }) 191 | 192 | // Allow all modules to build on the router 193 | HookBuildRouter.Dispatch(e) 194 | } 195 | ``` 196 | 197 | ```go 198 | package todo 199 | 200 | import ( 201 | "github.com/labstack/echo/v4" 202 | "github.com/mikestefanello/hooks" 203 | "github.com/myapp/router" 204 | ) 205 | 206 | func init() { 207 | router.HookBuildRouter.Listen(func(e hooks.Event[echo.Echo]) { 208 | e.Msg.GET("/todo", todoHandler.Index) 209 | e.Msg.GET("/todo/:todo", todoHandler.Get) 210 | e.Msg.POST("/todo", todoHandler.Post) 211 | }) 212 | } 213 | ``` 214 | 215 | ### Dependency creation (and injection) 216 | 217 | Rather than inititalize all of your dependencies in a single place, hooks can be used to distribute these tasks to the providing packages and great dependency injection libraries like _[do](https://github.com/samber/do)_ can be used to manage them. 218 | 219 | ```go 220 | package main 221 | 222 | import ( 223 | "github.com/mikestefanello/hooks" 224 | "github.com/samber/do" 225 | 226 | "example/services/app" 227 | "example/services/web" 228 | ) 229 | 230 | func main() { 231 | i := app.Boot() 232 | 233 | server := do.MustInvoke[*web.Web](i) 234 | server.Start() 235 | } 236 | ``` 237 | ```go 238 | package app 239 | 240 | import ( 241 | "github.com/mikestefanello/hooks" 242 | "github.com/samber/do" 243 | ) 244 | 245 | var HookBoot = hooks.NewHook[*do.Injector]("boot") 246 | 247 | func Boot() *do.Injector { 248 | injector := do.New() 249 | HookBoot.Dispatch(injector) 250 | return injector 251 | } 252 | ``` 253 | 254 | ```go 255 | package web 256 | 257 | import ( 258 | "net/http" 259 | 260 | "github.com/mikestefanello/hooks" 261 | "github.com/samber/do" 262 | 263 | "example/services/app" 264 | ) 265 | 266 | type ( 267 | Web interface { 268 | Start() error 269 | } 270 | 271 | web struct {} 272 | ) 273 | 274 | func init() { 275 | app.HookBoot.Listen(func(e hooks.Event[*do.Injector]) { 276 | do.Provide(e.Msg, NewWeb) 277 | }) 278 | } 279 | 280 | func NewWeb(i *do.Injector) (Web, error) { 281 | return &web{}, nil 282 | } 283 | 284 | func (w *web) Start() error { 285 | return http.ListenAndServe(":8080", nil) 286 | } 287 | ``` 288 | 289 | 290 | ### Modifications 291 | 292 | Hook listeners can be used to make modifications to data prior to some operation being executed if the _message_ is provided as a pointer. For example, using the `User` from above: 293 | 294 | ```go 295 | var HookUserPreInsert = hooks.NewHook[*User]("user.pre_insert") 296 | 297 | func (u *User) Insert() { 298 | // Let other modules make any required changes prior to inserting 299 | HookUserPreInsert.Dispatch(u) 300 | 301 | db.Insert("INSERT INTO users ...") 302 | 303 | // Notify other modules of the inserted user 304 | HookUserInsert.Dispatch(*u) 305 | } 306 | ``` 307 | 308 | ```go 309 | HookUserPreInsert.Listen(func(e hooks.Event[*user.User]) { 310 | // Change the user's name 311 | e.Msg.Name = fmt.Sprintf("%s-changed", e.Msg.Name) 312 | }) 313 | ``` 314 | 315 | ### Validation 316 | 317 | Hook listeners can also provide validation or other similar input on data that is being acted on. For example, using the `User` again. 318 | 319 | ```go 320 | type UserValidation struct { 321 | User User 322 | Errors *[]error 323 | } 324 | 325 | var HookUserValidate = hooks.NewHook[UserValidation]("user.validate") 326 | 327 | func (u *User) Validate() []error { 328 | errs := make([]error, 0) 329 | uv := UserValidation{ 330 | User: *u, 331 | Errors: &errs, 332 | } 333 | 334 | if u.Email == "" { 335 | uv.Errors = append(uv.Errors, errors.New("missing email")) 336 | } 337 | 338 | // Let other modules validate 339 | HookUserValidate.Dispatch(uv) 340 | 341 | return uv.Errors 342 | } 343 | ``` 344 | 345 | ```go 346 | HookUserValidate.Listen(func(e hooks.Event[user.UserValidate]) { 347 | if len(e.Msg.User.Password) < 10 { 348 | e.Msg.Errors = append(e.Msg.Errors, errors.New("password too short")) 349 | } 350 | }) 351 | ``` 352 | 353 | ### Full application example 354 | 355 | For a full application example see [hooks-example](https://github.com/mikestefanello/hooks-example). This aims to provide a modular monolithic architectural approach to a Go application using _hooks_ and [do](https://github.com/samber/do) _(dependency injection)_. 356 | 357 | ## Logging 358 | 359 | By default, nothing will be logged, but you have the option to specify a _logger_ in order to have insight into what is happening within the hooks. Pass a function in to `SetLogger()`, for example: 360 | 361 | ```go 362 | hooks.SetLogger(func(format string, args ...any) { 363 | log.Printf(format, args...) 364 | }) 365 | ``` 366 | 367 | ``` 368 | 2022/09/07 13:42:19 hook created: user.update 369 | 2022/09/07 13:42:19 registered listener with hook: user.update 370 | 2022/09/07 13:42:19 registered listener with hook: user.update 371 | 2022/09/07 13:42:19 registered listener with hook: user.update 372 | 2022/09/07 13:42:19 dispatching hook user.update to 3 listeners (async: false) 373 | 2022/09/07 13:42:19 dispatch to hook user.update complete 374 | ``` -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | // Event is a wrapper containing the data being dispatched through a hook 4 | type Event[T any] struct { 5 | // Msg contains the data being dispatched through a hook 6 | Msg T 7 | 8 | // Hook contains the hook that dispatched this event 9 | Hook *Hook[T] 10 | } 11 | 12 | // newEvent creates a new event 13 | func newEvent[T any](hook *Hook[T], message T) Event[T] { 14 | return Event[T]{ 15 | Msg: message, 16 | Hook: hook, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /event_test.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNewEvent(t *testing.T) { 8 | msg := &message{id: 100} 9 | h := NewHook[*message](hookName) 10 | e := newEvent(h, msg) 11 | 12 | if e.Msg != msg { 13 | t.Fail() 14 | } 15 | 16 | if e.Hook != h { 17 | t.Fail() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mikestefanello/hooks 2 | 3 | go 1.18 4 | -------------------------------------------------------------------------------- /hook.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | // Listener is a function that can listen and react to a hook event 8 | type Listener[T any] func(event Event[T]) 9 | 10 | // Hook is a mechanism which supports the ability to dispatch data to arbitrary listener callbacks 11 | type Hook[T any] struct { 12 | // name stores the name of the hook 13 | name string 14 | 15 | // listeners stores the functions which will be invoked during dispatch 16 | listeners []Listener[T] 17 | 18 | // mu stores the mutex to provide concurrency-safe operations 19 | mu sync.RWMutex 20 | } 21 | 22 | // NewHook creates a new Hook 23 | func NewHook[T any](name string) *Hook[T] { 24 | logf("hook created: %s", name) 25 | 26 | return &Hook[T]{ 27 | name: name, 28 | listeners: make([]Listener[T], 0), 29 | mu: sync.RWMutex{}, 30 | } 31 | } 32 | 33 | // GetName returns the hook's name 34 | func (h *Hook[T]) GetName() string { 35 | return h.name 36 | } 37 | 38 | // Listen registers a callback function to be invoked when the hook dispatches data 39 | func (h *Hook[T]) Listen(callback Listener[T]) { 40 | h.mu.Lock() 41 | defer h.mu.Unlock() 42 | 43 | h.listeners = append(h.listeners, callback) 44 | 45 | logf("registered listener with hook: %s", h.GetName()) 46 | } 47 | 48 | // GetListenerCount returns the number of listeners currently registered 49 | func (h *Hook[T]) GetListenerCount() int { 50 | h.mu.RLock() 51 | defer h.mu.RUnlock() 52 | 53 | return len(h.listeners) 54 | } 55 | 56 | // Dispatch invokes all listeners synchronously with the provided message 57 | func (h *Hook[T]) Dispatch(message T) { 58 | h.dispatch(message, false) 59 | } 60 | 61 | // DispatchAsync invokes all listeners asynchronously with the provided message 62 | func (h *Hook[T]) DispatchAsync(message T) { 63 | h.dispatch(message, true) 64 | } 65 | 66 | // dispatch invokes all listeners either synchronously or asynchronously with the provided message 67 | func (h *Hook[T]) dispatch(message T, async bool) { 68 | h.mu.RLock() 69 | defer h.mu.RUnlock() 70 | 71 | e := newEvent[T](h, message) 72 | 73 | // Check if the logger is available here to avoid the call since dispatching can happen very often and 74 | // this can help with performance 75 | if logger != nil { 76 | logf("dispatching hook %s to %d listeners (async: %v)", h.GetName(), len(h.listeners), async) 77 | defer logf("dispatch to hook %s complete", h.GetName()) 78 | } 79 | 80 | for _, callback := range h.listeners { 81 | if async { 82 | go callback(e) 83 | } else { 84 | callback(e) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /hook_test.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestHook_Dispatch(t *testing.T) { 8 | l, h, msg := newListener(t) 9 | 10 | h.Dispatch(msg) 11 | 12 | if listenerCount != l.counter { 13 | t.Fail() 14 | } 15 | } 16 | 17 | func TestHook_DispatchAsync(t *testing.T) { 18 | l, h, msg := newListener(t) 19 | 20 | h.DispatchAsync(msg) 21 | l.wg.Wait() 22 | 23 | if listenerCount != l.counter { 24 | t.Fail() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /hooks_test.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | ) 7 | 8 | const ( 9 | hookName = "test.hook" 10 | listenerCount = 3 11 | ) 12 | 13 | // message holds the data being dispatched via the test hooks 14 | type message struct { 15 | id int 16 | } 17 | 18 | // listener facilitate the testing of hook listeners 19 | type listener struct { 20 | counter int 21 | msg *message 22 | hook *Hook[*message] 23 | wg sync.WaitGroup 24 | t *testing.T 25 | } 26 | 27 | // newListener creates and initializes a new listener with a hook and message 28 | func newListener(t *testing.T) (*listener, *Hook[*message], *message) { 29 | msg := &message{id: 123} 30 | h := NewHook[*message](hookName) 31 | l := &listener{ 32 | t: t, 33 | msg: msg, 34 | hook: h, 35 | } 36 | 37 | for i := 0; i < listenerCount; i++ { 38 | h.Listen(l.Callback) 39 | } 40 | 41 | l.wg.Add(listenerCount) 42 | 43 | if listenerCount != h.GetListenerCount() { 44 | t.Fail() 45 | } 46 | 47 | return l, h, msg 48 | } 49 | 50 | // Callback is the callback method for the test hooks that counts executions, confirms the event data, and 51 | // handles waitgroups for concurrency 52 | func (l *listener) Callback(event Event[*message]) { 53 | l.counter++ 54 | 55 | if l.msg != event.Msg { 56 | l.t.Fail() 57 | } 58 | 59 | if l.hook != event.Hook { 60 | l.t.Fail() 61 | } 62 | 63 | if hookName != event.Hook.GetName() { 64 | l.t.Fail() 65 | } 66 | 67 | l.wg.Done() 68 | } 69 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | // Logger is a function to handle logging 4 | type Logger func(format string, args ...any) 5 | 6 | // logger stores the logging function 7 | var logger Logger 8 | 9 | // SetLogger sets a logger function to log hook events 10 | func SetLogger(function Logger) { 11 | logger = function 12 | } 13 | 14 | // logf logs output to the logger 15 | func logf(format string, args ...any) { 16 | if logger == nil { 17 | return 18 | } 19 | 20 | logger(format, args...) 21 | } 22 | -------------------------------------------------------------------------------- /logger_test.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestSetLogger(t *testing.T) { 9 | // No logger 10 | logf("test %d %d", 1, 2) 11 | 12 | // Add a logger that stores the output 13 | var output string 14 | SetLogger(func(format string, args ...any) { 15 | output = fmt.Sprintf(format, args...) 16 | }) 17 | 18 | // Log 19 | logf("test %d %d", 1, 2) 20 | 21 | // Verify the output 22 | if output != "test 1 2" { 23 | t.Fail() 24 | } 25 | 26 | // Remove the logger 27 | SetLogger(nil) 28 | } 29 | --------------------------------------------------------------------------------