Skip to main content


Argumentos, flags e configuração​


Ferramentas de linha de comando precisam de uma maneira de receber informações que alteram seu comportamento durante a execução. Essas informações podem ser passadas diretamente pelo terminal, definidas em variáveis de ambiente ou armazenadas em arquivos de configuração. Em Go, podemos trabalhar com essas diferentes formas de entrada usando recursos da biblioteca padrão, sem precisar adicionar dependências externas.


Cada forma de configuração atende a uma necessidade diferente. Argumentos são usados para informar valores relacionados à operação que será executada, enquanto flags permitem controlar opções específicas. Variáveis de ambiente ajudam a configurar programas sem modificar seu código, e arquivos de configuração permitem manter um conjunto maior de informações organizado e disponível entre diferentes execuções.


Essas formas de entrada também podem trabalhar juntas. Uma ferramenta pode carregar configurações de um arquivo, substituir determinados valores por variáveis de ambiente e permitir que alguns parâmetros sejam alterados diretamente por flags. Para isso, precisamos entender como cada mecanismo funciona e definir quais informações terão prioridade quando a mesma configuração estiver presente em mais de uma fonte.



Lendo argumentos da linha de comando​


Argumentos de linha de comando são valores informados depois do nome de um programa durante sua execução. Eles permitem passar informações diretamente pelo terminal, sem precisar modificar o código ou solicitar que os dados sejam digitados depois que o programa iniciar. Esses argumentos podem representar endereços, portas, caminhos de arquivos ou outras informações necessárias para executar determinada operação.


Considere uma ferramenta chamada redecli, que recebe o endereço de um servidor e uma porta. Podemos informar essas informações diretamente durante sua execução, colocando os valores depois do nome do programa.

redecli example.com 443

Nesse comando, redecli representa o programa que será executado, enquanto example.com e 443 são os argumentos enviados para ele. A aplicação pode interpretar o primeiro argumento como o endereço do servidor e o segundo como a porta que será usada em alguma operação de rede. Como esses valores são informados em posições específicas, também são conhecidos como argumentos posicionais.


Em Go, os argumentos recebidos durante a execução ficam disponíveis na variável os.Args, que faz parte do pacote os. Essa variável possui o tipo []string, que representa um slice de strings. Um slice é uma estrutura que permite armazenar uma sequência de valores e acessar cada elemento por sua posição, começando pelo índice zero.


O primeiro elemento, os.Args[0], representa o nome ou caminho usado para executar o programa. Os argumentos informados depois dele começam em os.Args[1], seguindo a ordem em que foram recebidos. Podemos visualizar esse comportamento criando um programa que percorre todos os elementos disponíveis em os.Args.

package main

import (
"fmt"
"os"
)

func main() {
// Percorre todos os argumentos recebidos pelo programa.
for indice, argumento := range os.Args {
fmt.Printf(
"Índice %d: %s\n",
indice,
argumento,
)
}
}

Executando o programa com os seguintes argumentos:

go run main.go example.com 443

Teremos uma saída semelhante a esta:

Índice 0: /tmp/go-build.../exe/main
Índice 1: example.com
Índice 2: 443

O valor exato de os.Args[0] depende de como o programa foi executado. Quando usamos go run, o Go compila e executa um programa temporário, por isso o primeiro elemento pode conter um caminho gerado automaticamente. Se compilarmos a aplicação e executarmos seu binário diretamente, esse elemento normalmente terá o nome ou caminho usado para iniciar o executável.


O laço for percorre todos os elementos de os.Args, enquanto range permite acessar tanto o índice quanto o valor de cada elemento. A função fmt.Printf escreve essas informações na saída padrão, usando %d para representar o índice numérico e %s para representar a string recebida. Como todos os argumentos são armazenados como strings, mesmo um valor que representa um número será recebido inicialmente como texto.


Também precisamos considerar que o terminal interpreta a linha de comando antes de iniciar o programa. Quando um argumento contém espaços, normalmente precisamos colocá-lo entre aspas para que seja recebido como um único valor. Sem esse cuidado, o terminal pode dividir o conteúdo em vários argumentos, alterando as posições que o programa espera receber.

go run main.go "servidor de teste" 443

Nesse exemplo, a expressão servidor de teste será recebida como um único argumento, enquanto 443 será recebido como outro. As aspas são interpretadas pelo terminal para definir os limites do argumento e normalmente não fazem parte do valor recebido pelo programa.



Acessando argumentos específicos​


Nem sempre precisamos percorrer todos os argumentos recebidos. Quando sabemos a posição esperada de cada informação, podemos acessar os elementos diretamente pelos seus índices. Para uma ferramenta que recebe um endereço e uma porta, podemos considerar que os.Args[1] representa o servidor e os.Args[2] representa a porta.


Antes de acessar essas posições, precisamos verificar se os argumentos realmente existem. Isso evita que o programa tente acessar uma posição inexistente no slice, provocando um panic e interrompendo sua execução. Podemos usar a função len para verificar a quantidade de elementos recebidos e apresentar uma mensagem adequada quando algum argumento estiver faltando.

package main

import (
"fmt"
"os"
)

func main() {
// Verifica se o endereço e a porta foram informados.
if len(os.Args) != 3 {
fmt.Fprintln(
os.Stderr,
"Uso: redecli <host> <porta>",
)

os.Exit(2)
}

// Obtém os argumentos pelas posições esperadas.
host := os.Args[1]
porta := os.Args[2]

fmt.Println("Host:", host)
fmt.Println("Porta:", porta)
}

Executando o programa com os dois argumentos esperados:

go run main.go example.com 443

A saída será:

Host: example.com
Porta: 443

A função len retorna a quantidade de elementos presentes no slice. Nesse exemplo, esperamos exatamente três elementos em os.Args, considerando o nome do programa e os dois argumentos obrigatórios. Se a quantidade for diferente, o programa apresenta uma mensagem explicando como deve ser executado e encerra o processo com os.Exit(2).


A função fmt.Fprintln escreve uma mensagem no destino informado pelo primeiro argumento. Nesse caso, usamos os.Stderr, que representa a saída padrão de erros. Já os.Exit(2) encerra o processo com um código diferente de zero, seguindo uma convenção comum para indicar problemas relacionados aos argumentos recebidos. Os códigos de saída serão explicados com mais detalhes na seção de mensagens de erro e ajuda.


Os argumentos posicionais dependem da ordem em que os valores são informados. Se invertermos example.com e 443, o programa não identifica automaticamente essa troca, pois apenas acessa os elementos nas posições esperadas. Por isso, quando os argumentos representam informações de tipos diferentes, precisamos validar seus valores antes de continuar.



Convertendo argumentos para outros tipos​


Os valores disponíveis em os.Args são sempre strings. Quando um argumento representa um número inteiro, precisamos converter esse texto antes de realizar operações numéricas ou validar seus limites. O pacote strconv fornece funções para converter strings em diferentes tipos de dados, incluindo strconv.Atoi, que tenta converter uma string contendo um número inteiro decimal para o tipo int.


Podemos adaptar o exemplo anterior para converter a porta recebida e verificar se ela está dentro do intervalo esperado. Dessa maneira, além de garantir que o argumento existe, também verificamos se seu conteúdo representa um número válido para a operação.

package main

import (
"fmt"
"os"
"strconv"
)

func main() {
if len(os.Args) != 3 {
fmt.Fprintln(
os.Stderr,
"Uso: redecli <host> <porta>",
)

os.Exit(2)
}

host := os.Args[1]

// Converte o argumento da porta para um número inteiro.
porta, err := strconv.Atoi(os.Args[2])

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro: a porta precisa ser um número inteiro.",
)

os.Exit(2)
}

// Verifica se a porta está dentro do intervalo permitido.
if porta < 1 || porta > 65535 {
fmt.Fprintln(
os.Stderr,
"Erro: a porta deve estar entre 1 e 65535.",
)

os.Exit(2)
}

fmt.Printf("Destino: %s:%d\n", host, porta)
}

A função strconv.Atoi retorna dois valores. O primeiro contém o número convertido, enquanto o segundo representa um possível erro durante a conversão. Quando a string contém um número inteiro decimal válido e representável pelo tipo int, a conversão é concluída e err recebe nil. Se o valor informado for abc, por exemplo, a conversão falha e precisamos tratar o erro antes de continuar.


A validação seguinte verifica se a porta está entre 1 e 65535. O campo de porta dos protocolos TCP e UDP possui 16 bits, permitindo representar valores de 0 até 65535. O valor zero possui usos específicos, mas não é aceito nesse exemplo porque estamos representando uma porta de destino convencional. A conversão para número não garante que o valor seja adequado para a operação, por isso precisamos verificar também as regras relacionadas ao dado recebido.


Embora possamos trabalhar diretamente com os.Args, essa abordagem começa a ficar menos prática quando uma ferramenta possui muitas opções, argumentos opcionais ou diferentes comandos. Também precisamos lembrar que a ordem dos argumentos deve ser respeitada, o que pode tornar a execução menos clara quando existem muitos parâmetros. Para organizar melhor essas opções, podemos trabalhar com flags.



Trabalhando com flags​


Flags são opções informadas na linha de comando para configurar ou modificar o comportamento de um programa. Diferentemente dos argumentos posicionais, elas possuem nomes que identificam qual configuração está sendo alterada. Isso permite receber diferentes parâmetros sem depender exclusivamente da posição de cada valor, tornando a interface da ferramenta mais clara.


Em uma ferramenta de rede, podemos definir flags para informar o endereço de destino, a porta e se mensagens adicionais devem ser exibidas durante a execução. Cada configuração recebe um nome específico, permitindo identificar sua responsabilidade diretamente no comando.

redecli -host example.com -port 443 -verbose

Nesse comando, -host identifica a opção responsável pelo endereço, -port indica a porta e -verbose representa uma opção booleana que pode ativar mensagens adicionais. Flags também permitem definir valores padrão, evitando que todas as opções precisem ser informadas em cada execução. Dessa maneira, podemos manter um comportamento inicial e alterar somente as configurações necessárias.


Go possui o pacote flag, que faz parte da biblioteca padrão e permite declarar, interpretar e acessar flags sem depender de bibliotecas externas. Entre as funções disponíveis estão flag.String, flag.Int, flag.Bool e flag.Duration, responsáveis por declarar flags que recebem strings, números inteiros, valores booleanos e durações de tempo.



Declarando flags​


Para declarar uma flag, precisamos informar seu nome, seu valor padrão e uma descrição que será apresentada nas mensagens de ajuda. O valor padrão será usado quando a flag não for informada durante a execução. Podemos criar uma ferramenta que recebe um host, uma porta e uma opção para habilitar mensagens adicionais, mantendo valores iniciais para todas essas configurações.

package main

import (
"flag"
"fmt"
"os"
)

func main() {
// Define as flags aceitas pelo programa.
host := flag.String(
"host",
"127.0.0.1",
"Endereço do servidor",
)

porta := flag.Int(
"port",
8080,
"Porta do servidor",
)

verbose := flag.Bool(
"verbose",
false,
"Exibe informações adicionais",
)

// Interpreta as flags recebidas pela linha de comando.
flag.Parse()

if *porta < 1 || *porta > 65535 {
fmt.Fprintln(
os.Stderr,
"Erro: porta fora do intervalo permitido.",
)

os.Exit(2)
}

// Exibe os valores definidos após a interpretação.
fmt.Println("Host:", *host)
fmt.Println("Porta:", *porta)
fmt.Println("Verbose:", *verbose)
}

As funções flag.String, flag.Int e flag.Bool recebem três argumentos. O primeiro representa o nome da flag, o segundo define seu valor padrão e o terceiro contém a descrição apresentada na ajuda do programa. Essas funções retornam ponteiros para os valores associados às flags, por isso usamos o operador * para acessar seus valores depois da interpretação.


A função flag.Parse() é responsável por interpretar os argumentos da linha de comando e atualizar os valores das flags declaradas. Ela deve ser chamada depois que as flags forem definidas e antes de acessarmos seus valores para executar a operação. Quando usamos as funções globais do pacote flag, a interpretação acontece sobre os argumentos recebidos pelo processo.


Se executarmos o programa sem informar nenhuma flag, os valores padrão serão mantidos.

go run main.go

A saída será:

Host: 127.0.0.1
Porta: 8080
Verbose: false

Se informarmos novos valores, eles substituirão os padrões definidos no código.

go run main.go -host example.com -port 443 -verbose

Nesse caso, teremos:

Host: example.com
Porta: 443
Verbose: true

A flag -verbose não precisa receber um valor explícito nesse caso. Como ela representa um booleano, sua presença sem um valor faz com que seja interpretada como true. Também podemos informar explicitamente o valor false, permitindo desativar a opção mesmo quando seu valor padrão estiver definido como verdadeiro.



Formas de informar flags​


O pacote flag aceita diferentes formas de informar valores pela linha de comando. Para flags que recebem strings, números ou outros tipos que precisam de um valor explícito, podemos separar o nome e o valor por um espaço ou pelo caractere =. As duas formas produzem o mesmo resultado durante a interpretação.

redecli -host example.com

redecli -host=example.com

Também podemos usar dois hífens antes do nome da flag, mantendo o mesmo comportamento.

redecli --host example.com

Na biblioteca padrão do Go, um ou dois hífens são aceitos para identificar flags, sem diferença no nome interpretado. Entretanto, o pacote não implementa automaticamente todas as convenções encontradas em ferramentas Unix, como agrupar várias flags curtas em uma única expressão. Cada nome precisa corresponder a uma flag declarada no programa.


Flags booleanas possuem um comportamento específico. Para ativar uma flag, podemos informar apenas seu nome, enquanto sua desativação explícita exige que o valor seja associado diretamente à opção.

redecli -verbose

redecli -verbose=false

Não devemos escrever -verbose false esperando que o segundo elemento seja interpretado como o valor da flag. Nesse formato, -verbose será interpretada como true e false ficará disponível como um argumento posicional. Isso acontece porque o pacote flag não consome automaticamente o argumento seguinte para flags booleanas, evitando ambiguidades na interpretação dos comandos.



A ordem das flags e dos argumentos​


O pacote flag da biblioteca padrão não interpreta todas as opções independentemente de sua posição. A leitura das flags é interrompida quando encontra o primeiro argumento que não representa uma flag ou quando encontra o separador --. Por isso, normalmente precisamos informar as flags antes dos argumentos posicionais que serão processados pelo programa.


Considere uma ferramenta que recebe flags e também um argumento chamado destino. Se informarmos esse argumento antes de todas as opções, as flags que aparecem depois dele não serão interpretadas pelo conjunto padrão.

redecli -host example.com destino -port 443

Nesse caso, -host será interpretada normalmente, mas a leitura das flags será interrompida ao encontrar destino. Os elementos destino, -port e 443 permanecerão como argumentos posicionais, sem que -port altere seu valor configurado. Para evitar esse comportamento, podemos informar todas as flags antes dos argumentos posicionais.

redecli -host example.com -port 443 destino

Depois de chamar flag.Parse(), podemos acessar os argumentos posicionais restantes usando flag.Args(). Essa função retorna um slice contendo os argumentos que não foram interpretados como flags, permitindo que o programa continue processando esses valores separadamente.

// Interpreta as flags declaradas anteriormente.
flag.Parse()

// Obtém os argumentos que não foram interpretados como flags.
argumentos := flag.Args()

for _, argumento := range argumentos {
fmt.Println(argumento)
}

Esse trecho pressupõe que as flags já foram declaradas e que os pacotes flag e fmt foram importados. Também podemos usar flag.Arg(0) para acessar o primeiro argumento restante e flag.NArg() para consultar a quantidade de argumentos posicionais disponíveis. Diferentemente do acesso direto a um slice, flag.Arg retorna uma string vazia quando a posição solicitada não existe.


O separador -- permite encerrar explicitamente a interpretação das flags. Isso é útil quando precisamos passar argumentos que começam com hífen, mas não devem ser tratados como opções do programa. Todos os elementos que aparecem depois desse separador permanecem disponíveis como argumentos posicionais.

redecli -host example.com -- -arquivo

Nesse exemplo, -arquivo será tratado como um argumento posicional. O separador -- não faz parte da lista retornada por flag.Args(), pois sua responsabilidade é apenas indicar onde a interpretação das flags deve terminar.



Associando flags a variáveis existentes​


As funções flag.String, flag.Int e flag.Bool criam variáveis associadas às flags e retornam ponteiros para elas. Quando já temos uma variável declarada, podemos associá-la diretamente a uma flag usando funções como flag.StringVar, flag.IntVar e flag.BoolVar. Essa abordagem permite trabalhar com os valores sem precisar acessar ponteiros explicitamente depois da interpretação.

package main

import (
"flag"
"fmt"
)

func main() {
var host string
var porta int

// Associa as flags às variáveis já declaradas.
flag.StringVar(
&host,
"host",
"127.0.0.1",
"Endereço do servidor",
)

flag.IntVar(
&porta,
"port",
8080,
"Porta do servidor",
)

flag.Parse()

fmt.Printf("Destino: %s:%d\n", host, porta)
}

Nesse caso, passamos &host e &porta, que representam os endereços das variáveis que receberão os valores interpretados. Como estamos trabalhando diretamente com essas variáveis, não precisamos usar o operador * para acessar seus valores depois de flag.Parse(). Essa abordagem costuma ser conveniente quando os parâmetros fazem parte de uma estrutura de configuração ou precisam ser compartilhados entre diferentes funções do programa.



Trabalhando com flags de duração​


Algumas configurações representam intervalos de tempo, como o tempo máximo para estabelecer uma conexão ou aguardar a resposta de um servidor. Para esses casos, podemos usar flag.Duration, que interpreta valores de duração e retorna um ponteiro para time.Duration. Esse tipo, definido no pacote time, representa um intervalo de tempo armazenado internamente como uma quantidade de nanossegundos.


Podemos criar uma flag chamada timeout e definir um valor padrão de cinco segundos. O próprio pacote flag será responsável por converter a duração informada na linha de comando, desde que ela possua um formato aceito pelo tipo time.Duration.

package main

import (
"flag"
"fmt"
"time"
)

func main() {
// Define o tempo limite padrão de cinco segundos.
timeout := flag.Duration(
"timeout",
5*time.Second,
"Tempo máximo de espera",
)

flag.Parse()

fmt.Println("Timeout:", *timeout)
}

Podemos informar a duração diretamente na linha de comando:

go run main.go -timeout 10s

A saída será:

Timeout: 10s

Também podemos informar valores como 500ms, 2m ou 1h, que representam 500 milissegundos, dois minutos e uma hora. O pacote flag converte automaticamente o texto recebido para time.Duration, retornando um erro de interpretação quando o formato é inválido. A flag apenas armazena a duração configurada, por isso precisamos passar esse valor para a operação responsável por aplicar o timeout.


Outro cuidado é que uma duração válida não significa necessariamente uma duração adequada para a aplicação. O valor 0s, por exemplo, pode ser interpretado corretamente, mas talvez não faça sentido para uma operação que exige um tempo máximo positivo. Nesse caso, precisamos validar o valor depois de interpretar a flag, assim como fizemos com o intervalo de portas.



Tratamento automático de flags inválidas​


Quando usamos as funções globais do pacote flag, o Go trabalha com um conjunto padrão de flags que possui tratamento automático para erros de interpretação. Se uma flag desconhecida for informada ou receber um valor incompatível com seu tipo, o programa apresentará uma mensagem de erro e encerrará a execução com o código 2.


Considere uma ferramenta que possui apenas as flags host e port. Se tentarmos informar uma opção que não foi declarada, o pacote identificará que o nome recebido não corresponde a nenhuma flag conhecida.

redecli -protocolo tcp

O programa exibirá uma mensagem indicando que a flag não foi definida, seguida pelas informações de uso disponíveis. O mesmo acontece quando uma flag declarada com flag.Int recebe um valor que não pode ser convertido para número inteiro. Esse comportamento automático pode ser suficiente para ferramentas pequenas, mas também podemos controlar esses erros criando conjuntos independentes com flag.FlagSet.



Criando subcomandos simples​


Subcomandos permitem organizar uma ferramenta de linha de comando em diferentes operações, mantendo todas elas dentro do mesmo programa. Em vez de criar um executável separado para cada funcionalidade, podemos definir comandos que representam ações específicas. Cada subcomando pode receber seus próprios argumentos e flags, permitindo construir uma interface organizada conforme as responsabilidades da ferramenta.


Um exemplo conhecido é a própria ferramenta go, que possui subcomandos como run, build, test e version. Para uma ferramenta de rede, poderíamos adotar uma organização semelhante, criando comandos para consultar um destino, apresentar a versão da aplicação ou executar outras operações.

redecli destino -host example.com -port 443

redecli versao

Nesse formato, destino e versao são subcomandos. O primeiro representa uma operação relacionada ao endereço e à porta informados, enquanto o segundo apenas apresenta a versão da ferramenta. Essa separação permite adicionar novas funcionalidades sem concentrar todos os parâmetros em uma única lista de flags.


A biblioteca padrão do Go não possui uma estrutura automática de subcomandos equivalente à encontrada em algumas bibliotecas externas. Entretanto, podemos implementar esse comportamento combinando os.Args com flag.FlagSet. O tipo FlagSet representa um conjunto independente de flags, permitindo definir opções diferentes para cada operação sem misturar suas configurações.



Identificando o subcomando​


Quando executamos um programa com subcomandos, normalmente o primeiro argumento representa a operação solicitada. Como os argumentos ficam disponíveis em os.Args, podemos verificar o conteúdo de os.Args[1] para identificar qual comando deve ser executado. Antes disso, precisamos confirmar que esse argumento existe, evitando um acesso fora dos limites do slice.


Considere a seguinte execução:

redecli destino -host example.com

Os argumentos recebidos pelo programa terão uma estrutura semelhante a esta:

os.Args[0] = redecli
os.Args[1] = destino
os.Args[2] = -host
os.Args[3] = example.com

Nesse exemplo, os.Args[1] identifica o subcomando destino, enquanto os elementos seguintes representam os parâmetros recebidos por ele. Podemos usar uma estrutura switch para selecionar a operação correspondente e encaminhar os argumentos restantes para uma função específica. Dessa maneira, cada subcomando fica responsável por interpretar seus próprios parâmetros.



Criando subcomandos com FlagSet​


Vamos criar uma ferramenta com dois subcomandos. O comando destino receberá um host e uma porta, enquanto versao exibirá a versão do programa. Para manter as responsabilidades separadas, vamos criar uma função responsável por identificar o comando e outra responsável por interpretar as flags do subcomando destino.

package main

import (
"errors"
"flag"
"fmt"
"os"
)

func main() {
// Executa o comando solicitado e retorna seu código de saída.
os.Exit(executar(os.Args[1:]))
}

func executar(args []string) int {
if len(args) == 0 {
fmt.Fprintln(
os.Stderr,
"Uso: redecli <comando> [opções]",
)

return 2
}

// Identifica qual subcomando foi solicitado.
switch args[0] {

case "destino":
return executarDestino(args[1:])

case "versao":
if len(args) != 1 {
fmt.Fprintln(
os.Stderr,
"Erro: o comando versao não recebe argumentos.",
)

return 2
}

fmt.Println("redecli versão 0.1.0")
return 0

default:
fmt.Fprintln(
os.Stderr,
"Erro: comando desconhecido.",
)

return 2
}
}

func executarDestino(args []string) int {
// Cria um conjunto independente de flags para o subcomando.
flags := flag.NewFlagSet(
"destino",
flag.ContinueOnError,
)

host := flags.String(
"host",
"127.0.0.1",
"Endereço do servidor",
)

porta := flags.Int(
"port",
8080,
"Porta do servidor",
)

// Interpreta somente os argumentos do subcomando.
if err := flags.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}

return 2
}

if flags.NArg() != 0 {
fmt.Fprintln(
os.Stderr,
"Erro: argumentos posicionais não são aceitos.",
)

return 2
}

if *porta < 1 || *porta > 65535 {
fmt.Fprintln(
os.Stderr,
"Erro: porta inválida.",
)

return 2
}

fmt.Printf("Destino: %s:%d\n", *host, *porta)

return 0
}

A função executar recebe os argumentos informados depois do nome do programa. Ela verifica se existe um subcomando e usa switch para selecionar a operação correspondente. Quando o primeiro argumento é destino, os demais são enviados para executarDestino, permitindo que essa função interprete suas próprias flags sem precisar conhecer as opções dos outros comandos.


A função flag.NewFlagSet recebe o nome do conjunto de flags e a forma como os erros de interpretação devem ser tratados. Nesse exemplo, usamos flag.ContinueOnError, que faz com que Parse retorne um erro quando encontra uma opção inválida, em vez de encerrar automaticamente o processo. Isso permite controlar o código de saída e decidir como a aplicação deve reagir aos erros.


Quando a opção -h ou -help é informada e não foi declarada como uma flag comum, Parse retorna flag.ErrHelp. Verificamos esse erro para permitir que uma solicitação de ajuda termine com o código de saída 0, indicando que o programa não falhou. Para os demais erros de interpretação, retornamos o código 2, mantendo uma distinção entre ajuda solicitada e parâmetros inválidos.


Executando o primeiro subcomando:

go run main.go destino -host example.com -port 443

Teremos:

Destino: example.com:443

Executando o segundo subcomando:

go run main.go versao

Teremos:

redecli versão 0.1.0

Se informarmos um subcomando desconhecido, o programa apresentará uma mensagem de erro. Também verificamos flags.NArg() para impedir que o comando destino receba argumentos posicionais inesperados. Essa validação evita que determinados valores sejam ignorados silenciosamente, o que poderia provocar comportamentos diferentes daqueles esperados por quem executa a ferramenta.


O uso de FlagSet permite manter os parâmetros de cada operação separados. Uma ferramenta pode ter um comando que recebe um endereço e uma porta, outro que recebe um domínio e um terceiro que trabalha com arquivos, sem precisar compartilhar todas essas opções. Para ferramentas maiores, bibliotecas como Cobra podem ajudar a organizar comandos mais complexos, mas não são obrigatórias para implementar subcomandos em Go.



Lendo variáveis de ambiente​


Variáveis de ambiente são valores associados ao ambiente em que um processo é executado. Elas permitem fornecer configurações para programas sem precisar informar todos os parâmetros diretamente na linha de comando. Cada variável possui um nome e um valor, representados como texto, que podem ser consultados pelo programa durante sua execução.


O sistema operacional disponibiliza essas informações para o processo, normalmente a partir do ambiente herdado de quem iniciou a aplicação. Podemos usar variáveis de ambiente para configurar endereços, portas, caminhos de arquivos, níveis de log e outras informações que podem mudar conforme o ambiente de execução. Isso permite executar o mesmo programa com configurações diferentes sem modificar seu código.


Considere uma ferramenta que precisa se conectar a um servidor. Em vez de informar o endereço em todas as execuções, podemos definir uma variável chamada REDECLI_HOST. Em sistemas Linux e macOS, usando um shell compatível com essa sintaxe, podemos definir a variável somente para a execução de determinado comando.

REDECLI_HOST=example.com go run main.go

Também podemos exportar a variável para que os comandos seguintes executados a partir do mesmo shell possam recebê-la. Nesse caso, a variável permanece disponível no ambiente do shell até que seja removida, modificada ou que a sessão seja encerrada.

export REDECLI_HOST=example.com

go run main.go

No Windows, usando PowerShell, podemos definir a variável da seguinte maneira:

$env:REDECLI_HOST = "example.com"

go run main.go

A sintaxe usada para definir variáveis de ambiente depende do shell e do sistema operacional. Entretanto, a forma como acessamos essas variáveis dentro do programa Go permanece a mesma, permitindo escrever código que funciona em diferentes plataformas sem precisar tratar manualmente a sintaxe de cada terminal.



Lendo variáveis com os.Getenv​


O pacote os fornece a função os.Getenv, responsável por consultar o valor de uma variável de ambiente pelo nome. Essa função recebe uma string que representa o nome da variável e retorna seu conteúdo, também como string. Se a variável não existir, o resultado será uma string vazia, sem que um erro seja retornado.

package main

import (
"fmt"
"os"
)

func main() {
// Lê o endereço definido no ambiente.
host := os.Getenv("REDECLI_HOST")

fmt.Println("Host:", host)
}

Se a variável REDECLI_HOST estiver definida como example.com, o programa exibirá:

Host: example.com

Quando uma configuração não é obrigatória, podemos definir um valor padrão para os casos em que a variável não estiver preenchida. Isso evita que a aplicação dependa da existência de todas as variáveis de ambiente e permite manter um comportamento inicial conhecido. Podemos fazer essa verificação comparando o resultado de os.Getenv com uma string vazia.

package main

import (
"fmt"
"os"
)

func main() {
host := os.Getenv("REDECLI_HOST")

// Usa o endereço local quando não existe um valor preenchido.
if host == "" {
host = "127.0.0.1"
}

fmt.Println("Host:", host)
}

Nesse exemplo, o endereço 127.0.0.1 será usado quando a variável não existir ou estiver definida com uma string vazia. A função os.Getenv não permite distinguir essas duas situações, porque retorna "" em ambos os casos. Quando precisamos identificar se uma variável realmente foi definida, mesmo que seu valor esteja vazio, podemos usar os.LookupEnv.



Verificando a existência com os.LookupEnv​


A função os.LookupEnv permite consultar o valor de uma variável e verificar se ela existe no ambiente do processo. Diferentemente de os.Getenv, essa função retorna dois valores. O primeiro contém o texto armazenado na variável, enquanto o segundo é um booleano que indica se ela foi encontrada, permitindo distinguir uma variável ausente de uma variável definida com valor vazio.

package main

import (
"fmt"
"os"
)

func main() {
// Consulta o valor e verifica se a variável existe.
host, existe := os.LookupEnv("REDECLI_HOST")

if !existe {
fmt.Println("A variável não foi definida.")
return
}

fmt.Println("Host:", host)
}

Se a variável não existir, existe será false. Se ela estiver definida, mesmo com uma string vazia, existe será true. Essa diferença é útil quando um valor vazio possui um significado diferente de uma configuração ausente, como acontece em programas que permitem desativar determinados parâmetros ou exigem que uma variável obrigatória seja preenchida explicitamente.


Também podemos alterar o ambiente do processo usando os.Setenv, que recebe o nome e o valor da variável e retorna um erro caso não consiga realizar a operação. Essa alteração afeta o processo atual e pode ser herdada por processos filhos iniciados posteriormente, mas não modifica permanentemente as variáveis do sistema operacional nem altera o ambiente do terminal que iniciou o programa.



Convertendo valores recebidos do ambiente​


Assim como acontece com os argumentos de linha de comando, variáveis de ambiente armazenam valores como strings. Se uma variável representa uma porta, precisamos converter seu conteúdo para um número inteiro antes de trabalhar com ele. Podemos combinar os.LookupEnv com strconv.Atoi para realizar essa conversão e identificar quando o valor informado não possui o formato esperado.


Vamos criar um programa que utiliza a porta 8080 como padrão, mas permite substituí-la pela variável REDECLI_PORT. Se a variável existir, seu conteúdo será convertido e validado antes de substituir a configuração inicial.

package main

import (
"fmt"
"os"
"strconv"
)

func main() {
porta := 8080

// Verifica se existe uma porta definida no ambiente.
valor, existe := os.LookupEnv("REDECLI_PORT")

if existe {
// Converte o valor recebido para um número inteiro.
numero, err := strconv.Atoi(valor)

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro: REDECLI_PORT deve conter um número inteiro.",
)

os.Exit(2)
}

porta = numero
}

// Valida a porta depois de aplicar a configuração.
if porta < 1 || porta > 65535 {
fmt.Fprintln(
os.Stderr,
"Erro: porta fora do intervalo permitido.",
)

os.Exit(2)
}

fmt.Println("Porta:", porta)
}

O valor padrão será 8080, mas poderá ser substituído pela variável REDECLI_PORT. Se a variável existir e contiver um texto que não representa um número inteiro, a conversão falhará e o programa apresentará uma mensagem de erro. Depois da conversão, também verificamos se a porta está dentro do intervalo permitido, evitando aceitar valores numéricos incompatíveis com a operação.


A mesma validação aplicada aos argumentos de linha de comando também precisa ser aplicada aos valores recebidos do ambiente. Não devemos considerar uma configuração válida apenas porque foi definida externamente, pois o sistema operacional não conhece as regras específicas da aplicação. Além disso, precisamos estabelecer se valores vazios serão tratados como ausência de configuração ou como erros, mantendo esse comportamento consistente.



Cuidados com variáveis de ambiente​


Variáveis de ambiente são convenientes para configurar aplicações executadas em diferentes máquinas, contêineres e ambientes de desenvolvimento. Entretanto, elas não possuem um mecanismo automático de validação ou proteção de informações. O programa precisa verificar os valores recebidos e definir como deve se comportar quando uma variável estiver ausente, vazia ou possuir um conteúdo inválido.


Informações sensíveis, como senhas e tokens, também podem ser fornecidas por variáveis de ambiente, mas isso não significa que estarão protegidas automaticamente. Dependendo do sistema e das permissões, outros processos ou ferramentas de diagnóstico podem ter acesso a esses valores. Por isso, não devemos imprimir informações sensíveis em mensagens de erro, logs ou saídas de diagnóstico sem necessidade.


Outro cuidado é que variáveis de ambiente não são armazenadas permanentemente pelo programa apenas porque foram consultadas. Quando uma ferramenta precisa manter configurações entre diferentes execuções, podemos armazená-las em arquivos específicos e carregá-las quando necessário. Isso permite separar os parâmetros da aplicação de seu código e manter configurações organizadas em um local conhecido.



Lendo arquivos de configuração​


Arquivos de configuração são arquivos que armazenam parâmetros responsáveis por controlar o comportamento de um programa. Diferentemente dos argumentos e flags, esses valores não precisam ser informados novamente em cada execução, pois permanecem armazenados no sistema de arquivos. Uma ferramenta pode carregar essas informações durante sua inicialização e aplicar as configurações antes de executar suas operações.


Uma ferramenta de rede pode possuir configurações como endereço padrão, porta, tempo limite de conexão e nível de detalhamento das mensagens. Podemos armazenar essas informações em um arquivo separado do código, permitindo alterar o comportamento da aplicação sem precisar compilá-la novamente. Também podemos manter arquivos diferentes para ambientes de desenvolvimento, teste ou produção.


Existem diferentes formatos de arquivos de configuração, como JSON, YAML, TOML e INI. Cada formato possui regras próprias para representar valores e organizar informações. Neste capítulo, vamos trabalhar com JSON, porque Go possui suporte a esse formato na biblioteca padrão, por meio do pacote encoding/json, permitindo criar e interpretar arquivos sem depender de bibliotecas externas.



Criando um arquivo de configuração manualmente​


Antes de ler configurações pelo Go, precisamos entender como criar e organizar o arquivo que armazenará essas informações. Vamos considerar uma ferramenta chamada redecli, que precisa armazenar um endereço, uma porta, um tempo limite e uma opção para exibir mensagens adicionais. Esses parâmetros serão mantidos em um arquivo chamado config.json.


Podemos criar o arquivo dentro do próprio diretório do projeto, mantendo o código da aplicação e suas configurações separados. A estrutura inicial ficará da seguinte maneira:

redecli/
├── main.go
└── config.json

O arquivo main.go contém o código da aplicação, enquanto config.json armazena os parâmetros que serão lidos pelo programa. Para criar o arquivo, podemos abrir o diretório do projeto em um editor de texto, adicionar um novo arquivo com esse nome e inserir as configurações desejadas. O conteúdo precisa seguir a sintaxe JSON para que o pacote responsável pela leitura consiga interpretá-lo.

{
"host": "127.0.0.1",
"port": 8080,
"timeout": "5s",
"verbose": false
}

Nesse arquivo, host representa o endereço do servidor, port define a porta, timeout informa o tempo máximo de espera e verbose controla a exibição de mensagens adicionais. Cada campo possui um nome e um valor, permitindo que o programa identifique qual configuração está sendo representada. Depois de salvar o arquivo, seu conteúdo poderá ser lido diretamente pela aplicação.


Também podemos criar o arquivo pelo terminal. Em sistemas Linux e macOS, por exemplo, podemos usar um editor como nano, que permite abrir arquivos de texto e salvar alterações diretamente pelo terminal. Se o arquivo não existir, ele poderá ser criado quando salvarmos o conteúdo.

nano config.json

No Windows, podemos criar o mesmo arquivo usando o Bloco de Notas, o Visual Studio Code ou outro editor de texto. O formato do conteúdo não muda conforme o sistema operacional, porque JSON possui uma sintaxe própria. O principal cuidado é salvar o arquivo com o nome e a extensão corretos, evitando que ele seja armazenado acidentalmente como config.json.txt.



Entendendo a estrutura do JSON​


JSON é um formato textual usado para representar dados estruturados. Um objeto JSON é delimitado por chaves {} e contém pares formados por nomes e valores, separados por vírgulas. Os nomes dos campos precisam estar entre aspas duplas, enquanto os valores podem representar strings, números, booleanos, objetos, listas ou o valor null.


No arquivo de configuração, cada campo representa uma informação que será carregada pelo programa. Podemos alterar seus valores mantendo a mesma estrutura, desde que os tipos continuem compatíveis com o que a aplicação espera receber.

{
"host": "example.com",
"port": 443,
"timeout": "10s",
"verbose": true
}

O valor de host é uma string, identificada pelas aspas. O campo port contém um número inteiro, enquanto verbose recebe um valor booleano, que pode ser true ou false. O campo timeout também é uma string, porque representa uma duração escrita com uma unidade de tempo. Posteriormente, precisaremos converter esse valor para time.Duration, que é o tipo usado pelo Go para representar intervalos de tempo.


JSON possui regras de sintaxe que precisam ser respeitadas. Os nomes dos campos e as strings precisam usar aspas duplas, os elementos devem ser separados por vírgulas e não podemos deixar uma vírgula depois do último elemento. O exemplo seguinte é inválido porque existe uma vírgula depois do último campo.

{
"host": "example.com",
"port": 443,
}

Também não podemos adicionar comentários diretamente em arquivos JSON padrão. Diferentemente de algumas linguagens de programação, JSON não reconhece comentários iniciados por // ou delimitados por /* */. Se precisarmos documentar configurações dentro do próprio arquivo, podemos considerar outros formatos, como TOML ou YAML, desde que o programa possua um mecanismo adequado para interpretá-los.



Representando a configuração em Go​


Depois de criar o arquivo JSON, precisamos definir como seus valores serão representados dentro do programa. Uma maneira de organizar essas informações é criar uma struct, que permite agrupar campos relacionados em um único tipo. Cada campo da estrutura representa uma configuração que será carregada do arquivo, mantendo os dados organizados e disponíveis para outras partes da aplicação.


Podemos representar nosso arquivo de configuração usando uma estrutura chamada Config, que possui os mesmos campos definidos no JSON.

type Config struct {
Host string `json:"host"`
Port int `json:"port"`
Timeout string `json:"timeout"`
Verbose bool `json:"verbose"`
}

A estrutura Config possui quatro campos que correspondem às informações armazenadas no arquivo JSON. O campo Host recebe uma string, Port recebe um número inteiro, Timeout armazena a duração como texto e Verbose recebe um booleano. Como os tipos estão definidos na estrutura, o pacote responsável pela conversão consegue verificar se os valores recebidos são compatíveis.


As expressões json:"host", json:"port" e semelhantes são chamadas de tags de estrutura. Essas tags informam ao pacote encoding/json qual nome deve ser associado a cada campo durante a conversão entre JSON e valores Go. Isso permite manter os campos da estrutura com nomes exportados, iniciados por letras maiúsculas, enquanto o arquivo JSON usa nomes em letras minúsculas.


O pacote encoding/json trabalha com campos exportados da estrutura. Se declararmos os campos começando com letras minúsculas, eles não serão preenchidos automaticamente durante a conversão comum de JSON para a estrutura. Por isso, precisamos manter os campos exportados quando queremos que sejam acessados pelo pacote responsável pela interpretação do arquivo.



Lendo um arquivo JSON​


Para ler arquivos em Go, podemos usar os.ReadFile, que recebe o caminho de um arquivo e retorna seu conteúdo como um slice de bytes. Um byte representa uma unidade de dados de oito bits, e um slice do tipo []byte permite armazenar uma sequência dessas unidades. Como o conteúdo do arquivo é retornado nesse formato, podemos passá-lo diretamente para o pacote encoding/json.


A função json.Unmarshal interpreta o conteúdo JSON e tenta armazenar seus valores em uma variável Go. Para isso, precisamos informar os bytes recebidos e o endereço da variável que será preenchida. Vamos criar um programa que lê config.json, converte seu conteúdo para a estrutura Config e apresenta os valores encontrados.

package main

import (
"encoding/json"
"fmt"
"os"
)

type Config struct {
Host string `json:"host"`
Port int `json:"port"`
Timeout string `json:"timeout"`
Verbose bool `json:"verbose"`
}

func main() {
// Lê todo o conteúdo do arquivo de configuração.
dados, err := os.ReadFile("config.json")

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao ler configuração:",
err,
)

os.Exit(1)
}

var config Config

// Converte os dados JSON para a estrutura Config.
err = json.Unmarshal(dados, &config)

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao interpretar JSON:",
err,
)

os.Exit(1)
}

fmt.Println("Host:", config.Host)
fmt.Println("Porta:", config.Port)
fmt.Println("Timeout:", config.Timeout)
fmt.Println("Verbose:", config.Verbose)
}

Se o arquivo config.json possuir os valores definidos anteriormente, a execução produzirá:

Host: 127.0.0.1
Porta: 8080
Timeout: 5s
Verbose: false

A função os.ReadFile abre o arquivo, lê seu conteúdo e retorna os dados. Não precisamos fechar manualmente o arquivo porque essa função gerencia a abertura e o fechamento internamente. Se o arquivo não existir ou não puder ser lido, a função retornará um erro, permitindo identificar problemas relacionados ao caminho informado, às permissões ou ao acesso ao sistema de arquivos.


Depois da leitura, usamos json.Unmarshal para converter os dados recebidos em uma estrutura Go. O primeiro argumento contém os bytes que representam o JSON, enquanto o segundo recebe o endereço da variável que será preenchida. Por isso, usamos &config, permitindo que a função modifique os campos da estrutura com os valores encontrados no arquivo.


Se o conteúdo possuir um erro de sintaxe ou algum campo tiver um tipo incompatível, json.Unmarshal poderá retornar um erro. Entretanto, essa conversão não substitui a validação das regras da aplicação. Um arquivo pode conter um JSON válido e ainda apresentar informações inadequadas, como uma porta negativa ou um endereço vazio, por isso precisamos verificar esses valores antes de executar operações que dependem deles.



Criando um arquivo de configuração pelo Go​


Também podemos fazer com que o próprio programa crie seu arquivo de configuração. Isso é útil quando uma ferramenta precisa gerar configurações iniciais, fornecer valores padrão ou permitir que o usuário execute um comando específico para criar o arquivo. Dessa maneira, não precisamos depender exclusivamente da criação manual e podemos garantir que o conteúdo inicial siga a estrutura esperada pela aplicação.


Para essa operação, podemos combinar json.MarshalIndent com os.WriteFile. A função json.MarshalIndent converte um valor Go para JSON e adiciona indentação, deixando o conteúdo mais fácil de ler. Já os.WriteFile recebe o caminho do arquivo, os dados que serão escritos e as permissões que deverão ser consideradas caso o arquivo precise ser criado.


Vamos criar um programa responsável por gerar automaticamente config.json, preenchendo os campos com valores padrão. O programa primeiro monta a estrutura Config, converte seus valores para JSON e depois grava o resultado no sistema de arquivos.

package main

import (
"encoding/json"
"fmt"
"os"
)

type Config struct {
Host string `json:"host"`
Port int `json:"port"`
Timeout string `json:"timeout"`
Verbose bool `json:"verbose"`
}

func main() {
// Define os valores iniciais da configuração.
config := Config{
Host: "127.0.0.1",
Port: 8080,
Timeout: "5s",
Verbose: false,
}

// Converte a estrutura para JSON com indentação.
dados, err := json.MarshalIndent(
config,
"",
" ",
)

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao gerar JSON:",
err,
)

os.Exit(1)
}

// Adiciona uma quebra de linha ao final do arquivo.
dados = append(dados, '\n')

// Cria o arquivo e escreve a configuração.
err = os.WriteFile(
"config.json",
dados,
0600,
)

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao criar configuração:",
err,
)

os.Exit(1)
}

fmt.Println("Arquivo config.json criado.")
}

Ao executar esse programa, o arquivo config.json será criado no diretório de trabalho atual. Seu conteúdo será organizado com indentação, mantendo os campos e os valores definidos na estrutura Go.

{
"host": "127.0.0.1",
"port": 8080,
"timeout": "5s",
"verbose": false
}

A função json.MarshalIndent recebe três argumentos. O primeiro contém o valor que será convertido, o segundo define um prefixo para as linhas e o terceiro informa a string usada para a indentação. Nesse exemplo, usamos uma string vazia como prefixo e quatro espaços como indentação, produzindo um arquivo organizado e mais simples de modificar manualmente.


A função os.WriteFile recebe o nome do arquivo, os dados e um valor que representa suas permissões. O valor 0600 indica permissões de leitura e escrita para o proprietário em sistemas Unix, considerando as regras de permissões do sistema. No Windows, o tratamento dessas permissões pode ser diferente, pois o sistema possui mecanismos próprios para controlar o acesso aos arquivos.


Um cuidado com os.WriteFile é que ela sobrescreve o conteúdo de um arquivo existente. Se executarmos o programa novamente depois de modificar manualmente config.json, as configurações anteriores serão substituídas pelos valores definidos no código. Além disso, a escrita não possui garantia de atomicidade, o que significa que uma falha durante a operação pode deixar o arquivo parcialmente escrito.



Evitando sobrescrever arquivos existentes​


Quando um programa precisa criar uma configuração inicial, normalmente não queremos que ele substitua um arquivo que já foi personalizado. Podemos impedir esse comportamento usando os.OpenFile, que permite controlar como um arquivo será aberto ou criado. Essa função recebe o caminho, um conjunto de opções de abertura e as permissões consideradas durante a criação.


Entre as opções disponíveis estão os.O_CREATE, responsável por criar o arquivo quando necessário, e os.O_EXCL, que faz a operação falhar quando o arquivo já existe, desde que seja combinada com os.O_CREATE. Podemos usar essas opções junto com os.O_WRONLY, que abre o arquivo para escrita, evitando a substituição acidental de configurações existentes.

package main

import (
"encoding/json"
"fmt"
"os"
)

type Config struct {
Host string `json:"host"`
Port int `json:"port"`
}

func executar() error {
// Define a configuração inicial.
config := Config{
Host: "127.0.0.1",
Port: 8080,
}

// Converte a configuração para JSON.
dados, err := json.MarshalIndent(config, "", " ")

if err != nil {
return fmt.Errorf("gerar JSON: %w", err)
}

dados = append(dados, '\n')

// Cria o arquivo somente se ele não existir.
arquivo, err := os.OpenFile(
"config.json",
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
0600,
)

if err != nil {
return fmt.Errorf("criar configuração: %w", err)
}

// Escreve os dados no arquivo recém-criado.
_, escritaErr := arquivo.Write(dados)

// Fecha o arquivo e verifica também o fechamento.
fechamentoErr := arquivo.Close()

if escritaErr != nil {
return fmt.Errorf("escrever configuração: %w", escritaErr)
}

if fechamentoErr != nil {
return fmt.Errorf("fechar configuração: %w", fechamentoErr)
}

return nil
}

func main() {
if err := executar(); err != nil {
fmt.Fprintln(os.Stderr, "Erro:", err)
os.Exit(1)
}

fmt.Println("Arquivo config.json criado.")
}

Nesse exemplo, os.OpenFile tenta criar config.json usando as opções os.O_CREATE e os.O_EXCL. Se o arquivo já existir, a operação retornará um erro e o programa não continuará com a escrita. Isso evita sobrescrever um arquivo configurado anteriormente, inclusive quando duas execuções tentam criá-lo ao mesmo tempo.


Depois de criar o arquivo, usamos o método Write para gravar o JSON e Close para fechar o recurso aberto. Verificamos os erros de escrita e fechamento porque algumas falhas podem ser identificadas somente durante essas operações. O exemplo considera uma escrita simples, mas uma implementação mais completa também deve verificar a quantidade de bytes gravados e tratar arquivos que ficaram incompletos após uma falha.


Quando precisamos modificar um arquivo existente com maior segurança, uma abordagem comum é escrever o novo conteúdo em um arquivo temporário, garantir que a gravação foi concluída e depois substituir o arquivo original. Essa estratégia exige cuidados adicionais com sincronização, permissões e diferenças entre sistemas operacionais, mas reduz o risco de perder o conteúdo anterior durante uma atualização.



Definindo onde os arquivos serão armazenados​


Até agora, usamos config.json diretamente como nome do arquivo. Esse é um caminho relativo, ou seja, sua localização depende do diretório de trabalho atual do processo. Se executarmos o programa dentro do diretório do projeto, o arquivo será procurado nesse local. Entretanto, se executarmos o binário a partir de outro diretório, o programa tentará acessar config.json em relação ao novo diretório de trabalho.


Esse comportamento pode provocar erros quando uma ferramenta funciona durante o desenvolvimento, mas não encontra suas configurações depois de ser instalada. Uma solução simples é permitir que o caminho seja informado por uma flag, deixando a localização do arquivo sob controle de quem executa o programa. Dessa maneira, a ferramenta não precisa assumir que a configuração estará sempre no mesmo diretório do executável.

redecli -config /etc/redecli/config.json

Também podemos armazenar configurações em diretórios próprios do usuário. O pacote os possui a função os.UserConfigDir, que retorna o diretório padrão destinado às configurações específicas do usuário. Sua localização depende do sistema operacional e das variáveis de ambiente disponíveis, permitindo que o programa siga as convenções da plataforma em que está sendo executado.


Podemos obter esse caminho e criar um subdiretório para nossa aplicação usando filepath.Join e os.MkdirAll. A primeira função combina partes de um caminho respeitando as regras do sistema operacional, enquanto a segunda cria o diretório informado e os diretórios intermediários necessários.

package main

import (
"fmt"
"os"
"path/filepath"
)

func main() {
// Obtém o diretório padrão de configurações do usuário.
diretorioBase, err := os.UserConfigDir()

if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

// Monta o caminho específico da aplicação.
diretorio := filepath.Join(
diretorioBase,
"redecli",
)

// Cria o diretório caso ele ainda não exista.
err = os.MkdirAll(diretorio, 0700)

if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

// Monta o caminho completo do arquivo.
caminho := filepath.Join(
diretorio,
"config.json",
)

fmt.Println("Configuração:", caminho)
}

A função filepath.Join evita precisar escrever separadores específicos para Linux, macOS ou Windows. Já os.MkdirAll cria o diretório informado e seus diretórios intermediários, quando necessário. Se o diretório já existir, a função normalmente termina sem erro, desde que o caminho corresponda a um diretório válido e a operação não encontre problemas de acesso.


No Linux, os.UserConfigDir normalmente retorna $HOME/.config quando XDG_CONFIG_HOME não está definido. No macOS, normalmente retorna $HOME/Library/Application Support, enquanto no Windows usa o diretório indicado pela variável de ambiente AppData. Essas localizações permitem armazenar configurações específicas do usuário sem depender diretamente do diretório de execução.


O exemplo apenas obtém e prepara o caminho onde a configuração poderá ser armazenada. Para criar ou ler o arquivo nesse local, precisamos passar a variável caminho para os.WriteFile, os.ReadFile ou outra função responsável pelo acesso. Nem toda aplicação precisa armazenar configurações nesses diretórios, pois ferramentas executadas como serviços, contêineres ou aplicações portáteis podem adotar outras estratégias.



Validando configurações carregadas​


Conseguir interpretar um arquivo JSON não significa que todos os seus valores estejam corretos. Podemos ter um arquivo com sintaxe válida, mas contendo uma porta fora do intervalo permitido, um endereço vazio ou uma duração inválida. Para evitar que essas informações causem problemas durante a execução, precisamos validar as configurações depois de carregá-las e antes de aplicá-las nas operações da ferramenta.


Uma maneira de organizar essa validação é criar uma função que recebe a estrutura Config e retorna um erro quando encontra algum campo incompatível com as regras da aplicação. Dessa maneira, podemos manter a leitura do arquivo separada da validação e aplicar as mesmas regras independentemente da origem dos valores.

package main

import (
"errors"
"fmt"
"time"
)

type Config struct {
Host string `json:"host"`
Port int `json:"port"`
Timeout string `json:"timeout"`
Verbose bool `json:"verbose"`
}

func validarConfig(config Config) error {
// O endereço não pode estar vazio.
if config.Host == "" {
return errors.New("host não pode estar vazio")
}

// Verifica o intervalo permitido para a porta.
if config.Port < 1 || config.Port > 65535 {
return errors.New("porta deve estar entre 1 e 65535")
}

// Converte a duração para verificar seu formato.
timeout, err := time.ParseDuration(config.Timeout)

if err != nil {
return fmt.Errorf(
"timeout inválido: %w",
err,
)
}

if timeout <= 0 {
return errors.New("timeout deve ser maior que zero")
}

return nil
}

func main() {
// Exemplo de configuração que será validada.
config := Config{
Host: "example.com",
Port: 443,
Timeout: "5s",
Verbose: false,
}

if err := validarConfig(config); err != nil {
fmt.Println("Erro:", err)
return
}

fmt.Println("Configuração válida.")
}

A função validarConfig verifica primeiro se o endereço está preenchido e se a porta pertence ao intervalo esperado. Depois, usa time.ParseDuration para converter o valor de Timeout em um intervalo de tempo. Essa função aceita strings como 5s, 500ms e 2m, retornando um erro quando o formato não representa uma duração válida.


Também verificamos se a duração é maior que zero, pois um valor negativo ou igual a zero não atende à regra definida para esse timeout. O uso de fmt.Errorf com %w permite acrescentar contexto a um erro recebido sem perder sua identificação original, facilitando tratamentos posteriores. Quando todos os campos passam pelas verificações, a função retorna nil, indicando que nenhuma regra foi violada.


O programa completo apresentado apenas demonstra a validação de uma estrutura preenchida diretamente no código. Em uma ferramenta que lê arquivos, essa função deve ser chamada depois de converter o JSON para Config. Separar essas responsabilidades permite manter a leitura e a validação independentes, facilitando alterações futuras na origem das configurações.



Campos ausentes e desconhecidos no JSON​


Quando usamos json.Unmarshal para preencher uma estrutura, os campos ausentes no JSON não provocam automaticamente um erro. Se a estrutura tiver sido criada sem valores iniciais, esses campos permanecerão com seus valores zero. Para strings, o valor zero é ""; para números inteiros, é 0; para booleanos, é false. Isso significa que um arquivo incompleto pode ser interpretado sem apresentar um erro de sintaxe.


Se inicializarmos a estrutura com valores padrão antes de chamar json.Unmarshal, os campos ausentes normalmente manterão os valores que já possuíam. Esse comportamento pode ser conveniente para configurações opcionais, mas exige cuidado quando determinados campos são obrigatórios. Também precisamos considerar que alguns valores presentes no JSON podem substituir os padrões, mesmo quando representam informações inadequadas para a aplicação.


Outro comportamento do pacote encoding/json é que json.Unmarshal ignora, por padrão, campos desconhecidos quando o destino é uma estrutura. Considere um arquivo que contém um campo chamado timeuot, escrito incorretamente no lugar de timeout.

{
"host": "example.com",
"port": 443,
"timeuot": "5s"
}

Como timeuot não corresponde a nenhum campo da estrutura, esse valor será ignorado durante a conversão comum. Isso pode dificultar a identificação de erros de digitação, principalmente quando o programa possui valores padrão e continua executando normalmente. Se quisermos impedir esse comportamento, podemos usar json.Decoder com o método DisallowUnknownFields.


O exemplo seguinte mostra como interpretar os dados de um arquivo rejeitando campos desconhecidos. Ele pressupõe que dados contém os bytes lidos anteriormente, que config representa a estrutura de destino e que o trecho está dentro de uma função que retorna error.

// Cria um decoder a partir dos bytes do arquivo.
decoder := json.NewDecoder(bytes.NewReader(dados))

// Rejeita campos desconhecidos na estrutura.
decoder.DisallowUnknownFields()

// Interpreta o JSON preenchendo a configuração.
if err := decoder.Decode(&config); err != nil {
return fmt.Errorf(
"configuração inválida: %w",
err,
)
}

// Verifica se existe outro valor JSON após o primeiro.
var extra any

if err := decoder.Decode(&extra); err != io.EOF {
return errors.New("conteúdo extra no arquivo JSON")
}

Esse trecho exige os pacotes bytes, encoding/json, errors, fmt e io. O método DisallowUnknownFields faz com que a interpretação retorne um erro quando o JSON possui campos que não correspondem aos campos exportados da estrutura. A segunda chamada de Decode verifica se existe outro valor JSON depois do primeiro, porque um arquivo de configuração normalmente deve conter apenas um objeto principal.


Mesmo com essas verificações, o pacote não exige automaticamente que todos os campos da estrutura estejam presentes. Também não aplica regras específicas, como validar o intervalo de uma porta ou verificar se uma string está vazia. Essas responsabilidades continuam sendo da aplicação, por isso precisamos combinar a interpretação do JSON com uma etapa de validação dos valores carregados.



Combinando arquivos, variáveis de ambiente e flags​


Uma ferramenta pode receber a mesma configuração de diferentes fontes. Por exemplo, o endereço de um servidor pode estar definido no arquivo config.json, em uma variável de ambiente e em uma flag informada durante a execução. Quando isso acontece, precisamos estabelecer qual valor terá prioridade, evitando que o comportamento do programa dependa de uma ordem de leitura que não foi definida claramente.


Uma organização comum é carregar primeiro os valores padrão, depois as informações do arquivo de configuração, em seguida as variáveis de ambiente e, por último, as flags informadas na linha de comando. Dessa maneira, configurações mais específicas podem substituir valores gerais, enquanto opções não informadas continuam usando as configurações carregadas anteriormente.

Valores padrão
|
v
Arquivo de configuração
|
v
Variáveis de ambiente
|
v
Flags da linha de comando
|
v
Configuração final

Nessa organização, cada etapa pode substituir os valores definidos anteriormente. Se o arquivo config.json definir a porta 8080 e a variável de ambiente REDECLI_PORT estiver configurada como 9000, a variável poderá substituir o valor do arquivo. Se executarmos o programa com -port 443, a flag terá prioridade e a configuração final será 443.


Essa ordem não é imposta automaticamente pelo Go. Precisamos implementá-la de acordo com as regras da ferramenta, garantindo que os valores sejam interpretados e validados corretamente. Também precisamos tomar cuidado com os valores padrão das flags, pois eles não devem substituir uma configuração carregada anteriormente quando a opção não foi informada explicitamente na linha de comando.



Identificando quais flags foram informadas​


O pacote flag permite definir valores padrão, mas nem sempre queremos aplicar esses valores diretamente sobre uma configuração carregada de outra fonte. Se um arquivo definir a porta 443 e uma flag possuir o padrão 8080, não devemos substituir automaticamente o valor do arquivo quando a flag não foi informada. Para identificar quais opções apareceram na linha de comando, podemos usar o método Visit de um FlagSet.


O método Visit percorre apenas as flags que foram definidas explicitamente durante a interpretação dos argumentos. Isso permite diferenciar uma opção informada pelo usuário de outra que apenas mantém seu valor padrão. Podemos usar esse comportamento para atualizar uma estrutura de configuração somente quando determinada flag realmente estiver presente.

package main

import (
"flag"
"fmt"
"os"
)

func main() {
// Representa uma configuração carregada anteriormente.
configPorta := 443

flags := flag.NewFlagSet(
"redecli",
flag.ExitOnError,
)

// Define a flag que pode substituir a configuração.
porta := flags.Int(
"port",
8080,
"Porta do servidor",
)

flags.Parse(os.Args[1:])

// Aplica somente as flags informadas explicitamente.
flags.Visit(func(f *flag.Flag) {
if f.Name == "port" {
configPorta = *porta
}
})

fmt.Println("Porta:", configPorta)
}

Se executarmos o programa sem informar nenhuma flag, a porta continuará sendo 443, que representa o valor carregado anteriormente. Se informarmos -port 9000, o método Visit identificará que essa flag foi definida e atualizará a configuração para 9000. Essa abordagem permite manter a prioridade da linha de comando sem substituir valores apenas porque uma flag possui um padrão diferente.



Exemplo de configuração com diferentes fontes​


Vamos criar uma ferramenta que combina valores padrão, arquivo JSON, variáveis de ambiente e flags. O programa começará com um endereço e uma porta definidos no código, tentará carregar um arquivo de configuração e depois verificará se existem valores no ambiente. Por último, aplicará as flags informadas explicitamente, permitindo que elas tenham prioridade sobre as demais fontes.


Neste exemplo, o arquivo config.json será opcional quando o caminho padrão estiver sendo usado. Se ele não existir, o programa continuará com os valores definidos anteriormente. Entretanto, se um caminho for informado explicitamente pela flag -config, sua ausência será tratada como erro, evitando ignorar silenciosamente uma configuração solicitada.

package main

import (
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"strconv"
)

type Config struct {
Host string `json:"host"`
Port int `json:"port"`
}

func main() {
// Centraliza a execução e o código de retorno.
os.Exit(executar())
}

func executar() int {
// Define os valores padrão.
config := Config{
Host: "127.0.0.1",
Port: 8080,
}

flags := flag.NewFlagSet(
"redecli",
flag.ContinueOnError,
)

// Define as opções aceitas pela linha de comando.
caminho := flags.String(
"config",
"config.json",
"Caminho do arquivo de configuração",
)

host := flags.String(
"host",
"",
"Endereço do servidor",
)

porta := flags.Int(
"port",
0,
"Porta do servidor",
)

// Interpreta as flags recebidas.
if err := flags.Parse(os.Args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}

return 2
}

if flags.NArg() != 0 {
fmt.Fprintln(
os.Stderr,
"Erro: argumentos inesperados.",
)

return 2
}

// Identifica quais flags foram informadas.
definidas := make(map[string]bool)

flags.Visit(func(f *flag.Flag) {
definidas[f.Name] = true
})

// Tenta carregar as configurações do arquivo.
dados, err := os.ReadFile(*caminho)

if err == nil {
if err := json.Unmarshal(dados, &config); err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao interpretar configuração:",
err,
)

return 1
}
} else if definidas["config"] || !errors.Is(err, os.ErrNotExist) {
fmt.Fprintln(
os.Stderr,
"Erro ao ler configuração:",
err,
)

return 1
}

// Aplica o endereço recebido do ambiente.
if valor, existe := os.LookupEnv("REDECLI_HOST"); existe {
config.Host = valor
}

// Aplica a porta recebida do ambiente.
if valor, existe := os.LookupEnv("REDECLI_PORT"); existe {
numero, err := strconv.Atoi(valor)

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro: REDECLI_PORT deve conter um número inteiro.",
)

return 2
}

config.Port = numero
}

// Aplica somente as flags informadas pelo usuário.
if definidas["host"] {
config.Host = *host
}

if definidas["port"] {
config.Port = *porta
}

// Valida a configuração final.
if config.Host == "" {
fmt.Fprintln(
os.Stderr,
"Erro: host não pode estar vazio.",
)

return 2
}

if config.Port < 1 || config.Port > 65535 {
fmt.Fprintln(
os.Stderr,
"Erro: porta inválida.",
)

return 2
}

fmt.Printf(
"Destino: %s:%d\n",
config.Host,
config.Port,
)

return 0
}

O programa começa definindo valores padrão para o endereço e a porta. Depois, declara as flags e chama flags.Parse, permitindo identificar o caminho do arquivo antes de tentar carregá-lo. A função os.ReadFile procura o arquivo informado e, quando ele existe, json.Unmarshal preenche a estrutura com os valores encontrados, mantendo os padrões nos campos que não foram definidos no JSON.


Se o arquivo padrão não existir, o programa continuará sem interromper a execução. Entretanto, quando a flag -config for informada explicitamente, qualquer falha de leitura será tratada como erro. Essa diferença evita que a aplicação ignore silenciosamente um caminho solicitado pelo usuário, mantendo a ausência do arquivo opcional apenas para o comportamento padrão.


Depois de carregar o arquivo, o programa consulta REDECLI_HOST e REDECLI_PORT, substituindo os valores quando essas variáveis estiverem definidas. A porta recebida do ambiente é convertida usando strconv.Atoi, permitindo identificar erros de formato antes de aplicar a configuração. Por último, verificamos quais flags foram informadas e substituímos somente os campos correspondentes.


A validação acontece depois dessas etapas, garantindo que os parâmetros finais estejam adequados para a operação. Isso permite que um valor inválido definido no arquivo seja substituído por uma configuração válida de maior prioridade antes da validação final. Entretanto, erros de formato, como uma porta que não pode ser convertida para número, continuam sendo tratados durante a leitura da fonte correspondente.


Se o arquivo config.json definir a porta 8080, podemos substituí-la diretamente pela linha de comando:

go run main.go -port 443

Nesse caso, a porta final será 443, porque a flag possui prioridade sobre o arquivo. Se executarmos sem informar -port, o programa poderá usar a porta definida no arquivo ou na variável de ambiente, seguindo a ordem estabelecida. Também podemos carregar um arquivo diferente por meio da opção -config.

go run main.go -config ambiente-teste.json

O exemplo demonstra uma estratégia de carregamento e substituição de valores, mas não implementa todas as regras que podem existir em uma aplicação maior. Dependendo da ferramenta, podemos precisar de configurações obrigatórias, validações específicas para cada fonte e mecanismos adicionais para detectar campos desconhecidos. A organização escolhida deve deixar claro de onde os valores são recebidos e qual fonte possui prioridade.



Exibindo mensagens de erro e ajuda​


Uma ferramenta de linha de comando precisa informar quando uma operação falha e explicar quais argumentos são necessários para executá-la corretamente. Mensagens de erro ajudam a identificar problemas, enquanto mensagens de ajuda apresentam os comandos e as opções aceitas pelo programa. Em Go, podemos construir essas mensagens usando o pacote fmt, os fluxos de saída disponibilizados pelo pacote os e os mecanismos de ajuda do pacote flag.


Existem dois destinos principais para mensagens escritas por programas CLI. A saída padrão, representada por os.Stdout, normalmente recebe os resultados produzidos pela aplicação. Já a saída de erros, representada por os.Stderr, é usada para mensagens de diagnóstico e falhas. Essa separação permite que outros programas processem os resultados sem misturá-los com mensagens que não fazem parte da operação solicitada.



Escrevendo mensagens na saída de erros​


O pacote fmt possui a função fmt.Fprintln, que permite escrever uma mensagem em um destino específico. Podemos usá-la para enviar mensagens diretamente para os.Stderr, mantendo os erros separados dos resultados normais da aplicação. Essa distinção é especialmente útil quando a ferramenta será executada por scripts ou terá sua saída redirecionada para outros programas.

package main

import (
"fmt"
"os"
)

func main() {
// Escreve uma mensagem de erro no fluxo apropriado.
fmt.Fprintln(
os.Stderr,
"Erro: endereço do servidor não informado.",
)

os.Exit(2)
}

Nesse exemplo, a mensagem será escrita na saída de erros e o processo terminará com o código 2. A diferença entre os.Stdout e os.Stderr fica mais evidente quando redirecionamos a saída de um programa no terminal. Em sistemas Unix, podemos usar o operador > para enviar a saída padrão para um arquivo, mantendo as mensagens de erro no terminal.

redecli > resultado.txt

Nesse caso, mensagens enviadas para os.Stdout serão gravadas em resultado.txt, enquanto mensagens enviadas para os.Stderr continuarão aparecendo no terminal, a menos que também sejam redirecionadas. Se quisermos armazenar somente as mensagens de erro, podemos usar o operador 2>, que representa o redirecionamento do fluxo de erros em shells que seguem essa convenção.

redecli 2> erros.txt

Essa separação permite que o resultado produzido por uma ferramenta seja processado automaticamente sem depender da remoção manual de mensagens de diagnóstico. Também facilita identificar falhas em scripts, porque os erros podem ser armazenados separadamente ou encaminhados para mecanismos de registro. O comportamento exato dos redirecionamentos depende do shell usado para executar o programa.



Trabalhando com códigos de saída​


Além das mensagens escritas no terminal, programas podem informar o resultado de sua execução por meio de um código de saída. Esse código é disponibilizado ao sistema operacional quando o processo termina, permitindo que outros programas identifiquem se a operação foi concluída normalmente. Por convenção, o código 0 representa sucesso, enquanto valores diferentes de zero indicam alguma condição de erro.


Não existe uma tabela universal que obrigue todas as ferramentas a associar cada erro a um número específico. Entretanto, podemos definir códigos diferentes para erros de argumentos, falhas de operação e problemas de configuração, desde que essa organização seja mantida de maneira consistente. Uma ferramenta simples pode adotar a seguinte convenção:

CódigoSignificado
0Execução concluída normalmente
1Erro durante a operação
2Argumentos ou configurações inválidas

Em Go, usamos os.Exit para encerrar o processo informando o código desejado. Podemos chamar essa função quando uma operação não pode continuar, permitindo que o terminal ou outro programa identifique o resultado pelo código retornado.

package main

import (
"fmt"
"os"
)

func main() {
// Simula a identificação de um erro.
erro := true

if erro {
fmt.Fprintln(
os.Stderr,
"Erro ao executar a operação.",
)

os.Exit(1)
}

fmt.Println("Operação concluída.")
}

Quando os.Exit é chamado, o processo termina imediatamente. Isso significa que funções registradas com defer não serão executadas, porque o encerramento não acontece por meio do retorno normal das funções. Esse comportamento exige cuidado em aplicações que precisam fechar arquivos, conexões de rede ou outros recursos antes de terminar.


Por esse motivo, em programas maiores costuma ser melhor concentrar o encerramento do processo na função main e deixar as outras funções retornarem erros ou códigos de saída. Dessa maneira, as funções responsáveis pelas operações podem concluir seus tratamentos e liberar recursos antes que o processo termine. Também precisamos considerar que, durante a execução com go run, a ferramenta Go pode apresentar informações adicionais quando o programa executado retorna um código diferente de zero.



Criando mensagens de ajuda com flag​


O pacote flag possui um mecanismo automático para apresentar as opções disponíveis em um programa. Quando declaramos flags, suas descrições podem ser exibidas por meio da opção -h ou -help, desde que esses nomes não tenham sido definidos para outro comportamento. Isso permite oferecer uma ajuda básica sem precisar criar manualmente uma mensagem para cada opção disponível.


Considere um programa que recebe um endereço e uma porta. Podemos declarar essas opções com descrições que explicam sua responsabilidade, permitindo que o pacote flag apresente essas informações quando uma mensagem de ajuda for solicitada.

package main

import (
"flag"
"fmt"
)

func main() {
host := flag.String(
"host",
"127.0.0.1",
"Endereço do servidor",
)

porta := flag.Int(
"port",
8080,
"Porta do servidor",
)

flag.Parse()

fmt.Printf(
"Destino: %s:%d\n",
*host,
*porta,
)
}

Podemos solicitar a ajuda usando:

go run main.go -h

O programa apresentará uma mensagem semelhante a esta:

Usage of ...:
-host string
Endereço do servidor (default "127.0.0.1")
-port int
Porta do servidor (default 8080)

O cabeçalho exato depende de como o programa foi executado, mas as descrições são obtidas diretamente dos argumentos informados durante a declaração das flags. Isso evita precisar manter uma lista manual de todas as opções apenas para apresentar a ajuda básica. Quando usamos o conjunto padrão de flags, a solicitação de ajuda é tratada automaticamente e normalmente encerra a execução com o código 0.



Personalizando a mensagem de ajuda​


Embora a ajuda automática seja suficiente para programas pequenos, algumas ferramentas precisam apresentar uma descrição mais completa, incluindo exemplos de uso e informações sobre argumentos obrigatórios. Quando trabalhamos com flag.FlagSet, podemos definir uma função personalizada no campo Usage, permitindo controlar como essas instruções serão apresentadas. Também podemos usar PrintDefaults para listar automaticamente as flags declaradas no conjunto.


Vamos criar uma ferramenta que possui uma mensagem de ajuda personalizada e verifica se o endereço e a porta foram informados corretamente. Para manter o comportamento organizado, o programa retornará códigos de saída diferentes conforme o resultado da operação, enquanto a função main ficará responsável por encerrar o processo.

package main

import (
"flag"
"fmt"
"os"
)

func main() {
os.Exit(executar(os.Args[1:]))
}

func executar(args []string) int {
flags := flag.NewFlagSet(
"redecli",
flag.ContinueOnError,
)

host := flags.String(
"host",
"",
"Endereço do servidor",
)

porta := flags.Int(
"port",
0,
"Porta do servidor",
)

var ajuda bool

// Permite solicitar ajuda por dois nomes diferentes.
flags.BoolVar(
&ajuda,
"h",
false,
"Exibe a ajuda",
)

flags.BoolVar(
&ajuda,
"help",
false,
"Exibe a ajuda",
)

// Define a mensagem apresentada pelo programa.
flags.Usage = func() {
fmt.Fprintln(
flags.Output(),
"Uso: redecli -host <endereço> -port <porta>",
)

fmt.Fprintln(flags.Output())

fmt.Fprintln(
flags.Output(),
"Opções disponíveis:",
)

flags.PrintDefaults()
}

// Interpreta os argumentos recebidos.
if err := flags.Parse(args); err != nil {
return 2
}

// Exibe a ajuda na saída padrão quando solicitada.
if ajuda {
flags.SetOutput(os.Stdout)
flags.Usage()

return 0
}

if flags.NArg() != 0 {
fmt.Fprintln(
os.Stderr,
"Erro: argumentos inesperados.",
)

return 2
}

if *host == "" {
fmt.Fprintln(
os.Stderr,
"Erro: a flag -host é obrigatória.",
)

return 2
}

if *porta < 1 || *porta > 65535 {
fmt.Fprintln(
os.Stderr,
"Erro: porta inválida.",
)

return 2
}

fmt.Printf(
"Destino: %s:%d\n",
*host,
*porta,
)

return 0
}

Nesse exemplo, criamos um FlagSet com flag.ContinueOnError, permitindo que a função executar controle o código de saída quando a interpretação falha. Depois, definimos flags.Usage com uma função que apresenta a sintaxe esperada e lista as opções disponíveis. A chamada flags.PrintDefaults() escreve as descrições das flags cadastradas no conjunto, evitando repetir manualmente essas informações.


Também declaramos as flags booleanas -h e -help, que compartilham a variável ajuda. Quando uma delas é informada, o programa altera o destino da mensagem para os.Stdout, apresenta as instruções e termina normalmente. Se ocorrer um erro de interpretação, o pacote continuará usando a saída de erros configurada para o conjunto, mantendo os diagnósticos separados dos resultados normais.


Executando:

go run main.go -help

Teremos uma mensagem semelhante a esta:

Uso: redecli -host <endereço> -port <porta>

Opções disponíveis:
-h
Exibe a ajuda
-help
Exibe a ajuda
-host string
Endereço do servidor
-port int
Porta do servidor

O programa também informa erros específicos quando o endereço não é fornecido ou quando a porta está fora do intervalo aceito. Como essas verificações acontecem depois da interpretação das flags, conseguimos diferenciar problemas relacionados ao formato dos argumentos de problemas relacionados às regras da aplicação. Isso permite apresentar mensagens mais claras e retornar códigos de saída consistentes.



Organizando mensagens de erro​


Mensagens de erro devem explicar o problema de maneira suficiente para permitir sua correção. Uma mensagem genérica como Erro: configuração inválida não informa qual parâmetro precisa ser corrigido, enquanto Erro: porta deve estar entre 1 e 65535 identifica a configuração e descreve a regra esperada. Quando possível, também podemos indicar qual operação falhou, mantendo as informações relacionadas ao problema na mesma mensagem.


Em Go, funções que podem falhar normalmente retornam um valor do tipo error, permitindo que o código responsável pela chamada decida como tratar o problema. Podemos usar fmt.Errorf para acrescentar contexto a um erro recebido, preservando sua identificação original por meio do formato %w. Isso facilita entender em qual etapa a falha aconteceu sem precisar descartar o erro retornado pela função.

func carregarArquivo(caminho string) ([]byte, error) {
// Tenta ler o arquivo informado.
dados, err := os.ReadFile(caminho)

if err != nil {
return nil, fmt.Errorf(
"ler arquivo %q: %w",
caminho,
err,
)
}

return dados, nil
}

Esse trecho pressupõe que os pacotes fmt e os foram importados. A função recebe o caminho de um arquivo e tenta ler seu conteúdo usando os.ReadFile. Se ocorrer um erro, fmt.Errorf acrescenta o nome da operação e o caminho envolvido, permitindo que a função responsável pela execução apresente uma mensagem mais completa sem precisar conhecer todos os detalhes internos da leitura.


Também devemos evitar mensagens excessivamente detalhadas quando elas podem expor informações sensíveis, como senhas, tokens ou conteúdo de arquivos privados. Para ferramentas de rede, mensagens de erro podem incluir o endereço consultado, a operação realizada e o erro retornado pelo sistema, desde que essas informações sejam adequadas para o contexto em que a ferramenta será executada. O objetivo é facilitar a identificação do problema sem revelar dados que não precisam aparecer na saída.