uber-go/fx

Feature request: allow sorting within a group of dependencies

talgendler opened this issue · 2 comments

Currently when adding a dependency to a group the order within the group is not guaranteed.

How about adding a Weight field within the Annotated struct and a structure tag option for group

type Annotated struct {
	Name string
	Group string
	Target interface{}

        // Set an order weight for this dependency 0 < X < 100
        Weight float32
}

OR

type result struct {
 fx.Out

 MyHandler http.Handler `group: "handlers,10.5"`
}

Weight range should be within (0,100) 0 < X < 100.
The default weight will be 100 and sort will not be stable.

Hello, @talgendler, thanks for the proposal.
Sorry, we don't think Dig/Fx should support weight-based value groups.
However, there's a pattern we can recommend instead.
Read on for details.

Background:
Value groups are intended to be used for unordered collections only.
We specifically made that decision when designing the API,
and we intentionally randomize the order in which the value group is filled:
https://github.com/uber-go/dig/blob/173b7b1935ec5ea6b26ebaf0b513658752f58c79/dig.go#L479

We felt that for cases when ordering mattered, it really mattered.
For those cases, it was desirable for the application to have full control on the order.
An API based on weights/z-indexing allows any included module anywhere to
alter the ordering without informing the consumer.

Recommendation:
Internally, we have use value groups to inject middleware into one of our systems.
Middleware ordering matters:
whether the rate limiting middleware runs before or after the retry middleware is important;
whether the logging middleware runs before or after retry middleware changes how many times the request is logged.

The rough design we went with there is:

  • middleware is fed into a value group
  • each middleware has a name
  • the user has a separate YAML configuration file where they order the middleware by name
  • middleware not listed in the file are not applied

So the consumer looks roughly like the following:

package foo

type Middleware interface {
	// Name specifies the name of the middleware.
	// It must be unique in an application.
	Name() string
	
	// Wrap applies the middleware on the method.
	Wrap(Method) (Method, error)
}

type Config struct {
    // List of middleware that should run in-order.
    Middleware []string `yaml:"middleware"`
}

var Module = fx.Provide(New)

type Params struct {
    fx.In
    
    Config      Config
    Middlewares []Middleware `group:"myapp"`
}

func New(p Params) (Result, error) {
    // Collate middleware by name.
    mws := make(map[string]Middleware)
    for _, m := range p.Middlewares {
        name := m.Name()
        if _, ok := mws[name]; ok {
            return nil, fmt.Errorf("received middleware %q multiple times", name)
        }
        mws[name] = m
    }

    var result []Middleware  // ordered list of requested middleware
    for _, name := range p.Config.Middleware {
        mw, ok := mws[name]
        if !ok {
            return nil, fmt.Errorf("unknown middleware %q requested in config", name)
        }
        result = append(result, mw)
    }
    
    return applyMiddlewares(..., result)
}

Various middleware modules look roughly like this:

package retry

const Name = "retry"

var Module = fx.Provide(
    fx.Annotated{
        Target: New,
        Group: "myapp",
    },
)

type middleware struct {
    // ...
}

func New(...) foo.Middleware {
    // ...
}

func (mw *middleware) Name() string {
    return Name
}

// ...

And finally, the application does something similar:

fx.New(
    foo.Module,
    retry.Module,
    config.Module,
    logging.Module,
    // ...
).Run()

Where config.Module parses the YAML configuration and feeds foo.Config into the container.

So the user can do:

middlewares: [retry, logging, ratelimit]

Hope this helps!

Thanks that was a great example.