Arquivos, Streams e JSON: Dados Que Sobrevivem ao Programa

Arquivos, Streams e JSON: Dados Que Sobrevivem ao Programa

Arquivos, streams e JSON no C#: caminhos com Path.Combine, File e ReadLines, codificação UTF-8 e o BOM para o Excel, streams empilhados com GZip, gravação atômica com arquivo temporário, System.Text.Json com DTOs polimórficos e um catálogo que sobrevive ao programa.
Linguagem C#

• • 18 min de leitura

Tudo o que o programa sabe desaparece quando ele termina, a menos que seja gravado em disco, num formato que ele próprio, ou outro sistema, consiga ler de volta.

Em "Span<T> e Memory<T>" interpretamos milhões de linhas de vendas, mas o conteúdo vinha de uma string escrita no próprio código. E o catálogo, os pedidos e os registros que a Livraria cria somem a cada vez que o programa fecha. Nesta aula ensinamos a Livraria a persistir: a gravar e ler arquivos, a entender a abstração de streams que está por trás de qualquer leitura e escrita no .NET e a usar o JSON, o formato de troca de dados mais usado da atualidade. No caminho, vamos tratar de codificação de caracteres, de caminhos que funcionam em qualquer sistema operacional e de como gravar um arquivo sem destruir o anterior se algo der errado no meio.

Caminhos: onde fica o arquivo

Antes de abrir um arquivo, é preciso dizer onde ele está, e aqui mora a primeira armadilha. O Windows separa pastas com \, e Linux e macOS com /. Nunca monte caminhos concatenando strings. Use Path.Combine, que usa o separador certo de cada sistema:

string pasta = Path.Combine(AppContext.BaseDirectory, "dados");
string arquivo = Path.Combine(pasta, "catalogo.json");
Console.WriteLine(arquivo);   // .../bin/Debug/net10.0/dados/catalogo.json

Um caminho relativo, como "catalogo.json", é resolvido a partir do diretório atual do processo, que depende de onde e como o programa foi iniciado: pelo terminal, pelo editor, como serviço. É uma fonte clássica de "funciona na minha máquina". Prefira caminhos construídos a partir de uma base conhecida:

  • AppContext.BaseDirectory: a pasta onde o programa está instalado;
  • Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData): a pasta de dados do usuário, adequada para preferências e arquivos que o programa cria;
  • Path.GetTempPath(): a pasta de temporários do sistema.

Outros utilitários de Path que você vai usar sempre: GetFileName, GetExtension, GetDirectoryName e ChangeExtension. E, para pastas, Directory.CreateDirectory(pasta), que cria a pasta e todas as intermediárias e não faz nada se ela já existir.

Os atalhos da classe File

Para casos simples, a classe File resolve em uma linha:

File.WriteAllText(arquivo, "Dom Casmurro;Machado de Assis;59.90");
string conteudo = File.ReadAllText(arquivo);

File.AppendAllText(arquivo, Environment.NewLine + "Vidas Secas;Graciliano Ramos;38.00");
string[] linhas = File.ReadAllLines(arquivo);

Relembrando "Sequências Sob Demanda": ReadAllLines carrega todas as linhas num array de uma vez, e ReadLines devolve um iterador que lê uma linha por vez, sob demanda. Para arquivos pequenos, tanto faz. Para um registro de vendas de 5 GB, ReadAllLines tentaria colocar 5 GB (na verdade, o dobro, já que char ocupa 2 bytes) na memória, enquanto ReadLines processa o arquivo inteiro usando memória para uma linha.

Codificação: como os caracteres viram bytes

Um arquivo guarda bytes, e não caracteres. A regra que converte um no outro é a codificação, e errar nela é o que produz textos como "Sert\u00e3o" ou "Sertão". O .NET moderno usa UTF-8 por padrão em todas as operações de arquivo, e essa é a escolha certa em praticamente todos os casos: UTF-8 representa qualquer caractere de qualquer idioma, e é o padrão da web, do Linux e dos bancos de dados modernos.

Dois cenários ainda exigem atenção. O primeiro são arquivos antigos, gerados por sistemas legados brasileiros, que costumam estar em Latin-1 (ISO-8859-1) ou Windows-1252. Lidos como UTF-8, os acentos viram lixo. Informe a codificação na leitura: File.ReadAllText(caminho, Encoding.Latin1). O segundo é o Excel. Ao abrir um CSV com dois cliques, ele frequentemente assume a codificação regional do Windows e mostra os acentos errados, a menos que o arquivo comece com uma marca especial de três bytes, o BOM (byte order mark), que anuncia "este arquivo é UTF-8". O .NET não grava o BOM por padrão, mas é fácil pedir:

using System.Text;

var utf8ComBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
File.WriteAllText("relatorio.csv", ExportadorCsv.Exportar(linhas), utf8ComBom);

É um detalhe pequeno que faz o relatório de "Reflexão e Atributos" abrir corretamente na planilha do gerente.

Streams: a abstração por trás de tudo

Os atalhos de File escondem um conceito que atravessa todo o .NET: o stream, uma sequência de bytes que pode ser lida ou escrita aos pedaços. Stream é uma classe abstrata, e cada derivada liga essa sequência a uma origem diferente:

  • FileStream: um arquivo em disco;
  • MemoryStream: um array de bytes em memória;
  • NetworkStream: uma conexão de rede;
  • GZipStream: bytes comprimidos, que envolve outro stream.

A força do modelo está na composição. Streams se empilham como camadas, cada uma acrescentando um comportamento à de baixo. Para gravar texto comprimido em disco:

using System.IO.Compression;

using (FileStream arquivo = File.Create("vendas.log.gz"))
using (var compressor = new GZipStream(arquivo, CompressionLevel.Optimal))
using (var escritor = new StreamWriter(compressor, Encoding.UTF8))
{
    escritor.WriteLine("2026-09-01;17;3");
    escritor.WriteLine("2026-09-01;18;5");
}

O StreamWriter converte texto em bytes UTF-8, o GZipStream comprime esses bytes, e o FileStream os grava no disco. Para ler, empilha-se o caminho inverso: FileStream, depois GZipStream em modo de descompressão, depois StreamReader. Cada camada só conhece a interface Stream da camada de baixo. É o padrão de projeto Decorator, e é por isso que o mesmo código de leitura funciona para um arquivo local, um arquivo comprimido ou uma resposta vinda da rede.

Todo stream é IDisposable, porque segura um recurso do sistema operacional. Os using garantem que, ao final, o StreamWriter descarregue o que estiver no seu buffer, o compressor escreva o final do formato gzip e o arquivo seja fechado, nessa ordem, a inversa da criação, como vimos em "Memória no .NET". Esquecer um using aqui produz um arquivo gzip truncado, que ninguém consegue abrir.

Quando o arquivo não colabora

Operações de arquivo falham por motivos que o programa não controla, e cada motivo tem sua exceção:

  • FileNotFoundException e DirectoryNotFoundException: o caminho não existe;
  • UnauthorizedAccessException: sem permissão;
  • IOException: disco cheio, arquivo travado por outro programa e outros problemas de entrada e saída (as duas primeiras derivam dela).

Uma tentação comum é verificar antes: if (File.Exists(caminho)) ... File.ReadAllText(caminho). Isso não resolve o problema, porque entre a verificação e a leitura outro processo pode apagar, mover ou travar o arquivo. É uma condição de corrida, tema da aula de concorrência. A forma robusta é tentar a operação e tratar a exceção. File.Exists continua útil para decisões que não dependem de a resposta continuar verdadeira no instante seguinte.

Gravação atômica: não destruir o que já existia

Imagine a Livraria gravando o catálogo por cima do arquivo anterior e o programa sendo encerrado no meio da gravação, por queda de energia ou disco cheio. O resultado é um arquivo pela metade: o catálogo antigo foi destruído, e o novo está incompleto. O padrão para evitar isso é gravar num arquivo temporário e, só quando a gravação terminar com sucesso, substituir o original de uma vez:

static void GravarComSeguranca(string caminho, string conteudo)
{
    string temporario = caminho + ".tmp";
    File.WriteAllText(temporario, conteudo);
    File.Move(temporario, caminho, overwrite: true);
}

Mover um arquivo dentro do mesmo disco é uma operação que o sistema de arquivos executa de uma vez: em qualquer instante, o caminho aponta para o arquivo antigo completo ou para o novo completo, nunca para uma mistura. Se algo falhar durante a escrita, o que sobra é um .tmp incompleto, e o original continua intacto.

JSON com System.Text.Json

Para guardar objetos, e não só texto solto, precisamos de um formato. O JSON (JavaScript Object Notation) é o padrão de fato para troca de dados: legível por pessoas, suportado por todas as linguagens e usado por praticamente toda API web, inclusive a que construiremos na Fase 6. O .NET tem um serializador rápido e embutido, o System.Text.Json, que conhecemos de passagem em "Source Generators":

using System.Text.Json;

public sealed record Autor(string Nome, int AnoNascimento);

var machado = new Autor("Machado de Assis", 1839);
string json = JsonSerializer.Serialize(machado);
// {"Nome":"Machado de Assis","AnoNascimento":1839}

Autor? lido = JsonSerializer.Deserialize<Autor>(json);

Serializar transforma o objeto em texto. Desserializar faz o caminho de volta. O comportamento é configurado por um JsonSerializerOptions:

using System.Text.Encodings.Web;
using System.Text.Unicode;

static readonly JsonSerializerOptions Opcoes = new()
{
    WriteIndented = true,                                   // quebra linhas e indenta
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,      // "anoNascimento", convenção da web
    Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),  // grava "Sertão", e não "Sert\u00E3o"
};

A terceira opção merece explicação. Por segurança, para o caso de o JSON acabar embutido numa página HTML, o System.Text.Json escapa todo caractere fora do ASCII por padrão, e "Sertão" vira "Sert\u00E3o". Isso é JSON perfeitamente válido, e qualquer leitor de JSON o interpreta como "Sertão". Mas o arquivo fica ilegível para pessoas. Para arquivos que não vão parar dentro de HTML, liberar todos os caracteres Unicode deixa o conteúdo legível.

Outra recomendação: crie o JsonSerializerOptions uma vez e reaproveite-o, como fizemos com o campo static readonly. Na primeira vez que um objeto de opções é usado, o serializador analisa os tipos envolvidos e guarda essa análise nele. Criar opções novas a cada chamada refaz esse trabalho sempre, e é um dos erros de desempenho mais comuns com JSON no .NET.

Atributos refinam a serialização de propriedades específicas: [JsonPropertyName("titulo")] fixa o nome, e [JsonIgnore] exclui a propriedade.

Objetos de domínio não são formatos de arquivo

Aqui está uma decisão de desenho que vale para arquivos, bancos de dados e APIs. Poderíamos tentar serializar direto as nossas classes LivroImpresso e Ebook. Mas elas foram desenhadas para proteger regras, e não para virar texto: o Id é gerado por um contador estático (desserializar criaria um Id novo), o Estoque só muda por Vender e Repor, e o evento EstoqueBaixo não faz sentido nenhum num arquivo. Forçar o domínio a se adaptar ao serializador, abrindo set públicos e construtores vazios, destruiria o encapsulamento que construímos na Fase 2.

A solução padrão é separar as duas coisas. Records simples, chamados DTOs (Data Transfer Objects, objetos de transferência de dados), descrevem a forma do arquivo, e funções de conversão fazem a ponte com o domínio. Para uma hierarquia, o System.Text.Json sabe gravar um discriminador que indica o tipo de cada item:

using System.Text.Json.Serialization;

[JsonPolymorphic(TypeDiscriminatorPropertyName = "tipo")]
[JsonDerivedType(typeof(LivroImpressoDto), "impresso")]
[JsonDerivedType(typeof(EbookDto), "ebook")]
public abstract record ProdutoDto(string Titulo, string Autor, decimal Preco);

public sealed record LivroImpressoDto(string Titulo, string Autor, decimal Preco, int PesoGramas, int Estoque)
    : ProdutoDto(Titulo, Autor, Preco);

public sealed record EbookDto(string Titulo, string Autor, decimal Preco, double TamanhoMb)
    : ProdutoDto(Titulo, Autor, Preco);

No JSON, cada produto ganha uma propriedade "tipo": "impresso" ou "tipo": "ebook", e na leitura o serializador usa esse valor para decidir qual record criar. A conversão entre domínio e DTO é um trabalho perfeito para o pattern matching de "Enums e Pattern Matching":

static ProdutoDto ParaDto(Produto p) => p switch
{
    LivroImpresso l => new LivroImpressoDto(l.Titulo, l.Autor, l.Preco, l.PesoGramas, l.Estoque),
    Ebook e => new EbookDto(e.Titulo, e.Autor, e.Preco, e.TamanhoMb),
    _ => throw new NotSupportedException($"Formato não suportado para gravação: {p.GetType().Name}")
};

static Produto ParaDominio(ProdutoDto d) => d switch
{
    LivroImpressoDto l => new LivroImpresso(l.Titulo, l.Autor, l.Preco, l.PesoGramas, l.Estoque),
    EbookDto e => new Ebook(e.Titulo, e.Autor, e.Preco, e.TamanhoMb),
    _ => throw new NotSupportedException($"Tipo de registro desconhecido: {d.GetType().Name}")
};

Um bônus da separação: ParaDominio passa pelos construtores do domínio, com todas as validações. Um arquivo editado à mão com preço negativo é rejeitado na leitura, em vez de colocar um livro inválido no catálogo.

Juntando tudo: um catálogo que sobrevive ao programa

using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Unicode;

public static class ArquivoDeCatalogo
{
    private static readonly JsonSerializerOptions Opcoes = new()
    {
        WriteIndented = true,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
    };

    public static void Salvar(IEnumerable<Produto> produtos, string caminho)
    {
        List<ProdutoDto> dtos = produtos.Select(ParaDto).ToList();
        Directory.CreateDirectory(Path.GetDirectoryName(caminho)!);

        string temporario = caminho + ".tmp";
        using (FileStream fluxo = File.Create(temporario))
            JsonSerializer.Serialize(fluxo, dtos, Opcoes);

        File.Move(temporario, caminho, overwrite: true);
    }

    public static List<Produto> Carregar(string caminho)
    {
        try
        {
            using FileStream fluxo = File.OpenRead(caminho);
            List<ProdutoDto> dtos = JsonSerializer.Deserialize<List<ProdutoDto>>(fluxo, Opcoes) ?? [];
            return dtos.Select(ParaDominio).ToList();
        }
        catch (FileNotFoundException)
        {
            return [];   // primeira execução: ainda não há catálogo
        }
        catch (JsonException ex)
        {
            throw new InvalidOperationException($"O arquivo de catálogo \"{caminho}\" está corrompido.", ex);
        }
    }

    // ParaDto e ParaDominio como mostrados acima
}

E o programa:

string caminho = Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Livraria", "catalogo.json");

List<Produto> catalogo = ArquivoDeCatalogo.Carregar(caminho);
Console.WriteLine($"Carregados: {catalogo.Count} produto(s).");

if (catalogo.Count == 0)
{
    catalogo.Add(new LivroImpresso("Grande Sertão: Veredas", "Guimarães Rosa", 89.00m, pesoGramas: 1200, estoque: 3));
    catalogo.Add(new Ebook("Vidas Secas", "Graciliano Ramos", 24.90m, tamanhoMb: 1.8));
    ArquivoDeCatalogo.Salvar(catalogo, caminho);
    Console.WriteLine($"Catálogo inicial gravado em {caminho}");
}

Na primeira execução, o arquivo não existe, Carregar devolve uma lista vazia, e o catálogo inicial é gravado. Na segunda, ele é lido de volta, com "Grande Sertão" gravado com os acentos legíveis. O arquivo gerado tem esta forma (a ordem das propriedades de cada objeto pode variar):

[
  {
    "tipo": "impresso",
    "pesoGramas": 1200,
    "estoque": 3,
    "titulo": "Grande Sertão: Veredas",
    "autor": "Guimarães Rosa",
    "preco": 89.00
  },
  ...
]

Repare nas decisões acumuladas desta aula. O serializador escreve e lê direto do stream, sem montar uma string intermediária com o arquivo inteiro. A gravação é atômica. A ausência do arquivo é tratada como caso esperado. Um arquivo corrompido vira uma exceção com significado de negócio, preservando a causa técnica em InnerException, como vimos em "Quando Algo Dá Errado". E os objetos de domínio nunca foram deformados para agradar o serializador.

Há ainda um desperdício escondido em cada uma dessas operações. Enquanto o disco gira, ou a rede entrega os bytes de uma resposta, a thread que pediu a leitura fica parada, esperando, sem fazer nada útil. Num programa de console, ninguém percebe. Numa interface gráfica, a janela congela. Num servidor atendendo milhares de clientes, cada thread parada é memória e capacidade jogadas fora. O C# tem uma forma de esperar por operações de entrada e saída sem bloquear a thread, e ela se tornou uma das marcas da linguagem.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Grave um arquivo de texto com três títulos na pasta de temporários do sistema, leia-o de volta e imprima cada linha numerada. Monte o caminho sem concatenar strings.

Ver resposta

✓ Resposta:

string caminho = Path.Combine(Path.GetTempPath(), "titulos.txt");
File.WriteAllLines(caminho, ["Dom Casmurro", "Vidas Secas", "Grande Sertão: Veredas"]);

int numero = 1;
foreach (string linha in File.ReadLines(caminho))
    Console.WriteLine($"{numero++}. {linha}");

Path.Combine usa o separador correto em cada sistema, e Path.GetTempPath() devolve a pasta de temporários de qualquer plataforma. File.WriteAllLines recebe uma coleção e grava uma linha por elemento, em UTF-8. File.ReadLines lê sob demanda. Para três linhas, ReadAllLines daria o mesmo resultado, mas é um bom hábito usar a versão que funciona para qualquer tamanho.

Exercício 2

Um colega precisa contar as linhas de um arquivo de 8 GB e escreveu File.ReadAllLines(caminho).Length. O que deve acontecer? Reescreva de forma que o uso de memória seja pequeno e constante.

Ver resposta

✓ Resposta: ReadAllLines tenta carregar o arquivo inteiro num array de strings. Como cada caractere ocupa 2 bytes em memória e cada linha é um objeto com cabeçalho próprio, 8 GB em disco viram bem mais de 16 GB em memória. Na maioria das máquinas o programa falha com OutOfMemoryException e, antes disso, provoca uma pressão enorme sobre o coletor de lixo. A versão correta:

long linhas = File.ReadLines(caminho).LongCount();

ReadLines é um iterador que mantém só a linha atual em memória, e LongCount percorre a sequência contando, com um long porque um arquivo desse tamanho pode passar de 2,1 bilhões de linhas. O uso de memória fica pequeno e constante, não importa o tamanho do arquivo. Para o máximo de desempenho, daria ainda para ler blocos de bytes com um FileStream e contar as quebras de linha com spans, como em "Span<T> e Memory<T>", sem nem criar as strings.

Exercício 3

Grave o CSV do catálogo, gerado pelo exportador de "Reflexão e Atributos", de modo que o Excel mostre os acentos corretamente ao abrir o arquivo com dois cliques. Explique o que muda nos bytes do arquivo.

Ver resposta

✓ Resposta:

using System.Text;

string csv = ExportadorCsv.Exportar(linhasDoCatalogo);
var utf8ComBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
File.WriteAllText(Path.Combine(Path.GetTempPath(), "catalogo.csv"), csv, utf8ComBom);

A única mudança nos bytes está no início do arquivo: três bytes (EF BB BF em hexadecimal) formando a marca de ordem de bytes do UTF-8. O restante é idêntico. Sem ela, o Excel costuma interpretar o arquivo na codificação regional do Windows, e "Sertão" aparece como "Sertão", porque os dois bytes UTF-8 do "ã" são lidos como dois caracteres separados. Com a marca, ele reconhece o UTF-8 e exibe tudo certo. A marca é opcional no padrão UTF-8 e alguns programas Unix a consideram incômoda, por isso o .NET não a grava por padrão. Use-a quando o destino for o Excel.

Exercício 4

Serializando um livro com o título "São Bernardo" usando as opções padrão, o resultado contém "S\u00E3o Bernardo". O arquivo está errado? Por que o serializador faz isso, e quando é seguro mudar esse comportamento?

Ver resposta

✓ Resposta: Não está errado. \u00E3 é uma sequência de escape válida em JSON para o caractere "ã", e qualquer leitor de JSON, inclusive o próprio System.Text.Json, devolve "São Bernardo" ao desserializar. O serializador escapa todos os caracteres fora do ASCII por padrão como medida de defesa: se aquele JSON for inserido diretamente numa página HTML ou num bloco de JavaScript, caracteres especiais poderiam ser explorados para injetar código. Escapar tudo o que não é ASCII fecha essa porta de antemão. Mudar para JavaScriptEncoder.Create(UnicodeRanges.All) é seguro quando o JSON é gravado em arquivo, enviado por uma API ou guardado num banco, ou seja, sempre que não for embutido diretamente em HTML sem outro tratamento. O ganho é legibilidade para pessoas e arquivos um pouco menores. Continue evitando o UnsafeRelaxedJsonEscaping, que relaxa bem mais regras do que o necessário.

Exercício 5

Explique o que acontece com o catálogo em disco, nas duas versões abaixo, se o programa for encerrado abruptamente no meio da gravação.

// Versão A
File.WriteAllText(caminho, json);

// Versão B
File.WriteAllText(caminho + ".tmp", json);
File.Move(caminho + ".tmp", caminho, overwrite: true);
Ver resposta

✓ Resposta: Na versão A, WriteAllText primeiro trunca o arquivo existente para tamanho zero e depois começa a escrever. Se o programa morrer no meio, o arquivo fica com o JSON pela metade: o catálogo antigo foi destruído, e o novo está incompleto e inválido. Na próxima execução, Carregar encontra um JsonException, e todos os dados se perdem. Na versão B, a interrupção durante a escrita só afeta o arquivo .tmp, e o catálogo original continua intacto. A substituição acontece no Move, que dentro do mesmo sistema de arquivos é uma operação indivisível: ou ainda não aconteceu, e o arquivo antigo está lá, ou já aconteceu, e o novo está lá completo. O pior cenário da versão B é sobrar um .tmp incompleto, inofensivo e fácil de limpar na inicialização. Bancos de dados usam variações bem mais sofisticadas da mesma ideia, os logs de escrita antecipada, para garantir que nenhuma transação fique pela metade. Veremos isso na Fase 5.

Comentários

Mais em Linguagem C#

Operadores, Texto e Conversa com o Usuário
Operadores, Texto e Conversa com o Usuário

Operadores e texto em C#: a divisão inteira que perde a vírgula, o resto com…

Assincronia Avançada: Muitas Tarefas, Cancelamento e Fluxos Assíncronos
Assincronia Avançada: Muitas Tarefas, Cancelamento e Fluxos Assíncronos

Assincronia avançada no C#: Task.WhenAll e as exceções múltiplas, WaitAsync e…

Razor Pages: HTML montado no servidor para a vitrine da Livraria
Razor Pages: HTML montado no servidor para a vitrine da Livraria

A Livraria ganha uma vitrine com Razor Pages: páginas montadas no servidor…