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:
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
.
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()
:
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.
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:
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:
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:
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:
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 confirmando que a correção chegou a aplicação em execução.
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:
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:
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:
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.
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!