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. Tentarei esclarecer algumas delas

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():

go
1file, 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.

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

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:

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

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.

go
 1func TestRead(t *testing.T) {
 2	reader := strings.NewReader("neste buffer cabe somente 10 caracteres\n")
 3	buffer := make([]byte, 10)
 4	numOfBytesRead, err := reader.Read(buffer)
 5	if err != nil {
 6		log.Fatalln(err)
 7	}
 8
 9	fmt.Printf("bytes lidos: %d\n", numOfBytesRead)
10	fmt.Println(string(buffer))
11}

Saída no console:

1=== RUN   TestRead
2bytes lidos: 10
3neste buff
4--- PASS: TestRead (0.00s)
5PASS
6
7Process 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.

go
 1func TestRead(t *testing.T) {
 2	reader := strings.NewReader("neste buffer cabe somente 10 caracteres\n")
 3
 4	for {
 5		buffer := make([]byte, 10)
 6		if _, err := reader.Read(buffer); err == io.EOF {
 7			fmt.Println("leitura finalizada")
 8			return
 9		}
10
11		fmt.Print(string(buffer))
12	}
13}

Saída no console:

1=== RUN   TestRead
2neste buffer cabe somente 10 caracteres
3leitura finalizada
4--- PASS: TestRead (0.00s)
5PASS
6
7Process 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 🤯.

go
 1func TestTeeReader(t *testing.T) {
 2	reader := strings.NewReader("assim funcionavam os antigos aparelhos VCR")
 3	writer := new(bytes.Buffer)
 4
 5	// tee vai ler do reader e escrever no writer
 6	tee := io.TeeReader(reader, writer)
 7	output, _ := io.ReadAll(tee)
 8	fmt.Println(string(output))
 9	fmt.Println(writer.String())
10}

Saída do console:

1=== RUN   TestTeeReader
2assim funcionavam os antigos aparelhos VCR
3assim funcionavam os antigos aparelhos VCR
4--- PASS: TestTeeReader (0.00s)
5PASS
6
7Process 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.

go
1func TestSeek(t *testing.T) {
2	reader := strings.NewReader("um reader nem sempre pode ser lido somente uma vez")
3	bytes, _ := io.ReadAll(reader)
4	fmt.Println(string(bytes))
5
6	_, _ = reader.Seek(0, 0)
7	moreBytes, _ := io.ReadAll(reader)
8	fmt.Println(string(moreBytes))
9}

Saída do console:

go
1=== RUN   TestTeeReader
2um reader só pode ser lido uma vez
3um reader só pode ser lido uma vez
4--- PASS: TestTeeReader (0.00s)
5PASS
6
7Process 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.

↑