Go es un lenguaje de programación que, desde sus inicios, se ha querido caracterizar por, entre otros, dos aspectos principales: la sencillez y la retrocompatibilidad. De hecho, es esta última la que ha permitido que, hasta día de hoy, la gran mayoría de proyectos Go estén operando en su versión más reciente casi sin apenas inconvenientes, y si tenéis algún proyecto Go en producción seguro que ya conocéis esa sensación tan agradable.
Sin embargo, el aspecto de la sencillez sí que ha sido algo que siempre ha estado en tela de juicio, especialmente por ser el paraguas (o la excusa) bajo el que se han escondido (en cierto modo) varias limitaciones del lenguaje: tanto características más fundamentales como la falta de genéricos, como características más superfluas como el operador ternario.
Otros aspectos ligados a (o fruto de) esa sencillez son la no existencia de parámetros opcionales o la no existencia de sobrecarga de métodos. Y es precisamente en ese aspecto en el que nos queremos centrar hoy. Pues, como veremos a continuación, la falta de dichos recursos a veces nos puede llevar más de un dolor de cabeza y/o dirigirnos a recurrir a prácticas poco atractivas. Sin embargo, si analizamos la situación con un poco de temple, veremos que de la no existencia de dichas características lo que vamos a hacer será encontrar soluciones incluso más atractivas de las que podríamos recurrir en el caso de poseer dichas características. Entremos en materia.
Un ejemplo pseudo-real
Si bien para el propósito de este artículo no vamos a usar un ejemplo real, sí que tanto éste como uno muy similar podrían ser perfectamente extraídos de un proyecto real. De hecho, es probable que este ejemplo os recuerde a algún que otro caso al que os hayáis tenido que enfrentar anteriormente.
Supongamos que estamos desarrollando una aplicación web y que queremos exponer un nuevo servicio vía HTTP, ya sea de forma tradicional o vía gRPC. En ese caso, es muy probable que tengamos un código muy similar al siguiente:
type Server struct {
listener net.Listener
}
func (s *Server) Addr() net.Addr
func (s *Server) Shutdown()
func NewServer(addr string) (*Server, error) {
// initialization here
}
Y, de hecho, es probable que dicho código nos sea más que suficiente durante buena parte del ciclo de vida de desarrollo de nuestro servicio. Sin embargo, es probable que, más tarde o más temprano, nos empiecen a llegar nuevas preocupaciones adyacentes: exponer el servicio bajo un certificado TLS, permitir configurar los timeouts, el número de conexiones máximas, etc. Y es, en este preciso momento, cuándo empiezan a surgir las dudas. Probablemente, si no somos de darle muchas vueltas a las cosas, lo primero que nos surgirá es evolucionar nuestro código anterior en algo similar a:
func NewServer(addr string) (*Server, error)
func NewTLSServer(addr string, cert *tls.Cert) (*Server, error)
func NewServerWithTimeout(addr string, timeout time.Duration) (*Server, error)
func NewTLSServerWithTimeout(addr string, cert *tls.Cert, timeout time.Duration) (*Server, error)
Sin embargo, más pronto que tarde nos empezaremos a dar cuenta de que dicha solución no es especialmente atractiva. Ya no solo porqué la API pública de nuestros paquetes estará dejando de ser clara y concisa, ni porqué el número de constructoras sea el resultado de multiplexar cada uno de los posibles parámetros teóricamente opcionales, sino porqué además veremos que nos resulta complicado evitar la duplicación teóricamente innecesaria de código. Y esto definitivamente no mola.
El struct de configuración
Otra posible evolución natural del código original es lo que se suele conocer como “el struct de configuración”. Qué básicamente consiste en encapsular todos esos posibles parámetros opcionales en un único struct de configuración, de forma que podemos volver al punto de partida original y simplemente añadir un argumento extra a la firma de nuestra constructora o, en su defecto, añadir una nueva constructora con dicho argumento, de forma que podamos mantener la retrocompatibilidad de nuestra API pública:
type Config struct {
Timeout time.Duration
Cert *tls.Cert
MaxConnections int
}
func NewServer(addr string) (*Server, error)
func NewServerWithConfig(addr string, config Config) (*Server, error)
Ahora bien, las posibilidades a explorar en éste nuevo punto vuelven a ser casi tantas como en el punto anterior.
- ¿Qué ocurre si nos pasan una configuración por defecto?
- O, en caso de querer evitarlo mediante un puntero, ¿qué ocurre si nos pasan un
nil
? - ¿Y si hacemos que el parámetro de configuración sea un variadic?
Los diferentes enfoques posibles tienen problemas intrínsecos. Por desgracia, los valores por defecto no suelen
ser válidos para todos los parámetros necesarios (párate a pensar en, por ejemplo, el puerto, que de ser un entero, se
correspondería con el puerto cero). Permitir argumentos nil
suele resultar en APIs poco intuitivas, con un exceso
de comprobaciones innecesario y contra la filosofía Go de hacer útil el valor por defecto. Así como permitir varias
configuraciones (variadics), que también nos llevará a tener una API de cualquier forma menos clara y concisa.
Functional options
Visto lo visto, lo más adecuado es dar un paso hacia atrás y volver a plantear el problema. Y es aquí dónde llegamos a lo que podríamos considerar un enfoque aceptablemente atractivo: las functional options. Y es probable que, de entrada, os parezca un enfoque un tanto extraño y muy específico del lenguaje. Pero como veremos a continuación, no deja de ser un enfoque plenamente portable a cualquier lenguaje que soporte funciones de alto orden (que puedan recibir otras funciones como argumentos). Como también lo podría ser un enfoque mediante el patrón de diseño Builder con una fluent interface.
Pero, ¿y qué son esto de las functional options?
Pues no dejan de ser funciones que vamos a ofrecer como parte de la API pública de nuestro paquete que nos permitiran configurar todas esas opciones al gusto del consumidor. Veamos un ejemplo de cómo quedaría el nuevo código:
type Option func(*Server) error
func NewServer(addr string, options ...Option) (*Server, error) {
l, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
srv := Server{listener: l}
for _, option := range options {
err = option(&srv)
if err != nil {
return nil, err
}
}
return &srv, nil
}
func WithTimeout(timeout time.Duration) Option {
return func(server *Server) error {
// timeout checks & assignment here
}
}
func WithTLS(cert *tls.Cert) Option {
return func(server *Server) error {
// cert checks & assignment here
}
}
func WithMaxConnections(maxConnections int) Option {
return func(server *Server) error {
// max connections checks & assignment here
}
}
Es decir, lo primero de todo será definir qué es una opción para nosotros. Aunque éste es solo un paso sintáctico,
pues podríamos aplicar los pasos siguientes aunque no hubiéramos definido el tipo Option
.
Posteriormente, vamos a definir nuestra constructora de modo que espere un número variable (variadic) de Option
.
Finalmente, vamos a proporcionar las funciones que le permitirán al consumidor pasar las opciones que crea convenientes.
De este modo, el consumo de nuestro paquete sería mucho más agradable, claro y conciso:
func main() {
// initialization here (env vars, etc)
srv, err := http.NewServer(
":80",
http.WithTLS(cert),
http.WithTimeout(timeout),
http.WithMaxConnections(maxConnections),
)
}
De hecho, con este enfoque:
- Podríamos permitir al consumidor definir sus propias
Option
sobre los campos públicos de nuestra estructura de datos. - Nos será mucho más fácil obtener un código mantenible en el lado de la implementación, sin comprobaciones innecesarias, sin necesidad de duplicar el código y, de hecho, como hemos visto, con una constructora mucho más simple.