GO: O que raios é "config pattern"?

Olá, leitores do CodeAspiras! Estou aqui novamente com mais um artigo de Golang para disseminar conhecimentos relevantes para a área. Dessa vez, venho com mais um conceito de desenvolvimento que é bom conhecer mas que não deve ser tão utilizado assim, que é o Config Pattern ou "padrão de configuração".

Como o próprio nome diz, trata-se de uma estratégia de estrutura de argumentos e/ou configurações de qualquer coisa. Você consegue até aplicar em outras linguagens, pois o conceito é abrangente o suficiente para produzir em quaisquer linguagens que fornecem alguma ferramenta de captura de argumentos dinâmicos. No caso do Golang, nós usamos o pack operator.

O que raios é isso, Kiko?!

É o oposto de unpack ou spread. Em outras linguagens, em situações onde você quer quebrar um array em uma lista de valores, geralmente você "desempacota" o array com a assinatura array..., onde os três pontos no final significam que você vai pegar todos os valores e distribuir na linha onde a sentença está escrita.

No caso do pack, é justamente o oposto. Você vai ter uma lista de valores e quer "empacotar" tudo em um único array. No caso do Golang, é em um slice, ficando assim:

func MyFunc(arguments ...string) {
    // nesse caso, a assinatura final do argumento 'arguments' é []string
}

Se a linguagem que você usa fornece algum recurso para capturar uma lista indefinida de argumentos, então você consegue aplicar o Config Pattern.

OK, mas o que é isso?

Config Pattern nada mais é que um DTO gerenciado por pequenas funções.

Não sabe o que é DTO? Dá uma lidanesse outro artigo que expliquei direitinho(escrevi via Telegraph, numa época obscura que eu publicava coisas para só uma pessoa ler... Eu deveria republicar aqui no CodeAspiras? Comenta aí).

A principal diferença seria sobre qual lado da função retém a responsabilidade de alimentar os dados do objeto que carrega informação. O DTO é alimentado por quem chama a função, antes de chamá-la. Já o Config Pattern, é alimentado dentro da função. Por isso você cria funções que servem para injetar valores no objeto de configuração.

Exemplo visual descrevendo os cenários DTO versus Config Pattern, onde a função em DTO recebe um objeto construído e a função em Config Pattern recebe vários valores para construir um objeto.

E pra que serve isso tudo, Kiko?!

Para ter um controle rígido do que pode e não pode ser inserido. Por exemplo, no Golang, você pode desenvolver uma configuração 100% privada, expondo somente algumas possibilidades pra quem vai chamar sua função.

Digamos que estamos desenvolvendo um Logger e queremos que o desenvolvedor que irá usá-lo possa configurar somente prefixo ou sufixo. Se você implementar em DTO, você teria algo mais ou menos assim:

package mylog // logger.go

type LoggerConfigDTO struct {
    Prefix string
    Suffix string
}

type Logger struct {
    config LoggerConfigDTO
}

func New(config LoggerConfigDTO) *Logger {
    return &Logger{config: config}
}

E aí, quem fosse inicializar seu Logger poderia fazer algo assim:

mylog.New(mylog.LoggerConfigDTO{
    Prefix: "$timestamp [$level]",
    Suffix: "/n",
})

Só tem um problema nisso. Lá no enunciado, eu disse que era para ter somente prefixo ou sufixo, não ambos simultaneamente, correto? Então essa implementação não está adequada.

Uma possível solução seria somente aplicar o prefixo se o sufixo estiver vazio e/ou vice-versa, porém isso iria impactar o tempo de escrita de logs, adicionando uma condição para o momento de execução. Outra solução seria, já na hora de inicializar a struct, verificar se os dois estão preenchidos e apagar um arbitrariamente.

Em ambas as soluções, nós estamos decidindo algo e gerando um comportamento obscuro na nossa aplicação. O desenvolvedor que usasse seu Logger só iria perceber esse comportamento quando já estivesse vendo os logs em produção com alguma informação faltando. Claro, você também pode deixar um aviso gigante na documentação... Enfim...

Em busca do Config Pattern

Personagem de O Hobbit correndo com a legenda em inglês "I'm going on an adventure!" (Estou indo em uma aventura!)

Antes de prosseguir, deixe-me alertá-lo(a) novamente: só use isso se você precisar de controle rígido sobre configurações, pois a implementação pode deixar o código bem massante. Outro ponto: embora você possa implementar a configuração de forma privada, sempre que possível, faça-a pública. Assim você facilita a escrita de testes de outros desenvolvedores, beleza? Tendo uma interface de configuração pública, eles conseguem mockar o setup e fazer seu código ter um comportamento específico nos testes. Mas chega de papo e vamos lá.

O primeiro passo que você precisa para transformar seu DTO em um Config Pattern é criar uma assinatura para funções de manipulação dos dados. No caso, eu vou chamar de LoggerOption:

package mylog // logger_config.go

type LoggerOption func(c *LoggerConfigDTO)

Nessa assinatura, estou dizendo que um LoggerOption é uma função que recebe uma referência/ponteiro de LoggerConfigDTO e não retorna nada. Basicamente, essa função deve alterar a referência que recebe no argumento e nada mais.

Tendo essa definição, você pode criar as funções que manipulam os dados do DTO. Essas funções recebem como argumento o dado a ser inserido na configuração e retornam um LoggerOption, ou seja, retornam a função de injeção do dado no DTO:

package mylog // logger_config.go

type LoggerOption func(c *LoggerConfigDTO)

// WithPrefix defines the prefix to be injected in all logs. It erases the suffix, if it's filled.
func WithPrefix(prefix string) LoggerOption {
    return func(c *LoggerConfigDTO) {
        c.Prefix = prefix // aqui aplicamos o prefixo na configuração
        c.Suffix = "" // aqui limpamos o sufixo para não ter coexistência
    }
}

// WithSuffix defines the suffix to be injected in all logs. It erases the prefix, if it's filled.
func WithSuffix(suffix string) LoggerOption {
    return func(c *LoggerConfigDTO) {
        c.Suffix = suffix // aqui aplicamos o sufixo na configuração
        c.Prefix = "" // aqui limpamos o prefixo para não ter coexistência
    }
}

Note que adicionei dois comentários indicando o comportamento de cada configuração. Esses comentários são chamados de godoc e são suportados por quase toda IDE que podemos usar para desenvolver Golang. Dito isso, ao invocar a função, o desenvolvedor já vai saber dos efeitos obscuros - se ele ler, claro. E isso não é exclusivo de Config Pattern. Por favor, use godoc em tudo, até em propriedades, rs.

E por último mas não menos importante, agora podemos modificar a função de inicialização para receber um pack de LoggerOption ao invés de um DTO preenchido, migrando a responsabilidade de construção do DTO para dentro da função:

package mylog // logger.go

type LoggerConfigDTO struct { // isso pode permanecer assim
    Prefix string
    Suffix string
}

type Logger struct {
    config LoggerConfigDTO // aqui também
}

func New(options ...LoggerOption) *Logger {
    config := LoggerConfigDTO{} // construimos o DTO
    for index := 0; index < len(options); index++ {
        options[index](&config) // executamos cada LoggerOption na referência do DTO
    }

    return &Logger{config: config}
}

E com isso, ao invés de inicializar um DTO, agora o desenvolvedor poderá inicializar o Logger com várias chamadas de LoggerOption:

mylog.New(
    mylog.WithPrefix("$timestamp [$level]"),
    // mylog.WithSuffix("\n"),
)

Fim. Você rapidamente migrou o padrão do seu projeto de DTO para Config Pattern. Caso mantenha a assinatura das opções e do DTO como públicas, você dará ao desenvolvedor a possibilidade de criar novos controles de configuração, mas ele só vai poder manipular o que for público.

Então você realmente vai ter muito mais domínio sobre o código e a escrita vai ficar mais semântica. Às vezes temos de lidar com campos com nomes esquisitos e, com Config Pattern, temos a possibilidade de escrever funções com nomenclaturas mais claras sobre o que estamos configurando.

A documentação em godoc está disponível tanto para DTO quanto para Config Pattern, então isso não é exatamente vantagem pra ninguém. Porém, quando se trata de sobreescrever configurações padrões, o Config Pattern é a melhor escolha.

Como assim, Kiko?

Digamos que, por padrão, eu que o prefixo seja sempre "$timestamp [$level]". Com o pattern, é bem simples:

package mylog // logger.go

type LoggerConfigDTO struct {
    Prefix string
    Suffix string
}

type Logger struct {
    config LoggerConfigDTO
}

func New(options ...LoggerOption) *Logger {
    config := LoggerConfigDTO{
        Prefix: "$timestamp [$level]", // valor padrão
    }
    for index := 0; index < len(options); index++ {
        options[index](&config)
    }

    return &Logger{config: config}
}

Basta instanciar o DTO com o valor inicial de cada opção, certo? Mas se você não é responsável pela inicialização dele, como você vai garantir esse valor padrão? Você teria de partir para solução de mesclagem de structs e isso não é exatamente necessário se você usa Config Pattern, dado que você é quem inicializa o DTO.

É nesses casos que esse padrão ganha força e se torna prático.

Body builder feminina exibindo seus bíceps definidos, como analogia à "força" do Config Pattern.

Alguém já usou isso antes, Kiko?

Com certeza! Eu já cheguei a dar palpite num Config Pattern do Tracer do Datadog. Eles fizeram as configurações privadas e eu queria modificar o logger deles em outro pacote. Você pode ver a issue que abri aqui: https://github.com/DataDog/dd-trace-go/issues/1331 . No final das contas, eles perceberam que centralizaram a configuração de logger de todos os produtos no Tracer e resolveram refatorar essa lógica.

Outra biblioteca bem famosa que usa o Config Pattern é o GORM.

Se você procurar direitinho, você vai perceber que muitas bibliotecas usam essa estratégia para estabelecer um controle mais rígido sobre os argumentos. Então sim, muita gente usa.


Curtiu? Comenta e compartilha! E lembrando: esse blog é nosso! Se tiver algo que queira publicar por aqui, fala comigo que te convido como colaborador do blog. Pode ser sobre qualquer coisa da área de tecnologia! Algo que acabou de estudar e tal. Antes de publicar, nós revisamos o conteúdo, assim podemos te corrigir se tiver aprendido algo errado e reforçamos nossos conhecimentos juntos. Bora?

E por hoje é só!

Inté.