Overview of Fx¶
The Agent uses Fx as its application framework. While the linked Fx documentation is thorough, it can be a bit difficult to get started with. This document describes how Fx is used within the Agent in a more approachable style.
What Is It?¶
Fx's core functionality is to create instances of required types "automatically," also known as dependency injection. Within the agent, these instances are components, so Fx connects components to one another. Fx creates a single instance of each component, on demand.
This means that each component declares a few things about itself to Fx, including the other components it depends on. An "app" then declares the components it contains to Fx, and instructs Fx to start up the whole assembly.
Providing and Requiring¶
Fx connects components using types. Within the Agent, these are typically interfaces named Component
. For example, scrubber.Component
might be an interface defining functionality for scrubbing passwords from data structures:
type Component interface {
ScrubString(string) string
}
Fx needs to know how to provide an instance of this type when needed, and there are a few ways:
fx.Provide(NewScrubber)
whereNewScrubber
is a constructor that returns ascrubber.Component
. This indicates that if and when ascrubber.Component
is required, Fx should callNewScrubber
. It will callNewScrubber
only once, using the same value everywhere it is required.fx.Supply(scrubber)
wherescrubber
implements thescrubber.Component
interface. When another component requires ascrubber.Component
, this is the instance it will get.
The first form is much more common, as most components have constructors that do interesting things at runtime. A constructor can return multiple arguments, in which case the constructor is called if any of those argument types are required. Constructors can also return error
as the final return type. Fx will treat an error as fatal to app startup.
Fx also needs to know when an instance is required, and this is where the magic happens. In specific circumstances, it uses reflection to examine the argument list of functions, and creates instances of each argument's type. Those circumstances are:
- Constructors used with
fx.Provide
. ImagineNewScrubber
depends on the config module to configure secret matchers:func NewScrubber(config config.Component) Component { return &scrubber{ matchers: makeMatchersFromConfig(config), } }
- Functions passed to
fx.Invoke
:Like constructors, Invoked functions can take multiple arguments, and can optionally return an error. Invoked functions are called automatically when an app is created.fx.Invoke(func(sc scrubber.Component) { fmt.Printf("scrubbed: %s", sc.ScrubString(somevalue)) })
-
Pointers passed to
fx.Populate
.Populate is useful in tests to fill an existing variable with a provided value. It's equivalent tovar sc scrubber.Component // ... fx.Populate(&sc)
fx.Invoke(func(tmp scrubber.Component) { *sc = tmp })
.Functions can take multple arguments of different types, requiring all of them.
Apps and Options¶
You may have noticed that all of the fx
methods defined so far return an fx.Option
. They don't actually do anything on their own. Instead, Fx uses the functional options pattern from Rob Pike. The idea is that a function takes a variable number of options, each of which has a different effect on the result.
In Fx's case, the function taking the options is fx.New
, which creates a new fx.App
. It's within the context of an app that requirements are met, constructors are called, and so on.
Tying the example above together, a very simple app might look like this:
someValue = "my password is hunter2"
app := fx.New(
fx.Provide(scrubber.NewScrubber),
fx.Invoke(func(sc scrubber.Component) {
fmt.Printf("scrubbed: %s", sc.ScrubString(somevalue))
}))
app.Run()
// Output: scrubbed: my password is *******
For anything more complex, it's not practical to call fx.Provide
for every component in a single source file. Fx has two abstraction mechanisms that allow combining lots of options into one app:
fx.Options
simply bundles several Option values into a single Option that can be placed in a variable. As the example in the Fx documentation shows, this is useful to gather the options related to a single Go package, which might include un-exported items, into a single value typically namedModule
.fx.Module
is very similar, with two additional features. First, it requires a module name which is used in some Fx logging and can help with debugging. Second, it creates a scope for the effects offx.Decorate
andfx.Replace
. The second feature is not used in the Agent.
So a slightly more complex version of the example might be:
func Module() fxutil.Module {
return fx.Module("scrubber",
fx.Provide(newScrubber)) // now newScrubber need not be exported
}
someValue = "my password is hunter2"
app := fx.New(
scrubber.Module(),
fx.Invoke(func(sc scrubber.Component) {
fmt.Printf("scrubbed: %s", sc.ScrubString(somevalue))
}))
app.Run()
// Output: scrubbed: my password is *******
Lifecycle¶
Fx provides an fx.Lifecycle
component that allows hooking into application start-up and shut-down. Use it in your component's constructor like this:
func newScrubber(lc fx.Lifecycle) Component {
sc := &scrubber{..}
lc.Append(fx.Hook{OnStart: sc.start, OnStop: sc.stop})
return sc
}
func (sc *scrubber) start(ctx context.Context) error { .. }
func (sc *scrubber) stop(ctx context.Context) error { .. }
This separates the application's lifecycle into a few distinct phases:
- Initialization - calling constructors to satisfy requirements, and calling invoked functions that require them.
- Startup - calling components' OnStart hooks (in the same order the components were initialized)
- Runtime - steady state
- Shutdown - calling components' OnStop hooks (reverse of the startup order)
Ins and Outs¶
Fx provides some convenience types to help build constructors that require or provide lots of types: fx.In
and fx.Out
. Both types are embedded in structs, which can then be used as argument and return types for constructors, respectively. By convention, these are named dependencies
and provides
in Agent code:
type dependencies struct {
fx.In
Config config.Component
Log log.Component
Status status.Component
)
type provides struct {
fx.Out
Component
// ... (we'll see why this is useful below)
}
func newScrubber(deps dependencies) (provides, error) { // can return an fx.Out struct and other types, such as error
// ..
return provides {
Component: scrubber,
// ..
}, nil
}
In and Out provide a nice way to summarize and document requirements and provided types, and also allow annotations via Go struct tags. Note that annotations are also possible with fx.Annotate
, but it is much less readable and its use is discouraged.
Value Groups¶
Value groups make it easier to produce and consume many values of the same type. A component can add any type into groups which can be consumed by other components.
For example:
Here, two components add a server.Endpoint
type to the server
group (note the group
label in the fx.Out
struct).
type provides struct {
fx.Out
Component
Endpoint server.Endpoint `group:"server"`
}
type provides struct {
fx.Out
Component
Endpoint server.Endpoint `group:"server"`
}
Here, a component requests all the types added to the server
group. This takes the form of a slice received at instantiation (note once again the group
label but in fx.In
struct).
type dependencies struct {
fx.In
Endpoints []Endpoint `group:"server"`
}
Day-to-Day Usage¶
Day-to-day, the Agent's use of Fx is fairly formulaic. Following the component guidelines, or just copying from other components, should be enough to make things work without a deep understanding of Fx's functionality.