Depuración eficiente de excepciones

Otros idiomas: English 한국어 Português 中文

En tu viaje con Java, uno de los primeros conceptos que se aprende es excepciones. Este término define un escenario inesperado durante la ejecución de un programa, como una falla en la red o un fin de archivo inesperado. O, como lo explica la documentación de Oracle:

La clase Exception y sus subclases son una forma de Throwable que indica condiciones que una aplicación razonable podría querer atrapar.

Si tu programa está equipado con un “plan b” para manejar eficazmente estas situaciones, continuará operando sin problemas independientemente de si alguna de ellas ocurre. De lo contrario, el programa puede bloquearse inesperadamente o terminar en un estado incorrecto.

Cuando un programa falla debido a una excepción, tendrás que depurarlo. Los lenguajes de programación facilitan la depuración de errores relacionados con excepciones proporcionando un rastreo de pila – un mensaje especial que señala el camino del código que ha llevado al fallo. Esta información es increíblemente útil, y a veces suficiente; sin embargo, hay casos en los que podremos beneficiarnos de detalles y técnicas adicionales.

En este artículo, vamos a analizar un caso de estudio, enfocándonos en depurar una excepción que ocurre durante el análisis de JSON. Al hacerlo, iremos más allá de solo mirar los rastreos de pila y descubriremos los beneficios de usar el depurador.

Ejemplo de aplicación

La aplicación de ejemplo para este caso de uso será un pequeño programa de Java que analiza un conjunto de archivos JSON que contienen datos sobre aeropuertos en todo el mundo. Los archivos incluyen detalles, como el código IATA de los aeropuertos, país, latitud y longitud. Aquí tienes un ejemplo de una 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"
}

El programa es bastante sencillo. Itera sobre un conjunto de archivos, lee y analiza cada uno, filtra los objetos de aeropuerto contra restricciones de ingreso, como "country=AR", luego imprime la lista de aeropuertos coincidentes:

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) { }
}

El problema

Cuando ejecutamos el programa, falla con un NumberFormatException . Aquí está el rastreo de pila que obtenemos:

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)

Está apuntando a la línea 53 del archivo Airports.java . Al observar esta línea, podemos decir que hay un problema en la conversión de la propiedad elevationFt a un número para uno de los aeropuertos. El mensaje de la excepción nos dice que esto se debe a que esta propiedad está en blanco en el archivo correspondiente.

¿No es suficiente la traza de pila?

Si bien la traza de pila anterior ya nos da mucha información, tiene varias limitaciones. Apunta a las líneas de código que están fallando, pero ¿nos dice qué archivo de datos es la causa? El nombre del archivo sería útil si queremos inspeccionarlo más de cerca o corregir los datos. Lamentablemente, los detalles de tiempo de ejecución como estos se pierden cuando la aplicación se bloquea.

Sin embargo, existe una forma de suspender la aplicación antes de que haya terminado, lo cual te permite acceder a un contexto que no se capta en las trazas de pila y los logs. Además, con una aplicación suspendida, puedes prototipar, probar, y hasta cargar la solución mientras la aplicación está en ejecución.

Suspender en una excepción

Para comenzar, vamos a establecer un punto de interrupción de excepción. Puedes hacer eso ya sea en el diálogo de puntos de interrupción o haciendo clic en Create breakpoint directamente en la traza de pila:

El botón 'Create breakpoint' se muestra cerca de la traza de pila en la consola El botón 'Create breakpoint' se muestra cerca de la traza de pila en la consola

En lugar de dirigirse a una línea específica, este tipo de punto de interrupción suspende la aplicación justo antes de lanzar una excepción. Actualmente, estamos interesados en NumberFormatException .

Una línea resaltada indica que el punto de interrupción funcionó y el programa ha sido suspendido Una línea resaltada indica que el punto de interrupción funcionó y el programa ha sido suspendido

Entonces, suspendimos la aplicación cuando iba a lanzar la excepción. El error ya ha ocurrido, pero la aplicación aún no se ha bloqueado. Llegamos justo a tiempo, así que veamos qué nos ofrece esto.

En la pestaña Threads, ve al marco de parse() :

Seleccionando el marco 'parse()' en la pestaña 'Threads' del depurador Seleccionando el marco 'parse()' en la pestaña 'Threads' del depurador

Fíjate en cuánta más información tenemos ahora: vemos el nombre del archivo donde ocurre el error, el contenido completo del archivo, todas las variables cercanas, y, por supuesto, la propiedad que está causando el error. Se hace evidente que el problema está en el archivo llamado 816.json porque le falta la propiedad elevationFt.

Ahora podemos proceder a solucionar el problema. Dependiendo de nuestro caso de uso, podríamos querer simplemente corregir los datos, o corregir la manera en que el programa maneja el error. Corregir los datos es sencillo, así que veamos cómo el depurador puede ayudarnos con el manejo de errores.

Prototipar la solución

Evaluate expression es una excelente herramienta para prototipar cambios, incluyendo soluciones a tu código existente. Exactamente lo que estamos buscando. Selecciona todo el cuerpo del método parse() y ve a Run | Debugging Actions | Evaluate Expression.

Tip icon

Si seleccionas el código antes de abrir el diálogo Evaluate, el fragmento seleccionado se copiará, por lo que no tendrás que introducirlo manualmente.

Cuando evaluamos el código del método, lanza una excepción, tal como lo hizo en el código del programa:

Al evaluar el mismo código fallido en el diálogo 'Evaluate', también resulta en NumberFormatException Al evaluar el mismo código fallido en el diálogo 'Evaluate', también resulta en NumberFormatException

Vamos a cambiar un poco el código haciéndolo almacenar un null cuando encuentre datos faltantes. Además, vamos a agregar una declaración de impresión para mostrar el error en el flujo de error estándar:

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);
}

Ejecutando el código ajustado en el diálogo de Evaluate, podemos verificar que este código realmente maneja el error como se esperaba. Ahora establece el campo en null en lugar de bloquear la aplicación:

Después de hacer clic en 'Evaluate' para el código corregido, el resultado muestra un valor de retorno válido Después de hacer clic en 'Evaluate' para el código corregido, el resultado muestra un valor de retorno válido

No solo podemos probar que el método devuelve sin lanzar la excepción, sino también ver cómo el texto de error se verá en la consola:

La consola dice 'No se pudo analizar la elevación para el archivo: ./data/816.json' La consola dice 'No se pudo analizar la elevación para el archivo: ./data/816.json'

Restablecer marco

Ok, corregimos el código para que el error no ocurra en el futuro, pero ¿podemos deshacer el mismo error? ¡De hecho, podemos! El depurador de IntelliJ IDEA nos permite quitar el marco defectuoso de la pila y ejecutar el método desde cero.

Haz clic en Reset Frame en la pestaña Threads:

Apuntando al botón 'Reset frame' en el marco 'parse()' de la pestaña 'Threads' Apuntando al botón 'Reset frame' en el marco 'parse()' de la pestaña 'Threads'
Info icon

Reset Frame solo retrocede el estado interno de un marco. En nuestro caso, el método es puro, lo que significa que no cambia nada fuera de su alcance local, por lo que esto no es un problema. De lo contrario, si se realizan cambios en el estado global de la aplicación, estos no se revertirán. Ten esto en cuenta al usar funciones como Evaluate y Reset Frame

Corregir y hot-reload

Después de descartar el error y hacer como si nunca hubiera ocurrido, también podemos entregar la corrección al tiempo de ejecución. Dado que el punto de ejecución se encuentra actualmente fuera del método que queremos cambiar, y no cambiamos la firma del método, podemos usar la opción Reload Changed Classes, que ya cubrí en uno de los posts anteriores.

Primero, copia el código corregido del diálogo Evaluate al editor. Puedes encontrar el código previamente evaluado navegando por el historial (⌥↓ / Alt↓). Después de reemplazar el código en la clase Airports , puedes cargarlo al JVM en ejecución. Para esto, selecciona Run | Debugging Actions | Reload Changed Classes.

Aparece un globo diciendo que las clases se recargaron Aparece un globo diciendo que las clases se recargaron

Aparece un globo confirmando que la corrección ha llegado al aplicación en ejecución.

Tip icon

Si estás usando la versión actual EAP de IntelliJ IDEA (2024.3 EAP1), prueba el nuevo botón de recarga en caliente, que aparece directamente en el editor:

Aparece un popup en la esquina superior derecha del editor preguntando si deseas recargar los archivos Aparece un popup en la esquina superior derecha del editor preguntando si deseas recargar los archivos

Filtro de excepción no detectada

Si reanudamos la aplicación ahora, se suspenderá nuevamente en la misma excepción. ¿Por qué está pasando esto?

Modificamos el código para capturar NumberFormatException . Esta corrección evita bloqueos de la aplicación, pero no evita que se lance la excepción. Entonces, el punto de interrupción aún se dispara cada vez que se produce la excepción, aunque eventualmente se capture.

Digamos que IntelliJ IDEA solo queremos suspender la aplicación cuando se produzca una excepción no capturada. Para esto, haz clic con el botón derecho en el punto de interrupción en la regla y desmarca la casilla Caught exception:

Casilla 'Caught exception' sin marcar en el diálogo 'Exception breakpoint' que se abrió al hacer clic en el icono de punto de interrupción en la regla Casilla 'Caught exception' sin marcar en el diálogo 'Exception breakpoint' que se abrió al hacer clic en el icono de punto de interrupción en la regla
Tip icon

El icono de punto de interrupción de excepción solo aparece en la regla cuando la aplicación está suspendida en una excepción. Alternativamente, puedes configurar los puntos de interrupción a través de Run | View Breakpoints.

Con esta configuración, la aplicación se ejecutará sin interrupciones a menos que ocurra un NumberFormatException no manejado.

Reanudar y disfrutar

Ahora podemos reanudar la aplicación:

Señalando el botón 'Reanudar Programa' en la barra de herramientas del depurador Señalando el botón 'Reanudar Programa' en la barra de herramientas del depurador

Funciona perfectamente, notificándonos sobre los datos faltantes en la consola. Observa cómo el archivo 816.json está en la lista de errores, confirmando que realmente procesamos este archivo, no sólo lo saltamos.

La consola muestra la salida de la aplicación junto con la lista de errores La consola muestra la salida de la aplicación junto con la lista de errores

De hecho, hay dos entradas para 816.json - una de nuestro experimento de evaluación de expresiones, y la otra colocada allí por el programa en sí, después de que lo corregimos.

Resumen

En esta publicación, aprendimos cómo depurar eficazmente las excepciones de Java recopilando contexto adicional que apuntaba a la causa del fallo. Luego, utilizando las capacidades del depurador, pudimos deshacer el error, convertir la sesión de depuración en un entorno aislado para probar una solución, y entregar la corrección al tiempo de ejecución justo en medio del fallo, todo sin reiniciar la aplicación.

Estas sencillas técnicas pueden ser muy poderosas y ahorrarte mucho tiempo en ciertas situaciones. En las próximas publicaciones, discutiremos más consejos y trucos de depuración, ¡así que mantente atento!

all posts ->