Mostrando entradas con la etiqueta buenas prácticas. Mostrar todas las entradas
Mostrando entradas con la etiqueta buenas prácticas. Mostrar todas las entradas

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!

viernes, mayo 01, 2015

Técnicas de optimización en VFP: Tablas, archivos de texto, accesos al disco y buffering

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.



Una de las operaciones más costosas a nivel de recursos en una PC es la de accesos al disco, ya que el disco es probablemente el componente más lento del sistema. Aunque los discos SSD minimizan el problema, la solución real pasa por la programación, ya que tanto los discos rígidos y sobre todo las redes, son los principales cuellos de botella.

Muchas veces un programa o un sistema comienza teniendo un único usuario o unos pocos, pero puede que con el tiempo esa condición cambie, y que comiencen a haber más usuarios, incluso muchos usuarios, y es aquí cuando comienzan a verse realmente los problemas de las decisiones de diseño tomadas y donde ya es tarde para cambiarlo por el esfuerzo que puede requerir, implicando a veces un rediseño, por eso cada línea de código cuenta.



Optimizaciones en el uso de archivos y accesos a disco


Caso 1: Los LOGs


Normalmente, cuando se quiere hacer un log al disco de alguna información importante para nosotros o para su posterior explotación o análisis, se suele usar STRTOFILE() por comodidad o por falta de tiempo. He aquí algunos ejemplos típicos:


1.a) Ejemplo de un LOG dentro de un bucle que puede tener cualquier cantidad de registros:

SCAN FOR <condición>
  ...
  IF <condicion_que_requiere_loguear>
     STRTOFILE( 'alguna información importante', 'LOG.txt', 1 )
  ENDIF
ENDSCAN


1.b) Ejemplo de un LOG en un evento Timer que podría ejecutarse varias veces por segundo:

PROCEDURE TIMER
   ...
  IF <condicion_que_requiere_loguear>
     STRTOFILE( 'alguna información importante', 'LOG.txt', 1 )
  ENDIF
ENDPROC


1.c) Ejemplo de un LOG en un método de cálculo reutilizable, que podría llamarse cientos de veces desde distintas partes de un sistema:

PROCEDURE Sumar_Porcentaje
  ...
  IF <condicion_que_requiere_loguear>
     STRTOFILE( 'alguna información importante', 'LOG.txt', 1 )
  ENDIF
ENDPROC


1.d) Ejemplo de un LOG en un método de proceso largo, donde se realizan diversas operaciones y por cada una se va actualizando el LOG:

PROCEDURE Proceso_Largo
  <operación-1> 
  STRTOFILE( 'alguna información importante', 'LOG.txt', 1 )
  <operación-2> 
  STRTOFILE( 'alguna información importante', 'LOG.txt', 1 )
  ...
  <operación-n>
  ...
ENDPROC

En todos los casos, STRTOFILE() escribirá al disco para ir añadiendo información al LOG. Para unas pocas repeticiones esto puede no ser un problema, pero siempre hay que asumir un caso de uso desfavorable, en este caso intensivo.

Veamos qué ocurre cuando la repetición es de 100 iteraciones:
  • Para 1 usuario implicará 100 accesos a disco
  • Para 5 usuarios implicará 500 accesos a disco
  • Para 10 usuarios implicará 1000 accesos a disco

Puede verse que el impacto se multiplica rápidamente a medida que crece la cantidad de usuarios, lo que puede causar que el sistema de archivos se vaya poniendo cada vez más lento por la metralla de escrituras, pudiendo llegar incluso a no dar abasto en responder las peticiones y provocar el colapso del sistema.

Otro error común al escribir LOGs es que se suele elegir un nombre (ej: LOG.txt) y se olvida el hecho de que esto puede ser usado por varios usuarios concurrentes, lo que puede provocar la de contención de recursos porque dos ó más personas intentan usar el mismo archivo con el mismo nombre; también puede provocar errores, en el caso de que el LOG se use en exclusiva (con FOPEN/FWRITE) o también puede provocar pérdida de información de partes del LOG, ya que mientras un usuario está escribiendo en el LOG, otro no puede hacerlo y STRTOFILE() simplemente no escribe y tampoco generará un error por ello (STRTOFILE solo devuelve la cantidad de bytes escritos, 0 en caso de no poder escribir, pero nadie suele verificar esto)



Soluciones


Para resolver el problema de los usuarios concurrentes, se puede usar un LOG distinto por usuario, que puede tener una numenclatura parecida a "LOG_UsuarioDeRed.txt", o una más completa como "LOG_UsuarioDeRed_NombreMaquina.txt". Si se quiere un historial de LOGs diarios, se podría agregar la fecha al nombre del LOG. Como se ve, variantes hay para todos los gustos.

Para minimizar el impacto de estas escrituras se suele usar la estrategia del buffering (o caché), que implica ir guardando la información que se quiere escribir en una variable de memoria o una propiedad y cada tanto realizar la escritura de todo lo guardado. En este caso hay varias alternativas, que van desde un objeto LOG que autogestione la cantidad de texto que puede guardar para hacer escrituras automáticas al llegar a cierto tamaño acumulado, o a algo más sencillo y práctico como hacer dos métodos, uno de escritura en buffer y uno de flush al disco, que es la técnica que usé en FoxBin2Prg y que muestro a continuación, simplificado y adaptado para el ejemplo:


DEFINE CLASS C_LOG
  #DEFINE CR_LF CHR(13)+CHR(10)
  c_TextoLog = ''
  c_LogFile  = ''

  PROCEDURE Init
      c_LogFile = ADDBS( SYS(2023) ) + 'LOG_' ;
          + CHRTRAN( SYS(0), ' ', '_' ) + '.TXT'
  ENDPROC
 
  PROCEDURE Destroy
      THIS.writeLog_Flush()
  ENDPROC
 
  PROCEDURE writeLog
    LPARAMETERS tcText

    TRY
      WITH THIS
        .c_TextoLog = .c_TextoLog + EVL(tcText,'') + CR_LF
      ENDWITH
    CATCH && En este caso no me interesa reportar errores
    ENDTRY
  ENDPROC

  PROCEDURE writeLog_Flush

    WITH THIS
      IF NOT EMPTY(.c_TextLog)
        STRTOFILE( .c_TextLog + CR_LF, .c_LogFile, 1 )
      ENDIF
      .c_TextLog    = ''
    ENDWITH
  ENDPROC 


ENDDEFINE


En el ejemplo, en el Init() se define en nombre del LOG, luego se usa el método writeLog() para escribir el texto que se quiera en la propiedad c_TextoLog y a la que se agrega un fin de línea, y finalmente el método writeLog_Flush() que se encarga de escribir al disco todo el texto acumulado y de vaciar la propiedad que lo acumula. Cuando se descarga la clase, también se escribe automáticamente al disco lo que quede por escribir.

Este es un método muy simple y efectivo que permite sustituir los STRTOFILE() de los ejemplos anteriores por writeLog() y que solo requiere ubicar las llamadas a writeLog_Flush() fuera de los bucles de repetición o de las rutinas de uso frecuente, pero siempre teniendo en cuenta que en puntos estratégicos debe ser ejecutado, para evitar que el LOG se acumule indefinidamente y cause problemas de memoria.

Usando esta técnica, las escrituras al disco o por la red se pueden minimizar de forma muy notoria, y lograr que el sistema siga siendo responsivo.



Caso 2: Actualización de TABLAS


En Fox, una de las cosas que más se usa son las tablas y los cursores, tanto para guardar datos como para guardar información temporal de proceso, ya que Fox está optimizado para eso a tal punto que manejar un cursor de un millón de registros es más rápido que manejar un array de la misma cantidad de filas.

Pero esta orientación a datos muchas veces no es bien implementada, y es muy común encontrarse con código como este:


DO WHILE <condición>
  ...
  REPLACE campo1 WITH valor1
  REPLACE campo2 WITH valor2
  REPLACE campo3 WITH valor3
  REPLACE campo4 WITH valor4
  REPLACE...
  ...
ENDDO


Al igual que en el Caso 1, si este bucle se repite en 100 iteraciones y usando solo 5 REPLACE:
  • Para 1 usuario implicará 500 accesos a disco
  • Para 5 usuarios implicará 2500 accesos a disco
  • Para 10 usuarios implicará 5000 accesos a disco

Puede notarse que en este caso todavía es peor que en el anterior, ya que muchos están acostumbrados a poner varios REPLACE en sucesión, por motivos como que "es más fácil para copiar" o simplemente porque les gusta verlos separados...



Esta es otra variante bastante común que se puede encontrar, donde es necesario hacer REPLACE solo bajo ciertas condiciones y muchos lo implementan así:

SCAN FOR <condición>
  ...
  IF <cond.1>
    REPLACE campo1 WITH valor1
  ENDIF
  IF <cond.2>
    REPLACE campo2 WITH valor2
  ENDIF
  IF <cond.3>
    REPLACE campo3 WITH valor3
  ENDIF
  IF <cond.4>
    REPLACE campo4 WITH valor4
  ENDIF
  IF ...
    REPLACE...
  ...
ENDSCAN


La única diferencia con el caso anterior, es que en cada iteración la cantidad de reemplazos no es fija porque depende de condiciones, pero como siempre, hay que ubicarse en el peor caso donde todas las condiciones puedan ser verdaderas, por lo que en este caso se puede llegar a los mismos 5 REPLACE del ejemplo anterior y a las mismas estadísticas de accesos a disco por usuario.


La realidad es que tanto esta como la otra son malas prácticas y deben evitarse a toda costa, porque penalizan mucho el rendimiento, y más en una red.



Soluciones


Básicamente las soluciones pasan por realizar estos reemplazos en una sola operación, y estas técnicas sirven para cubrir ambos casos con mucha facilidad, como puede verse en los siguientes ejemplos.


Ejemplo 1: Usando un objeto de registro


DO WHILE <condición>
  ...
  SCATTER NAME loReg && o SCATTER FIELDS para más precisión
  loReg.campo1 = valor1
  loReg.campo2 = valor2
  loReg.campo3 = valor3
  loReg.campo4 = valor4
  loReg.campoN...
  GATHER NAME loReg && Un único reemplazo
  ...
ENDDO


Ejemplo 2: Usando variables

SCAN FOR <condición>
  ...
  lc_campo1 = valor1
  lc_campo2 = valor2
  lc_campo3 = valor3
  lc_campo4 = valor4
  lc_campoN...
  REPLACE campo1 WITH lc_campo1 ;
    , campo2 WITH lc_campo2 ;
    , campo3 WITH lc_campo3 ;
    , campo4 WITH lc_campo4 ;
    , campoN WITH lc_campoN && Un único REPLACE para todos
  ...
ENDSCAN


Ejemplo 3: Usando un array

SCAN FOR <condición>
  ...
  SCATTER TO ARRAY laReg && o SCATTER FIELDS para más precisión
  laReg(1) = valor1
  laReg(2) = valor2
  laReg(3) = valor3
  laReg(4) = valor4
  laReg(N)...
  GATHER FROM laReg && Un único reemplazo
  ...
ENDSCAN


Y para reemplazos condicionados se puede usar el mismo código de los ejemplos, solo que condicionando las asignaciones de las variables o propiedades.



El caso anterior fue para reemplazos de datos existentes, pero para registros nuevos es lo mismo, con la salvedad de que como primera opción más recomendable se agrega el Insert-SQL:

Ejemplo 1: Usando Insert-SQL para un registro

SCAN FOR <condición>
  ...
  SCATTER BLANK NAME loReg
  loReg.campo1 = valor1
  loReg.campo2 = valor2
  loReg.campo3 = valor3
  loReg.campo4 = valor4
  loReg.campoN...
  INSERT INTO <tabla> FROM NAME loReg
  ...
ENDSCAN


Ejemplo 2: Usando Insert-SQL para varios registros

SCAN FOR <condición>
  ...
  DIMENSION laReg(3,5)
  laReg(1,1) = valor1
  laReg(1,2) = valor2
  laReg(1,3) = valor3
  laReg(1,4) = valor4
  laReg(1,5...
  ...
  INSERT INTO <tabla> FROM laReg
  ...
ENDSCAN


Otra variante es usar un cursor con la misma estructura que la tabla, realizar los Inserts en el cursor y luego volcarlo en la tabla.

Como se puede ver opciones hay muchas, y cada una puede ser más óptima que la otra dependiendo de nuestras necesidades o de cómo queramos implementarlo, pero para el buffering en memoria podemos usar tanto variables como cursores, pudendo incluso usar buffering de tablas o registros mediante CursorSetprop().

Nota: En el caso de usar cursores o buffering de tablas o registros, recordar cada tanto usar la función SYS(1104) para liberar los buffers de memoria. Como ejemplo, cada 100 registros reemplazados se podría forzar la limpieza de los buffers para liberar memoria.




Caso 3: Escritura de grandes cantidades de texto (>1 MB y <= 2 GB)


Hay situaciones en las que tenemos un proceso que requiere ir generando información al disco en formato texto. La diferencia con un LOG, como vimos al inicio, es que un LOG puede desactivarse o condicionarse, mientras que en este caso hablamos de un proceso que sí o sí debe escribir al disco para generar un archivo de texto en múltiples pasos o subprocesos. Un ejemplo de este tipo de proceso puede ser un parser o un conversor, donde se va interpretando el origen de datos (o un archivo origen) y a la vez se va generando la información de salida a un archivo de texto.

Si bien la primera reacción puede ser la de usar la técnica del buffering del Caso 1 mediante el uso de una variable o una propiedad acumuladora, es necesario saber que para textos superiores a 1 MB FoxPro se empieza a poner lento, principalmente por la acumulación en una variable que va creciendo y que además va consumiendo cada vez más memoria.

En estos casos, lo más óptimo es usar funciones de bajo nivel como FOPEN/FWRITE con buffering, lo que da una velocidad que como mínimo duplica al buffering por variable o propiedad acumulada.

Aunque pueda parecer contradictorio decir que una función de manejo de archivos de bajo nivel sea más rápida en estos casos que una variable, la explicación está en que no solo Fox no se maneja bien con variables con mucho contenido que se actualiza constantemente, sino que además las funciones de manejo de archivos a bajo nivel con uso de buffering están optimizadas para justamente para no escribir todo el tiempo al disco, sino que implementan su propio esquema de buffering.




Caso 4: Generación de texto con TEXT/ENDTEXT


No se puede hablar de generación de texto sin hablar de TEXT/ENDTEXT, que es uno de los comandos más potentes y versátiles de FoxPro desde los inicios, pero primero veamos un ejemplo de la salida que queremos conseguir:




Fecha: 01/05/2015                    Fernando Caimo

Número de viajes: 30
Kilómetros hechos: 275

------------------------------------------------------------



Así es cómo se puede generar este texto usando variables y contenido dinámico:


cTexto = ''
cTexto = cTexto + chr(13)+chr(10)
cTexto = cTexto + 'Fecha: ' + dtoc(date()) + space(20) ;
  + cApellidoYNombre + chr(13)+chr(10)
cTexto = cTexto + chr(13)+chr(10)
cTexto = cTexto + 'Número de viajes:  ' + TRANSFORM(nViajes) ;
  + chr(13)+chr(10)
cTexto = cTexto + 'Kilómetros hechos: ' + TRANSFORM(nKM) ;
  + chr(13)+chr(10)
cTexto = cTexto + chr(13)+chr(10)
cTexto = cTexto + replicate('-', 60)



Y esta es la forma en que se hace con TEXT/ENDTEXT:

TEXT TO cTexto ADDITIVE TEXTMERGE NOSHOW FLAGS 1+2 PRETEXT 1+2

Fecha: <<date()>>                    <<cApellidoYNombre>>

Número de viajes: <<nViajes>>
Kilómetros hechos: <<nKM>>

------------------------------------------------------------

ENDTEXT


....no hay contraste! TEXT/ENDTEXT es tan increíblemente versátil y claro para generar este tipo de documentos que deja en vergüenza al resto de métodos disponibles.

Pero tanta potencia tiene sus casos de uso y su coste, por lo que tampoco es cuestión de usarlo para cada línea de texto que se quiera generar, y es que para líneas individuales, y más si se usa de la siguiente forma, no es recomendable:

FOR X=1 TO loProcedure._ProcLine_Count
  TEXT TO lcMemo ADDITIVE TEXTMERGE NOSHOW FLAGS 1+2 PRETEXT 1+2
    <<loProcedure._ProcLines(X)>>
  ENDTEXT
ENDFOR

STRTOFILE(lcMemo, 'salida.txt', 1)



Como puede verse, aunque la parte de escritura con STRTOFILE está optimizada porque está fuera del bucle, lo que de por sí impactará poco en accesos a disco, el TEXT/ENDTEXT está en un bucle de repetición cuya iteración puede ser alta, y el problema con este caso en particular es que por cada línea que se genera y agrega a lcMemo, se requiere inicializar una estructura de parseo, parsear cada uno de los parámetros indicados y luego procesar el texto entre TEXT y ENDTEXT usando los parámetros indicados. Y todo esto para una línea en un bucle es demasiado coste para la CPU.

En su lugar, y para este caso particular, lo más conveniente y óptimo es usar una variable:

FOR X=1 TO loProcedure._ProcLine_Count
  lcMemo = lcMemo + chr(13)+chr(10) + loProcedure._ProcLines(X)
ENDFOR

STRTOFILE(lcMemo, 'salida.txt', 1)




Resumen


El objetivo siempre debe ser hacer el código más óptimo para cada situación, y en el caso de los archivos siempre se trata de minimizar los accesos a disco usando técnicas de buffering.

Lo que se logra con estas técnicas, específicamente, es:
  • Menor cantidad de accesos a disco (o red)
  • Menor uso de recursos del sistema
  • Disminución de posibilidades de contención
  • Mayor velocidad de proceso
  • Alargamiento de la vida útil de los discos, tanto rígidos como SSD

Puede que hayan quedado fuera algunas cosas, pero quería hacer un resumen con algunas de las más importantes a tener en cuenta.


Hasta la próxima! :D



domingo, octubre 12, 2014

VFP: La Interfaz, las reglas de negocio y los Datos - Cómo separarlos y por qué

Por: Fernando D. Bozzo

Actualizaciones:
03/06/2015 - Agregada nueva sección de Posibles mejoras al modelo propuesto
04/06/2015 - Agregada nueva sección sobre ¿y el control de errores?

Cuando se quiere actualizar alguna propiedad de un control, como Enabled, ReadOnly, BackColor, ForeColor u otra, dependiendo del valor de uno o más controles, normalmente los programadores hacen esas actualizaciones desde los mismos controles.

Por ejemplo, si tenemos un form donde se debe activar el botón cmdImprimir solamente si se pone un código (txtCodArticulo) y un valor (txtValor), muchos harán algo como esto:


Ejemplo 1:

Form1.txtCodArt.Valid
IF NOT EMPTY(this.value) AND NOT EMPTY(thisform.txtValor.value)
  THISFORM.cmdImprimir.Enabled = .T.
ELSE
  THISFORM.cmdImprimir.Enabled = .F.
ENDIF

Form1.txtValor.Valid
IF NOT EMPTY(this.value) AND NOT EMPTY(thisform.txtCodArt.value)
  THISFORM.cmdImprimir.Enabled = .T.
ELSE
  THISFORM.cmdImprimir.Enabled = .F.
ENDIF

Form1.Activate
IF NOT EMPTY(thisform.txtCodArt.value) AND NOT EMPTY(thisform.txtValor.value)
  THISFORM.cmdImprimir.Enabled = .T.
ELSE
  THISFORM.cmdImprimir.Enabled = .F.
ENDIF



¿Qué problema hay con el código de arriba, si al final funciona?

El problema es que no importa solamente que "funcione", sino que además el código sea fácil de mantener, de modificar, de ampliar y de contemplar nuevos casos posibles, como veremos luego.

Aunque en un principio pueda parecer que lo de "copiar/pegar/adaptar" es mucho más fácil y rápido, luego, al tener que modificar todo lo "copiado/pegado/adaptado" no lo será, y cada vez será más difícil, lo que nos dejará con "Código Spaguetti", que se llama a esos bloques de código dispersos por ahí, duplicados y difíciles de mantener.



¿Y cómo lograr todo eso a la vez? ¿No será muy complicado?

Evalúenlo ustedes mismos, y comparen lo de arriba del ejemplo 1 con esto:


Ejemplo 2:

Form1.txtCodArt.Valid
THISFORM.EvaluarEstadosDeHabilitacion()

Form1.txtValor.Valid
THISFORM.EvaluarEstadosDeHabilitacion()

Form1.Activate
THISFORM.EvaluarEstadosDeHabilitacion()

Form1.EvaluarEstadosDeHabilitacion
IF NOT EMPTY(thisform.txtCodArt.value) AND NOT EMPTY(thisform.txtValor.value)
  THISFORM.cmdImprimir.Enabled = .T.
ELSE
  THISFORM.cmdImprimir.Enabled = .F.
ENDIF



Demostración rápida del coste de mantenimiento:

Si en el Ejemplo 1 hubiera que agregar una condición más para activar el botón cmdImprimir, habría que modificar los 3 métodos y además volver a copiar y adaptar el código de uno de ellos para el nuevo control que interviene en esa condición, lo que nos deja con ¡4 versiones distintas de código que hacen lo mismo!

En el Ejemplo 2, agregar esa condición implica solo 2 cambios: el primero es poner THISFORM.EvaluarEstadosDeHabilitacion() en el nuevo control, y el segundo es agregar la condición en el método EvaluarEstadosDeHabilitacion.

¿Y si hubiera que agregar 50 controles más? Con el Ejemplo 1 esa tarea podría llevar toda la tarde y con una posibilidad de error muy alta, mientras que con el Ejemplo 2 sería cosa de 10 minutos máximo!


Ahora preguntémonos: ¿cómo es mejor, de la primera forma o de la segunda? La primera es una mala práctica y la segunda es una buena práctica porque encapsula la condición en un solo sitio, que es de lo que se trata al hacer cualquier programa o sistema, y eso que solamente hemos hablado de algo tan simple como un Enabled.



Técnicas de encapsulación - Trabajo en N capas


Si trabajamos en capas (negocio, datos, Interfaz) o con separación de responsabilidades, el Ejemplo 2 de antes se puede hacer de una forma parecida, que requiere algo más de código y de diseño, pero que al final también tiene más ventajas.

Una de las primeras separaciones que conviene hacer es la de los datos y la interfaz. En FoxPro tenemos tan arraigada esta mezcla interfaz+datos, que muy pocos se la cuestionan. Me refiero a no usar el ControlSource de los controles, o a usarlo apuntando a propiedades del mismo form, como se hace habitualmente.


Este es un caso de uso típico:

Form1.txtCodArt.Valid
IF EMPTY(this.value)
  MESSAGEBOX( "EL CÓDIGO DE ARTICULO NO PUEDE QUEDAR VACÍO" )
ENDIF

Form1.txtValor.Valid
IF EMPTY(this.value)
  MESSAGEBOX( "EL VALOR NO PUEDE QUEDAR VACÍO" )
ENDIF



El ControlSource de los controles conviene apuntarlo a propiedades de un objeto externo, de forma que ese objeto luego se puede reutilizar o incluso pasar como parámetro.


a) Creando un Objeto de negocio

Usando el primer ejemplo, nuestro objeto de negocio debería contener 2 propiedades, codArt y Valor:


*--  ARTICULO_BUS.PRG 
Define Class cl_articulo As Custom
   c_CodArt = ""
   n_Valor  = 0
EndDefine



b) Instanciando la clase de negocio en el form


En el form creamos una propiedad oBus y la cargamos con el objeto antes definido, agregándolo en el LOAD:


Form1.LOAD
THISFORM.oBus = NEWOBJECT( "cl_articulo", "articulo_bus.prg" )


c) Definiendo el nuevo ControlSource


Luego definimos los ControlSource de los controles en modo diseño:

txtCodArt.ControlSource = "thisform.oBus.c_CodArt"
txtValor.ControlSource = "thisform.oBus.n_Valor"



d) Referenciando los datos


Finalmente, modificamos el método del Ejemplo 2 para que no tome los valores directamente de los controles (nunca hay que tomar los datos de los controles!), sino de las propiedades del nuevo objeto:


Form1.EvaluarEstadosDeHabilitacion
LOCAL loBus As cl_articulo OF "articulo_bus.prg"
loBus = thisform.oBus

IF NOT EMPTY(loBus.c_CodArt) AND NOT EMPTY(loBus.n_Valor)
  THISFORM.cmdImprimir.Enabled = .T.
ELSE
  THISFORM.cmdImprimir.Enabled = .F.
ENDIF



e) Ampliando el uso del objeto de negocio: Validaciones


Supongamos que queremos agregar validaciones de datos, esas que normalmente se ponen en el VALID del control (como los ejemplos de más arriba), y que en ciertos casos terminan duplicándose o incluso cayendo en algo tan feo como teniendo que llamar desde un control al VALID de otros controles, o peor, un Valid llamando al InteractiveChange de otro control que a su vez llame al evento de otro control, y así sucesivamente...   Obviamente que eso no es una buena práctica, aunque sean muchos los que lo sigan haciendo.

Hay mejores formas y más limpias y fáciles de mantener para eso, como ser el poner esas validaciones en nuestro objeto de negocio:

*--  ARTICULO_BUS.PRG 
Define Class cl_articulo As Custom
   c_CodArt = ""
   n_Valor  = 0

   PROCEDURE EvaluarError_CodArt
      LOCAL lcCodError
      lcCodError = ""

      IF EMPTY(THIS.c_CodArt)
         lcCodError = "EL CÓDIGO DE ARTICULO NO PUEDE QUEDAR VACÍO"
      ENDIF

      RETURN lcCodError
   ENDPROC

   PROCEDURE EvaluarError_Valor
      LOCAL lcCodError
      lcCodError = ""

      IF EMPTY(THIS.n_Valor)
         lcCodError = "EL VALOR NO PUEDE QUEDAR VACÍO"
      ENDIF

      RETURN lcCodError
   ENDPROC

EndDefine

Nota: Como puede verse, las validaciones de negocio "devuelven" valores, cadenas o incluso arrays de valores, pero nunca muestran mensajes, ni siquiera de error, ya que eso es responsabilidad de la Interfaz (el form o sus controles)


Recuerden:

Al trabajar en capas, las validaciones no son responsabilidad de la Interfaz (pantallas), sino del Negocio, al igual que los cálculos y los procesos. En esta metodología de trabajo las pantallas solo deben contener el código necesario para la navegación, activación, desactivación o cualquier cosa específica de la Interfaz, y para el resto debe llamar a métodos de negocio.


Para usar estas validaciones, el formulario podría tener esto:

Form1.txtCodArt.Valid
THISFORM.EvaluarEstadosDeHabilitacion()
THISFORM.MostrarMensajeError( THIS.oBus.EvaluarError_CodArt() )

Form1.txtValor.Valid
THISFORM.EvaluarEstadosDeHabilitacion()
THISFORM.MostrarMensajesError( THIS.oBus.EvaluarError_Valor() )

Form1.MostrarMensajesError
LPARAMETERS tcMensajeError
IF NOT EMPTY(tcMensajeError)
   MESSAGEBOX( tcMensajeError )
ENDIF



Posibles mejoras en el esquema de validaciones


La forma de validar anterior puede ser útil cuando se quiere que los mensajes sean invasivos y bloqueantes, como se ha hecho por muchos años, pero puede resultar muy molesto hacerlo así en un formulario donde se quiere que el usuario tenga la mayor libertad posible para navegar por los controles sin que le vayan apareciendo mensajes a cada paso.

Para este caso, donde se quiere que el usuario vea los errores cuando realmente quiera verlos, hay varias alternativas.


Alternativa 1 - Mostrar mensajes en un panel especial


La idea es dejar un espacio reservado para el mensaje de error actual, por ejemplo un editbox en la parte inferior del form, de 2 ó 3 líneas de altura, donde el usuario pueda siempre ir viendo los mensajes a medida que recorre los controles. Para actualizar esa zona especial se podría adaptar el método MostrarMensajesError del ejemplo anterior, por ejemplo así:

Form1.MostrarMensajesError
LPARAMETERS tcMensajeError
IF EMPTY(tcMensajeError)
   THISFORM.edtMensajes.VALUE = ""
ELSE
   THISFORM.edtMensajes.VALUE = tcMensajeError
ENDIF

Observen lo simple que fue adaptar este método centralizado de evaluación de mensajes para cambiarle la implementación de cómo mostrar los mensajes, sin haber tenido que tocar ni un control.

Un ejemplo de este tipo de validación:




Alternativa 2 - Mostrar una ventana independiente de mensajes


En este caso la idea es que el usuario pueda ver todos los mensajes de validación juntos en una ventana separada o dentro del form y mostrados en formato tabular, por ejemplo con un grid o un ListBox.

Para ello, podríamos poner un botón "Ver Validaciones" en el form, cuyo evento click tenga esto:

Form1.cmdVerValidaciones.Click
THISFORM.oBus.ObtenerValidaciones(@laMensajes)
DO FORM MostrarMensajes WITH laMensajes


Y el form podría mostrarlos de forma similar a esto:




Alternativa 3 - Mensajes de validación en un proceso masivo


En el caso de un proceso en lotes, o masivo, donde no se muestran mensajes, sino que se loguean para posterior análisis, se podría usar el método oBus.ObtenerValidaciones() desde un proceso externo, y guardar esos mensajes en una tabla o archivo log, y esto sería posible gracias a haber separado la parte negocio en una clase independiente del form que se puede instanciar sin necesidad del form.



f) La parte que falta: Los Datos


En la programación tradicional de FoxPro, la Interfaz y el Negocio están mezclados, y los Datos no iban a ser la excepción, ya que cualquier consulta de datos se suele hacer en la misma Interfaz, similar a esta forma:

Form1.cmdObtenerArticulos.Click

*-- Recupero los datos
SELECT * FROM ARTICULOS ;
   WHERE CodArt = THISFORM.txtCodArt.Value ;
   INTO CURSOR c_Arts

*-- Asigno los datos
THISFORM.txtValor.Value = c_Arts.Valor
THISFORM.txtDescrip.Value = c_Arts.Descrip

*-- Proceso los datos
(código que hace varias cosas con los datos recuperados)


Cuando se separan responsabilidades y se trabaja con capas, nunca se debe hacer un acceso a datos (Select, Update, Insert, etc) directamente desde la Interfaz, sino que se debe hacer desde una clase externa para acceso a datos, por ejemplo así:

*--  ARTICULO_DB.PRG 
Define Class cl_articulo As Custom

  PROCEDURE DevolverDatosArticulo
    LPARAMETERS tcCodArt, taDatosArt
    USE ARTICULOS AGAIN SHARED NOUPDATE ALIAS _ART IN 0
    SELECT * FROM _ART ;
       WHERE CodArt = tcCodArt ;
       INTO ARRAY taDatosArt
    USE IN (SELECT("_ART"))
    RETURN _TALLY
  ENDPROC

EndDefine

Nota: No incluí control de errores ni apertura/cierre de tablas para simplificar.


Y esta clase de datos es usada por el negocio, de esta forma:

*--  ARTICULO_BUS.PRG 
Define Class cl_articulo As Custom
   c_CodArt = ""
   n_Valor  = 0
   o_DB     = NULL

   PROCEDURE ProcesarDatos
      LOCAL laDatosArt(1,1)
      THIS.o_DB = NEWOBJECT( "cl_articulo", "ARTICULO_DB.PRG" )
      THIS.o_DB.DevolverDatosArticulo( @laDatosArt )
      THIS.c_CodArt = laDatosArt(1,1)
      THIS.c_Valor  = laDatosArt(1,2)


      *-- Proceso los datos
      (código que hace varias cosas con los datos recuperados)
   ENDPROC

EndDefine


El código original del form, solo haría esta llamada:

Form1.cmdObtenerArticulos.Click
THISFORM.oBus.ProcesarDatos()

Nota: Es aquí, en la Interfaz, donde deben mostrarse los errores de los métodos invocados.



[Nuevo. 03/06/2015>>]

Posibles mejoras al modelo propuesto


El modelo propuesto es una versión muy simplificada de una arquitectura en capas, y obviamente hay mucho espacio para la mejora.

A continuación dejo algunas ideas de una estructura de aplicación basada en un objeto público (oApp) que contiene la misma clase de artículos de antes, pero dentro de una estructura más versátil. Dentro de oApp se podrá tener a las demás clases de negocio, para ser accedidas como oApp.oArticulo.oDatos u oApp.oCliente.oDatos, y para poder realizar tareas como oApp.oArticulo.ListarStock() u oApp.oCliente.DevolverSaldo()

En el programa principal (ej: Main.prg), es donde realizamos la configuración inicial de la aplicación (comandos SET, PATH, cambio de directorio, etc) y donde instanciamos el objeto oApp:


*-- MAIN.PRG
PUBLIC oApp
oApp = NewObject("cl_app_bus", "APP_BUS.PRG")
oApp.CargarNegocio()


Y podemos definir la clase cl_app de esta forma:

*-- APP_BUS.PRG
Define Class cl_app_bus AS Custom
  oArticulo = Null

  PROCEDURE cargarNegocio
    THIS.oArticulo = NewObject("cl_articulo_bus", "articulo_bus.prg")
  ENDPROC
EndDefine


En vez de usar directamente propiedades del objeto oBus para los datos, sería mejor usar una propiedad oDatos inicializada a NULL y luego guardar en ella un objeto de tipo Empty con las propiedades que se correspondan con los campos a tratar, de forma uqe luego el ControlSource de los controles del form apunten a ella, de la forma: oApp.oArticulo.oDatos.cod_Art

Para crear este objeto, se podría hacer un método de la clase de acceso a datos que lo devuelva, junto a otros métodos para su tratamiento de guardado y recuperación, por ejemplo:



*-- ARTICULO_DB.PRG
DEFINE CLASS cl_articulo_db AS Custom

  PROCEDURE DevolverDatosArticulo
    LPARAMETERS tcCodArt, taDatosArt
    LOCAL lnSelect
    lnSelect = SELECT()
    USE ARTICULOS AGAIN SHARED NOUPDATE ALIAS _ART IN 0
    SELECT * FROM _ART ;
       WHERE CodArt = tcCodArt ;
       INTO ARRAY taDatosArt
    USE IN (SELECT("_ART"))
    SELECT (lnSelect)
    RETURN _TALLY
  ENDPROC

  PROCEDURE devolverObjetoDatosVacio
    LOCAL loReg
    loReg = CreateObject("Empty")
    AddProperty(loReg, "c_CodArt", "")
    AddProperty(loReg, "n_Valor", 0)
    RETURN loReg
  ENDPROC

  PROCEDURE guardarDatos(toDatos)
    LOCAL lnSelect
    lnSelect = SELECT()
    SELECT 0
    USE ARTICULOS SHARED AGAIN ALIAS _ART ORDER c_CodArt
    IF SEEK(toDatos.c_CodArt) THEN
      GATHER MEMO NAME toDatos
    ELSE
      INSERT INTO _ART FROM NAME toDatos
    ENDIF
    USE IN (SELECT("_ART"))
    SELECT (lnSelect)
  ENDPROC

  PROCEDURE recuperarDatos(tcCodArt)
    LOCAL loDatos, lnSelect
    lnSelect = SELECT()
    loDatos = NULL
    SELECT 0
    USE ARTICULOS SHARED AGAIN NOUPDATE ALIAS _ART ORDER c_CodArt
    IF SEEK(toDatos.c_CodArt) THEN
      SCATTER MEMO NAME loDatos
    ENDIF
    USE IN (SELECT("_ART"))
    SELECT (lnSelect)
  ENDPROC

ENDDEFINE




Pero como no se puede acceder directamente a la clase de acceso a datos, sino por medio de la clase de negocio, también agregamos los métodos en la clase de negocio de Articulos:


*--  ARTICULO_BUS.PRG 
Define Class cl_articulo_bus As Custom
  o_DB    = Null
  oDatos  = Null

  PROCEDURE Init
    THIS.o_DB = NEWOBJECT( "cl_articulo_db", "ARTICULO_DB.PRG" )
    THIS.cargarObjetoDatosVacio() && objeto inicial vacío
  ENDPROC

  PROCEDURE devolverObjetoDatosVacio
     RETURN THIS.o_DB.devolverObjetoDatosVacio()
  ENDPROC

  PROCEDURE cargarObjetoDatosVacio
     THIS.oDatos = THIS.devolverObjetoDatosVacio()
  ENDPROC

  PROCEDURE guardarDatos
     THIS.o_DB.guardarDatos(THIS.oDatos)
  ENDPROC

  PROCEDURE recuperarDatos(tcCodArt)
     THIS.oDatos = THIS.o_DB.recuperarDatos(tcCodArt)
  ENDPROC

  PROCEDURE DevolverDatosArticulo
    LPARAMETERS tcCodArt, taDatosArt
    RETURN THIS.o_DB.devolverDatosArticulo(tcCodArt, taDatosArt)
  ENDPROC

EndDefine


Como se ve, la arquitectura de objetos que elijan les permite poder relacionarlos y usarlos de forma autodocumentada, si se diseña bien.

Hay muchas formas de estructurar los objetos de negocio y de armar la estructura de una aplicación, y lo anterior solo pretende dar algunas ideas de algunas posibilidades.

Algo importante a recordar es no encasillarse en una única solución, si la solución que se usa no se puede aplicar el tipo de problema que se quiere resolver, simplemente se cambia la solución.
[<<]



[Nuevo. 04/06/2015>>]

¿Y el control de errores?


No lo puse en los ejemplos para mantenerlos lo más simples y entendibles posible, además del espacio que ocupa poner más código, pero vamos a dedicarle un apartado a esto, que es fundamental en toda aplicación, y que debe hacerse mientras se programa, nunca al final.

Algunos usarán el antiguo método del ON ERROR, porque es cómodo, se escribe una sola vez y sirve para toda la aplicación, lo que lo hace tentador, pero la realidad es que ese método no es suficiente cuando se quiere controlar la recolección de basura o ciertas condiciones en un sistema, ya que deja muy pocas alternativas de gestión y salida, y es que si no se liberan bien las referencias de objeto, se pueden quedar enganchadas (si es se vinculan de forma cruzada) y esto puede causar que no se pueda salir de la aplicación.

Hay una mezcla de pereza y miedo a controlar los errores, y muchas veces se peca de demasiado optimismo pensando de que todo es correcto y está todo pensado, pero los imprevistos existen, y los errores le ocurren incluso a los mejores.

La mejor estrategia, es contar con los errores como una parte normal del desarrollo y usarlos a nuestro favor, para generar información de diagnóstico que nos permitan identificarlos y corregirlos lo antes posible.

¿Cuántas veces ven en los foros a personas que piden ayuda por un error que le da su aplicación y que no saben cómo o dónde buscarlo? Eso pasa porque no se gestionan los errores. En el mejor caso algunos ponen una rutina genérica y en el peor no ponen nada y dejan que FoxPro muestre el conocido mensaje para "Continuar, Cancelar, Reintentar", lo que es bochornoso y muy peligroso también, ya que el usuario puede elegir algo que no debe poder elegir.

En nuestro caso vamos a ver un ejemplo aplicado al método DevolverDatosArticulo(), y que servirá de ejemplo para los demás.

*-- ARTICULO_DB.PRG
<resto del código excluido por comodidad>

  PROCEDURE DevolverDatosArticulo
    LPARAMETERS tcCodArt, taDatosArt

    TRY
      LOCAL loEx as Exception, lnSelect
      lnSelect = SELECT()
      USE ARTICULOS AGAIN SHARED NOUPDATE ALIAS _ART IN 0
      SELECT * FROM _ART ;
        WHERE CodArt = tcCodArt ;
        INTO ARRAY taDatosArt

    CATCH TO loEx
      IF EMPTY(loEx.UserValue) AND _VFP.StartMode=0 THEN
        SET STEP ON
      ENDIF 

      loEx.UserValue = loEx.UserValue + "tcCodArt=" ;
        + TRANSFORM(tcCodArt) + CHR(13) + CHR(13)

      THROW

    FINALLY
      USE IN (SELECT("_ART")) 
      SELECT (lnSelect)
    ENDTRY

    RETURN _TALLY
  ENDPROC

<resto del código excluido por comodidad>


El código anterior sería la implementación completa de la funcionalidad con control de errores, en gris está el código previo para que se pueda contrastar con el código agregado.

Como este método realiza tratamiento de datos, al inicio se guarda el área de trabajo en la que se estaba, luego se abre la tabla en modo compartido y de sólo-lectura con un alias temporal (_ART) y se hace la consulta.

Si todo va bien, se ejecuta el FINALLY donde se cierra el alias temporal y se restaura el área de trabajo original.

Ahora, si hay un error, viene la parte interesante:

- Se verifica si en UserValue hay algo (inicialmente está vacío) y si estamos en modo Desarrollo (StartMode=0), en cuyo caso se abre la ventana del depurador para que podamos ver in-situ el origen del problema.

- Luego se recoge el valor del parámetro tcCodArt y se guarda en la propiedad UserValue del objeto de errores, lo cuál es muy útil, porque los valores que pongamos irán dentro de este objeto de tipo Exception hasta su destino final, donde se mostrará si es un proceso interactivo con el usuario) o se logueará al disco (si es un proceso desatendido). Esto nos dará la pista de qué artículo se estaba consultando cuando ocurrió el error. Normalmente aquí se suelen poner los valores relevantes y específicos del método que nos puedan dar pistas claras del problema o que nos ayuden a poder reproducirlo (por eso lo de "diagnóstico" que comenté antes)

- A continuación relanzamos el error al nivel superior, que normalmente será el método de negocio que haya llamado a este, o el anterior si es que el método de negocio no tiene control de errores.

- Finalmente se ejecuta el Finally, que se ejecuta cuando se relanza el error como parte del funcionamiento de la estructura Try/Catch, donde se cierra la tabla temporal y se restaura el área de trabajo original.

Lo bueno del Finally es que se ejecuta siempre, tanto haya errores como no los haya, y esto permite realizar siempre la recolección de basura.


El método de negocio DevolverDatosArticulo() no tiene casi código, por lo que es casi imposible que ocurra un error aquí (salvo uno de sintaxis), y por lo tanto no tiene sentido agregarle control de errores, ya que no aporta nada al proceso:

  PROCEDURE DevolverDatosArticulo
    LPARAMETERS tcCodArt, taDatosArt
    RETURN THIS.o_DB.devolverDatosArticulo(tcCodArt, taDatosArt)
  ENDPROC



Finalmente tenemos la interfaz, que es desde donde se lanzó el proceso. Como es un método de consulta, vamos a asumir de que se lanzó desde un form de consulta con un botón cmdConsultar y un textbox txtCodArt en el que se indicó el artículo a consultar.

Queda por aclarar un pequeño punto, que es dónde se guarda el dato ingresado por el usuario en txtCodArt. Como ya comenté al inicio, no es buena idea tomar los datos directamente del Value de los controles, por lo que vamos a crear un objeto de datos nada más que para este form, y vamos a usarlo como "THISFORM.oDatos.c_CodArt" en el ControlSource del control txtCodArt:


DEFINE CLASS cl_form AS Form
  oDatos = NULL
  BindControls = .F.

  <resto del código excluido por comodidad>

  PROCEDURE Init
    THIS.oDatos = CREATEOBJECT("Empty")
    AddProperty( THIS.oDatos, "c_CodArt", "" )
    BindControls = .T.
  ENDPROC

  PROCEDURE cmdConsultar.Click
    LOCAL laDatosArt, loEx as Exception, lcMsg
    STORE "" TO lcMsg

    TRY
      oApp.oArticulo.devolverDatosArticulo( ;
        THISFORM.oDatos.c_cCodArt, @laDatosArt)
      ...hacer algo con laDatosArt

    CATCH TO loEx
      TEXT TO lcMsg ADDITIVE TEXTMERGE NOSHOW FLAGS 1+2 PRETEXT 1+2
        Error <<loEx.ErrorNo>>, <<loEx.Message>>
        Procedure <<loEx.Procedure>>, line <<loEx.LineNo>>
        Details <<loEx.Details>
        UserValue <<loEx.UserValue>>
      ENDTEXT

      MESSAGEBOX( lcMsg )

    FINALLY
      * Recolección de basura
    ENDTRY
  ENDPROC

ENDDEFINE


Nota: Esta definición de form está incompleta y solo incluí las partes importantes, que se pueden agregar o configurar en un form real o por código, similar a este.

Veamos, por partes, qué sucede aquí:

- Primero asumimos que txtCodArt.ControlSource = "THISFORM.oDatos.c_CodArt" y que la propiedad BindControls del form está en .F. en diseño (importante para que no de error por objeto oDatos inexistente)

- En el Init se crea el objeto oDatos del form y se le agrega la propiedad c_CodArt que se usará para la consulta, y como última instrucción se habilita el bindeo del ControlSource con BindControls = .T., cosa que cuando se carga el form ya está todo lo necesario inicializado

- Cuando se ingresa el código de artículo en txtCodArt, este se guarda en THISFORM.oDatos.c_CodArt y al pulsar el botón cmdConsultar se invoca a la consulta de Artículos del negocio, pasándole el código del artículo y recibiendo un array con los datos

- Si va todo bien, con el array luego se hace lo que se necesite, pero si ocurre un error en algún punto de todo el proceso, el mismo se irá relanzando desde donde ocurra hasta llegar aquí a la interfaz (lo que se conoce como "propagación del error"), donde finalmente se arma un mensaje para visualizar con los datos obtenidos y se muestra.

Como se ve, no es nada difícil manejar la propagación del error, ya que desde fuera de la interfaz, lo único que hay que hacer es propagarlo, y solo en la interfaz hay que mostrarlo. También sería conveniente guardarlo en un archivo LOG en el momento que se produce.


Estas son algunas preguntas comunes que surgen sobre este tema (FAQ):

¿Cómo se puede identificar al error original de uno relanzado?

  Cuando se captura un error y se relanza, en todos los puntos intermedios en los que se relance, tendrá la misma información, pero si tomamos como costumbre siempre poner algún dato en la propiedad UserValue, aunque sea "." si no hay nada útil que agregar, podemos saber si se estamos evaluando el error original solo evaluando si UserValue está vacío, que lo estará solamente la primera vez.

¿Cómo se puede distinguir a un error de verdad de un error de usuario lanzado con ERROR "mensaje"?

  Los errores de usuario lanzados con ERROR "mensaje" tienen el código 1098, los errores de usuario lanzados con THROW "mensaje" tienen el código 2071.
[<<]



Ventajas de separar en capas


Como hemos visto, hacer esta separación entre Interfaz (los controles, el form, lo "visual"), Negocio (validaciones, cálculos, procesos) y Datos (consultas, eliminación, actualizaciones) permite poder hacer varias cosas que estando metidas en un form son difíciles o imposibles, por ejemplo:

  • Cuando un método necesita pasar datos a otro, puede pasarle directamente el objeto de negocio, dependiendo de dónde esté definido cada uno, y así evitar pasar datos individuales
  • Permite reutilizar las validaciones en otros forms u otras situaciones o procesos
  • Permite encapsular las validaciones, lo que hace que sean más fáciles de mantener
  • Permite encapsular los accesos a datos, lo que hace que sean más fáciles de mantener también
  • Hace que el código sea más específico y por lo tanto más reducido y fácil de entender
  • Permite usar Testeo Unitario automatizado, por ejemplo, con FoxUnit, lo que puede ahorrar muchas horas o incluso días de pruebas manuales y de regresión
  • La encapsulación favorece la reusabilidad del código, y más todavía cuanto mejor se parametrice
  • Permite poder trabajar en cada capa de forma independiente, hasta el punto de que cada capa la puede hacer un desarrollador distinto si se quiere, y reducir tiempos de desarrollo y pruebas
  • Incluso aunque se trabaje en solitario, las pruebas también se hacen más simples
  • La búsqueda de errores se simplifica, ya que al tener bloques de código más reducidos y especificos, se recorren de forma más rápida
  • Al separar la Interfaz de las validaciones, los cálculos, las consultas y demás, la Interfaz queda con lo mínimo y necesario para la navegación, lo que permitiría poder sustituirla por un nuevo diseño de Interfaz sin perjudicar ni perder ninguno de los procesos, y obviamente sin tener que copiar su código (salvo las llamadas a los procesos)


¿Cuántas capas existen?


Se llama N capas porque la cantidad de ellas que se usen dependerá del diseño y arquitectura que se quiera. Es muy importante no encasillarse en "3", ya que fácilmente pueder ser varias más, y para evitar ese encasillamiento es que también se habla de "separación de responsabilidades".

Tomemos por ejemplo una conexión a web-services: En este caso podríamos hablar de una nueva capa o responsabilidad que se encargue de todas las comunicaciones de este tipo.

Dentro de la capa de comunicaciones, se podrían tener sub-capas más específicas, como la antes comentada de web-services, o una de envío de mensajes o SMSs, etc.

Otro ejemplo: La impresión se puede considerar como otra capa, donde todo lo que tenga que ver con eso esté encapsulado en una o más librerías específicas, que pueden ser utilizadas desde donde sea necesario.

Como se podrán ir dando cuenta, a medida que vayan hilando más fino podrán ver que subyace la idea de "servicios" específicos con APIs que pueden ser reutilizados desde distintos puntos del sistema, incluso al extremo de poder ser tan reutilizables que sirvan para otros sistemas. Esa misma idea es la que hay detrás de "servicios" tan útiles y específicos como el portapapeles (clipboard), el servicio de Impresión, el servicio de mensajes del sistema, los servicios de red, etc.


Aunque no lleguen a ese punto, lo importante es que puedan ver el potencial de hacer componentes o servicios tan específicos que puedan ser utilizados desde cualquier programa, incluso no FoxPro.



Notas finales


La idea de este artículo es poder ver las diferencias entre una y otra forma de trabajar, la forma antigua ya tradicional de FoxPro donde se hace todo junto, y la otra que no es nada nueva y donde los resultados pueden ser mejor aprovechados bajo la filosofía de separar en componentes y responsabilidades, llevando la encapsulación del código a los niveles necesarios para permitir la reusabilidad y favorecer la mejor claridad del código.

Comparando los ejemplos de la programación tradicional en FoxPro del "todo junto" con los ejemplos separados en sus distintos componentes (visual, negocio y datos), creo que puede comprobarse que aunque lo segundo requiera algo más de código, realmente cada parte es muy específica y simple de entender.

Intenté que los ejemplos sean sencillos y no he incluido el control de errores para mantener los ejemplos cortos y fáciles de leer y de entender, ya que se asume que el control de errores forma parte integral de la programación y no algo separado.

De la misma forma, el Testing Unitario automatizado es una herramienta muy importante a la que se le puede sacar todavía mayor provecho cuando se usa esta metodología de trabajo, y que tampoco debiera ser considerada como algo aparte.

Si se habitúan a pensar de esta forma y a trabajar con esta metodología, de pronto se encontrarán con que comenzarán a tener varias posibilidades, soluciones e ideas que hasta ahora no habían podido ver.



Hasta la próxima! :D

Artículos Relacionados:
Unit Testing: Qué es y cómo se usa en VFP 9
Desmitificando el Control de Errores con Try/Catch
Guía de Buenas Prácticas de programación y recomendaciones