La Gaceta de Linux... haciendo de Linux algo un poco más divertido.


Introducción a los Scripts de Shell

Por Ben Okopnik


El mes pasado, le echamos un vistazo a los aspectos básicos de la creación de scripts de shell, al igual que a algunos de los mecanismos sobre los que basa su trabajo. En esta ocasión, veremos como los ciclos y la ejecución condicional nos permiten dirigir el flujo del programa en los scripts, del mismo modo veremos algunas prácticas correctas de escritura de estos programas.
 

CONVENCIONES

La única cosa a notar en este artículo son los puntos suspensivos (...) - Los utilizo para indicar que el código mostrado es solo un fragmento y no el script completo. Si te es de ayuda, imagina que los puntos suspensivos son una o más líneas de código que no están realmente escritas.

LOS CICLOS Y LA EJECUCIÓN CONDICIONAL

"FOR;DO;DONE"

A menudo, se escriben scripts para automatizar algunas tareas repetitivas; como un ejemplo al azar, si usted tiene que editar repetidamente un grupo de archivos en un directorio específico, puede usar un script como el siguiente:



#!/bin/bash

for n in ~/weekly/*.txt
do
    ae $n
done

echo "Done."


O como este:



#!/bin/bash

for n in ~/weekly/*.txt; do ae $n; done; echo "Done."


El código en ambos casos hace exactamente lo mismo - pero la primera versión es mucho más legible, especialmente si está construyendo scripts largos con varios niveles. Como una buena práctica general al escribir código, debe indentar cada nivel (los comandos dentro de los ciclos); esto hace la depuración y lectura de su código mucho más fácil.

La estructura de control anterior es llamada "ciclo for" - ésta, prueba si quedan elementos restantes en una lista (vgr. ¿existen más archivos, además de los ya leídos, que cumplen con la condición "~/weekly/*.txt"?). Si el resultado de la prueba es verdadero, le asigna el nombre del elemento actual en la lista a la variable del ciclo ("n" en este caso) y ejecuta el cuerpo del ciclo (la parte entre "do" y "done"), entonces verifica de nuevo. Cuando la lista se termina, El 'for' deja de ciclar y le pasa el control a la línea que sigue de la palabra 'done' - en nuestro ejemplo, el comando "echo".

Me gustaría mencionar un truco aquí. Si usted quiere que el ciclo efectúe un cierto número de iteraciones, la sintaxis del script puede ser algo laboriosa:



#!/bin/bash

for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
do
    echo $i
done


¡Qué molestia! ¿Qué tal si quisiera iterar, digamos, 250 veces?, ¡tendría que teclear todos esos números! Afortunadamente, existe un atajo - el comando "seq", el cual imprime una secuencia de números desde 1 hasta un máximo dado, por ejemplo:



#!/bin/bash

for i in $(seq 15)
do
    echo $i
done


Esto es funcionalmente lo mismo que en el script previo. "seq" es parte del paquete de utilerías del shell de GNU y probablemente ya está instalado en su sistema. Existe también la opción de efectuar este tipo de iteraciones con un ciclo "while", pero es un poco más laborioso.
 

"WHILE;DO;DONE"

Frecuentemente, necesitamos un mecanismo de control que actúe basado en una condición específica más que en un recorrido a lo largo de una lista. El ciclo 'while' llena este requerimiento:



#!/bin/bash

pppd call provider &

while [ -n "$(ping -c 1 192.168.0.1|grep 100%)" ]
do
    echo "Connecting..."
done

echo "Connection established."


El flujo general del script es el siguiente: invocamos el daemon de PPP, con "pppd", entonces lo mantenemos ciclando hasta que se establece una conexión (si desea usar este script, reemplace 192.168.0.1 con la dirección IP de su proveedor de Internet). Veamos los detalles:

1) El comando "ping -c 1 xxx.xxx.xxx.xxx" envía una señal de ping a la dirección IP suministrada; nótese que debe ser una dirección IP y no una URL (vgr. www.linux.org) - el "ping" fallaría inmediatamente por la falta de DNS. Si no hay respuesta dentro de 10 segundos, imprimirá algo como esto:
 

PING xxx.xxx.xxx.xxx (xxx.xxx.xxx.xxx): 56 data bytes
ping: sendto: Network is unreachable
ping: wrote xxx.xxx.xxx.xxx 64 chars, ret=-1

--- xxx.xxx.xxx.xxx ping statistics ---
1 packets transmitted, 0 packets received, 100% packet loss

2) La única línea que nos interesa es la que nos da el porcentaje de pérdida de los paquetes; con un solo paquete, solo puede ser 0% (un ping exitoso) ó 100%. Redirigiendo la salida del "ping" a través del comando "grep 100%", nos enfocamos solo en esa línea, si la pérdida es de hecho100%; una pérdida de 0% no producirá salida alguna. Nótese que la cadena "100%" no tiene nada de especial: pudimos haber utilizado "ret=-1", "unreachable", o cualquier otra cosa que sea específica de una respuesta de falla en el ping.

3) Los paréntesis rectangulares que contienen la instrucción son un sinónimo del comando 'test', el cual produce '0' ó '1' (falso o verdadero) basado en la evaluación de lo que está dentro de los paréntesis rectangulares. El operador '-n' produce un 'verdadero' si la longitud de la cadena de caracteres dada es mayor que cero. Dado que suponemos que la cadena es contigua (sin espacios en blanco), y la línea que estamos verificando no lo es, necesitamos encerrar la salida entre comillas dobles - esta es una técnica que utilizara una y otra vez al escribir scripts. Hay que enfatizar que los paréntesis rectangulares requieren de espacios en blanco a su alrededor - vgr., [-n $STRING] no funcionará; [ -n $STRING ] es la forma correcta. Para mayor información sobre los operadores utilizados con 'test', digite "help test"; hay disponibles un buen número de ellos.

4) Mientras la prueba de arriba arroje resultados "verdaderos" (vgr., mientras el "ping" falle), el ciclo 'while' continuará ejecutándose - imprimiendo la cadena "Connecting..." cada 10 segundos. Tan pronto como un solo ping sea exitoso (la prueba produce un "falso"), el ciclo 'while' se romperá y pasará el control a la línea "done".
 

"UNTIL;DO;DONE"

El ciclo 'until' es lo contrario del ciclo 'while' - continua iterando mientras la condición sea falsa, y falla cuando se vuelve verdadera. Nunca he tenido la oportunidad de usarlo; el ciclo 'while' y la flexibilidad de las pruebas o condiciones disponibles han sido suficientes para todo lo que he necesitado hasta el momento.
 

"IF;THEN;[ELSE];FI"

Existen muchas ocasiones en las que simplemente necesitamos verificar la existencia de una condición y bifurcar la ejecución del script basado en el resultado. Para estos casos, tenemos el comando 'if ':



...

if [ $JEFE="maldito" ]
then
    echo '!Toma tu trabajo, yo me marcho!'
else
    echo 'Relajate; la paga es buena.'
fi

...


<Bueno> Supongo que no es tan sencillo... pero la lógica tiene sentido. De cualquier modo, si la variable llamada JEFE ha sido definida como "maldito" (Programadores de C tomen nota: '=' y '==' son equivalentes en una instrucción de prueba - no ocurre ninguna asignación), entonces el primer comando 'echo' será ejecutado. En todos los demás casos, el segundo comando 'echo' se ejecutará (si $JEFE="idiota", todavía estarías trabajando allá. Discúlpenme por lo anterior. :). Nótese que la instrucción 'else' es opcional, como en este fragmento de script:



...

if [ -n $ERROR ]
then
    echo 'Detected an error; exiting.'
    exit
fi

...


Esta rutina obviamente saldrá si la variable ERROR es cualquier otra cosa que no sea una cadena vacía o nula - de lo contrario no afectará el flujo de ejecución.
 

"CASE;IN;;ESAC"

La herramienta restante que podemos utilizar para bifurcación condicional es básicamente una instrucción 'if' múltiple, basada en la evaluación de una prueba. Si, por ejemplo, sabemos que los únicos resultados posibles de un programa imaginario llamado 'intel_cpu_test' son 4, 8, 16, 32, or 64, entonces podemos escribir lo siguiente:



#!/bin/bash

case $(intel_cpu_test) in
    4) echo "¿Estás corriendo Linux en una calculadora ????";;
    8) echo "A este 8088 ya se la pasó la jubilación...";;
    16) echo "Eres de la banda del 286, verdad???";;
    32) echo "¡Una de las Novedades por aqui!";;
    64) echo "¡Orale!... esto si es un CPU, que envidia!";;
     *) echo "¿En qué rayos estás corriendo Linux?, me pregunto yo";;
esac


(Antes de que me bombardeen con correos acerca de correr Linux en una 286 u 8088... tampoco corre en una calculadora)

Obviamente, el "*" al final abarca cualquier otra opción: si alguien en el laboratorio secreto de Intel corriera este programa en su nuevo CPU (nombre clave "UltraSuperHyperMaxiMiniBati"), queremos que el script nos de una respuesta controlada en vez de una falla. Note que utilizamos doble punto y coma - estos sirven para cerrar cada uno de los juegos "patrón/comando" y son (por alguna razón) un error común en las cláusulas "case/esac" . ¡Ponga atención extra en esto!
 

BREAK y CONTINUE

Estas instrucciones interrumpen el flujo del programa de diversas formas. El "break", una vez ejecutado, sale inmediatamente del ciclo que lo contiene; la instrucción "continue" se salta la iteración actual del ciclo. Esto es de utilidad en un número situaciones, particularmente en ciclos largos donde la existencia de una condición dada hace que todas las pruebas siguientes sean innecesarias. Veamos un pseudo-ejemplo largo (pero entendible, espero):



...

while [ hosting_party ]
do
    case $FOOD_STATUS
    in
        potato_chips_gone) replace_potato_chips;;
        peanuts_finished) refill_peanut_bowl;;
        pretzels_gone) open_new_pretzel_bag;;
        ...
        ...
    esac
 

    if [ police_on_scene ]
    then
        talk_to_nice_officers
        continue
    fi

    case $LIQUOR_STATUS
    in
        vodka_gone) open_new_vodka_bottle;;
        rum_gone) open_new_rum_bottle;;
        ...
        ...
    esac

    case $ANALYZE_GUEST_BEHAVIOR
    in
        lampshade_on_head)     echo "He's been drinking";;
        talking_to_plants)     echo "She's been smoking";;
        talking_to_martians)   echo "They're doing LSD";;
        levitating_objects)    echo "Who spiked my lemonade??";;

...

   ...

    ...

    esac

done

echo "Dude... what day is it?"


Un par de puntos clave: note que al verificar el estatus de los diferentes suministros de fiesta, tal vez es más fácil de escribir con comandos "if" múltiples - ambos potato chips y pretzels se pueden terminar al mismo tiempo (ya que no son mutuamente excluyentes). De la manera actual, los chips tienen la más alta prioridad; si dos artículos se terminan al mismo tiempo, tomará dos ciclos para reemplazarlos.

Podemos seguir verificando el estatus de la comida mientras tratamos de convencer a la policía de que en realidad tenemos una reunión de coleccionistas de estampillas (de hecho, el suministro de doughnut es un factor crucial en este punto), pero nos saltaremos justo después del estatus del licor - como estaba, bajamos a Joe del candil justo a tiempo...

La instrucción "continue" se salta la última parte del ciclo "while" mientras la función "police_on_scene" regrese un "verdadero"; esencialmente, el cuarpo del ciclo es truncado en ese punto. Vea que aunque está dentro del "if", afecta todo el ciclo que la contiene: ambos "continue" y "break" aplican solo a ciclos, por ejemplo, "for", "while", y "until".
 
 

REGRESAR AL FUTURO

Aquí está el script que creamos el mes pasado:



#!/bin/bash
# "bkup" - copies specified files to the user's ~/Backup
# directory after checking for name conflicts.

a=$(date +%T-%d_%m_%Y)
cp -i $1 ~/Backup/$1.$a


Interesantemente, poco después de terminar el artículo del mes pasado, estaba introduciendo un poco de código en C en una computadora que no tenía 'rcs' (El sistema de control de revisiones de GNU) instalado - y este script resultó ser muy útil como un 'micro-rcs'; Lo utilicé para tomar "fotos" del estatus del proyecto. Scripts simples y generales como este son muy útiles en tiempos difíciles...
 

VERIFICANDO ERRORES

El script de arriba es totalmente funcional - para usted, o para cualquier otro que se tome la molestia de leerlo y entenderlo. Enfrentémoslo, lo que esperamos de un script o programa es que funcione con solo digitar su nombre, ¿no es verdad? Eso, o que nos diga exactamente por que no trabajó. En este caso, sin embargo, lo que obtenemos es un mensaje encriptado:

cp: missing destination file Try `cp --help' for more information.

Para todos los demás, y para nosotros mismos mas adelante, cuando hayamos olvidado como usar este tremendamente complejo script con innumerables opciones :), necesitamos incluir verificación de error - específicamente, información de uso/sintáxis. Veamos como aplicaría lo que acabamos de aprender:


#!/bin/bash

if [ -z $1 ]
then
    clear
    echo "'bkup' - copies the specified file to the user's"
    echo "~/Backup directory after checking for name conflicts."
    echo
    echo "Usage: bkup filename"
    echo
    exit
fi

a=$(date +%T-%d_%m_%Y)
cp -i $1 ~/Backup/$1.$a


El operador '-z' de 'test' regresa '0' (verdadero) para una cadena de longitud cero; lo que estamos verificando es si 'bkup' está siendo ejecutado sin un nombre de archivo. El inicio es, en mi opinión, el mejor lugar para poner información de ayuda / utilización en un - si olvidas cuales son la opciones, solo corre el script sin ninguna, y el mismo te recordará como utilizarlo. Ni siquiera necesitas incluir los comentarios originales, ahora - note que básicamente hemos incorporado nuestros comentarios en la información de utilización. De todos modos, es una buena idea el colocar comentarios donde utilizamos trucos no tan obvios en el script - ese truco tan brillante que se te ocurrió puede hacer que te golpees la cabeza y te jales el cabello el año entrante, si no lo haces...

Antes de dejar de jugar con este script, démosle una cuantas capacidades adicionales. ¿Que tal si quisiéramos enviar diferentes tipos de archivos a directorios diferentes? Tratemos de hacerlo con lo que hemos aprendido:



#!/bin/bash

if [ -z $1 ]
then
    clear
    echo "'bkup' - copies the specified file to the user's ~/Backup"
    echo "directory tree after checking for name conflicts."
    echo
    echo "Usage: bkup filename [bkup_dir]"
    echo
    echo "bkup_dir Optional subdirectory in '~/Backup' where the file"
    echo " will be stored."
    echo
    exit
fi

if [ -n $2 ]
then
    if [ -d ~/Backup/$2 ]
    then
        subdir=$2/
    else
        mkdir -p ~/Backup/$2
        subdir=$2/
    fi
fi

a=$(date +%T-%d_%m_%Y)
cp -i $1 ~/Backup/$subdir$1.$a


El resumen de los cambios es:

1) La sección de comentarios de ayuda ahora dice "...directory tree" en vez de solo "directory", indicando el cambio que acabamos de hacer.

2) La línea "Usage:" ha sido alargada para mostrar el argumento opcional (indicado por los paréntesis rectangulares); Hemos agregado también una explicación sobre como usar el argumento, dado que puede no ser obvio a alguien más.

3) Se agregó una construcción "if" que verifica si $2 (un segundo argumento de 'bkup') existe; de ser así, verifica si existe un directorio con el nombre dado bajo "~/Backup", y lo crea si no existe (la "-d" prueba si el archivo existe y si es un directorio).

4) El comando 'cp' ahora tiene una variable 'subdir' entre "Backup/" y "$1".

Ahora, puede digitar cosas como:

bkup my_new_program.c c
bkup filter.awk awk
bkup filter.awk filters
bkup Letter_to_Mom.txt docs

etc., y ordenar todo en las categorías que guste. Además, el comportamiento anterior de "bkup" sigue disponible -

bkup file.xyz

enviará un respaldo de "file.xyz" a el directorio "~/Backup" ; útil para los archivos que no caigan en tu criterio de clasificación.
 

A propósito; ¿por que estamos agregando una "/" al $2 en la instrucción "if" en vez de justo en la línea del "cp"? Bueno, si $2 no existe , entonces queremos que 'bkup' actúe como lo hacía originalmente, esto es, que envíe el archivo al directorio de "Respaldo". Si escribimos algo como

cp -i $1 ~/Backup/$subdir/$1.$a

(note el "/" extra entre $subdir y $1), si $2 no está especificado, entonces $subdir queda en blanco, y la línea de arriba se transforma en

cp -i $1 ~/Backup//$1.$a

- un resultado no muy deseable, dado que nos queremos apegar a las prácticas de sintaxis tanto como sea posible.

De hecho, es una buena idea considerar todas las posibilidades cuando esté construyendo una cadena con variables; un error clásico es el que se puede ver en el siguiente script -



¡NO UTILICE ESTE SCRIPT!

#!/bin/bash
# Escrito por Larry, Moe, y Shemp
# Revisado por Curly:

# Todo lo que tiene que hacer es digitar el nombre de este archivo seguido por
# cualquier cosa que quiera borrar - directorios, archivos ocultos,
# archivos múltiples, cualquier cosa está bien!

rm -rf $1*

¡NO UTILICE ESTE SCRIPT!


<¡ Bueno!> Cuando menos los comentaron. :)

¿Que sucede si alguien corre el script "three_stooges", y no alimenta ningún parámetro? La línea activa en el script se convierte en

rm -rf *

Asumiendo que usted es el usuario Joe en su propio directorio, el resultado es bastante horrible - borrará todos sus archivos personales. Se convertirá en una catástrofe si usted es el súper usuario y está en el directorio raíz - ¡El sistema completo se evaporará!

Comparado con esto, los virus se ven tan inofensivos y amigables...

Tenga cuidado con la escritura de scripts. Como acaba de ver, usted tiene el poder para destruir un sistema completo en solo un parpadeo.



El Unix nunca fue diseñado para evitar que la gente haga cosas estúpidas,
por que eso evitaría que hicieran cosas brillantes.
-- Doug Gwyn

El Unix te da justo la cuerda suficiente para que te cuelgues tu mismo, y entonces añade un metro más, solo para estar seguro.
-- Eric Allman


La filosofía tiene sentido: poder ilimitado en las herramientas, restricciones en permisos - pero impone una responsabilidad: debes tener el cuidado apropiado. Como resumen, siempre que entres como root, no ejecutes scripts de shell que sean "probablemente indefensos" (Note la gran suposición colgando de la frase - "probablemente indefensos"...)
 

YA PARA TERMINAR

Los ciclos y la ejecución condicional son una parte muy importante de la mayoría de los scripts. Mientras analizamos otros scripts de shell en artículos futuros, usted verá la gran variedad de formas en que los podemos utilizar - un script de complejidad promedio no puede existir sin ellos.

El mes entrante, veremos algunas herramientas que son utilizadas comúnmente en scripts de shell - herramientas que le pueden ser muy familiares como utilerías de la línea de comandos - y exploraremos como podemos conectarlas para producir los resultados deseados . También disectaremos un par de scripts - de los míos, si nadie mas es lo suficientemente valiente para enviar los resultados de su trabajo con el teclado. (Tengan miedo, Tengan mucho miedo.) :)
 

Los comentarios y correcciones en relación a esta serie de artículos son bienvenidos, al igual que cualquier script interesante que puedas enviar. Todos los flamazos serán enviados al /dev/null (Oh no, ya se llenó...)
 

Hasta el próximo mes -

¡Diviértanse con Linux!
 



"SCRIPT CITABLE DEL MES":

¿Qué hace el siguiente script?

'unzip; touch; finger; mount; gasp; yes; umount; sleep'

Ahí les va una ayudadita: no todo se relaciona con computadoras. Algunas veces usted se encuentra en una bolsa de dormir, acampando con su novia.''
 -- Frans van der Zande


REFERENCIAS

Las páginas "man" de 'bash', 'seq', 'ping', 'grep'
El comando "help" para 'for', 'while', 'until', 'if', 'case', 'test',
'break', 'continue'
"Introducción a los scripts de Shell - Lo básico" por Ben Okopnik, GL #53


Copyright © 2000, Ben Okopnik
Publicado en la edición No. 53 de Linux Gazette, Mayo de 2000