Skip to content

Commit

Permalink
WIP: [PoC] fx.Evaluate
Browse files Browse the repository at this point in the history
**This is just a PoC at this time and not yet ready for review.**

## Details

This change introduces the `fx.Evalute` option,
which allows for dynamic generation of provides, invokes, decorates,
and other evaluates based on the state of existing dependencies.

It addresses one of the more significant remaining pain points
with usage of Fx in large applications and ecosystems:
the ability to programmatically generate parts of the dependency graph.

Concretely, this makes the following possible:

```
fx.Evaluate(func(cfg *Config) fx.Option {
    if cfg.Environment == "production" {
        return fx.Provide(func(*sql.DB) Repository {
            return &sqlRepository{db: db}
        }),
    } else {
        return fx.Provide(func() Repository {
            return &memoryRepository{}
        })
    }
}),
fx.Provide(func(...) *sql.DB { ... }),
```

With fx.Evaluate, the dependency on `*sql.DB` is present in the graph
only in production environments.
In development environments, the dependency connection is absent,
and therefore the database connection is never established.

(Today, attempting to write an Fx module that switches on the backend
like this will result in the SQL connection being established even in
development environments.)

**TODO**

- [ ] Decide whether nilling out the slice is the right approach
      for tracking what's been already run
- [ ] fx.Module handling
- [ ] fx.WithLogger handling
- [ ] Testing with every other feature
- [ ] Documentation
  • Loading branch information
abhinav committed Jan 9, 2025
1 parent 0ad8a04 commit 045ba96
Show file tree
Hide file tree
Showing 4 changed files with 310 additions and 0 deletions.
17 changes: 17 additions & 0 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,23 @@ func New(opts ...Option) *App {
return app
}

// At this point, we can run the fx.Evaluates (if any).
// As long as there's at least one evaluate per iteration,
// we'll have to keep unwinding.
//
// Keep evaluating until there are no more evaluates to run.
for app.root.evaluateAll() > 0 {
// TODO: is communicating the number of evalutes the best way?
if app.err != nil {
return app
}

Check warning on line 516 in app.go

View check run for this annotation

Codecov / codecov/patch

app.go#L515-L516

Added lines #L515 - L516 were not covered by tests

// TODO: fx.Module inside evaluates needs to build subscopes.
app.root.provideAll()
app.err = multierr.Append(app.err, app.root.decorateAll())
// TODO: fx.WithLogger allowed inside an evaluate?
}

if err := app.root.invokeAll(); err != nil {
app.err = err

Expand Down
153 changes: 153 additions & 0 deletions evaluate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright (c) 2024 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

package fx

import (
"fmt"
"reflect"
"strings"

"go.uber.org/fx/internal/fxreflect"
)

// Evaluate specifies one or more evaluation functions.
// These are functions that accept dependencies from the graph
// and return an fx.Option.
// They may have the following signatures:
//
// func(...) fx.Option
// func(...) (fx.Option, error)
//
// These functions are run after provides and decorates.
// The resulting options are applied to the graph,
// and may introduce new provides, invokes, decorates, or evaluates.
//
// The effect of this is that parts of the graph can be dynamically generated
// based on dependency values.
//
// For example, a function with a dependency on a configuration struct
// could conditionally provide different implementations based on the value.
//
// fx.Evaluate(func(cfg *Config) fx.Option {
// if cfg.Environment == "production" {
// return fx.Provide(func(*sql.DB) Repository {
// return &sqlRepository{db: db}
// }),
// } else {
// return fx.Provide(func() Repository {
// return &memoryRepository{}
// })
// }
// })
//
// This is different from a normal provide that inspects the configuration
// because the dependency on '*sql.DB' is completely absent in the graph
// if the configuration is not "production".
func Evaluate(fns ...any) Option {
return evaluateOption{
Targets: fns,
Stack: fxreflect.CallerStack(1, 0),
}
}

type evaluateOption struct {
Targets []any
Stack fxreflect.Stack
}

func (o evaluateOption) apply(mod *module) {
for _, target := range o.Targets {
mod.evaluates = append(mod.evaluates, evaluate{
Target: target,
Stack: o.Stack,
})
}
}

func (o evaluateOption) String() string {
items := make([]string, len(o.Targets))
for i, target := range o.Targets {
items[i] = fxreflect.FuncName(target)
}
return fmt.Sprintf("fx.Evaluate(%s)", strings.Join(items, ", "))

Check warning on line 90 in evaluate.go

View check run for this annotation

Codecov / codecov/patch

evaluate.go#L85-L90

Added lines #L85 - L90 were not covered by tests
}

type evaluate struct {
Target any
Stack fxreflect.Stack
}

func runEvaluate(m *module, e evaluate) (err error) {
target := e.Target
defer func() {
if err != nil {
err = fmt.Errorf("fx.Evaluate(%v) from:\n%+vFailed: %w", target, e.Stack, err)
}

Check warning on line 103 in evaluate.go

View check run for this annotation

Codecov / codecov/patch

evaluate.go#L102-L103

Added lines #L102 - L103 were not covered by tests
}()

// target is a function returning (Option, error).
// Use reflection to build a function with the same parameters,
// and invoke that in the container.
targetV := reflect.ValueOf(target)
targetT := targetV.Type()
inTypes := make([]reflect.Type, targetT.NumIn())
for i := range targetT.NumIn() {
inTypes[i] = targetT.In(i)
}
outTypes := []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}

// TODO: better way to extract information from the container
var opt Option
invokeFn := reflect.MakeFunc(
reflect.FuncOf(inTypes, outTypes, false),
func(args []reflect.Value) []reflect.Value {
out := targetV.Call(args)
switch len(out) {
case 2:
if err, _ := out[1].Interface().(error); err != nil {
return []reflect.Value{reflect.ValueOf(err)}
}

Check warning on line 127 in evaluate.go

View check run for this annotation

Codecov / codecov/patch

evaluate.go#L124-L127

Added lines #L124 - L127 were not covered by tests

fallthrough

Check warning on line 129 in evaluate.go

View check run for this annotation

Codecov / codecov/patch

evaluate.go#L129

Added line #L129 was not covered by tests
case 1:
opt, _ = out[0].Interface().(Option)

default:
panic("TODO: validation")

Check warning on line 134 in evaluate.go

View check run for this annotation

Codecov / codecov/patch

evaluate.go#L133-L134

Added lines #L133 - L134 were not covered by tests
}

return []reflect.Value{
reflect.Zero(reflect.TypeOf((*error)(nil)).Elem()),
}
},
).Interface()
if err := m.scope.Invoke(invokeFn); err != nil {
return err
}

Check warning on line 144 in evaluate.go

View check run for this annotation

Codecov / codecov/patch

evaluate.go#L143-L144

Added lines #L143 - L144 were not covered by tests

if opt == nil {
// Assume no-op.
return nil
}

Check warning on line 149 in evaluate.go

View check run for this annotation

Codecov / codecov/patch

evaluate.go#L147-L149

Added lines #L147 - L149 were not covered by tests

opt.apply(m)
return nil
}
114 changes: 114 additions & 0 deletions evaluate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) 2025 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

package fx_test

import (
"bytes"
"io"
"testing"

"github.com/stretchr/testify/assert"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)

func TestEvaluate(t *testing.T) {
t.Run("ProvidesOptions", func(t *testing.T) {
type t1 struct{}
type t2 struct{}

var evaluated, provided, invoked bool
app := fxtest.New(t,
fx.Evaluate(func() fx.Option {
evaluated = true
return fx.Provide(func() t1 {
provided = true
return t1{}
})
}),
fx.Provide(func(t1) t2 { return t2{} }),
fx.Invoke(func(t2) {
invoked = true
}),
)
defer app.RequireStart().RequireStop()

assert.True(t, evaluated, "Evaluated function was not called")
assert.True(t, provided, "Provided function was not called")
assert.True(t, invoked, "Invoked function was not called")
})

t.Run("OptionalDependency", func(t *testing.T) {
type Config struct{ Dev bool }

newBufWriter := func(b *bytes.Buffer) io.Writer {
return b
}

newDiscardWriter := func() io.Writer {
return io.Discard
}

newWriter := func(cfg Config) fx.Option {
if cfg.Dev {
return fx.Provide(newDiscardWriter)
}

return fx.Provide(newBufWriter)
}

t.Run("NoDependency", func(t *testing.T) {
var got io.Writer
app := fxtest.New(t,
fx.Evaluate(newWriter),
fx.Provide(
func() *bytes.Buffer {
t.Errorf("unexpected call to *bytes.Buffer")
return nil
},
),
fx.Supply(Config{Dev: true}),
fx.Populate(&got),
)
defer app.RequireStart().RequireStop()

assert.NotNil(t, got)
_, _ = io.WriteString(got, "hello")
})

t.Run("WithDependency", func(t *testing.T) {
var (
buf bytes.Buffer
got io.Writer
)
app := fxtest.New(t,
fx.Evaluate(newWriter),
fx.Supply(&buf, Config{Dev: false}),
fx.Populate(&got),
)
defer app.RequireStart().RequireStop()

assert.NotNil(t, got)
_, _ = io.WriteString(got, "hello")
assert.Equal(t, "hello", buf.String())
})
})
}
26 changes: 26 additions & 0 deletions module.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ type module struct {
provides []provide
invokes []invoke
decorators []decorator
evaluates []evaluate
modules []*module
app *App
log fxevent.Logger
Expand Down Expand Up @@ -174,6 +175,7 @@ func (m *module) provideAll() {
for _, p := range m.provides {
m.provide(p)
}
m.provides = nil

for _, m := range m.modules {
m.provideAll()
Expand Down Expand Up @@ -264,6 +266,7 @@ func (m *module) installAllEventLoggers() {
}
}
m.fallbackLogger = nil
m.logConstructor = nil
} else if m.parent != nil {
m.log = m.parent.log
}
Expand Down Expand Up @@ -308,6 +311,7 @@ func (m *module) invokeAll() error {
return err
}
}
m.invokes = nil

return nil
}
Expand All @@ -334,6 +338,7 @@ func (m *module) decorateAll() error {
return err
}
}
m.decorators = nil

for _, m := range m.modules {
if err := m.decorateAll(); err != nil {
Expand Down Expand Up @@ -405,3 +410,24 @@ func (m *module) replace(d decorator) error {
})
return err
}

func (m *module) evaluateAll() (count int) {
for _, e := range m.evaluates {
m.evaluate(e)
count++
}
m.evaluates = nil

for _, m := range m.modules {
count += m.evaluateAll()
}

return count
}

func (m *module) evaluate(e evaluate) {
// TODO: events
if err := runEvaluate(m, e); err != nil {
m.app.err = err
}

Check warning on line 432 in module.go

View check run for this annotation

Codecov / codecov/patch

module.go#L431-L432

Added lines #L431 - L432 were not covered by tests
}

0 comments on commit 045ba96

Please sign in to comment.