app

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 20, 2019 License: Apache-2.0 Imports: 8 Imported by: 0

README

app - A dependency orchestration and application framework

codecov Build Status GoDoc

Do You Need This?

This project is designed to help system, or service, developers to create and manage a runtime. Specifically, this projects provides for:

  • Dependency orchestration and injection
  • Runtime hooks for custom start and stop behaviors
  • Integration with configuration systems
  • Signal handling

If you are building a library then you do not need these features as they are specific to managing runtimes.

How To Use

Wrapping Code With Factories

This project is designed to be applied as a set of layers on top of otherwise functioning Go code. We value loose coupling and have built this framework such that no amount of this framework should be present in core application code. To that end we have developed a structure we call the Factory Protocol that can be applied around any existing code that constructs a value you want to have injected as a dependency.

The Factory Protocol is an abstract interface that would normally be defined using generics or templates in other languages. However, Go generics are still some time away from inclusion in the language so we cannot define the protocol using an actual type definition. Instead we will use pseudo-code:

type Protocol interface{
    Config(ctx context.Context) *C
    Make(ctx context.Context, conf *C) (T, error)
}

In this definition C refers to a struct you will define that acts as a data container for all configuration data needed to create the instance. The Config method must return a pointer to an instance of C that contains any default configuration values.

The instance of C returned from Config will be processed using a configuration loading system. By default we use stackopsd/config but the section titled Extending The Project details how other configuration systems may be used. Once the configuration processing is complete the Make method is called with the instance of C to obtain an instance of T. The type T is any type your code constructs whether that is a primitive value, such as a string, or an instance of a struct, pointer to a struct, or an interface type, etc.

The names an definitions of C and T are entirely up to you. For example, if we wanted to create a factory that generates *http.Client instances then we might do the following:

type Configuration struct{
    Timeout time.Duration
}

type Factory struct {}

func(*Factory) Config(ctx context.Context) *Configuration {
    return &Configuration{Timeout: 5*time.Second}
}

func(*Factory) Make(ctx context.Context, conf *Configuration) (*http.Client, error) {
    return &http.Client{Timeout: conf.Timeout, Transport: http.DefaultTransport}, nil
}

This example defines a configuration struct called Configuration that contains an input value needed by the Make method. The Make method constructs an instance of *http.Client. Having a configuration struct and a constructor method is a fairly common practice in Go. This is merely a structure that encapsulates those behaviors in a way that can be integrated with configuration loading system.

Factories With Dependencies

If your factory requires things beyond configuration values then those dependencies should be defined as attributes of the factory struct. To illustrate we will modify the previous factory to depend on an http.RoundTripper rather than using the Go default:

type Configuration struct{
    Timeout time.Duration
}

type Factory struct {
	Transport http.RoundTripper
}

func(*Factory) Config(ctx context.Context) *Configuration {
    return &Configuration{Timeout: 5*time.Second}
}

func(f *Factory) Make(ctx context.Context, conf *Configuration) (*http.Client, error) {
    return &http.Client{Timeout: conf.Timeout, Transport: f.Transport}, nil
}

The factory is nearly identical except that it now references a Transport value that is attached to the factory. This is the recommended way to manage dependencies for a factory for easiest integration with the system later.

Determine the difference between a dependency and configuration is an important step. The rule of thumb is that any complex object, like our http.RoundTripper in the example, is a dependency that will be constructed separately to our factory and any value we expect a human to set is configuration that should be in our struct.

For more on the Factory Protocol see https://github.com/stackopsd/factory#the-factory-protocol.

Defining Dependencies

After defining a set of factories that wrap all of your dependencies it's time to then actually declare the dependencies for the system. To do this we recommend defining a function like:

func CreateDependencies(ds app.Dependencies) (app.Dependencies, error) {
	httpClient := app.NewDependency("http_client")
	
	return append(ds, httpClient)
}

Each dependency required by an application must be defined first as an abstract dependency. The name given to the dependency must be unique across all dependencies for the system.

Kinds Of Dependencies

The default kind of dependency returned from NewDependency is a "driver". A driver dependency may have one or more implementations registered but only one will be selected at runtime. This is the most common form of dependency. There is also a second kind of dependency, returned from NewList, that can tolerate having zero or more implementations active simultaneously. This kind of dependency is less common but can be used to support dynamic middleware and optional decorator chains.

Defining And Linking Implementations

With some set of abstract dependencies defined you then define some number of concrete implementations for those dependencies and link them:

func CreateDependencies(ds app.Dependencies) (app.Dependencies, error) {
	httpClient := app.NewDependency("http_client")
	httpTransport := app.NewDependency("http_transport")
	
	httpClientImpl, err := app.NewImplementation(
		"default",
		func(ctx context.Context, rt http.RoundTripper) (*Factory, error) {
			return &Factory{Transport: rt}, nil
		},
	)
	if err != nil {
		return nil, err
	}
	httpClientImpl.Implements(httpClient)
	httpClientImpl.Requires(httpTransport)

	return append(ds, httpClient)
}

Each implementation must be given a name that is unique within the dependencies to which it is linked. Each implementation must also provide a constructor function that accepts any dependencies, shown as attributes of the factory struct in the previous examples, and returns an instance of the factory that can create the implementation. The process of called NewDependency will validate the constructor function so it is important to handle any errors returned.

Once the implementation is created successfully you then must link it to both any dependencies it implements and any dependencies it requires. This is done using the Implementation.Implements and Implementation.Requires methods. Calls to Requires take any number of dependencies in the order they are requested by the constructor function. Each call to Requires overwrites any previous settings with a new set of dependencies.

Note: If a requirement is a "list" kind of dependency then the parameter of the constructor function must be a slice.

Installing Runtime Hooks

Once you have all of your dependencies and implementations linked then you may need to define some runtime hooks. Hooks are processed at system start up or shut down and are the appropriate place to perform actions such as starting goroutines for network listeners, etc. Defining hooks works very much like defining implementations:

func CreateHooks(lc app.Lifecycle, ds app.Dependencies) error {
	server, _ := ds.Get("http_server")

	startServer, err := NewHook(
		"start_server",
		func(ctx context.Context, srv *http.Server) error {
			go srv.ListenAndServe()
			return nil
		},
	)
	if err != nil {
		return nil, err
	}
	startServer.Requires(server)
	lc.OnStart(startServer)

	stopServer, err := NewHook(
		"stop_server",
		func(ctx context.Context, srv *http.Server) error {
			return srv.ShutDown(ctx)
		},
	)
	if err != nil {
		return nil, err
	}
	stopServer.Requires(server)
	lc.OnStop(stopServer)
}

Each hook is defined using a call to NewHook. The name of the hook does not need to be unique. Each hook must be given a function in nearly the same form as an implementation except that it returns only an error. The signature of the function should contain each dependency that the hook will operate on. The hook must also define the required dependencies using Requires which works the same as Requires for implementations. Finally, each hook is registered to a a lifecycle event using the OnStop or OnStart of a Lifecycle instance.

The NewHook method validates the signature of the hook function so it is important to handle any errors returned.

Combining Everything Into An Application

Finally, all of the defined dependencies, implementations, and hooks are brought together in an Application instance:

package main

import (
	"github.com/stackopsd/app"

	"github.com/myuser/myproject/pkg/runtime" // the package where you defined everything
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	var defaultDeps app.Dependencies
	lc := app.NewLifecycle()

	deps, err := runtime.CreateDeps(defaultDeps)
	if err != nil {
		panic(err)
	}
	if err = runtime.CreateHooks(lc, dep); err != nil {
		panic(err)
	}
	a := app.NewApplication(
		ctx,
		app.OptionDependencies(deps...),
		app.OptionLifecycle(lc),
	)

	if err = a.Start(ctx); err != nil {
		panic(err)
	}
	<-a.Wait()
	if err = a.Stop(ctx); err != nil {
		panic(err)
	}
}
Applying Configuration Values

Because it is a part of our ecosystem, we ship this project with built-in support for using https://github.com/stackopsd/config for managing the loading of configuration values. The default application will use environment variables as the source of configuration. If you want to use config but with a JSON or YAML file instead then you can swap out the defaults using the OptionConfigLoader feature:

confLoader, err := config.NewLoaderFile("config.json_or_yaml")
if err != nil {
    panic(err)
}

app, err := depo.NewApplication(
    context.Background(),
    depo.OptionConfigLoader(confLoader),
)
if err != nil {
    panic(err)
}

By default, the system will attempt to load configuration in order to select which implementations are active at any given time as well as loading relevant configuration values for the implementation factories. The configuration pattern to use for selection of implementations is:

APP__DEP_ONE__DRIVER="implementation_name"
APP__DEP_ONE__IMPLEMENTATION_NAME__CONFIG_VALUE_ONE="something"
APP__DEP_ONE__IMPLEMENTATION_NAME__CONFIG_VALUE_TWO="something2"
APP__LIST_ONE__ENABLED__0="implementation_one"
APP__LIST_ONE__ENABLED__1="implementation_two"
APP__LIST_ONE__ENABLED__2="implementation_three"
APP__LIST_ONE__IMPLEMENTATION_ONE__CONFIG_VALUE_ONE="something"
APP__LIST_ONE__IMPLEMENTATION_ONE__CONFIG_VALUE_TWO="something2"
APP__LIST_ONE__IMPLEMENTATION_TWO__CONFIG_VALUE_ONE="something"
APP__LIST_ONE__IMPLEMENTATION_TWO__CONFIG_VALUE_TWO="something2"
APP__LIST_ONE__IMPLEMENTATION_THREE__CONFIG_VALUE_ONE="something"
APP__LIST_ONE__IMPLEMENTATION_THREE__CONFIG_VALUE_TWO="something2"

or the same configuration express as YAML:

dep_one:
	driver: "implementation_name"
	implementation_name:
		config_value_one: "something"
		config_value_two: "something2"
list_one:
	enabled:
		- "implementation_one"
		- "implementation_two"
		- "implementation_three"
	implementation_one:
		config_value_one: "something"
		config_value_two: "something2"
	implementation_two:
		config_value_one: "something"
		config_value_two: "something2"
	implementation_three:
		config_value_one: "something"
		config_value_two: "something2"

The important notes are that each implementation must define an active driver. Each list is disabled by default and each value must be enabled in a list. All configuration for a dependency is rooted under a key that matches the dependency name. All configuration values of the factory structs are rendered according to the process documented in https://github.com/stackopsd/config.

Extending The Project

One of the primary goals for this project is to create a system of loose coupling. We want it to be a layer that is added onto a project but never something that shows up in application code. Strategies for maximizing this loose coupling are detailed in Best Practices.

In addition to having a low impact on application code, we also want this project to be extensible to specific project needs. We do bundle in support for http://github.com/stackopsd/config because that's our configuration loading system but we never want that to be the only configuration system possible. Likewise we make some opinionated decisions about how driver and list implementations are selected that may not be correct in all cases. For example, what if you wanted to have list items enabled by default?

To enable modifications of all these behaviors we provide two interfaces that need to be implemented: The Selector and the Loader.

Building A Selector

A Selector is a component used by the system to filter out any registered implementations that will not be loaded when the system starts. The Selector interface is:

// Selector filters the implementations of each Dependency to only those that
// will be loaded at runtime. Any selection process may be used so long as the
// DepdendencyKind rules are maintained such that each DependencyKindDriver has
// exactly one implementation, and each DependencyKindList has zero or more
// implementations. Whatever remains in the implementations lists after this
// component processes them will be loaded.
type Selector interface {
	Select(ctx context.Context, ds []depo.Dependency) ([]depo.Dependency, error)
}

Like the code documentation says, any method may be used to filter the implementations so long as the rules for each kind of dependency are preserved. This is the component to create if you want to change how implementations are selected. For example, this is where you would implement lists that default to "enable all" or drivers that automatically select the first implementation if there is only one.See the LoaderConfig implementation in this project for an example of how filtering may be done.

Building A Loader

A Loader is the component that actually constructs an instance of the factory output for all implementations. A Loader must implement this interface:

// Loader implementations construct instances of all the implementations
// requested.
type Loader interface {
	Load(ctx context.Context, ds []depo.Dependency) ([]depo.LoadedDependency, error)
}

The dependency set given to the Loader will already be filtered to selected implementations and ordered such that each implementation is guaranteed to come after anything is depends on. This is the component to create if you want to change how factory configuration structs are populated. See the LoaderConfig for examples.

Best Practices

These are our current recommendations for how to most effectively use this, and any, dependency orchestration and injection system. They are likely to evolve over time so check back periodically.

Loose Coupling

This library should be used as a layer on top of otherwise valid Go code. This library should never be imported or referenced outside of code that is setting up a runtime, such as main.go. Your base code, your system design, and even your factory implementations should be free of any imports or references to this library.

Use A Dedicated Packages

We recommend using a dedicated package, such as /internal, /pkg/internal, or /pkg/runtime, etc., to contain the dependency orchestration code. This will both help prevent tight coupling inside application code and provide a clear place where new system dependencies are registered and organized. We recommend also breaking up the orchestration into multiple steps that can be leveraged by 3rd parties and tests. To illustrate, we recommend your runtime package have the following exports:

package runtime // or other name as desired

// Dependencies returns the collection of all system dependencies and their
// respective implementations. The method will be given a set of dependencies
// and append to them. In practice this set may be empty but this provides a
// future hook for passing in global dependencies such as a logger. This will
// make it easier to leverage re-usable dependency bundles.
//
// As an added values, returning the final dependency set provides a hook for
// test code to inject new implementations and dependencies into the set.
func Dependencies(existing app.Dependencies) (app.Dependency, error) {
	// ...
	return append(existing, newDeps...)
}

// RegisterHooks adds any relevant Start/Stop behaviors to the given lifcycle
// container. This helps create a dedicated place where start/stop behavior is
// defined for easier auditing later. This also provides a hook for testing
// by allowing for arbitrary test dependencies and implementations to be passed
// in for hook registration.
func RegisterHooks(deps app.Dependencies, ls app.Lifecycle) error {
    // ...
}

// NewApplication installs the given depenencies and lifecycle into an
// application that will be used to manage the system. This is also where any
// customization of the application would happen such as installing a different
// configuration loading or implementation selection system. Providing this as
// a feature like this enables consumers to re-use any custom configuration
// setup for cases such as creating a custom build of your application that has
// an extra driver implementation added.
func NewApplication(ctx context.Context, deps app.Dependencies , lc app.Lifecycle) (app.Application, error) {
    // ...
}
Add Validation To Your Build

This project uses reflection to perform most of its complex functions which means we've had to sacrifice a large amount of compile time protection. To account for this we also provide a fairly extensive suite of runtime protections by recreating many of the checks that the compiler would have performed for us. However, because our tooling operates at runtime it means you must have something that executes the validation code in order to report on pass or fail. We recommend running validation as a unit test.

Validating Factories

If you don't mind pulling in a Go project for testing then we recommend using https://github.com/stackopsd/factory to validate your factory implementations:

func TestMyFactory(t *testing.T) {
	if err := factory.Verify(&MyFactory{}); err != nil {
		t.Error(err)
	}
}

The Verify tool from our factory library will report on whether or not the given instance satisfies the Factory Protocol and give details on any aspect of the instance that does not.

Validating Implementations And Hooks

Implementation constructors and hook functions are validated during their respective calls to NewImplementation and NewHook. If you are using our suggested practice of using methods to create both of these resources then you can add a test like:

func TestImplementationsAndHooks(t *testing.T) {
	deps, err := CreateDependencies(nil)
	if err != nil {
		t.Fatal(err)
	}
	lc := app.NewLifecycle()
	if err = RegisterHooks(deps, lc); err != nil {
		t.Fatal(err)
	}
}
Validating Applications

Finally, there is some validation that can only be done when all the pieces are available together. Notably, validation of dependency cycles, missing dependencies, and advanced type protection can only happen when all values are together. To help with this we provide a method on the Application called Validate that performs all runtime validation:

func TestApplication(t *testing.T) {
	deps, err := CreateDependencies(nil)
	if err != nil {
		t.Fatal(err)
	}
	lc := app.NewLifecycle()
	if err = RegisterHooks(deps, lc); err != nil {
		t.Fatal(err)
	}
	a, err := NewApplication(context.Background(), deps, lc)
	if err != nil {
		t.Fatal(err)
	}
	if err = a.Validate(); err != nil {
		t.Fatal(err)
	}
}

Planned Features

This project is still very young and there are a few features we'd like to deliver in the future:

  • Dependency graph generation.

    Especially for large, complex projects and when debugging cycles, it would be useful to visualize the dependency graph of a system. Because this feature would likely bring in a number of unrelated dependencies we may implement this as a separate module.

  • Code generation.

    Even though we've re-implemented most of the compiler type checks, it may still be a useful feature to have an option of generating the final orchestration code.

  • Sample configuration generation.

    For non-config configuration loading systems this would really only be another interface to implement. However, for folks using config we could help by generating sample configuration files.

Developing

Make targets

This project includes a Makefile that makes it easier to develop on the project. The following are the included targets:

  • fmt

    Format all Go code using goimports.

  • generate

    Regenerate any generated code. See gen.go for code generation commands.

  • lint

    Run golangci-lint using the included linter configurations.

  • test

    Run all unit tests for the project and generate a coverage report.

  • integration

    Run all integration tests for the project and generate a coverage report.

  • coverage

    Generate a combined coverage report from the test and integration target outputs.

  • update

    Update all project dependencies to their latest versions.

  • tools

    Generate a local bin/ directory that contains all of the tools used to lint, test, and format, etc.

  • updatetools

    Update all project ools to their latest versions.

  • vendor

    Generate a vendor directory.

  • clean/cleancoverage/cleantools/cleanvendor

    Remove files created by the Makefile. This does not affect any code changes you may have made.

License

Copyright 2019 Kevin Conway

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	NewDependency     = depo.NewDependency     //nolint
	NewList           = depo.NewList           //nolint
	NewImplementation = depo.NewImplementation //nolint
)

Functions

func VerifyAction

func VerifyAction(f interface{}) error

VerifyAction returns an error if the given value is not a function that complies with the Action specification.

Types

type Action

type Action interface {
	// Inputs returns the types of the required input values in the order they
	// are required.
	Inputs() []reflect.Type
	Fire(ctx context.Context, vs ...interface{}) error
}

Action is a wrapper around a hook function that provides some reflected data.

func NewAction

func NewAction(f interface{}) (Action, error)

NewAction converts a given hook function into an Action implementation.

type Application

type Application interface {
	// Dependencies emits the current depdency graph.
	Dependencies() []depo.Dependency
	// Validate runs the enclosed validation chain and reports whether or not
	// the Application will start.
	Validate(ctx context.Context) error
	// Start the application and call any lifecycle hooks.
	Start(ctx context.Context) error
	// Stop the application and call any lifecycle hooks.
	Stop(ctx context.Context) error
}

Application is the high level API for developers to manage a runtime.

func NewApplication

func NewApplication(ctx context.Context, options ...Option) (Application, error)

NewApplication generates an Application from the given options. All values except OptionDependencies has a default value.

type Dependencies

type Dependencies = depo.Dependencies

Dependencies is an alias for the depo type.

type Hook

type Hook interface {
	// Name returns the symbolic name of the hook. This is mostly for human
	// purposes as it will be used when generating errors or information about
	// the hook.
	Name() string

	// Requirements returns the set of dependencies for the implementation.
	// These are ordered according to the parameters of the Action method.
	Requirements() []depo.Dependency

	// Action is the wrapped hook function.
	Action() Action

	// Requires registers a set of dependencies that are required to call the
	// Action function. These must be ordered according to the Action function
	// parameters. Each call to this method overwrites any existing set.
	Requires(ds ...depo.Dependency)
}

func NewHook

func NewHook(name string, action interface{}) (Hook, error)

NewHook returns a Hook that can be used with a Lifecycle. It fails if the given action function is invalid.

type Lifecycle

type Lifecycle interface {
	OnStart(hs ...Hook)
	OnStop(hs ...Hook)
	StartHooks() []Hook
	StopHooks() []Hook
	Start(ctx context.Context, loaded []depo.LoadedDependency) error
	Stop(ctx context.Context, loaded []depo.LoadedDependency) error
}

Lifecycle is used to attach event hooks to dependencies. These hooks are used in the context of an application for cases where resolved dependencies need to be managed beyond construction.

func NewLifecycle

func NewLifecycle() Lifecycle

NewLifecycle generates the default implementation of a Lifecycle hook manager.

type LoaderConfig

type LoaderConfig struct {
	Loader config.Loader
}

LoaderConfig is an integration with https://github.com/stackopsd/config that uses a config.Loader to populate factory configurations before constructing instances.

Dependencies of will be loaded by pulling values from subtrees named [<Dependency.Name()>, <Implementation.Name()>].

func (*LoaderConfig) Load

Load all dependency implementations in the order provided.

type Option

type Option interface {
	// contains filtered or unexported methods
}

Option instances modify the runtime.

func OptionConfigLoader

func OptionConfigLoader(ld config.Loader) Option

OptionConfigLoader installs a config.Loader instance into the default Loader implementation. The default value for this is a loader attached to the environment variables that uses the prefix of APP.

func OptionDependencies

func OptionDependencies(ds ...depo.Dependency) Option

OptionDependencies sets the given list of dependencies at those to load and orchestrate for the application.

func OptionLifecycle

func OptionLifecycle(lc Lifecycle) Option

OptionLifecycle install a Lifecycle instance. The default is an empty Lifecycle implementation that does nothing. Use the NewLifecycle() method to generate an instance and attach hooks to it before installing here.

func OptionLoader

func OptionLoader(ld depo.Loader) Option

OptionLoader installs a dependency loader. A loader is responsible for managing the factory configurations and using the factories to produce instances. The default implementation makes use of the default OptionConfigLoader value. Overridding this results in the OptionConfigLoader becoming unused.

func OptionSelector

func OptionSelector(s depo.Selector) Option

OptionSelector installs an implementation selector into the application. The default leverages https://github.com/stackopsd/config to check environment variables for which implementations to select for each dependency.

func OptionSorter

func OptionSorter(s depo.Sorter) Option

OptionSorter installs a dependency sorter. The sorter must order dependency appropriately for loading. The default implementation is the SorterTopological.

func OptionValidator

func OptionValidator(v depo.Validator) Option

OptionValidator overrides the default Validator for dependency sets. The default is the same thing returned by NewValidator().

type SelectorConfig

type SelectorConfig struct {
	Loader config.Loader
}

SelectorConfig is an integration with https://github.com/stackopsd/config that uses a config.Loader to make selections.

No lookups are performed for DependencyKindSingle as they have only one possible implementation. This implementation is selected by default.

In order to determine selection for DependencyKindDriver the selector will look in the configuration for [<Dependency.Name()>, "driver"]. The expected value is a string that identifies the implementation to use by its name. The name value is case insensitive.

In order to determine selection for DependencyKindList the selector will look in the configuration for [<Dependency.Name()>, "enabled"]. The expected value is an array of strings that each identify an implementation to load by its name. The name values are case insensitive.

func (*SelectorConfig) Select

func (s *SelectorConfig) Select(ctx context.Context, ds []depo.Dependency) ([]depo.Dependency, error)

Select which implementations to load.

type ValidatorHooks

type ValidatorHooks struct {
	Hooks []Hook
}

ValidatorHooks enforces that every hook can be satisfied by the set of requirements defined.

func (*ValidatorHooks) Validate

func (v *ValidatorHooks) Validate(ctx context.Context, ds []depo.Dependency) error

Validate that every hook requirement can be satisfied by the dependency set.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL