jueves, mayo 14, 2015

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

Por: Fernando D. Bozzo

Este es un artículo de una serie que se enfocará en técnicas de programación y optimización en distintas áreas.

Un buen desarrollador asimilará estas técnicas como parte de su forma de trabajo habitual, independientemente de que haga un sistema, ún módulo, una rutina o un programa de pruebas personales, ya que le permitirá programar siempre orientado a la eficiencia, la velocidad de ejecución, la encapsulación, la reutilización y la legibilidad y mantenibilidad del código, o sea, las buenas prácticas.

Al tener en cuenta estas técnicas en cada parte del código y en cada rutina, al final lo que se logra es que el sistema completo esté más optimizado porque sus partes lo están.



   Cuando hablamos de procesos desatendidos, hablamos de procesos que no deben bloquearse esperando la entrada del usuario, son procesos que deben realizar una tarea sin ningún tipo de interacción ni interrupción y que deben controlar tanto la finalización exitosa como la aparición de errores, y en ambos casos no debe haber ningún tipo de mensaje "modal" como un messagbox o form que pida datos de ningún tipo mientras se está ejecutando.

Para esto, es necesario entender la separación de capas (visual, negocio, datos, etc) y el por qué de esa separación (pueden leer un artículo sobre el tema).

Lo primero que a cualquiera no adentrado en el uso de capas (o que las conoce pero es muy cómodo:) se le viene a la cabeza, es hacer un bonito formulario con un botón para procesar, uno para salir y luego, obviamente, un método para hacer el proceso... ¡mal! El proceso conviene que esté aparte, en una librería de clases por ejemplo, o en una librería de procedimientos, pero nunca en el formulario. Este proceso debe hacerse pensando en la reusabilidad, que pueda ser invocado tanto desde un formulario como desde el sistema operativo, si se quiere, o desde otro proceso o programa.



Creando el Proceso Desatendido


Crear un proceso desatendido es realmente simple, pero hay que tener en cuenta algunos pilares fundamentales y respetarlos:

1) Debe estar bien encapsulado: por eso lo ideal es hacerlo basado en una clase de tipo custom o session

2) Debe tener un buen control de errores: esto también es fácil, sobre todo con Try/Catch. Este artículo les explica cómo funciona.

3) No debe contener mensajes de ningún tipo: Ni messagebox, ni wait window, nada, solo el proceso

4) Debe generar una salida de información: Lo habitual es un LOG que luego se pueda consultar o incluso procesar por alguna herramienta para obtener información de estado, pero también debe devolver información de errores, de ocurrir alguno.

5) Opcionalmente puede generar información de avance del proceso

6) Es deseable --aunque no imprescindible-- que también tenga una forma de poder testear el control de errores

7) Opcionalmente puede devolver un código de error DOS para usar con programas externos


¿Parece difícil? Ya van a ver que no lo es para nada.


Primero debemos seleccionar el tipo de clase que vamos a usar: ¿custom o session?

La elección depende de a qué nivel queramos encapsular. La ventaja de la clase session es que está preparada especialmente para usar sesión privada de datos y para ser ligera. La "desventaja" es que no se pueden hacer usando el diseñador de clases, y deben hacerse por código, aunque esto tampoco es un problema.

Por otro lado, la clase custom tiene la ventaja de que se puede hacer usando el diseñador de clases y la desventaja de que es algo más pesada que la session y que no tiene sesión privada de datos, con lo que habrá que tener cuidado al abrir tablas y restaurar áreas de trabajo.

Si el proceso es lo suficientemente complejo como para requerir de más de una clase, una tercer alternativa es hacer una clase session principal que es con la que se interactuará luego, y que esta instancie a las demás clases de tipo custom, con lo que logramos tener una envoltura de sesión de datos privada para todas ellas, aunque entre las clases internas puedan compartir datos.



Veamos un ejemplo práctico simple de un proceso desatendido:

Cálculo que requiere recorrer una tabla y totalizar


Como es un ejemplo, la cantidad de registros no es importante aquí, y el proceso de los datos podrá ser tan complejo como ustedes necesiten, pero a efectos del ejemplo lo vamos a simplificar también.

La tabla la podemos crear con este código desde la ventana de comandos de VFP o con un PRG:

CREATE TABLE T_DATOS ( valor N(10,2) )
FOR I = 1 TO 100
   INSERT INTO T_DATOS (valor) VALUES (I)
ENDFOR
USE


Y la clase la podríamos hacer de esta forma, dentro de lib_proceso.prg, donde intercalaré algunos comentarios para explicar lo que hace y cómo funciona, aunque es muy simple, como puede verse:

*-- lib_Proceso.prg
LPARAMETERS toEx as Exception, tlRelanzarError, tnTotal
#DEFINE CR_LF CHR(13)+CHR(10)
LOCAL loProceso, lnResp
loProceso = CreateObject("c_proceso")
lnResp = loProceso.procesar(@toEx, tlRelanzarError, @tnTotal)
loProceso = NULL
RELEASE loProceso

IF _VFP.StartMode <> 4 OR NOT sys(16) == sys(16,0)
  RETURN lnResp
ENDIF

IF EMPTY(lnResp) && Salida sin error
  QUIT
ENDIF

DECLARE INTEGER OpenProcess IN Win32API INTEGER dwDesiredAccess ;

  , INTEGER bInheritHandle, INTEGER dwProcessID
lnHandle = OpenProcess(1, 1, _VFP.PROCESSID)
DECLARE INTEGER TerminateProcess IN Win32API INTEGER hProcess ;

  , INTEGER uExitCode
=TerminateProcess(lnHandle,1) && Salida con error DOS




La parte anterior es la cabecera del proceso, donde se define la variable del objeto del proceso loProceso y la de respuesta lnResp. En la misma se crea al objeto, que está basado en la clase c_proceso definida bajo este texto, se ejecuta el método Procesar y se guarda el resultado del mismo, que será un código de error, en lnResp.

Con StartMode se determina qué tipo de ejecución estamos haciendo, en este caso solo interesa saber si es desde EXE o APP o si este programa es el principal o no, y por eso se comparan los sys(16) y sys(16,0)

En la última parte de la cabecera, si no hay error simplemente se termina y si hay error (y no es modo desarrollo) se termina con ExitProcess(1), que devuelve ese código como retorno de aplicación, que puede ser interpretado desde otros programas externos o scripts.


La siguiente parte, dentro del mismo prg, define la clase c_proceso, basada en  Session, con 3 métodos: procesar, actualizarAvance y writeLog.

Procesar: Es el método principal, ya que lleva a cabo el proceso que nos interesa que sea desatendido. Genera un log al inicio y fin con algunos datos y normalmente también debería generar algún dato intermedio, que no agrego por mantener el ejemplo corto, usa la tabla con los recursos AGAIN, SHARED y ALIAS que nos permite abrirla más de una vez, tiene un método de testeo de errores sencillo, para comprobar cómo se gestionan, y luego el proceso en sí, que es el SCAN donde se va totalizando en una variable y con un retardo ficticio para que parezca un proceso pesado. Finalmente está la parte de captura del error, obtención de datos extra, generación de Log y recolección de basura, donde se cierra la tabla.


DEFINE CLASS c_proceso AS Session
  cLogFile    = 'log_proceso.txt'
  nTestError  = 0

  PROCEDURE procesar(toEx as Exception, tlRelanzarError, tnTotal)
    LOCAL lnCodError, lcMenErr

    TRY
      WITH THIS as c_Proceso OF lib_proceso.prg
        .writeLog( REPLICATE('-',80) )
        .writeLog( 'Inicio Proceso' )
        STORE 0 TO lnCodError, tnTotal
        USE T_DATOS IN 0 AGAIN SHARED ALIAS _T_DATOS
       
        IF THIS.nTestError = 1
            ERROR 'Error provocado (' + STR(.nTestError,2) + ')'
        ENDIF

        SCAN
           IF MOD(RECNO(),10)=0 THEN
              .actualizarAvance( 'Reg.', RECNO(), RECCOUNT() )
           ENDIF
           *-- El proceso real va aquí.
           tnTotal = tnTotal + valor
           INKEY(0.1,'H') && Simulo retardo proceso c/inkey()
        ENDSCAN

        .writeLog( 'Fin Proceso OK!  Total=' + STR(tnTotal) )
      ENDWITH

    CATCH TO toEx
      lnCodError = toEx.ErrorNo
      toEx.UserValue = 'algun dato importante para agregar'

      TEXT TO lcMenErr TEXTMERGE NOSHOW FLAGS 1 PRETEXT 1+2
         Error <<toEx.ErrorNo>>, <<toEx.Message>>
         Proc.<<toEx.Procedure>>, Line <<toEx.LineNo>>
         LineContents: <<toEx.LineContents>>
         Details: <<toEx.Details>>
         UserValue: <<toEx.UserValue>>
      ENDTEXT

      THIS.writeLog( lcMenErr )

      IF tlRelanzarError
        THROW
      ENDIF

    FINALLY
      USE IN (SELECT('_T_DATOS'))
    ENDTRY

    RETURN lnCodError
  ENDPROC


  PROCEDURE actualizarAvance(tcTexto, tnValor, tnTotal)
  ENDPROC


  PROCEDURE writeLog(tcTexto)
    STRTOFILE( tcTexto + CR_LF, THIS.cLogFile, 1 )
  ENDPROC


ENDDEFINE



actualizarAvance: Es un método vacío, sólo con parámetros, que sirve para ofrecer una posibilidad de monitorizar el proceso desde un programa externo, lo cuál veremos que puede ser muy útil. Observen como la llamada a actualizarAvance() está estratégicamente metida en el código para que no se ejecute por cada registro, sino cada cierta cantidad de registros. Esto es importante para evitar que cualquier monitarización externa afecte al rendimiento del proceso.

writeLog: Es la rutina centralizada para escribir el Log. Para el ejemplo opté por un log básico, sin sistema de buffering y escritura directo al disco, aunque esto en la realidad no debería ser así y es más conveniente agregarle un sistema de buffering, para evitar todas las escrituras intermedias al disco, como ya les comenté en este artículo.


Listo! Ya tenemos nuestro proceso terminado! :D



Primero vamos a probar que funciona desde la ventana de comandos de VFP:

oEx = null
nTot = 0
oo = NEWOBJECT("c_proceso", "lib_proceso.prg")
? oo.procesar(@oEx,,@nTot), oEx, nTot

0 .NULL.     5050


> Como vemos, luego de 5 segundos devuelve código de error 0, que es el retorno del método procesar, devuelve .NULL. para al objeto de errores oEx porque no hubo errores, y devuelve nTot=5050 como resultado de la sumatoria del proceso.


Ahora vamos a testear el manejo de errores:

oEx = null
nTot = 0
oo = NEWOBJECT("c_proceso", "lib_proceso.prg")
oo.nTESTERROR = 1
? oo.procesar(@oEx,,@nTot), oEx, nTot
1098 (object)   0


> En este caso devuelve el código de error 1098 como retorno del método procesar, devuelve (object) como valor de oEx y devuelve nTot = 0 porque no pudo hacer el cálculo (aunque si hubiera fallado en medio del cálculo podría haber devuelto un valor intermedio).

Finalmente comprobamos el mensaje del error devuelto:

? oEx.Message
Error provocado ( 1)


Como pudimos comprobar, el proceso funciona perfectamente y sin mostrar ninguna ventana ni mensaje, tal como debe comportarse un proceso desatendido.

Así como está, este proceso se puede utilizar dentro de un programa, o como proceso independiente en un EXE, o como proceso principal en un web-service, y obviamente también se puede llamar desde un form.

Primero vamos a probar nuestro proceso desde una ventana DOS del sistema operativo, para lo cual antes tenemos que generar un proyecto y un EXE de Fox:

BUILD PROJECT lib_proceso.pjx FROM lib_proceso.prg
BUILD EXE lib_proceso.exe FROM lib_proceso.pjx RECOMPILE



Ahora creamos el archivo test_proc.vbs y le ponemos este contenido:

Dim WSHShell, nExitCode, cEXETool
Set WSHShell = CreateObject( "WScript.Shell" )

cEXETool = Replace(WScript.ScriptFullName, WScript.ScriptName, "lib_proceso.exe")
nExitCode = WSHShell.run(cEXETool & " ""0"" ""0"" ""0"" ""0"" ", 0, True)
MsgBox "CodError = " & nExitCode , 0+4096, "Ejecución 1"

cEXETool = Replace(WScript.ScriptFullName, WScript.ScriptName, "lib_proceso.exe")
nExitCode = WSHShell.run(cEXETool & " ""0"" ""0"" ""0"" ""1"" ", 0, True)
MsgBox "CodError = " & nExitCode , 0+4096, "Ejecución 2"



Aquí estamos probando dos casos de prueba:

  • El primero ejecutará el proceso normal, que puede tardar hasta 10 segundos (esperen, no lo corten! :), y muestra un mensaje con el código de error 0 en un messagebox
  •  El segundo ejecutará el proceso simulando un error, por lo que será instantáneo y mostrará un mensaje con el código de error 1 en un messagebox

Con esto comprobamos que desde otro ejecutable o desde el Sistema Operativo, el proceso devuelve un código de error al estilo ERRORLEVEL de DOS.


Finalmente vamos a probar nuestro proceso desde un formulario de lanzamiento y monitorización, como el de esta imagen, donde pueden ver la situación inicial (el form cargado):



En esta captura está el proceso ejecutándose (va por el 60%) :



Y el fin del proceso con el total al terminar:




El código completo lo adjunto al final del artículo, pero vamos a ver la parte importante en detalle.

El botón que lanza el proceso se llama cmd_Procesar, y en su evento click tiene este código:

Local loEx As Exception, lnTot, lnRet, lcText ;
    , loProceso As c_proceso Of lib_proceso.prg

Try
    This.Enabled = .F.
    Thisform.chk_GenerarError.Enabled = .F.
    loEx = Null
    Store 0 To lnTot, lnRet
    loProceso = Newobject("c_proceso", "lib_proceso.prg")
    loProceso.nTestError = Thisform.chk_GenerarError.Value
    Bindevent( loProceso, 'ActualizarAvance', Thisform, 'ActualizarAvance' )
    lnRet = loProceso.procesar(.F.,.T.,@lnTot)
    lcText = Transform(lnTot)

Catch To loEx
    lcText    = "Error " + Transform(loEx.ErrorNo) + ', ' + loEx.Message

Finally
    Unbindevents( loProceso, 'ActualizarAvance', Thisform, 'ActualizarAvance' )
    Messagebox( lcText )
    This.Enabled = .T.
    Thisform.chk_GenerarError.Enabled = .T.
    Store Null To loProceso, loEx
Endtry



Lo más importante es el Bindevents() que se hace del método actualizarAvance() del proceso, y que se bindea a un método llamado igual en el form, donde está la implementación de la visualización, que solamente tiene este código:


LPARAMETERS tcTexto, tnValor, tnTotal

THISFORM.lbl_progreso.Caption = tcTexto + ' (' + ;

   TRANSFORM(tnValor) + '/' + TRANSFORM(tnTotal) + ')'


Con esto pueden comprobar cómo a un proceso desatendido se le puede acoplar una interfaz para controlarlo. Si quisiéramos comprobar el control de errores, solo debemos marcar el check correspondiente (puesto solo para pruebas) y elegir Procesar:




Y esta es una copia del log generado en log_proceso.txt:

--------------------------------------------------------------------------------
Inicio Proceso
Error 1098, Error provocado ( 1)
Proc.procesar, Line 39
LineContents: ERROR 'Error provocado (' + STR(.nTestError,2) + ')'
Details:
UserValue: algun dato importante para agregar
--------------------------------------------------------------------------------
Inicio Proceso
Fin Proceso OK!  Total=      5050



Pero esto deja abierta otra puerta, y es que podemos cambiar la implementación de la visualización del proceso según cómo lo queramos. Por ejemplo, podemos querer que para ver el avance del proceso se muestre una barra de progreso, que es mucho más visual y útil que un porcentaje, o incluso podemos combinar ambos para tener información más precisa:




Bueno, y con esto acabamos el ejemplo. Hemos conseguido complir todos los objetivos de un servicio desatendido comentados al inicio, tenemos la seguridad del control de errores sencillo pero eficiente, tenemos el log de todo lo ocurrido, podemos lanzar el proceso desde otros ejecutables o scripts y podemos acoplar interfaces para controlarlo.


Una cosa que muchos programadores pasan por alto, sin darse cuenta, es que en un sistema, muchos de los subprocesos que se ejecutan (un cálculo, una actualización) son realmente subprocesos desatendidos, sy solo la interfaz que los llama debería mostrar cualquier mensaje de error, de finalización o con resultados.

Vean vuestros programas y podrán darse cuenta de en muchos casos están mezclando mensajes de estado o messagebox en medio de un proceso. Como ven, hay mucho margen para mejorar eso.


Espero que les haya sido útil :)

Estos son los archivos del ejemplo


Hasta la próxima!

No hay comentarios:

Publicar un comentario