alancesar.dev

Um pouco sobre arquivos e o pacote io do Go

Trabalhar com arquivos em Golang pode parecer algo trivial, porém é comum aparecer algumas dúvidas. A maneira mais prática de abrir um arquivo é com o comando os.Open():

file, err := os.Open("file.path")

Esse comando recebe um caminho e irá abrir o arquivo com permissões padrões, que no caso são somente para leitura.

// Open opens the named file for reading. If successful, methods on
// the returned file can be used for reading; the associated file
// descriptor has mode O_RDONLY.
// If there is an error, it will be of type *PathError.
func Open(name string) (*File, error) {
	return OpenFile(name, O_RDONLY, 0)
}

Caso seja necessário realizar alterações no arquivo, precisamos usar o os.OpenFile(), que recebe mais dois argumentos: uma flag e uma permissão. A permissão segue o padrão Unix e essas são as flags disponíveis:

const (
	// Exactly one of O_RDONLY, O_WRONLY, or O_RDWR must be specified.
	O_RDONLY int = syscall.O_RDONLY // open the file read-only.
	O_WRONLY int = syscall.O_WRONLY // open the file write-only.
	O_RDWR   int = syscall.O_RDWR   // open the file read-write.
	// The remaining values may be or'ed in to control behavior.
	O_APPEND int = syscall.O_APPEND // append data to the file when writing.
	O_CREATE int = syscall.O_CREAT  // create a new file if none exists.
	O_EXCL   int = syscall.O_EXCL   // used with O_CREATE, file must not exist.
	O_SYNC   int = syscall.O_SYNC   // open for synchronous I/O.
	O_TRUNC  int = syscall.O_TRUNC  // truncate regular writable file when opened.
)

Abrir um arquivo não necessariamente significa carregar ele em memória na aplicação. É apenas criado um controle de leitura (e escrita, se for o caso) dentro do sistema operacional e algumas informações básicas, como nome e tamanho, são carregadas. Por isso é possível abrir arquivos maiores que a memória RAM disponível sem maiores problemas. Também é importante destacar que a API os.File implementa quatro interfaces do pacote io, que são elas:

io.Reader

Utilizar essa interface é como assistir uma partida de futebol ao vivo. Não há como “rebobinar” (jovens, me desculpe, não consigo pensar em outra expressão) e voltar ao pontapé inicial; a partida segue conforme o relógio avança. Essa interface possui somente o método Read(), que recebe como parâmetro um array de bytes e retorna um inteiro, que é a quantidade de bytes lidos e um error, que será nil, caso tudo dê certo. O array do parâmetro será usado como um buffer, ou seja, o seu tamanho será a quantidade máxima de bytes lidos.

func TestRead(t *testing.T) {
	reader := strings.NewReader("neste buffer cabe somente 10 caracteres\n")
	buffer := make([]byte, 10)
	numOfBytesRead, err := reader.Read(buffer)
	if err != nil {
		log.Fatalln(err)
	}

	fmt.Printf("bytes lidos: %d\n", numOfBytesRead)
	fmt.Println(string(buffer))
}

Saída no console:

=== RUN   TestRead
bytes lidos: 10
neste buff
--- PASS: TestRead (0.00s)
PASS

Process finished with the exit code 0

Conforme o arquivo é lido, um ponteiro interno do reader avança; então, na próxima vez que o método foi invocado, ele irá continuar a leitura de onde parou e assim sucessivamente até que chegue ao final do arquivo, retornando o error io.EOF (end of file). Por conta disso, não é possível ler um io.Reader mais de uma vez, porém há formas de se resolver isso que irei detalhar adiante.

func TestRead(t *testing.T) {
	reader := strings.NewReader("neste buffer cabe somente 10 caracteres\n")

	for {
		buffer := make([]byte, 10)
		if _, err := reader.Read(buffer); err == io.EOF {
			fmt.Println("leitura finalizada")
			return
		}

		fmt.Print(string(buffer))
	}
}

Saída no console:

=== RUN   TestRead
neste buffer cabe somente 10 caracteres
leitura finalizada
--- PASS: TestRead (0.00s)
PASS

Process finished with the exit code 0

O método io.ReadAll() é um utilitário que lê em memória todo o conteúdo de um io.Reader, encapsulando a lógica de concatenar todos os buffers em um só array. É importante lembrar que este método consome memória RAM, portanto deve ser utilizado com cautela em rotinas que lidam com arquivos muito grandes.

io.Writer

Tudo o que foi dito para o io.Reader se aplica para o io.Writer, mas, evidentemente, para operações de escrita. Contudo, não existe um método WriteAll(), porque é presumido que você tenha disponível em memória todo o conteúdo que deve ser gravado. Porém, o Go disponibiliza o método io.Copy() que permite enviar dados de um Reader para um Writer. Também há o método io.TeeReader(), inspirado no comando tee do Unix, que retorna um Reader que escreve em um Writer tudo que é lido de outro Reader 🤯.

func TestTeeReader(t *testing.T) {
	reader := strings.NewReader("assim funcionavam os antigos aparelhos VCR")
	writer := new(bytes.Buffer)

	// tee vai ler do reader e escrever no writer
	tee := io.TeeReader(reader, writer)
	output, _ := io.ReadAll(tee)
	fmt.Println(string(output))
	fmt.Println(writer.String())
}

Saída do console:

=== RUN   TestTeeReader
assim funcionavam os antigos aparelhos VCR
assim funcionavam os antigos aparelhos VCR
--- PASS: TestTeeReader (0.00s)
PASS

Process finished with the exit code 0

io.Seeker

Se o io.Reader é um futebol ao vivo, o io.Seeker é um serviço de streaming. O método Seek permite navegar pelo conteúdo de um arquivo ou stream, tornando possível iniciar uma leitura ou escrita a partir de determinado ponto. Sendo assim, se for necessário ler algum conteúdo já lido anterior, bastaria apenas retornar o ponteiro à posição zero.

func TestSeek(t *testing.T) {
	reader := strings.NewReader("um reader nem sempre pode ser lido somente uma vez")
	bytes, _ := io.ReadAll(reader)
	fmt.Println(string(bytes))

	_, _ = reader.Seek(0, 0)
	moreBytes, _ := io.ReadAll(reader)
	fmt.Println(string(moreBytes))
}

Saída do console:

=== RUN   TestTeeReader
um reader só pode ser lido uma vez
um reader só pode ser lido uma vez
--- PASS: TestTeeReader (0.00s)
PASS

Process finished with the exit code 0

io.Closer

O método Close() aciona tudo o que precisa ser feito pelo sistema operacional para que esse recurso seja liberado.

Variações

Além dessas quatro interfaces mencionadas, o pacote io fornece também algumas variações, como o WriterAt e ReaderAt (ambas implementadas pelo os.File) que permite ler ou escrever em uma posição específica, além de combinações como o ReadSeeker ou WriteCloser, entre outras, que nada mais são do que composições daquelas interfaces básicas.

Boas práticas

Usar o ponteiro de os.File como parâmetro pode acabar não sendo muito vantajoso, já que as interfaces do pacote io deixa o código desacoplado da implementação, consequentemente, tornando-o mais fácil de testar. É tentador ler todo o conteúdo de um Reader quando se deseja realizar várias atividades em paralelo, como gerar um hash e extrair um cabeçalho enquanto faz upload para um outro serviço externo. Porém, isso pode trazer problemas de performance e prejudicar a saúde da sua aplicação, já que, como dito, isso consome a memória RAM.

As APIs do Go fazem um bom trabalho de otimização, como o método ParseMultipartForm do http.Request que combina o uso de memória e disco para criar fragmentos do arquivo. Estudar e entender seu comportamento pode trazer dicas valiosas.