CodeGym /Cursos /Go SELF /Injecao de io.Writer/ io.Reader em vez de stdout/stdin d...

Injecao de io.Writer/ io.Reader em vez de stdout/stdin direto

Go SELF
Nível 53, Lição 0
Disponível

1. Por que evitar os.Stdout/os.Stdin diretos em CLI

Quando voce escreve a sua primeira CLI, a mao naturalmente vai para fmt.Println(...), e depois — para fmt.Scan(...). Essa e uma fase normal de amadurecimento: primeiro “desde que funcione”, depois “por que isso nao da para testar direito?”. O problema nao esta no fmt, mas no fato de que, por padrao, o fmt escreve no stdout global do processo e le do stdin global do processo.

Imagine que voce quer verificar um comando automaticamente: passou argumentos, obteve a saida, comparou com o esperado. Se o comando imprime diretamente em os.Stdout, o teste precisa “capturar” o stdout global de todo o processo (e torcer para que testes paralelos nao armem uma bagunca). E se o comando le de os.Stdin, o teste precisa substituir o stdin (e ai aparece de novo estado global e fragilidade). No fim, os testes deixam de ser “validacao da logica” e viram uma sessao de exorcismo.

Injecao de I/O (passar io.Reader e io.Writer como parametros) resolve isso com calma e do jeito Go: o comando vira uma funcao comum, que le da origem passada e escreve no destino passado.

Contraexemplo: “tudo direto”

Primeiro, vamos ver um codigo que parece logico para quem esta comecando, mas traz problemas mais adiante. Este exemplo nao e “ruim”; ele so e “cedo demais”.

package main

import (
	"fmt"
	"os"
)

func main() {
	fmt.Println("todo-cli") // writes to the process stdout
	fmt.Fscan(os.Stdin, new(string))
}

Por fora, tudo bem: o programa imprime uma saudacao, depois le algo do stdin. Mas agora tente responder a pergunta: “como testar que ele imprime exatamente todo-cli\n?”. Vai ser preciso capturar o stdout de todo o processo, e isso ja nao e sobre logica, e sim sobre acrobacia.

Queremos que a nossa logica consiga trabalhar com qualquer io.Reader/io.Writer, e que os.Stdin/os.Stdout sejam apenas uma das opcoes, conectada na borda — no main.

2. O que sao io.Reader e io.Writer

io.Reader e io.Writer sao dois interfaces muito pequenos da biblioteca padrao, que definem a ideia de “fluxo” de dados. Reader le bytes, Writer recebe bytes. O importante nem e que sao bytes, e sim que isso e um contrato, ao qual qualquer coisa pode ser conectada: arquivo, rede, buffer em memoria, string, logger, wrapper de gzip — tudo o que souber ler/escrever.

Por que Go gosta tanto dessa ideia? Porque ela deixa o codigo flexivel sem magia e sem frameworks. Nos materiais oficiais sobre o estilo Go, a ideia de “construir codigo em torno de interfaces” aparece varias vezes, e io.Writer e um dos exemplos mais populares: em cima dele e facil adicionar comportamento sem alterar o codigo chamador.

Para CLI isso e especialmente conveniente: stdout e apenas um io.Writer. Stdin e apenas um io.Reader. Logo, se os passamos para o comando, ele deixa de depender do terminal real e se torna testavel.

3. Contrato do comando: Run(...) int e um main enxuto

Comando como funcao Run

Agora vamos introduzir a ideia central do dia: o nucleo da CLI deve ser uma funcao que recebe argumentos e I/O como dependencias, e retorna um codigo de saida. Esse contrato e simples, mas poderoso: ele remove dependencias globais e torna o comportamento previsivel.

Normalmente a assinatura fica assim:


Run(args []string, in io.Reader, out, errOut io.Writer) int

Aqui e importante que out e errOut estejam separados. stdout e stderr nao sao “dois nomes diferentes da mesma coisa”, e sim dois canais de significado diferentes: um para o resultado, outro para problemas e dicas. E tambem e importante que Run nao chame os.Exit — porque os.Exit mata o processo imediatamente, e o teste nao consegue verificar mais nada.

Um esqueleto minimo para o nosso todo-cli:

package cli

import (
	"fmt"
	"io"
)

func Run(args []string, in io.Reader, out, errOut io.Writer) int {
	_ = in // not used yet
	fmt.Fprintln(out, "todo-cli") // todo-cli
	return 0
}

Sim, ainda esta sem graca. Mas agora Run pode ser chamada do main, de um teste, ou de outro codigo. E, em cada caso, a saida vai para onde quisermos.

main como uma casca fina

E muito facil transformar main por acidente em uma funcao enorme, onde tudo fica misturado. Mas, para CLI, e mais correto manter main enxuto: ele deve apenas conectar os “fios reais” (argumentos, stdin/stdout/stderr) e chamar o nosso nucleo.

Exemplo de main.go:

package main

import (
	"os"

	"example.com/todo/cli"
)

func main() {
	code := cli.Run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)
	os.Exit(code)
}

Repare numa coisa interessante: os.Exit aqui e permitido e ate util — esta e a fronteira da aplicacao. Nao estamos tentando “proibir os.Exit em geral”; estamos apenas impedindo que ele vaze para dentro da logica de negocio do comando.

Exit codes como parte do contrato

Parece que exit code e um tema separado, mas, na pratica, ele anda junto com a injecao de I/O. Quando voce transforma o comando em uma funcao Run, ele nao pode “simplesmente terminar como quiser”: ele precisa retornar um resultado, e esse resultado precisa ser padronizado.

Para nao espalhar numeros magicos, vamos criar constantes:

package cli

const (
	exitOK    = 0
	exitFail  = 1
	exitUsage = 2
)

E aplicar no codigo:

package cli

import (
	"fmt"
	"io"
)

func Run(args []string, in io.Reader, out, errOut io.Writer) int {
	_ = in

	if len(args) == 0 {
		fmt.Fprintln(errOut, "missing command") // missing command
		return exitUsage
	}

	fmt.Fprintln(out, "ok") // ok
	return exitOK
}

Preste atencao na disciplina: a mensagem de erro foi para errOut, e o “resultado bem-sucedido” foi para out. Isso vai ser crucial quando a aplicacao comecar a ser usada em scripts: stdout pode ser analisado, stderr pode ser visto com os olhos.

Regra: resultado vai para stdout, diagnostico vai para stderr

Agora vem uma regra pequena, que parece chata, mas economiza horas de debug. Quando voce escreve uma CLI, classifique mentalmente cada linha de saida: isso e resultado ou diagnostico? E entao imprima no writer correspondente.

Se no futuro voce quiser fazer todo-cli list e redirecionar o resultado para um arquivo, o usuario vai fazer assim:

todo-cli list > tasks.txt

E ele espera que em tasks.txt va apenas a lista de tarefas, e nao “erro: nao foi possivel abrir o arquivo” ou “dica: use a flag --help”. Por isso, erros e usage devem sempre ir para stderr.

Vamos mostrar isso em um despachante simples de comandos:

package cli

import (
	"fmt"
	"io"
)

func Run(args []string, in io.Reader, out, errOut io.Writer) int {
	_ = in

	switch {
	case len(args) == 0:
		fmt.Fprintln(errOut, "missing command") // missing command
		return exitUsage
	case args[0] == "version":
		fmt.Fprintln(out, "todo-cli dev") // todo-cli dev
		return exitOK
	default:
		fmt.Fprintf(errOut, "unknown command: %s\n", args[0]) // unknown command: ...
		return exitUsage
	}
}

4. Injecao de stdin e saida do pacote flag

Injecao de stdin: lemos de in

Com stdout, normalmente tudo e claro: “nao imprima diretamente, imprima no writer”. Com stdin, para quem esta comecando, costuma ocorrer uma pequena falha: “e como eu vou ler a string agora?”.

Resposta: do mesmo jeito de antes, so que com outra origem. A maior parte das funcoes de entrada em Go consegue trabalhar nao apenas com stdin, mas com qualquer io.Reader. Por exemplo, fmt.Fscan recebe o reader como primeiro argumento — e esse e exatamente o nosso caso.

Suponha que queremos um comando echo, que le uma palavra da entrada e a imprime (o exemplo e didatico, mas mostra a mecanica):

package cli

import (
	"fmt"
	"io"
)

func runEcho(in io.Reader, out, errOut io.Writer) int {
	var s string
	if _, err := fmt.Fscan(in, &s); err != nil {
		fmt.Fprintln(errOut, "failed to read input") // failed to read input
		return exitUsage
	}
	fmt.Fprintln(out, s) // prints the read word
	return exitOK
}

O ponto principal e: lemos de in, e nao de os.Stdin. No main real, passaremos os.Stdin; no teste, poderemos passar uma string por meio de strings.NewReader("hello"). Mas, mesmo sem conhecer testes, ja da para ver que a funcao ficou mais universal.

flag.FlagSet: configure a saida com SetOutput

Existe ainda outro detalhe desagradavel que quebra a testabilidade de CLI: o pacote padrao flag tambem gosta de escrever “por conta propria” em algum lugar. Por isso, quando voce cria uma subcomando com flags, e melhor nao usar flag.Parse() global, e sim criar um FlagSet separado e configurar a saida dele.

Ou seja, voce nao faz apenas flag.NewFlagSet; voce tambem chama SetOutput(errOut). Isso tambem e injecao de I/O: nao permitimos que as entranhas imprimam onde bem entendem.

Mini-exemplo de um comando list com a flag -limit:

package cli

import (
	"flag"
	"fmt"
	"io"
)

func runList(args []string, out, errOut io.Writer) int {
	fs := flag.NewFlagSet("list", flag.ContinueOnError)
	fs.SetOutput(errOut)

	limit := fs.Int("limit", 10, "max items")
	if err := fs.Parse(args); err != nil {
		return exitUsage
	}

	fmt.Fprintf(out, "limit=%d\n", *limit) // limit=10
	return exitOK
}

Aqui o importante e que ate os erros de parse das flags vao para errOut, que esta sob o nosso controle. Isso torna o comportamento previsivel tanto no terminal quanto na verificacao automatica.

5. Como os dados fluem e por que isso e melhor do que os.Stdout/os.Stdin diretos

Mini-esquema do fluxo de dados

As vezes, a compreensao melhora de repente quando vemos os “fios” explicitamente. No nosso desenho, main e o lugar onde os fluxos reais do sistema sao conectados, e cli.Run e a logica que nao sabe nada sobre o SO.

flowchart LR
    A[main.go] -->|args| B[cli.Run]
    A -->|os.Stdin| B
    B -->|out io.Writer| C[os.Stdout]
    B -->|errOut io.Writer| D[os.Stderr]
    B -->|exit code| A

E justamente essa abordagem que faz da CLI um codigo que pode ser executado em qualquer lugar: no terminal, nos testes, em outro aplicativo como biblioteca (sim, as vezes isso tambem acontece).

Tabela: como era e como ficou

Para consolidar, vamos resumir a ideia principal em uma tabela. Isso ajuda porque, visualmente, fica claro que nao estamos complicando a logica; estamos apenas movendo dependencias para a entrada das funcoes.

Tarefa “Direto” (ruim para testes) “Com injecao” (normal)
Imprimir resultado
fmt.Println(...)
fmt.Fprintln(out, ...)
Imprimir erro/usage
fmt.Fprintln(os.Stderr, ...)
fmt.Fprintln(errOut, ...)
Ler entrada
fmt.Fscan(os.Stdin, &x)
fmt.Fscan(in, &x)
Encerrar o programa
os.Exit(...)
em qualquer lugar
return code
na logica,
os.Exit
apenas em
main

6. Erros tipicos ao injetar io.Reader/io.Writer

Erro #1: dentro do comando, ainda imprimem em os.Stdout “por habito”.
Isso acontece muito quando voce criou uma assinatura “bonita” como Run(... out io.Writer ...), mas depois deixou um fmt.Println ou fmt.Fprintln(os.Stdout, ...) em algum canto. No fim, metade da saida vai para o buffer do teste, metade vai para o terminal real, e voce obtem uma misterio no nivel “por que ele imprime duas vezes?”. A correcao e simples: imprima apenas via out/errOut, e no IDE voce pode procurar os.Stdout e fmt.Print no projeto.

Erro #2: misturar stdout e stderr em um unico fluxo.
As vezes parece que “qual e a diferenca? O usuario vai ver o texto de qualquer jeito”. A diferenca aparece assim que alguem faz um pipe: todo-cli list | grep ... ou um redirecionamento > file. Se erros e dicas vao para stdout, eles quebram o processamento automatico do resultado. O habito de separar os canais desde o inicio economiza muito tempo e deixa a CLI mais profissional.

Erro #3: Run chama os.Exit e mata o teste (e o defer).
Isso nao e so sobre testes: os.Exit encerra o processo sem executar os defer adiados, entao voce pode nao fechar um arquivo, nao descartar um buffer, nao gravar os dados ate o fim. O contrato precisa ser de ferro: a logica retorna o codigo, e o main decide quando chamar os.Exit.

Erro #4: o flag imprime mensagens sozinho, “em algum lugar”, porque SetOutput nao foi configurado.
Mesmo que voce esteja fazendo tudo certo e imprimindo em errOut, o FlagSet padrao pode escrever um erro de parse de flag em um lugar diferente do esperado. Por isso, cada subcomando com flags deve ter seu proprio FlagSet, e ele deve receber fs.SetOutput(errOut) — isso faz parte da mesma disciplina de injecao de I/O.

Erro #5: tornam a saida nao deterministica e depois se surpreendem que “fica dificil comparar”.
Quando voce comecar a comparar saida em testes, vai ficar subitamente importante que as strings coincidam exatamente, inclusive o \n no final. Se um ramo imprime com quebra de linha e outro sem, o comportamento fica “quase igual”, mas o teste e os scripts do usuario nao vao concordar com isso. Escolha um estilo (normalmente cada linha termina com \n) e mantenha-o em todos os ramos.

1
Tarefa
Go SELF, nível 53, lição 0
Bloqueado
Banner do aplicativo
Banner do aplicativo
1
Tarefa
Go SELF, nível 53, lição 0
Bloqueado
Saudação de fluxos
Saudação de fluxos
1
Tarefa
Go SELF, nível 53, lição 0
Bloqueado
Gerenciador de comandos
Gerenciador de comandos
1
Tarefa
Go SELF, nível 53, lição 0
Bloqueado
Repetição com flags
Repetição com flags
Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION