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():
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.
go1// 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.
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 0Conforme 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.
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 0O 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 🤯.
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 0io.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.
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:
go1=== 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 0io.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.