cfg

package module
v0.0.0-...-2492f27 Latest Latest
Warning

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

Go to latest
Published: May 17, 2024 License: MIT Imports: 0 Imported by: 2

README

cfg

a functional options and configuration builder library in Go


Concept

cfg is a Go library that provides a simple and concise way of adding optional configuration settings to your Config data structures in Go, leveraging generics and a dash of functional programming patterns.

Generics allow the public functions and types in this library to work with any configuration type, without either breaking the static types and without forcing the caller to adhere to a certain type native of the configuration library.

The functional programming patterns are present in the way that the users of the library build their configuration options. Users provide the logic to change their Config data structure's elements however needed. More on this below.

Furthermore, the strategy is to enable libraries to expose one Config type per package, with all private elements; where consumers have zero control over changing those elements. Instead, the libraries expose configuration functions (WithXxx) that will in turn apply those changes to the Config type. Lastly, the Option type exported by this library is an interface which only contains one method, apply(T) T, a private one. This also ensure that consumers of the library cannot create functions that override elements in the Config, given that they are in fact private.


Motivation

Working and researching the OpenTelemetry Go SDK and API, I noticed this pattern being present in a lot of packages: You would find a Config type with all private fields, and the constructors are variadic functions that can take any number of XxxOption functions. This XxxOption type is also an interface that contains the same private method.

With this it clicked on my head why are they putting in so much effort in the Options, thinking that the library could just expose Config types with public elements, or to set the elements' fields within the actual XxxOption function.

Appreciating this approach, I saw it as a great means of allowing configurable, non-mandatory options and settings in a constructor (NewXxx functions and so on). It makes extra sense in SDKs and APIs that grow in complexity, yet want to maintain a controlled level of modularity.

While I was enjoying it through some projects and packages, it became a bit tiresome to create dedicated Option interfaces in every single package with options. So that is where the experiments with generics began. It worked great. It works regardless if the Config type is or isn't a pointer; it's up to the caller to set it as such in the Option-returning functions. It saves a lot of effort and has proven useful for something so simple, at least in my personal opinion.


Usage

cfg is served as a Go library. You need to import it in your project.

You can get the library as a Go module, by importing it and running go mod tidy on the top-level directory of your module, or where go.mod is placed.

Then, you're able to initialize it with your configuration functions and constructors, when registering option functions and when applying those options to a new or pre-made configuration data structure.

Creating a new config

If a package only contains a type that should be configured, then there is probably room to name your configuration data structure Config. Otherwise, it can be used as the suffix to the target type, like XxxConfig.

This type is usually exported (as a public type), but it contains all-private elements. Below is a simple example with a type holding two optional configuration settings:

package ping

import (
	"time"
)

type Config struct {
	url     string
	timeout time.Duration
}

That's it! This Config type is the target to your option functions and also the type returned from applying those same options.

Creating option functions

On each available option function you can define whichever type of behavior you desire. It is also an OK place to either add basic checks (like for zero values) where a NoOp type can be returned. Keep in mind that it is not the place to place all your validation where you may wish to return an error. For that it is best to inspect the resulting config with validation functions (like func(c Config) error).

Below you can see two examples, one library consumer just returns a function of type OptionFunc, and the second where they use the equivalent Register function instead:

package ping

import (
	"time"
	
	"github.com/zalgonoise/x/cfg"
)

type Config struct {
	url     string
	timeout time.Duration
}

func WithURL(url string) cfg.Option[Config] {
	if url == "" {
		return cfg.NoOp[Config]{}
	}

	// register an option by declaring the returned function as a OptionFunc type
	return cfg.OptionFunc[Config](func(config Config) Config {
		config.url = url

		return config
	})
}

func WithTimeout(dur time.Duration) cfg.Option[Config] {
	if dur <= 0 {
		return cfg.NoOp[Config]{}
	}

	// register an option via the cfg.Register function
	return cfg.Register(func(config Config) Config {
		config.timeout = dur

		return config
	})
}

The meat of the function is basically a builder pattern: I will take your configuration, make some changes to it, and return a modified version of it. While this is also achievable with pointers, Go allows you to do it with non-pointer types just as well, provided that you're OK with copying the data structure with each option func. Usually this not something that is done constantly, thus it shouldn't generally be a problem.

Creating a configuration from options

For this action, the library exposes two functions:

  • New[T any](options ...Option[T]) T: Creates a new configuration data structure from scratch and applies the input options on top of it. The resulting type is created with a *new(T) call.
  • Set[T any](config T, options ...Option[T]) T: Applies the input options on top of the input config. This call is useful when starting with configuration defaults, to have otherwise unset values with safe defaults.

You're free to use whichever you see fit. Below is an example that takes the Config data structure from above and applies the input options onto it, while providing safe defaults to begin with:

ping_config.go:

package ping

import (
	"time"
	
	"github.com/zalgonoise/x/cfg"
)

const (
	defaultTimeout = 15 * time.Second
	defaultURL     = "https://github.com/"
)

var (
	defaultConfig = Config{
		url:     defaultURL,
		timeout: defaultTimeout,
	}
)

type Config struct {
	url     string
	timeout time.Duration
}

// (...) rest of the configuration logic (option functions)

ping.go:

package ping

import (
	"time"

	"github.com/zalgonoise/x/cfg"
)

type Checker struct {
	url     string
	timeout time.Duration
}

func NewChecker(options ...cfg.Option[Config]) (*Checker, error) {
	// apply the input options on top of the defined default; the config is a value, not a pointer, in this case.
	config := cfg.Set(defaultConfig, options...)

	if err := validateURL(config); err != nil {
		return nil, err
	}

	// either use the config or pass it along to the data structure if it makes sense that way.
	return &Checker{
		url:     config.url,
		timeout: config.timeout,
	}, nil
}

The constructor to the type (NewChecker function) spawns the config with a cfg.Set call, using the default configuration as a base. Note also how validation is done separately -- targeting the resulting config -- which will check if the provided URL is OK. That function returns an error which can be useful. Validation is a different topic with dedicated logic / workflow if necessary.

Using the options

As a caller, you're effortlessly using these constructors and letting your LSP tell you what you can use to configure that type. These would be exported functions in the same package as the type and its constructor, that could be nicely prefixed (WithXxx) to help with alphabetical indexing of the exported types, as the text editors with LSP support and IDEs provide context on what they can use.

Below is an example for the same ping package referenced above; where in the main.go function the caller chooses what they want to configure the type with:

package main

import 	"github.com/zalgonoise/x/cfg/examples/pinger/ping"

func main() {
	myURL := "https://github.com/"

	c, err := ping.NewChecker(
		ping.WithURL(myURL),
		// in this case the service has a default for the timeout, but we could
		// override that value if WithTimeout below was not commented out.
		//
		// ping.WithTimeout(30 * time.Second),
	)
	if err != nil {
		// handle error 
	}
	
	// continue to use the checker
}
Structure and observations

A few important notes that may be useful when creating options of this nature:

  • keeping everything in the same package whenever possible; consumers shouldn't have to include multiple imports to reach the configuration and the actual type they will be working on.
  • Public types, private elements; which ensures that your package is in control of how you can modify the configuration.
  • Prefixing option functions with the keyword With, if possible; covered above, but in a nutshell enables LSP to list the available options easily.
  • Constructors should require mandatory elements as function arguments / parameters, and only the optional configuration settings served as an Option[T] type, where the Option[T] slice is variadic (allowing zero elements).
  • Validation of the input is performed after the configuration is created from the input parameters and options.

Example

A working example with a pinger application (that checks if GitHub is up or not) is present in the /examples/pinger directory. In this example, you will find the usual cmd/pinger entrypoint where the pinger service is being configured with these options. This is the same logic as covered in the Using the options section of this document.

On the other hand, you can also explore the ping package where the service exposes these configuration options. The example tries to cover a minimal environment so please take it as only a demonstration. This is the same logic that serves as example in the first chapters of the Usage section.

Disclaimer

This is not a one-size-fits-all solution! Please take your time to evaluate it for your own needs with due diligence. While having a library for this and a library for that is pretty nice, it could potentially be only overhead hindering the true potential of your app! Be sure to read the code that you are using to be a better judge if it is a good fit for your project. With that in mind, I hope you enjoy this library. Feel free to contribute by filing either an issue or a pull request.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func New

func New[T any](options ...Option[T]) T

New creates a new configuration data structure of type T and applies all configuration options passed by the caller; returning the resulting data structure.

func Set

func Set[T any](config T, options ...Option[T]) T

Set applies all Option configuration options to the input config, of any type. It returns a modified version of the input config with all applied options.

Types

type NoOp

type NoOp[T any] struct{}

NoOp is a placeholder option, suitable for any type, that can be used for, as an example, invalid input. The returned option will perform no changes to the input config and will return it as-is

type Option

type Option[T any] interface {
	// contains filtered or unexported methods
}

Option describes a generic interface type that can be used to set or define options in (any) configuration data structure.

func Register

func Register[T any](fn func(T) T) Option[T]

Register creates a new Option for a configuration data structure of type T.

It simply sets the input function as a OptionFunc type, if it isn't nil.

type OptionFunc

type OptionFunc[T any] func(T) T

OptionFunc is a function type which implements the Option interface.

Directories

Path Synopsis
examples

Jump to

Keyboard shortcuts

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