Skip to content

Commit

Permalink
Add validation feature, Add benchmark (#5)
Browse files Browse the repository at this point in the history
  • Loading branch information
firasdarwish authored Nov 9, 2024
2 parents fdc46c2 + 74aa051 commit 1eaaae7
Show file tree
Hide file tree
Showing 22 changed files with 1,299 additions and 192 deletions.
46 changes: 40 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,33 @@ func TestGetInterfaceAlias(t *testing.T) {

Alias is also scoped by key. When you "Get" an alias with keys for eg: `ore.Get[IPerson](ctx, "module1")` then Ore would return only Services registered under this key ("module1") and panic if no service found.

### Registration validation

Once finishing all your registrations, it is recommended to call `ore.Validate()`.

`ore.Validate()` invokes ALL your registered resolvers. The purpose is to panic early if your registrations were in bad shape:

- Missing depedency: you forgot to register certain resolvers.
- Circular dependency: A depends on B which depends on A.
- Lifetime misalignment: a longer lifetime service (eg. Singleton) depends on a shorter one (eg Transient).

### Registration recommendation

(1) You should call `ore.Validate()`

- either in a test which is automatically run on your CI/CD pipeline (option 1)
- or on application start, just after all the registrations (option 2)

option 1 (run `ore.Validate` on test) is usually a better choice.

(2) It is recommended to build your container (which means register ALL the resolvers) only ONCE on application start => Please don't call `ore.RegisterXX` all over the place.

(3) Keep the object creation function (a.k.a resolvers) simple. Their only responsibility should be **object creation**.

- they should not spawn new goroutine
- they should not open database connection
- they should not contain any "if" statement or other business logic

### Graceful application termination

On application termination, you want to call `Shutdown()` on all the "Singletons" objects which have been created during the application life time.
Expand All @@ -337,13 +364,15 @@ ore.RegisterEagerSingleton(&Logger{}) //*Logger implements Shutdowner
ore.RegisterEagerSingleton(&SomeRepository{}) //*SomeRepository implements Shutdowner
ore.RegisterEagerSingleton(&SomeService{}, "some_module") //*SomeService implements Shutdowner

//On application termination, Ore can help to retreive all the singletons implementation of the `Shutdowner` interface.
//There might be other `Shutdowner`'s implementation which were lazily registered but have never been created (a.k.a invoked).
//On application termination, Ore can help to retreive all the singletons implementation
//of the `Shutdowner` interface.
//There might be other `Shutdowner`'s implementation which were lazily registered but
//have never been created.
//Ore will ignore them, and return only the concrete instances which can be Shutdown()
shutdowables := ore.GetResolvedSingletons[Shutdowner]()

//Now we can Shutdown() them all and gracefully terminate our application.
//The most recently created instance will be Shutdown() first
//The most recently invoked instance will be Shutdown() first
for _, instance := range disposables {
instance.Shutdown()
}
Expand All @@ -353,7 +382,8 @@ In resume, the `ore.GetResolvedSingletons[TInterface]()` function returns a list

- It returns only the instances which had been invoked (a.k.a resolved).
- All the implementations including "keyed" one will be returned.
- The returned instances are sorted by creation time (a.k.a the invocation order), the first one being the most recently created one.
- The returned instances are sorted by the invocation order, the first one being lastest invoked one.
- if "A" depends on "B", "C", Ore will make sure to return "B" and "C" first in the list so that they would be shutdowned before "A".

### Graceful context termination

Expand All @@ -377,7 +407,7 @@ go func() {
<-ctx.Done() // Wait for the context to be canceled
// Perform your cleanup tasks here
disposables := ore.GetResolvedScopedInstances[Disposer](ctx)
//The most recently created instance will be Dispose() first
//The most recently invoked instance will be Dispose() first
for _, d := range disposables {
_ = d.Dispose(ctx)
}
Expand All @@ -392,7 +422,8 @@ The `ore.GetResolvedScopedInstances[TInterface](context)` function returns a lis

- It returns only the instances which had been invoked (a.k.a resolved) during the context life time.
- All the implementations including "keyed" one will be returned.
- The returned instances are sorted by creation time (a.k.a the invocation order), the first one being the most recently created one.
- The returned instances are sorted by invocation order, the first one being the lastest invoked one.
- if "A" depends on "B", "C", Ore will make sure to return "B" and "C" first in the list so that they would be Disposed before "A".

## More Complex Example

Expand Down Expand Up @@ -462,6 +493,9 @@ BenchmarkGetList
BenchmarkGetList-20 1852132 637.0 ns/op
```

Checkout also [examples/benchperf/README.md](examples/benchperf/README.md)


# 👤 Contributors

![Contributors](https://contrib.rocks/image?repo=firasdarwish/ore)
Expand Down
17 changes: 13 additions & 4 deletions concrete.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,19 @@ import "time"

// concrete holds the resolved instance value and other metadata
type concrete struct {
// the value implementation
//the value implementation
value any
// the creation time
createdAt time.Time
// the lifetime of this concrete

//invocationTime is the time when the resolver had been invoked, it is different from the "creationTime"
//of the concrete. Eg: A calls B, then the invocationTime of A is before B, but the creationTime of A is after B
//(because B was created before A)
invocationTime time.Time

//the lifetime of this concrete
lifetime Lifetime

//the invocation deep level, the bigger the value, the deeper it was resolved in the dependency chain
//for example: A depends on B, B depends on C, C depends on D
//A will have invocationLevel = 1, B = 2, C = 3, D = 4
invocationLevel int
}
8 changes: 8 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ func nilVal[T any]() error {
return fmt.Errorf("nil implementation for type: %s", reflect.TypeFor[T]())
}

func lifetimeMisalignment(resolver resolverMetadata, depResolver resolverMetadata) error {
return fmt.Errorf("detect lifetime misalignment: %s depends on %s", resolver, depResolver)
}

func cyclicDependency(resolver resolverMetadata) error {
return fmt.Errorf("detect cyclic dependency where: %s depends on itself", resolver)
}

var alreadyBuilt = errors.New("services container is already built")
var alreadyBuiltCannotAdd = errors.New("cannot appendToContainer, services container is already built")
var nilKey = errors.New("cannot have nil keys")
74 changes: 74 additions & 0 deletions examples/benchperf/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Benchmark comparison

This sample will compare Ore (current commit of Nov 2024) to [samber/do/v2 v2.0.0-beta.7](https://github.com/samber/do).
We registered the below dependency graphs to both Ore and SamberDo, then ask them to create the concrete `A`.

We will only benchmark the creation, not the registration. Because registration usually happens only once on application startup =>
not very interesting to benchmark.

## Data Model

- This data model has only 2 singletons `F` and `Gb` => they will be created only once
- Other concretes are `Transient` => they will be created each time the container create a new `A` concrete.
- We don't test the "Scoped" lifetime in this excercise because SamberDo doesn't have equivalent support for it. [The "Scoped" functionality of SamberDo](https://do.samber.dev/docs/container/scope) means "Sub Module" rather than a lifetime.

```mermaid
flowchart TD
A["A<br><sup></sup>"]
B["B<br><sup></sup>"]
C["C<br><sup></sup>"]
D["D<br><sup><br></sup>"]
E["E<br><sup><br></sup>"]
F["F<br><sup>Singleton</sup>"]
G(["G<br><sup>(interface)</sup>"])
Gb("Gb<br><sup>Singleton</sup>")
Ga("Ga<br><sup></sup>")
DGa("DGa<br><sup>(decorator)</sup>")
H(["H<br><sup>(interface)<br></sup>"])
Hr["Hr<br><sup>(real)</sup>"]
Hm["Hm<br><sup>(mock)</sup>"]
A-->B
A-->C
B-->D
B-->E
D-->H
D-->F
Hr -.implement..-> H
Hm -.implement..-> H
E-->DGa
E-->Gb
E-->Gc
DGa-->|decorate| Ga
Ga -.implement..-> G
Gb -.implement..-> G
Gc -.implement..-> G
DGa -.implement..-> G
```

## Run the benchmark by yourself

```sh
go test -benchmem -bench .
```

## Sample results

On my machine, Ore always perform faster and use less memory than Samber/Do:

```text
Benchmark_Ore-12 415822 2565 ns/op 2089 B/op 57 allocs/op
Benchmark_SamberDo-12 221941 4954 ns/op 2184 B/op 70 allocs/op
```

And with `ore.DisableValidation = true`

```text
Benchmark_Ore-12 785088 1668 ns/op 1080 B/op 30 allocs/op
Benchmark_SamberDo-12 227851 4940 ns/op 2184 B/op 70 allocs/op
```

As any benchmarks, please take these number "relatively" as a general idea:

- These numbers are probably outdated at the moment you are reading them
- You might got a very different numbers when running them on your machine or on production machine.
36 changes: 36 additions & 0 deletions examples/benchperf/bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package main

import (
"context"
i "examples/benchperf/internal"
"testing"

"github.com/firasdarwish/ore"
"github.com/samber/do/v2"
)

// func Benchmark_Ore_NoValidation(b *testing.B) {
// i.BuildContainerOre()
// ore.DisableValidation = true
// ctx := context.Background()
// b.ResetTimer()
// for n := 0; n < b.N; n++ {
// _, ctx = ore.Get[*i.A](ctx)
// }
// }

var _ = i.BuildContainerOre()
var injector = i.BuildContainerDo()
var ctx = context.Background()

func Benchmark_Ore(b *testing.B) {
for n := 0; n < b.N; n++ {
_, ctx = ore.Get[*i.A](ctx)
}
}

func Benchmark_SamberDo(b *testing.B) {
for n := 0; n < b.N; n++ {
_ = do.MustInvoke[*i.A](injector)
}
}
52 changes: 52 additions & 0 deletions examples/benchperf/internal/DiOre.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package internal

import (
"context"

"github.com/firasdarwish/ore"
)

func BuildContainerOre() bool {
ore.RegisterLazyFunc(ore.Transient, func(ctx context.Context) (*A, context.Context) {
b, ctx := ore.Get[*B](ctx)
c, ctx := ore.Get[*C](ctx)
return NewA(b, c), ctx
})
ore.RegisterLazyFunc(ore.Transient, func(ctx context.Context) (*B, context.Context) {
d, ctx := ore.Get[*D](ctx)
e, ctx := ore.Get[*E](ctx)
return NewB(d, e), ctx
})
ore.RegisterLazyFunc(ore.Transient, func(ctx context.Context) (*C, context.Context) {
return NewC(), ctx
})
ore.RegisterLazyFunc(ore.Transient, func(ctx context.Context) (*D, context.Context) {
f, ctx := ore.Get[*F](ctx)
h, ctx := ore.Get[H](ctx)
return NewD(f, h), ctx
})
ore.RegisterLazyFunc(ore.Transient, func(ctx context.Context) (*E, context.Context) {
gs, ctx := ore.GetList[G](ctx)
return NewE(gs), ctx
})
ore.RegisterLazyFunc(ore.Singleton, func(ctx context.Context) (*F, context.Context) {
return NewF(), ctx
})
ore.RegisterLazyFunc(ore.Transient, func(ctx context.Context) (*Ga, context.Context) {
return NewGa(), ctx
})
ore.RegisterLazyFunc(ore.Singleton, func(ctx context.Context) (G, context.Context) {
return NewGb(), ctx
})
ore.RegisterLazyFunc(ore.Transient, func(ctx context.Context) (G, context.Context) {
return NewGc(), ctx
})
ore.RegisterLazyFunc(ore.Transient, func(ctx context.Context) (G, context.Context) {
ga, ctx := ore.Get[*Ga](ctx)
return NewDGa(ga), ctx
})
ore.RegisterLazyFunc(ore.Transient, func(ctx context.Context) (H, context.Context) {
return NewHr(), ctx
})
return true
}
48 changes: 48 additions & 0 deletions examples/benchperf/internal/DiSamber.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package internal

import (
"github.com/samber/do/v2"
)

func BuildContainerDo() do.Injector {
injector := do.New()
do.ProvideTransient(injector, func(inj do.Injector) (*A, error) {
return NewA(do.MustInvoke[*B](inj), do.MustInvoke[*C](inj)), nil
})
do.ProvideTransient(injector, func(inj do.Injector) (*B, error) {
return NewB(do.MustInvoke[*D](inj), do.MustInvoke[*E](inj)), nil
})
do.ProvideTransient(injector, func(inj do.Injector) (*C, error) {
return NewC(), nil
})
do.ProvideTransient(injector, func(inj do.Injector) (*D, error) {
return NewD(do.MustInvoke[*F](inj), do.MustInvoke[H](inj)), nil
})
do.ProvideTransient(injector, func(inj do.Injector) (*E, error) {
gs := []G{
do.MustInvoke[*DGa](inj),
do.MustInvoke[*Gb](inj),
do.MustInvoke[*Gc](inj),
}
return NewE(gs), nil
})
do.Provide(injector, func(inj do.Injector) (*F, error) {
return NewF(), nil
})
do.ProvideTransient(injector, func(inj do.Injector) (*Ga, error) {
return NewGa(), nil
})
do.Provide(injector, func(inj do.Injector) (*Gb, error) {
return NewGb(), nil
})
do.ProvideTransient(injector, func(inj do.Injector) (*Gc, error) {
return NewGc(), nil
})
do.ProvideTransient(injector, func(inj do.Injector) (*DGa, error) {
return NewDGa(do.MustInvoke[*Ga](inj)), nil
})
do.ProvideTransient(injector, func(inj do.Injector) (H, error) {
return NewHr(), nil
})
return injector
}
Loading

0 comments on commit 1eaaae7

Please sign in to comment.