In this article, we will discuss the programming mode of Functional Options. This is an application case of functional programming with good programming skills. It is the most popular programming mode in Go language at present. However, before we formally discuss this model, we need to see what kind of problems to solve.
Configuration options problem
In our programming, we often need to configure an object (or business entity). For example, the following business entity (note that this is only an example):
type Server struct { Addr string Port int Protocol string Timeout time.Duration MaxConns int TLS *tls.Config }
In this Server object, we can see:
- The IP address Addr and Port number Port for listening are required (of course, both IP address and Port number can have default values. When we use this example to think that there is no default value and cannot be empty, they need to be required).
- Then, there are Protocol protocol, Timeout and MaxConns fields. These fields cannot be empty, but they have default values. For example, the Protocol is tcp, the Timeout is 30 seconds and the maximum number of links is 1024.
- There is also a TLS, which is a secure link. You need to configure relevant certificates and private keys. This can be empty.
Therefore, for the above configurations, we need to have a variety of different function signatures to create servers with different configurations, as shown below (the code is relatively wide and needs to scroll left and right):
func NewDefaultServer(addr string, port int) (*Server, error) { return &Server{addr, port, "tcp", 30 * time.Second, 100, nil}, nil } func NewTLSServer(addr string, port int, tls *tls.Config) (*Server, error) { return &Server{addr, port, "tcp", 30 * time.Second, 100, tls}, nil } func NewServerWithTimeout(addr string, port int, timeout time.Duration) (*Server, error) { return &Server{addr, port, "tcp", timeout, 100, nil}, nil } func NewTLSServerWithMaxConnAndTimeout(addr string, port int, maxconns int, timeout time.Duration, tls *tls.Config) (*Server, error) { return &Server{addr, port, "tcp", 30 * time.Second, maxconns, tls}, nil }
Because the Go language does not support overloaded functions, you have to use different function names to deal with different configuration options.
Configuration object scheme
The most common way to solve this problem is to use a configuration object, as shown below:
type Config struct { Protocol string Timeout time.Duration Maxconns int TLS *tls.Config }
We move all the non mandatory options to a structure, so the Server object becomes:
type Server struct { Addr string Port int Conf *Config }
Therefore, we only need a function of NewServer(). We need to construct the Config object before using it.
func NewServer(addr string, port int, conf *Config) (*Server, error) { //... } //Using the default configuratrion srv1, _ := NewServer("localhost", 9000, nil) conf := ServerConfig{Protocol:"tcp", Timeout: 60*time.Duration} srv2, _ := NewServer("locahost", 9000, &conf)
This code is good. In most cases, we may stop there. However, for programmers who are obsessed with cleanliness and pursuit, they can see that Config is not necessary. Therefore, you need to judge whether it is nil or Empty – Config {} which makes our code feel a little unclean.
Builder mode
If you are a Java programmer, those familiar with design patterns will naturally use the Builder pattern. For example, the following code:
User user = new User.Builder() .name("Hao Chen") .email("haoel@hotmail.com") .nickname("Left ear") .build();
Following the above mode, we can rewrite the above code into the following code (Note: the following code does not consider error handling. For more information about error handling, please refer to Go programming mode: error handling):
//Use a builder class to wrap type ServerBuilder struct { Server } func (sb *ServerBuilder) Create(addr string, port int) *ServerBuilder { sb.Server.Addr = addr sb.Server.Port = port //Other codes set default values for other members return sb } func (sb *ServerBuilder) WithProtocol(protocol string) *ServerBuilder { sb.Server.Protocol = protocol return sb } func (sb *ServerBuilder) WithMaxConn( maxconn int) *ServerBuilder { sb.Server.MaxConns = maxconn return sb } func (sb *ServerBuilder) WithTimeOut( timeout time.Duration) *ServerBuilder { sb.Server.Timeout = timeout return sb } func (sb *ServerBuilder) WithTLS( tls *tls.Config) *ServerBuilder { sb.Server.TLS = tls return sb } func (sb *ServerBuilder) Build() (Server) { return sb.Server }
So it can be used in the following ways
sb := ServerBuilder{} server, err := sb.Create("127.0.0.1", 8080). WithProtocol("udp"). WithMaxConn(1024). WithTimeOut(30*time.Second). Build()
The above method is also very clear. There is no need for additional Config classes. Instead, we use chained function calls to construct an object. We only need to add an additional Builder class. This Builder is similar to a little redundant. It seems that we can directly construct such a Builder on the Server. It is true. However, it may be a little troublesome when dealing with errors (you need to add an error member to the Server structure, which destroys the "purity" of the Server structure). It is better to use a wrapper class.
If we want to eliminate the structure of this package, it's our turn to play Functional Options, functional programming.
Functional Options
First, we define a function type:
type Option func(*Server)
Then, we can define the following set of functions in a functional way:
func Protocol(p string) Option { return func(s *Server) { s.Protocol = p } } func Timeout(timeout time.Duration) Option { return func(s *Server) { s.Timeout = timeout } } func MaxConns(maxconns int) Option { return func(s *Server) { s.MaxConns = maxconns } } func TLS(tls *tls.Config) Option { return func(s *Server) { s.TLS = tls } }
The above group of codes pass in a parameter and then return a function. The returned function will set its own Server parameters. For example:
- When we call one of these functions with MaxConns(30)
- Its return value is a function of func (s * server) {s.maxconns = 30}.
This is called higher order function. Mathematically, just like this mathematical definition, the formula for calculating the area of a rectangle is: rect(width, height) = width * height; This function requires two parameters. After we wrap it, it can become a formula for calculating the square area: square(width) = rect(width, width). That is, square (width) returns another function, rect(w,h), but its two parameters are the same. Namely: F (x) = g (x, x)
OK, now let's define a function of NewServer(), in which there is a variable parameter options, which can pass out multiple above functions, and then use a for loop to set our Server object.
func NewServer(addr string, port int, options ...func(*Server)) (*Server, error) { srv := Server{ Addr: addr, Port: port, Protocol: "tcp", Timeout: 30 * time.Second, MaxConns: 1000, TLS: nil, } for _, option := range options { option(&srv) } //... return &srv, nil }
So when we create the Server object, we can do this.
s1, _ := NewServer("localhost", 1024) s2, _ := NewServer("localhost", 2048, Protocol("udp")) s3, _ := NewServer("0.0.0.0", 8080, Timeout(300*time.Second), MaxConns(1000))
How about it? Is it highly neat and elegant? It not only solves the need to have a Config parameter when using Config object mode, but it is difficult to choose whether to put nil or Config {} when it is not needed, and there is no need to refer to a Builder control object. The trial of direct use of functional programming is also very elegant in code reading.
Therefore, in the future, when you want to play similar code, you are strongly recommended to use Functional Options, which brings at least the following benefits:
- Intuitive programming
- Highly configurable
- Easy to maintain and expand
- From document
- It's easy for newcomers
- There's nothing confusing (nil or empty)
This article is not written by myself. It reprints the left ear mouse blog and its source Cool shell – CoolShell