miércoles, 4 de octubre de 2017

Solución de Problemas

A medida que nuestros scripts se hacen más complejos, es hora de echar un vistazo a lo que ocurre cuando las cosas no funcionan y no hacen lo que queremos. En este capítulo, veremos algunos tipos comunes de errores que ocurren en nuestros scripts, y describiremos unas pocas técnicas útiles que pueden usarse para localizar y erradicar los problemas.

Errores sintácticos

Un tipo común de error es el sintáctico. Los errores sintácticos implica errores de escritura en algunos elementos de la sintaxis del shell. En la mayoría de los casos, estos tipos de errores liderarán los rechazos del shell a ejecutar el script.

En el siguiente párrafo, usaremos este script para comprobar los tipos comunes de errores:

 #!/bin/bash


 # trouble: script to demonstrate common errors

 number=1

 if [ $number = 1 ]; then
     echo "Number is equal to 1."
 else
     echo "Number is not equal to 1."
 fi

Tal como está escrito, este script se ejecuta con éxito:

 [me@linuxbox ~]$ trouble
 Number is equal to 1.

Comillas perdidas

Si editamos nuestro script y eliminamos las comillas finales del argumento que sigue al primer comando echo:

 #!/bin/bash


 # trouble: script to demonstrate common errors

 number=1

 if [ $number = 1 ]; then
     echo "Number is equal to 1.
 else
     echo "Number is not equal to 1."
 fi

y vemos que ocurre:

[me@linuxbox ~]$ trouble
/home/me/bin/trouble: line 10: unexpected EOF while looking for
matching `"'
/home/me/bin/trouble: line 13: syntax error: unexpected end of file

Genera dos errores. Interesantemente, los números de línea reportados no son de donde se han eliminado las comillas, si no que están mucho más avanzado el programa. Podemos ver por qué, si seguimos el programa tras las comillas perdidas. bashcontinua buscando las comillas de cierre hasta que encuentra unas, lo que ocurre inmediatamente tras el segundo comando echo. bash se queda muy confundido tras esto, y la sintaxis del comando if se rompe porque la sentencia fi está ahora dentro de una cadena entrecomillada (pero abierta).

En scripts largos, este tipo de errores pueden ser algo difíciles de encontrar. Usar un editor con resaltado sintáctico ayuda. Si está instalada la versión completa de vim, puede activarse el resaltado sintáctico introduciendo el comando:


:syntax on

Símbolos perdidos o inesperados

Otro error común es olvidarse de completar un comando compuesto, como if o while. Veamos que ocurre si eliminamos el punto y coma tras el test en el comando if:

 #!/bin/bash

 # trouble: script to demonstrate common errors

 number=1

 if [ $number = 1 ] then
     echo "Number is equal to 1."
 else
     echo "Number is not equal to 1."
 fi

El resultado es:

[me@linuxbox ~]$ trouble
/home/me/bin/trouble: line 9: syntax error near unexpected token
`else'
/home/me/bin/trouble: line 9: `else'


De nuevo, el mensaje de error apunta a un error que ocurre más tarde que el problema real. Lo que ocurre es realmente interesante. Como dijimos, if acepta una lista de comandos y evalúa el código de salida del último comando de la lista. En nuestro programa, nuestra intención es que esta lista sea un único comando, [, un sinónimo de test. El comando [ toma lo que le sigue como una lista de argumentos; en nuestro caso, cuatro argumentos: $number, 1, =, y ]. Con el punto y coma eliminado, se añade la palabra then a la lista de argumentos, lo que es sintácticamente legal. El siguiente comando echo es legal, también. Se interpreta como otro comando en la lista de comandos que if evaluará como código de salida. A continuación encontramos else, pero está fuera de sitio, ya que el shell lo reconoce como una palabra reservada (una palabra que tiene un significado especial para el shell) y no el nombre de un comando, de ahí el mensaje de error.

Expansiones inesperadas

Es posible tener errores que sólo ocurren de forma intermitente en un script. A veces el script se ejecutará correctamente y otras veces fallará debido al resultado de una expansión. Si devolvemos nuestro punto y coma perdido y cambiamos el valor de number a una variable vacía, podemos comprobarlo:

 #!/bin/bash


 # trouble: script to demonstrate common errors

 number=

 if [ $number = 1 ]; then
     echo "Number is equal to 1."
 else
     echo "Number is not equal to 1."
 fi

Ejecutar el script con este cambio da como resultado esta salida:

 [me@linuxbox ~]$ trouble
 /home/me/bin/trouble: line 7: [: =: unary operator expected
 Number is not equal to 1.

Obtenemos un mensaje de error bastante críptico, seguido de la salida del segundo comando echo. El problema es la expansión de la variable number dentro del comando test. Cuando el comando:

 [ $number = 1 ]

se somete a la expansión con number estando vacío, el resultado es este:

 [ = 1 ]

que no es válido y se genera el error. El operador = es un operador binario (requiere un valor a cada lado), pero el primer valor no está, así que el comando test espera un operador unario (como -Z) en su lugar. Más adelante, como el test ha fallado (debido al error), el comando if recibe un código de salida no-cero y actua de acuerdo con esto, y se ejecuta el segundo comando echo.

Este problema puede corregirse añadiendo comillas alrededor del primer argumento en el comando test:

 [ "$number" = 1 ]

De esta forma cuando se produce la expansión, el resultado será este:

 [ "" = 1 ]

que proporciona el número correcto de argumentos. Además de para las cadenas vacías, las comillas deben usarse en casos donde un valor puede expandirse en cadenas multipalabra, como nombres de archivo que contengan espacios incluidos.

Errores lógicos

Al contrario de los errores sintácticos, los errores lógicos no impiden que se ejecute un script. El script se ejecutará, pero no producirá el resultado deseado, debido a un problema con su lógica. Hay un número incontable de errores lógicos posibles, pero aquí os dejo unos pocos de los tipos más comunes que se encuentran en scripts:

  1. Expresiones condicionales incorrectas. Es fácil codificar mal un if/then/else y llevar a cabo una lógica equivocada. A veces la lógica se invierte, o queda incompleta.
  2. Errores "por uno". Cuando codificamos bucles que utilizan contadores, es posible pasar por alto que el bucle requiere que el contador empiece por cero en lugar de uno, para que concluya en el punto correcto. Este tipo de errores producen un bucle que "va más allá del final" contando demasiado lejos, o que pierde la última iteración del bucle terminando una iteración antes de tiempo.
  3. Situaciones no previstas. La mayoría de los errores lógicos son producidos porque un programa se encuentra con datos o situaciones que no han sido previstas por el programador. Esto puede incluir tanto expansiones no previstas, como un nombre de archivo que contiene espacios y que se expande en múltiples argumentos de comando en lugar de un nombre de archivo único.
Programación defensiva

Es importante verificar las suposiciones cuando programamos. Esto significa una evaluación cuidadosa del estado de salida de programas y comandos que se usan en un script. Aquí hay un ejemplo, basado en hechos reales. Un desafortunado administrador de sistemas escribió un script para realizar una tarea de mantenimiento en un importante servidor. El script contenía las siguientes dos líneas de código:

 cd $dir_name
 rm *

No hay nada intrínsecamente malo en estas dos líneas, ya que el directorio citado en la variable, dir_name existe. Pero ¿que ocurre si no es así? En ese caso, el comando cdfalla y el script continua con al siguiente línea y borra los archivos en el directorio de trabajo actual. ¡No es para nada el resultado deseado! Este desafortunado administrador destruyó una parte importante del servidor por esta decisión de diseño.

Veamos algunas formas en que este diseño puede mejorarse. Primero, sería prudente hacer que la ejecución de rm dependea del éxito de cd:

 cd $dir_name && rm *

De esta forma, si el comando cd falla, el comando rm no se ejecuta. Esto es mejor, pero aún deja abierta la posibilidad de que la variable, dir_name, esté sin configurar o vacía, lo que resultaría en que los archivos en el directorio home del usuario se borrarían. Esto podría evitarse también comprobando si dir_name en realidad contiene el nombre de un directorio existente:

 [[ -d $dir_name ]] && cd $dir_name && rm *

A menudo, es mejor terminar el script con un error cuando ocurre una situación como la anterior:

 # Delete files in directory $dir_name
 if [[ ! -d "$dir_name" ]]; then
     echo "No such directory: '$dir_name'" >&2
     exit 1
 fi
 if ! cd $dir_name; then
     echo "Cannot cd to '$dir_name'" >&2
     exit 1
 fi
 if ! rm *; then
     echo "File deletion failed. Check results" >&2
     exit 1
 fi

Aquí, comprobamos tanto el nombre, para ver si es el de un directorio existente, como el éxito del comando cd. Si ambos fallan, se envía un error descriptivo al error estándar y el script termina con un estado de salida de uno para indicar un fallo.


Verificando la entrada

Una regla general de buena programación es que, si un programa acepta entrada, debe ser capaz de gestionar cualquier cosa que reciba. Esto normalmente significa que la entrada debe ser cuidadosamente filtrada, para asegurar que sólo se acepte entrada válida para su procesamiento posterior. Vimos un ejemplo de esto en nuestro capítulo anterior cuando estudiamos el comando read. Un script conteniendo el siguiente test para verificar una selección de menú:

 [[ $REPLY =~ ^[0-3]$ ]]

Este test es muy específico. Sólo devolverá un estado de salida cero si la cadena devuelta por el usuario es un número en el rango entre cero y tres. Nada más será aceptado. A veces estos tipos de tests pueden ser muy complicados de escribir, pero el esfuerzo es necesario para producir un script de alta calidad.


El diseño va en función del tiempo

Cuando estudiaba diseño industrial en el instituto, un sabio profesor expuso que el grado de diseño en un proyecto viene determinado por la cantidad de tiempo que le den al diseñador. Si te dieran cinco minutos para diseñar un dispositivo "que mate moscas," diseñarías un matamoscas. Si te dieran cinco meses, volverías con un "sistema anti-moscas" guiado por láser.

El mismo principio se aplica a la programación. A veces un script "rápido y sucio" servirá si sólo va a usarse una vez y sólo va a usarlo el programador. Este tipo de script es común y debería desarrollarse rápidamente para hacer que el esfuerzo sea rentable. Tales scripts no necesitan muchos comentarios ni comprobaciones defensivas. En el otro extremo, si el script está destinado a un uso en producción, o sea, un script que se usará una y otra vez para una tarea importante o por muchos usuarios, necesita un desarrollo mucho más cuidadoso.

Testeo


El testeo es un paso importante en todo tipo de desarrollo de software, incluidos los scripts. Hay un dicho en el mundo del software libre, "libera pronto, libera a menudo", que refleja este hecho. Liberando pronto y a menudo, el software se hace más expuesto al uso y al testeo. La experiencia nos ha demostrado que los bugs son más fáciles de encontrar, y mucho menos caros de arreglar, si se encuentran pronto en el ciclo de desarrollo.

En un tema anterior, vimos cómo podemos usar stubs para verificar el flujo del programa. Desde las primeras fases del desarrollo del script, son una técnica valiosa para comprobar el progreso de nuestro trabajo.

Echemos un vistazo al problema de eliminación de archivos anterior y veamos como puede codificarse para que sea más fácil el testeo. Testear el fragmento original de código puede ser peligroso, ya que su propósito es borrar archivos, pero podríamos modificar el código para hacer que el testeo sea seguro:

 if [[ -d $dir_name ]]; then
     if cd $dir_name; then
         echo rm * # TESTING
     else
         echo "cannot cd to '$dir_name'" >&2
         exit 1
     fi
 else
     echo "no such directory: '$dir_name'" >&2
     exit 1
 fi
 exit # TESTING

Como las condiciones del error ya muestran mensajes útiles, no tenemos que añadir nada. El cambio más importante es colocar un comando echo justo antes del comando rm para permitir al comando y su lista de argumentos expandidos que se muestren, en lugar de que el comando se ejecute en realidad. Este cambio permite la ejecución segura del código. Al final del fragmento de código, colocamos un comando exit para concluir el test y prevenir que cualquier otra parte del script se ejecute. La necesidad de esto variará segun el diseño del script.

También incluimos algunos comentarios que actúan como "marcadores" de nuestros cambios referidos al testeo. Estos pueden usarse para ayudarnos a encontrar y eliminar los cambios cuando esté completo el testeo.


Casos de testeo

Para realizar un testeo útil, es importante desarrollar y aplicar buenos casos de testeo. Esto se hace eligiendo cuidadosamente la entrada de datos o las condiciones de operación que reflejen casos límite. En nuestro fragmento de código (que es muy simple), queremos saber como se comporta el código bajo tres condiciones específicas:

  1. dir_name contiene el nombre de un directorio existente
  2. dir_name contiene el nombre de un directorio no existente
  3. dir_name está vacío
Realizando el testeo con cada una de estas condiciones, obtenemos una buena cobertura de testeo.

Tal como con el diseño, el testo depende el tiempo, también. No todas las partes del script necesitan ser testeadas intensivamente. De hecho es una forma de determinar que es lo más importante. Como podría ser potencialmente destructivo si no funciona bien, nuestro fragmento de código se merece una consideración cuidadosa tanto durante el diseño como el testeo.

Depuración

Si el testeo revela un problema con un script, el siguiente paso es la depuración. "Un problema" normalmente significa que el script, de alguna forma, no está realizando lo que espera el programador. Si este es el caso, necesitamos determinar cuidadosamente que es lo que el script está haciendo en realidad y por qué. Encontrar errores puede implicar, a veces, mucho trabajo de detective.

Un script bien diseñado debería ayudar. Debería estar programado defensivamente, para detectar condiciones anormales y proporcionar retroalimentación útil al usuario. A veces, sin embargo, los problemas son algo extraños e inesperados, y se requieren más técnicas involucradas.

Encontrando el área del problema

En algunos scripts, particularmente los largos, es útil a veces aislar el área del script relacionado con el problema. Esto no siempre será el error real, pero aislarlo a menudo proporciona un mejor enfoque del problema real. Una técnica que puede usarse para aislar código es "comentar" secciones de un script. Por ejemplo, nuestro fragmento borrador de archivos podría modificarse para determinar si la sección eliminada está relacionada con un error:

 if [[ -d $dir_name ]]; then
     if cd $dir_name; then
     rm *
 else
     echo "cannot cd to '$dir_name'" >&2
     exit 1
 fi
 # else
 # echo "no such directory: '$dir_name'" >&2
 # exit 1

 fi

Colocar símbolos de comentarios al principio de cada línea en una sección lógica del script, impide que la sección se ejecute. El testeo puede ahora ser realizado de nuevo, para ver si la eliminación del código tiene algún impacto en el comportamiento del error.


Trazado

Los errores son a menudo casos de un flujo lógico inesperado dentro de un script. O sea, partes del script o nunca se ejecutan, o se ejecutan en un orden erróneo o en el momento equivocado. Para ver el flujo real del programa, usamos una técnica llamada tracing o trazado.

Un método de trazado implica colocar mensajes informativos en un script que muestre la localización de la ejecución. Podemos añadir mensajes a nuestro fragmento de código:

 echo "preparing to delete files" >&2
 if [[ -d $dir_name ]]; then
     if cd $dir_name; then
         echo "deleting files" >&2
         rm *
     else
         echo "cannot cd to '$dir_name'" >&2
         exit 1
     fi
 else
     echo "no such directory: '$dir_name'" >&2
     exit 1
 fi
 echo "file deletion complete" >&2

Enviamos mensajes al error estándar para separalo de la salida normal. No indentamos las líneas que contienen los mensajes, así es más fácil encontrarlas cuando sea el momento de borrarlas.

Ahora, cuando se ejecuta el script, es posible ver que el borrado de archivos se ha realizado:

 [me@linuxbox ~]$ deletion-script
 preparing to delete files
 deleting files
 file deletion complete
 [me@linuxbox ~]$

bash también proporciona un método de trazado, implementado por la opción -x y el comando set con la opción -x. Usando nuestro anterior script trouble, podemos activar el trazado para todo el script añadiendo la opción -x en la primera línea:

 #!/bin/bash -x


 # trouble: script to demonstrate common errors

 number=1

 if [ $number = 1 ]; then
     echo "Number is equal to 1."
 else
     echo "Number is not equal to 1."
 fi

Cuando se ejecuta, el resultado es el siguiente:

 [me@linuxbox ~]$ trouble
 + number=1
 + '[' 1 = 1 ']'
 + echo 'Number is equal to 1.'
 Number is equal to 1.

Cuando el trazado está activado, vemos que los comandos se ejecutan con expansiones aplicadas. El signo más al principio de la línea indica el resultado del trazado para distinguirlo de las líneas de salida normal. El signo más es el carácter por defecto de la salida del trazado. Está contenido en la variable de shell PS4 (prompt string 4 - cadena de prompt 4). El contenido de esta variable puede ajustarse para hacer el prompt más útil. Aquí, modificamos el contenido de la variable para incluir el número de línea actual en el script donde se realiza el trazado. Fíjate que se requieren comillas simples para impedir la expansión hasta que el prompt se haya usado realmente:

 [me@linuxbox ~]$ export PS4='$LINENO + '
 [me@linuxbox ~]$ trouble
 5 + number=1
 7 + '[' 1 = 1 ']'
 8 + echo 'Number is equal to 1.'
 Number is equal to 1.

Para realizar un trazado en una porción concreta del un script, en lugar del script completo, podemos usar el comando set con la opción -x:

 #!/bin/bash

 # trouble: script to demonstrate common errors

 number=1

 set -x # Turn on tracing
 if [ $number = 1 ]; then
     echo "Number is equal to 1."
 else
     echo "Number is not equal to 1."
 fi
 set +x # Turn off tracing

Usamos el comando set con la opción -x activada para habilitar el trazado y la opción +x para desactivar el trazado. Esta técnica puede usarse para examinar múltiples porciones de un script problemático.

Examinando valores durante la ejecución

A menudo es útil, junto con el trazado, mostrar el contenido de variables para ver los trabajos internos de un script mientras se está ejecutando. Normalmente el truco es aplicar instancias adicionales de echo:

 #!/bin/bash


 # trouble: script to demonstrate common errors

 number=1

 echo "number=$number" # DEBUG
 set -x # Turn on tracing
 if [ $number = 1 ]; then
     echo "Number is equal to 1."
 else
     echo "Number is not equal to 1."
 fi
 set +x # Turn off tracing

En este ejemplo trivial, simplemente mostramos el valor de la variable número y marcamos la línea añadida con un comentario para facilitar su posterior identificación y eliminación. Esta técnica es particularmente útil cuando vemos el comportamiento de bucles y aritmética dentro de scripts.

Resumiendo

En este capítulo, hemos visto sólo unos pocos de los problemas que pueden surgir durante el desarrollo de un script. De acuerdo, hay muchos más. Las técnicas descritas aquí permitirán encontrar los errores más comunes. El depurado es un arte fino que puede desarrollarse a través de la experiencia, tanto en conocimiento de cómo evitar errores (probando constantemente durante el desarrollo) como encontrando errores (uso efectivo del trazado).

Para saber más


No hay comentarios:

Publicar un comentario

Nota: solo los miembros de este blog pueden publicar comentarios.