Depuração eficiente de exceções

Outras línguas: English Español 한국어 中文

Em sua jornada com Java, um dos primeiros conceitos a aprender é exceções. Este termo define um cenário inesperado durante a execução de um programa, como uma falha de rede ou final de arquivo inesperado. Ou, como a documentação da Oracle coloca:

A classe Exception e suas subclasses são uma forma de Throwable que indica condições que uma aplicação razoável pode querer capturar.

Se seu programa estiver equipado com um “plano b” para lidar efetivamente com essas situações, ele continuará a operar sem problemas, independentemente de qualquer um deles ocorrer. Caso contrário, o programa pode travar inesperadamente ou acabar em um estado incorreto.

Quando um programa falha devido a uma exceção, você precisará depurá-lo. As linguagens de programação facilitam a depuração de erros relacionados a exceções fornecendo uma trace stack - uma mensagem especial apontando para o caminho do código que levou à falha. Essa informação é incrivelmente útil, e às vezes suficiente; no entanto, há casos em que podemos nos beneficiar de detalhes e técnicas adicionais.

Neste artigo, passaremos por um estudo de caso, focando na depuração de uma exceção que ocorre durante a análise de JSON. Ao fazer isso, iremos além de apenas olhar para as trace stacks e descobrir os benefícios de usar o depurador.

Aplicação de exemplo

A aplicação de exemplo para este caso de uso será um pequeno programa Java que analisa um conjunto de arquivos JSON que contêm dados sobre aeroportos em todo o mundo. Os arquivos incluem detalhes, como o código IATA dos aeroportos, país, latitude e longitude. Aqui está um exemplo de uma entrada:

{
    "iso_country": "AR",
    "iata_code": "MDQ",
    "longitude_deg": "-57.5733",
    "latitude_deg": "-37.9342",
    "elevation_ft": "72",
    "name": "Ástor Piazzola International Airport",
    "municipality": "Mar del Plata",
    "iso_region": "AR-B"
}

O programa é bastante simples. Ele itera sobre um conjunto de arquivos, lê e analisa cada um deles, filtra os objetos do aeroporto contra restrições de entrada, como "country=AR", então imprime a lista de aeroportos correspondentes:

public class Airports {

    static Path input = Paths.get("./data");
    static Gson gson = new Gson();
    static {
        System.setOut(new PrintStream(System.out, true, StandardCharsets.UTF_8));
    }

    public static void main(String[] args) throws IOException {

        List<Predicate<Airport>> filters = new ArrayList<>();

        for (String arg : args) {
            if (arg.startsWith("country=")) {
                filters.add(airport -> airport.isoCountry.equals(arg.substring("country=".length()))) ;
            } else if (arg.startsWith("region=")) {
                filters.add(airport -> airport.isoRegion.equals(arg.substring("region=".length()))) ;
            } else if (arg.startsWith("municipality=")) {
                filters.add(airport -> airport.municipality.equals(arg.substring("municipality=".length()))) ;
            }
        }

        try (Stream<Path> files = Files.list(input)) {
            Stream<Airport> airports = files.map(Airports::parse);
            for (Predicate<Airport> f : filters) {
                airports = airports.filter(f);
            }
            airports.forEach(System.out::println);
        }
    }

    static Airport parse(Path path) {
        try {
            JsonObject root = gson.fromJson(Files.readString(path), JsonObject.class);
            String name =           root.get("name").getAsString();
            String isoCountry =     root.get("isoCountry").getAsString();
            String iataCode =       root.get("iataCode").getAsString();
            String longitudeDeg =   root.get("longitudeDeg").getAsString();
            String latitudeDeg =    root.get("latitudeDeg").getAsString();
            String municipality =   root.get("municipality").getAsString();
            String isoRegion =      root.get("isoRegion").getAsString();
            Integer elevationFt =   Integer.parseInt(root.get("elevationFt").getAsString());
            return new Airport(name, isoCountry, iataCode, longitudeDeg, latitudeDeg, elevationFt, municipality, isoRegion);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    record Airport(String name, String isoCountry, String iataCode, String longitudeDeg, String latitudeDeg, Integer elevationFt, String municipality, String isoRegion) { }
}

O problema

Quando executamos o programa, ele falha com um NumberFormatException . Aqui está a trace stack que obtemos:

Exception in thread "main" java.lang.NumberFormatException: For input string: ""
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:672)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at dev.flounder.Airports.parse(Airports.java:53)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
	at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at dev.flounder.Airports.main(Airports.java:39)

Ele está apontando para a linha 53 do arquivo Airports.java . Olhando para essa linha, podemos dizer que há um problema na conversão do propriedade elevationFt para um número para um dos aeroportos. A mensagem de exceção nos diz que isso ocorre porque essa propriedade está em branco no arquivo correspondente.

A trace stack não é suficiente?

Enquanto a trace stack acima já nos dá muitas informações, ela é limitada de várias maneiras. Ele aponta para as linhas de código que estão falhando, mas ele nos diz qual arquivo de dados é a causa? O nome do arquivo seria útil se quiséssemos inspecioná-lo mais de perto ou corrigir os dados. Infelizmente, os detalhes de tempo de execução como esses são perdidos quando o aplicativo trava.

No entanto, existe uma maneira de suspender o aplicativo antes que ele tenha terminado, o que permite acessar o contexto que não é capturado nas trace stacks e logs. Além disso, com um aplicativo suspenso, você pode criar protótipos, testar e até mesmo carregar a correção enquanto o aplicativo ainda está sendo executado.

Suspenda em uma exceção

Primeiro de tudo, vamos começar definindo um breakpoint de exceção. Você pode fazer isso no diálogo de breakpoints ou clicando em Create breakpoint diretamente na trace stack:

O botão 'Create breakpoint' é mostrado perto da trace stack no console O botão 'Create breakpoint' é mostrado perto da trace stack no console

Em vez de direcionar uma linha específica, este tipo de breakpoint suspende a aplicação logo antes de uma exceção ser lançada. Atualmente, estamos interessados em NumberFormatException .

Uma linha destacada indica que o breakpoint funcionou e o programa foi suspenso Uma linha destacada indica que o breakpoint funcionou e o programa foi suspenso

Então, suspendemos a aplicação quando ela estava prestes a lançar a exceção. O erro já ocorreu, mas o aplicativo ainda não travou. Estamos chegando a tempo, então vamos ver o que isso nos dá.

Na guia Threads, vá para o frame parse() :

Selecionando o frame 'parse()' na guia 'Threads' do depurador Selecionando o frame 'parse()' na guia 'Threads' do depurador

Veja quanta mais informação temos agora: vemos o nome do arquivo onde o erro acontece, todo o conteúdo do arquivo, todas as variáveis próximas, e, é claro, a propriedade que está causando o erro. Fica evidente que o problema é no arquivo chamado 816.json porque falta a propriedade elevationFt

Agora podemos prosseguir para corrigir o problema. Dependendo do nosso caso de uso, podemos apenas corrigir os dados, ou corrigir a maneira como o programa lida com o erro. Corrigir os dados é simples, então vamos ver como o depurador pode nos ajudar com o tratamento de erros.

Protótipo da correção

Evaluate expression é uma ótima ferramenta para criar protótipos de mudanças, incluindo correções para seu código existente. Exatamente o que estamos procurando. Selecione todo o método parse() e vá para Run | Debugging Actions | Evaluate Expression.

Tip icon

Se você selecionar o código antes de abrir o diálogo Evaluate, o snippet selecionado é copiado, então você não precisa digitá-lo manualmente.

Quando avaliamos o código do método, ele lança uma exceção, assim como fez no código do programa:

Avaliando o mesmo código que falha no diálogo 'Evaluate' também resulta em NumberFormatException Avaliando o mesmo código que falha no diálogo 'Evaluate' também resulta em NumberFormatException

Vamos alterar o código um pouco, fazendo com que ele armazene um null quando encontrar dados ausentes. Além disso, vamos adicionar uma instrução de impressão para que o erro seja exibido no fluxo de erro padrão:

try {
    JsonObject root = gson.fromJson(Files.readString(path), JsonObject.class);
    String name =           root.get("name").getAsString();
    String isoCountry =     root.get("isoCountry").getAsString();
    String iataCode =       root.get("iataCode").getAsString();
    String longitudeDeg =   root.get("longitudeDeg").getAsString();
    String latitudeDeg =    root.get("latitudeDeg").getAsString();
    String municipality =   root.get("municipality").getAsString();
    String isoRegion =      root.get("isoRegion").getAsString();
    Integer elevationFt;
    try {
        elevationFt = Integer.parseInt(root.get("elevationFt").getAsString());
    } catch (NumberFormatException e) {
        elevationFt = null;
        System.err.println("Failed to parse elevation for file: " + path);
    }
    return new Airport(name, isoCountry, iataCode, longitudeDeg, latitudeDeg, elevationFt, municipality, isoRegion);
} catch (IOException e) {
    throw new RuntimeException(e);
}

Ao executar o código ajustado no diálogo Evaluate, podemos verificar que esse código realmente lida com o erro conforme o esperado. Agora ele define o campo como null em vez de travar o aplicativo:

Depois de clicar em 'Evaluate' para o código corrigido, o resultado mostra um valor de retorno válido Depois de clicar em 'Evaluate' para o código corrigido, o resultado mostra um valor de retorno válido

Não só podemos testar que o método retorna sem lançar a exceção, mas também ver como será o texto de erro no console:

O console diz 'Falha ao analisar a elevação para o arquivo: ./data/816.json' O console diz 'Falha ao analisar a elevação para o arquivo: ./data/816.json'

Reset frame

Ok, corrigimos o código para que o erro não ocorra no futuro, mas podemos desfazer o erro em si? Na verdade, podemos! O depurador do IntelliJ IDEA nos permite desempilhar o frame defeituoso da pilha e executar o método desde o início.

Clique em Reset Frame na guia Threads:

Apontando para o ícone 'Reset Frame' no frame 'parse()' na guia 'Threads' Apontando para o ícone 'Reset Frame' no frame 'parse()' na guia 'Threads'
Info icon

Reset Frame apenas reverter o estado interno de um frame. No nosso caso, o método é puro, o que significa que ele não muda nada fora do seu escopo local, então isso não é um problema. Caso contrário, se as mudanças forem feitas para o estado global do aplicativo, essas não serão revertidas. Lembre-se disso ao usar recursos como Evaluate e Reset Frame

Corrigir e recarregar em tempo real

Depois de descartar o erro e fingir que ele nunca aconteceu, também podemos entregar a correção para o tempo de execução. Desde o ponto de execução está atualmente fora do método que queremos mudar, e não mudamos a assinatura do método, podemos usar a opção Recarregar classes alteradas, que eu já abordei em um dos posts anteriores.

Primeiro, copie o código corrigido do diálogo Evaluate para o editor. Você pode encontrar o código avaliado anteriormente navegando pelo histórico (⌥↓ / Alt↓). Depois de substituir o código na classe Airports , você pode carregá-lo para a JVM em execução. Para isso, selecione Run | Debugging Actions | Reload Changed Classes.

Um balão aparece dizendo que as classes foram recarregadas Um balão aparece dizendo que as classes foram recarregadas

Um balão aparece confirmando que a correção chegou a aplicação em execução.

Tip icon

Se você estiver usando a versão atual do EAP do IntelliJ IDEA (2024.3 EAP1), tente o novo botão de recarga em tempo real, que aparece bem no editor:

Um popup aparece no canto superior direito do editor solicitando a recarga dos arquivos Um popup aparece no canto superior direito do editor solicitando a recarga dos arquivos

Filtro de exceção não capturada

Se retomarmos o aplicativo agora, ele será novamente suspenso na mesma exceção. Por que isso está acontecendo?

Nós modificamos o código para capturar NumberFormatException . Essa correção evita que o aplicativo trave, mas não impede que a exceção seja lançada. Então, o breakpoint ainda dispara cada vez que a exceção é levantada, mesmo que ela eventualmente seja capturada.

Vamos dizer ao IntelliJ IDEA que queremos suspender a aplicação apenas quando ocorrer uma exceção não capturada. Para isso, clique com o botão direito no breakpoint na calha e desmarque a caixa Caught Exception:

Caixa 'Caught Exception' desmarcada no diálogo 'Exception breakpoint' que abriu ao clicar no ícone de breakpoint na calha Caixa 'Caught Exception' desmarcada no diálogo 'Exception breakpoint' que abriu ao clicar no ícone de breakpoint na calha
Tip icon

O ícone do breakpoint de exceção só aparece na calha quando a aplicação é suspensa em uma exceção. Alternativamente, você pode configurar breakpoints através de Run | View Breakpoints.

Com essa configuração, a aplicação será executada sem interrupções a menos que ocorra um NumberFormatException não tratado.

Retome e aproveite

Agora podemos retomar a aplicação:

Apontando para o botão 'Resume Program' na barra de ferramentas do depurador Apontando para o botão 'Resume Program' na barra de ferramentas do depurador

Ela é executada perfeitamente, nos notificando sobre os dados ausentes no console. Observe como o arquivo 816.json está na lista de erros, confirmando que processamos de fato esse arquivo, não apenas o pulamos.

O console mostra a saída da aplicação junto com a lista de erros O console mostra a saída da aplicação junto com a lista de erros

Na verdade, há duas entradas para 816.json - uma de nossa experiência de avaliação de expressão, e a outra colocada lá pelo próprio programa, depois que o corrigimos.

Resumo

Neste post, aprendemos como depurar eficientemente as exceções do Java reunindo contexto adicional que apontou a causa da falha. Em seguida, usando as capacidades do depurador, conseguimos desfazer o erro, transformar a sessão de depuração em uma sandbox para testar uma solução, e entregar a correção ao tempo de execução bem no meio da falha, tudo isso sem reiniciar a aplicação.

Essas técnicas, embora básicas, podem ser muito poderosas e economizar muito tempo em determinadas situações. Nos próximos posts, discutiremos mais dicas e truques de depuração, fique atento!

all posts ->