martes, enero 14, 2014

Desmitificando el control de errores con Try/Catch

Por: Fernando D. Bozzo

He visto en muchas ocasiones que hay como cierto miedo a tratar los errores, como si fuera algo complejo, difícil de controlar o directamente como si los errores "no fueran a ocurrir", que es peor, porque no se controlan y se confía ciegamente en la suerte.

En esta entrada voy a presentar una forma de trabajar con el Try/Catch que cumple dos objetivos distintos con una misma rutina:
  1. Poder relanzar el error para que lo trate un nivel superior
  2. Poder tratar el error localmente sin relanzarlo
Y lo mejor de todo es que este comportamiento se puede elegir "desde fuera" del procedimiento, antes de llamarlo, de modo que un mismo procedimiento se puede usar de las dos formas, dependiendo de cómo se utilice.

¿Pero qué sentido tiene poder hacer esto? ¿Para qué puede servir llamarlo de una forma u otra?


Imaginemos dos situaciones distintas:

Situación 1: Tenemos una rutina en la que si ocurre un error, queremos mostrarlo para que el usuario esté notificado y nada más

Situación 2: Tenemos un método de negocio (procedimiento sin interfaz ni mensajes que se muestren al usuario que realiza alguna operación), donde de ocurrir un error, y al no poder mostrarlo, la única salida es reenviarlo hacia el nivel anterior para que sea tratado en el origen, por ejemplo, el botón de comando que originó todo, o un programa externo.

Normalmente esto requiere dos tratamientos distintos, pero hay una solución muy simple que permite utilizar una u otra de acuerdo a lo que nos convenga, que es la siguiente:

PROCEDURE Proc_X( toEx as Exception, tlRelanzarError, otros_params )
   TRY
      toEx = NULL
      * Aquí va el proceso
 

   CATCH TO toEx
      IF tlRelanzarError
         THROW
      ENDIF

   FINALLY
      * Este IF/MESSAGE/ENDIF sólo debe ir si este es un método de Interfaz!

      IF NOT ISNULL(toEx) AND NOT tlRelanzarError
         MESSAGEBOX( "Error " + TRANSFORM(toEx.ErrorNo) + ", " + toEx.Message )
      ENDIF

      * Recolección de basura
   ENDTRY
ENDPROC




En azul y amarillo están resaltados los dos parámetros que permiten esto. Por un lado, toEx guarda la información del error y por el otro tlRelanzarError indica si se desea, o no, relanzar el error al nivel superior. No hay más! Y así de simple como se ve, es sumamente versátil.

Veamos algunos ejemplos en la práctica.


Ejemplo 1: Tratamiento de errores local

En este caso se quiere controlar el error y mostrar un mensaje al usuario. Los métodos de este tipo suelen ser de la interfaz visual, y como se ve, es parecido al ejemplo original, pero más recortado y sin la parte de relanzar:


PROCEDURE Form1.cmdEjecutar.CLICK
   TRY
      LOCAL loEx as Esception
      loEx = NULL
      * Aquí va el proceso
   CATCH TO loEx

   FINALLY
      IF NOT ISNULL(loEx)
         MESSAGEBOX( "Error " + TRANSFORM(loEx.ErrorNo) + ", " + loEx.Message )
      ENDIF

      * Recolección de basura
   ENDTRY
ENDPROC




Dentro de este mismo tipo de tratamiento tenemos otro caso, una actualización que solo actualiza un campo TIMESTAMP que se llama con un ON KEY LABEL F2 y que, por llamado con una tecla ON KEY, no permite otro tratamiento de errores que no sea local:

ON KEY LABEL F2 DO Actualiza WITH "", .F., 0

PROCEDURE Actualiza( toEx as Exception, tlRelanzarError, tnCount )
   TRY
      LOCAL lnCodError
      lnCodError = 0 
      toEx = NULL 
      UPDATE USUARIO SET TIMESTAMP = DATETIME() WHERE USUA_PC = SYS(0)
      tnCount = _TALLY

   CATCH TO toEx
      IF tlRelanzarError
         THROW
      ENDIF
      lnCodError = toEx.ErrorNo

   FINALLY
      IF NOT ISNULL(toEx) AND NOT tlRelanzarError
         MESSAGEBOX( "Error " + TRANSFORM(toEx.ErrorNo) + ", " + toEx.Message )
      ENDIF

      USE IN (SELECT("USUARIO"))
   ENDTRY
   RETURN lnCodError
ENDPROC



Como se ve, desde el DO Actualiza se está indicando .F. para tlRelanzarError, por lo que en el FINALLY, si ocurrió un error, se mostrará el mensaje con sus datos. Además en el tercer parámetro se pasa 0 porque no se podrá leer su valor.




Ejemplo 2: Tratamiento de errores en nivel superior

En este caso vamos a usar los ejemplos anteriores, pero encadenados de tal forma que el segundo (la actualización) cambia su forma de trabajar para devolver el error y dejar que que sea el nivel superior quien muestre los datos del mismo:


PROCEDURE Form1.cmdEjecutar.CLICK
   TRY
      LOCAL loEx as Esception, lnCount
      loEx = NULL
      DO Actualiza WITH "", .T., lnCount
      IF lnCount = 0
         MESSAGEBOX( "No se actualizaron registros!" )
      ENDIF

   CATCH TO loEx

   FINALLY
      IF NOT ISNULL(loEx)
         MESSAGEBOX( "Error " + TRANSFORM(loEx.ErrorNo) + ", " + loEx.Message )
      ENDIF

      * Recolección de basura
   ENDTRY
ENDPROC


PROCEDURE Actualiza( toEx as Exception, tlRelanzarError, tnCount )
   TRY
      LOCAL lnCodError
      lnCodError = 0 
      toEx = NULL 
      UPDATE USUARIO SET TIMESTAMP = DATETIME() WHERE USUA_PC = SYS(0)
      tnCount = _TALLY

   CATCH TO toEx
      IF tlRelanzarError
         THROW
      ENDIF
      lnCodError = toEx.ErrorNo

   FINALLY
      IF NOT ISNULL(toEx) AND NOT tlRelanzarError
         MESSAGEBOX( "Error " + TRANSFORM(toEx.ErrorNo) + ", " + toEx.Message )
      ENDIF

      USE IN (SELECT("USUARIO"))
   ENDTRY
   RETURN lnCodError
ENDPROC



En este caso, si ocurriera un error en la rutina "Actualiza" se relanza el error (THROW) y se haría la recolección de basura (cerrar la tabla, limpiar vars, etc) tanto haya error o no.
El Throw salta hasta el CATCH del nivel anterior, que es el cmdEjecutar.Click() y recién ahí es donde se mostraría.

Así es como la misma rutina "Actualiza" se puede reutilizar controlando el error de dos formas distintas.


Control de errores compacto


Habiendo visto los casos de uso anteriores surgen varias ideas y optimizaciones para casos más concretos, por ejemplo, si sabemos de antemano que un método jamás mostrará un error porque es de negocio o para un proceso batch, entonces la estructura podría cambiar a esto:

PROCEDURE Actualiza( toEx as Exception, tlRelanzarError, tnCount )
   TRY
      LOCAL lnCodError
      lnCodError = 0 
      toEx = NULL 
      UPDATE USUARIO SET TIMESTAMP = DATETIME() WHERE USUA_PC = SYS(0)
      tnCount = _TALLY

   CATCH TO toEx
      IF tlRelanzarError
         THROW
      ENDIF
      lnCodError = toEx.ErrorNo

   FINALLY
      USE IN (SELECT("USUARIO")) 

   ENDTRY

   RETURN lnCodError
ENDPROC




Lo que sigue dejando 2 opciones:
  1. Llamar al método Actualiza relanzando el error, que lo redirigirá al Catch del nivel superior
  2. Llamarlo sin relanzar el error para evaluarlo localmente, como el siguiente caso

DO Actualiza WITH loEx, .F., lnCount
IF NOT ISNULL(loEx)
   *-- Ocurrió un error, hacer algo
ELSE
   *-- Ejecución OK
ENDIF




Achicando aún más


Finalmente hay una forma aún más compacta, asumiendo que solo se quiere relanzar el error y nunca se tratará como el caso anterior, sino directamente en el Catch del nivel superior que corresponda:

PROCEDURE Actualiza( tnCount )
   TRY
      LOCAL loEx as Exception
      lnCodError = 0 
      loEx = NULL 
      UPDATE USUARIO SET TIMESTAMP = DATETIME() WHERE USUA_PC = SYS(0)
      tnCount = _TALLY

   CATCH TO loEx
      THROW
   FINALLY
      USE IN (SELECT("USUARIO"))   ENDTRY
   RETURN
ENDPROC







Ayuda para Depurar en el control de errores


Esto es algo que no suele tenerse en cuenta, y que debería, ya que permite encontrar los errores mucho antes.
Cuando ocurre un error y se relanza la excepción hacia los niveles superiores, se pierde la información del entorno donde ocurrió (tablas abiertas, variables creadas, etc), ya que cada nivel fue cerrando y limpiando sus cosas, entonces se hace necesario una forma rápida que permita poder depurar "in-situ" cuando se está ejecutando en modo Desarrollo. Esta es una forma de hacerlo:

PROCEDURE Actualiza( tnCount )
   TRY
      LOCAL loEx as Exception
      lnCodError = 0 
      loEx = NULL 
      UPDATE USUARIO SET TIMESTAMP = DATETIME() WHERE USUA_PC = SYS(0)
      tnCount = _TALLY

   CATCH TO loEx
      IF _VFP.StartMode = 0 THEN
         SET STEP ON
      ENDIF
      THROW
 

   FINALLY
      USE IN (SELECT("USUARIO")) 

   ENDTRY

   RETURN
ENDPROC





Es así de simple y muy efectivo. Incluso se podría encapsular ese bloque en una función externa reutilizable, que también se encargue de generar un Log.


Una cosa importante sobre THROW:

No es lo mismo esto:

THROW


que esto:

THROW loEx



El primero relanzará el error tal cual, y se podrán seguir leyendo sus datos en loEx en cada Catch por el que pase, mientras que el segundo está relanzando específicamente un objeto Exception que al Catch de nivel superior le llegará en la propiedad UserValue, lo que lo hace más difícil de tratar si no se sabe esto previamente.


Información de error complementaria muy útil

Hay una propiedad del objeto Exception que suele pasarse por alto y que resulta ser muy útil para agregar información específica, que es UserValue. Obviamente que para aprovecharla se deben relanzar los errores con Throw solamente, o de otro modo se pisará su valor con una Excepción.

Lo que le da un valor especial es que al ser una propiedad "para el usuario" podremos poner allí lo que querramos, tanto un valor como un objeto como un XML, lo que sea, y esto permite agregar más información específica del sitio donde ocurrió el error.

Por ejemplo, en el procedimiento AnalizarBloque_CDATA_inline() de FoxBin2Prg encontramos esto:

CATCH TO loEx
    IF loEx.ErrorNo = 1470    && Incorrect property name.
        loEx.UserValue    = 'PropName=[' + TRANSFORM(tcPropName) + '], Value=[' ;

           + TRANSFORM(lcValue) + ']'
    ENDIF



En este caso concreto me resulta muy importante adjuntar al objeto del error el nombre y el valor de la propiedad que puede causarlo, ya que si no, no saldría reflejado en ningún otro sitio o a lo sumo saldría solo el nombre de la propiedad.

Luego, en el nivel superior, junto la información de esta forma para mostrarla o loguearla a un archivo:

lcError = 'Error ' + TRANSFORM(toEx.ERRORNO) + ', ' + toEx.MESSAGE + CR_LF ;
    + toEx.Procedure + ', ' + TRANSFORM(toEx.LineNo) + CR_LF ;

    + toEx.LineContents + CR_LF + CR_LF ; 
    + toEx.StackLevel + CR_LF + CR_LF ; 
    + toEx.Detailt + CR_LF + CR_LF ;
    + EVL(toEx.UserValue,'')



En el método escribirArchivoBin() de la clase c_conversor_prg_a_dbf, guardo datos sobre el índice, el campo y la sentencia de creación de la tabla, ya que se intentará crear en ese método, pero si falla necesito saber qué ocurrió:

loEx.UserValue = 'lcIndex="' + TRANSFORM(lcIndex) + '"' + CR_LF ;
    + 'lcFieldDef="' + TRANSFORM(lcFieldDef) + '"' + CR_LF ;
    + 'lcCreateTable="' + TRANSFORM(lcCreateTable) + '"'




Para cada caso se debe contemplar adjuntar información específica para que ubicar el error, en caso de ocurrir, se haga lo más rápido posible.


Conclusión

Vimos algunas técnicas para controlar los errores, que es una de las cosas más importantes en cualquier aplicación, ya que de ello depende que aparezca un error descontrolado y que el usuario decida si "Aceptar, Cancelar o Ignorar" (lo peor que puede pasar, sobre todo si "Ignora"), o que el sistema tenga el flujo de errores definido desde el método más profundo para reenviarlo hasta llegar la interfaz o al programa externo.

FoxBin2Prg, por ejemplo, usa el control compacto de errores, ya que no me interesa mostrar mensajes en cualquier momento, e incluso se puede configurar para no mostrar ningún mensaje y opcionalmente generar un log.

La recolección de basura también es muy importante, ya que no hacerlo de forma correcta puede llevar a un sistema inestable o que a la larga genere el temido error C0000005

Hasta la próxima!


Relacionados:
Técnicas de programación en VFP: Procesos desatendidos y control de progreso visual

4 comentarios:

  1. Muy buenos ejemplos y explicación del Try / Catch y sobre todo como evitar el famoso error C000005!!!
    Saludos!

    ResponderEliminar
  2. Como Capturo el nombre del progama que genero el error

    ResponderEliminar
    Respuestas
    1. Si usas try/catch, en la propiedad PROCEDURE está el nombre del procedimiento que provocó el error, y LineNo te da el número de línea.

      Eliminar