jueves, 5 de octubre de 2017

Cosas Exóticas

En este, el último capítulo de nuestro viaje, veremos algunos flecos. Aunque hemos cubierto mucho terreno en los capítulos anteriores, hay muchas características de bash que no hemos cubierto. Muchas son algo confusas, y útiles principalmente para los que integran bash en una distribución Linux. Sin embargo, hay unas pocas que, aunque no son de uso común, son de ayuda para algunos problemas de programación. Las veremos aquí.

Comandos agrupados y subshells

bash permite agrupar comandos. Esto puede hacerse de dos formas; con un comando agrupado o con un subshell. Aquí tenemos ejemplos de la sintáxis de cada uno de ellos:

Grupo de comandos:

 { comando1; comando2; [comando3; ...] }

Subshell:

 (comando1; comando2; [comando3;...])

Las dos formas difieren en que el grupo de comandos rodea sus comandos con llaves y el subshell usa paréntesis. Es importante fijarse en que, debido a la forma en que bash implementa los grupos de comandos, las llaves deben separarse de los comandos por un espacio y el último comando debe terminar con un punto y coma o con una nueva línea antes de la llave de cierre.

Entonces ¿para qué sirven los grupos de comandos y los subshells? Aunque tienen una diferencia importante (que veremos en un momento), ambos se usan para gestionar redirecciones. Consideremos un segmento de script que realiza redirecciones. Consideremos un segmento de script que realiza redirecciones en múltiples comandos:

 ls -l > output.txt
 echo "Listing of foo.txt" >> output.txt
 cat foo.txt >> output.txt

Esto es bastante directo. Tres comandos con su salida redireccionada a un archivo llamado output.txt. Usando un grupo de comandos, podríamos codificarlo de la siguiente forma:

 { ls -l; echo "Listing of foo.txt"; cat foo.txt; } > output.txt

Usando un subshell es similar:

 (ls -l; echo "Listing of foo.txt"; cat foo.txt) > output.txt

Usando esta técnica nos hemos ahorrado algo de escritura, pero donde brilla un comando agrupado o un subshell realmente es en los entubados. Cuando construimos un entubado de comandos, es útil a menudo combinar el resultado de varios comandos en una única cadena. Los comandos agrupados y los subshell hacen esto de forma fácil:

 { ls -l; echo "Listing of foo.txt"; cat foo.txt; } | lpr

Aquí hemos combinado la salida de nuestros tres comandos y la hemos entubado dentro de la salida de lpr para producir un informe impreso.

En el script que sigue, usaremos comandos agrupados y veremos varias técnicas de programación que pueden emplearse junto con arrays asociativos. Este script, llamado array-2, cuando se el da el nombre de un directorio, imprime una lista de archivos en el directorio junto a los nombres de los propietarios de los archivos y de los grupos de los propietario. Al final del listado, el script imprime un listado del número de archivos que pertenecen a cada usuario y grupo. Aquí vemos el resultados (resumidos para abreviar) cuando se le da al script el directorio /usr/bin:

 [me@linuxbox ~]$ array-2 /usr/bin
 /usr/bin/2to3-2.6 root root
 /usr/bin/2to3 root root
 /usr/bin/a2p root root
 /usr/bin/abrowser root root
 /usr/bin/aconnect root root
 /usr/bin/acpi_fakekey root root
 /usr/bin/acpi_listen root root
 /usr/bin/add-apt-repository root root
 .
 .

 .
 /usr/bin/zipgrep root root
 /usr/bin/zipinfo root root
 /usr/bin/zipnote root root
 /usr/bin/zip root root
 /usr/bin/zipsplit root root
 /usr/bin/zjsdecode root root
 /usr/bin/zsoelim root root

 File owners:
 daemon : 1 file(s)
 root : 1394 file(s)

 File group owners:
 crontab : 1 file(s)
 daemon : 1 file(s)
 lpadmin : 1 file(s)
 mail : 4 file(s)
 mlocate : 1 file(s)
 root : 1380 file(s)
 shadow : 2 file(s)
 ssh : 1 file(s)
 tty : 2 file(s)
 utmp : 2 file(s)

Aquí tenemos un listado (con los números de línea) del script:

  1  #!/bin/bash
  2
  3  # array-2: Use arrays to tally file owners
  4
  5  declare -A files file_group file_owner groups owners
  6
  7  if [[ ! -d "$1" ]]; then
  8      echo "Usage: array-2 dir" >&2
  9      exit 1
 10  fi
 11
 12  for i in "$1"/*; do
 13      owner=$(stat -c %U "$i")
 14      group=$(stat -c %G "$i")
 15      files["$i"]="$i"
 16      file_owner["$i"]=$owner
 17      file_group["$i"]=$group
 18      ((++owners[$owner]))
 19      ((++groups[$group]))
 20  done
 21
 22  # List the collected files
 23  { for i in "${files[@]}"; do
 24      printf "%-40s %-10s %-10s\n" \
 25          "$i" ${file_owner["$i"]} ${file_group["$i"]}
 26  done } | sort

Echemos un vistazo a la mecánica de este script:

Línea 5: Los arrays asociativos deben crearse con el comando declare usando la opción -A. en este script creamos los cinco arrays siguientes:

files contiene los nombres de los archivos en el directorio, indexados por nombre de archivo
file_group contiene el grupo del propietario de cada archivo, indexado por nombre de archivo
file_owner contiene el propietario de cada archivo, indexado por nombre de archivo
group contiene el número de archivos pertenecientes al grupo indexado
owner contiene el número de archivos pertenecientes al propietario indexado

Líneas 7-10: Comprueba para ver que se ha pasado un nombre de directorio válido como parámetro posicional. Si no, se muestra un mensaje de uso y el script sale con un estado de salida de 1.

Líneas 12-20: Hace un bucle a través de los archivos del directorio. Usando el comando stat, las lineas 13 y 14 extraen los nombres del propietario del archivo y del grupo del propietario y asigna valores a sus arrays respectivos (líneas 16, 17) usando el nombre del archivo como índice del array. De otra forma el propio nombre del archivo se asigna al array files (línea 15).

Líneas 18-19: El número total de archivos pertenecientes al propietario del archivo y al grupo del propietario se incrementan en uno.

Líneas 22-27: Se muestra la lista de archivos. Esto se hace usando la expansión de parámetros "${array[@]}" que se expande en la lista completa de elementos del array cada uno tratado como una palabra separada. Esto permite la posibilidad de que un nombre de archivo contenga espacios en blanco. Fíjate también que el bucle completo está incluido en llaves para que forme un comando agrupado. Esto permite que la salida completa del bucle sea entubada en el comando sort. Esto es necesario porque la expansión de los elementos del array no está ordenada.

Líneas 29-40: Estos dos bucles son similares al bucle de la lista de archivos excepto que usan la expansión "${!array[@]}" que se expande en la lista de índices del array en lugar de en la lista de elementos del array.

Sustitución de procesos

Aunque parecen iguales y ambos pueden usarse para combinar cadenas para redireccionarlas, hay una diferencia importante entre los comandos agrupados y lo subshells. Mientras que un comando agrupado ejecuta todos sus comandos en el shell actual, un subshell (como su nombre indica) ejecuta sus comandos en una copia hijo del shell actual. Esto significa que el entorno se copia y se pasa a una instancia del shell. Cuando el subshell termina, la copia del entorno se pierde, por lo que cualquier cambio hecho al entorno del subshell (incluyendo la asignación de variables) se pierde también. Por lo tanto, en la mayoría de los casos, a menos que un script requiera un subshell, los comandos agrupados son preferibles a los subshells. Los comandos agrupados son también más rápidos y requieren menos memoria.

Vimos un ejemplo del problema del entorno del subshell en el capítulo 28, cuando descubrimos que el comando read en un entubado no funciona como esperaríamos intuitivamente. Para resumir, si construimos un entubado como este:

 echo "foo" | read
 echo $REPLY

El contenido de la variable REPLY siempre está vacío porque el comando readse ejecuta en un subshell, y su copia de REPLY se destruye cuando el subshell termina.

Como los comandos en entubados siempre se ejecutan en subshells, cualquier comando que asigne variables se encontrará con este problema. Afortunadamente, el shell ofrece una forma exótica de expansión llamada sustitución de procesos que puede usarse para solucionar este problema.

La sustitución de procesos se expresa de dos formas:

Para procesos que producen salida estándar:
 <(lista)

o, para procesos que toman entrada estándar:
 >(lista)

donde lista es una lista de comandos.

Para resolver nuestro problema con read, podemos emplear sustitución de procesos así:

 read < <(echo "foo")
 echo $REPLY

La sustitución de procesos nos permite tratar la salida de un subshell como un archivo ordinario para propósitos de redirección. De hecho, como es una forma de expansión, podemos examinar su valor real:

 [me@linuxbox ~]$ echo <(echo "foo")
 /dev/fd/63

Usando echo para ver el resultado de la expansión, vemos que la salida del subshell está proporcionada por el archivo llamado /dev/fd/63.

La sustitución de procesos se una a menudo con bucles que contienen read.Aquí tenemos un ejemplo de un bucle read que procesa el contenido de un listado de directorio creado por un subshell:

 #!/bin/bash


 # pro-sub : demo of process substitution

 while read attr links owner group size date time filename; do
     cat <<- EOF
         Filename:   $filename
        
         Size:       $size        
         Owner:      $owner        
         Group:      $group        
         Modified:   $date $time        
         Links:      $links        
         Attributes: $attr
     EOF
 done < <(ls -l | tail -n +2)

El bucle ejecuta read para cada línea de un listado de directorio. El propio listado se produce en la línea final del script. Esta línea redirige la salida de la sustitución de procesos en la entrada estándar del bucle. El comando tail está incluido en el entubado de la sustitución de procesos para eliminar la primera línea del listado, que no se necesita.

Cuando se ejecuta, el script produce salida como esta:

 [me@linuxbox ~]$ pro_sub | head -n 20
 Filename:    addresses.ldif
 Size:        14540
 Owner:       me
 Group:       me
 Modified:    2009-04-02 11:12
 Links:       1
 Attributes:  -rw-r--r--


 Filename:    bin
 Size:        4096
 Owner:       me
 Group:       me
 Modified:    2009-07-10 07:31
 Links:       2
 Attributes:  drwxr-xr-x


 Filename:    bookmarks.html
 Size:        394213
 Owner:       me
 Group:       me

Trampas

En el Capítulo 10, vimos cómo, los programas, puede responder a señales. Podemos añadir esta capacidad a nuestros scripts también. Aunque los scripts que hemos escrito hasta ahora no han necesitado esta capacidad (porque tienen tiempos de ejecución muy cortos, y no crean archivos temporales), los scripts más largos y complicados pueden beneficiarse de tener una rutina de manejo de señales.

Cuando diseñamos un script largo y complicado, es importante considerar que ocurre si el usuario cierra la sesión o apaga el ordenador mientras el script se está ejecutando. Cuando ocurre un evento como este, debe enviarse una señal a todos los procesos afectados. En respuesta, los programas que representan estos procesos pueden realizar acciones para asegurar una terminación apropiada y ordenada del programa. Digamos, por ejemplo, que hemos escrito un script que crea un archivo temporal durante su ejecución. En nombre del buen diseño, habríamos hecho que el script borre el archivo cuando el script termine su trabajo. También sería inteligente hacer que el script borre el archivo si recibe una señal indicando que el programa va a terminar prematuramente.

bash ofrece un mecanismo para este propósito conocido como trampa. Las trampas se implementan con el apropiadamente denominado comando incluido, trap. trap usa la siguiente sintaxis:

 trap argumento señal [señal...]

donde argumento es una cadena que se leerá y tratará como un comando y señales la especificación de una señal que pone en funcionamiento la ejecución el comando interpretado.

Aquí tenemos un ejemplo simple:

 #!/bin/bash


 # trap-demo : simple signal handling demo

 trap "echo 'I am ignoring you.'" SIGINT SIGTERM
 for i in {1..5}; do
     echo "Iteration $i of 5"
     sleep 5
 done

Este script define una trampa que ejecutará un comando echo cada vez que recibe la señal SIGINT o SIGTERM mientras el script se está ejecutando. La ejecución del programa aparece así cuando el usuario intenta detener el script presionando Ctrl-c:

 [me@linuxbox ~]$ trap-demo
 Iteration 1 of 5
 Iteration 2 of 5
 I am ignoring you.
 Iteration 3 of 5
 I am ignoring you.
 Iteration 4 of 5
 Iteration 5 of 5

Como podemos ver, cada vez que el usuario intenta interrumpir el programa, se muestra el mensaje en su lugar.

Construir una cadena para formar una secuencia útil de comandos puede ser complicado, por lo que es una práctica habitual especificar una función de shell como comandos. En este ejemplo, se especifica una función de shell separada para manejar cada señal:

 #!/bin/bash
 # trap-demo2 : simple signal handling demo

 exit_on_signal_SIGINT () {
     echo "Script interrupted." 2>&1
     exit 0
 }

 exit_on_signal_SIGTERM () {
     echo "Script terminated." 2>&1
     exit 0
 }

 trap exit_on_signal_SIGINT SIGINT
 trap exit_on_signal_SIGTERM SIGTERM
 for i in {1..5}; do
     echo "Iteration $i of 5"
     sleep 5
 done

Este script presenta dos comandos trap, uno para cada señal. Cada trampa, por turnos, especifica una función de shell a ejecutar cuando se recibe una señal en particular. Fíjate en la inclusión de un comando exit en cada una de las señales de manejo de señales. Si un exit, el script continuaría tras completar la función.

Cuando el usuario presione Ctrl-c durante la ejecución de este script, el resultado aparece así:

 [me@linuxbox ~]$ trap-demo2
 Iteration 1 of 5
 Iteration 2 of 5
 Script interrupted.

Archivos temporales

Una razón por la que los gestores de señales se incluyen en los scripts es para eliminar los archivos temporales que el script pueda crear para manejar resultados intermedios durante su ejecución. Hay algo artístico en la denominación de los archivos temporales. Tradicionalmente, los programas en sistemas como-Unix crean sus archivos temporales en el directorio /tmp, un directorio compartido creado para tales archivos. Sin embargo, como el directorio está compartido, esto conlleva algunos problemas de seguridad, particularmente para programas que se ejecutan con privilegios de superusuario. Mas allá del paso obvio de establecer permisos apropiados para los archivos expuestos a todos los usuarios del sistema, es importante dar a los archivos temporales nombres no predecibles. Esto evita un exploit conocido como temp race attack. Una forma de crear un nombre no predecible (pero descriptivo) es hacer algo como esto:


archivotemporal=/tmp($(nombrebase $0).$$.$RANDOM

Esto creará un archivo consistente en el nombre del programa, seguido por su ID de proceso (PID), seguido por un entero aleatorio. Fíjate, sin embargo, que la variable de shell $RANDOM solo devuelve un valor del rango 1-32767, que no es un rango muy grande en términos informáticos, por lo que una única instancia no es suficiente para vencer a un posible atacante.

Una forma mejor es usar el programa mktemp (no confundir con la función de librería estándar mktemp) para crear y nombrar el archivo temporal. El progrma mktemp acepta una plantilla como argumento que se usa para construir el nombre del archivo. La plantilla debe incluir una serie de "X" caracteres, que se reemplazan con un número correspondiente de letras y números aleatorios. Cuanto más larga sea la serie de "X" caracteres, más larga será la serie de caracteres aleatorios. Aquí tenemos un ejemplo:

archivotemporal=$(mktemp /tmp/foobar.$$.XXXXXXXXXX)

Esto crea un archivo temporal y asigna su nombre a la variable tempfile. Los "X" caracteres en la plantilla se reemplazan con letras y números aleatorios de forma que el nombre del archivo final (que, en este ejemplo, también incluye el valor expandido del parámetro especial $$ para obtener el PID) debería ser algo así:

/tmp/foobar. 6593,UOZuvM6654

Para scripts que se ejecutan por usuarios normales, sería prudente evitar el uso del directorio /tmp y crear un directorio para archivos temporales dentro del directorio home del usuario, con una línea de código como esto:

[[ -d $HOME/tmp ]] || mkdir $HOME/tmp


Ejecución asíncrona

A veces es preferible realizar más de una tarea al mismo tiempo. Hemos visto como los sistemas operativos modernos son al menos multitarea incluso multiusuario. Los script pueden construirse para comportarse de forma multitarea.

Normalmente, esto implica arrancar un script, y por turnos, arrancar uno o más script hijos que realicen una tarea adicional mientras que el script padre continua ejecutando. Sin embargo, cuando una serie de scripts se ejecutan de esta forma, puede haber problemas en la coordinación entre el script padre y los hijos. O sea, ¿qué pasa si el padre o el hijo son dependientes el uno del otro, y un script debe esperar a que el otro termine su tarea ante de finalizar la suya propia?

bash tiene un comando interno para ayudarnos a manejar ejecución asíncronacomo esta. El comando wait hace que un script padre se pause hasta que un proceso especificado (p.ej., el script hijo) termine.

wait

Probaremos el comando wait primero. Para hacerlo, necesitaremos dos scripts. un script padre:

 #!/bin/bash 

 # async-parent : Asynchronous execution demo (parent) 

 echo "Parent: starting..." 

 echo "Parent: launching child script..."   async-child & 
 pid=$! 
 echo "Parent: child (PID= $pid) launched."

 echo "Parent: continuing..." 
 sleep 2

 echo "Parent: pausing to wait for child to finish..." 
 wait $pid

 echo "Parent: child is finished.   Continuing..." 
 echo "Parent: parent is done. Exiting."

y un script hijo:

 #!/bin/bash 

 # async-child : Asynchronous execution demo (child) 

 echo "Child: child is running..." 
 sleep 5 
 echo "Child: child is done. Exiting."

En este ejemplo, vemos que el script hijo es muy simple. La acción real la realiza el padre. En el script padre, se arranca el script hijo y se envía al fondo. El ID de proceso del script hijo se graba asignando a la variable pid el valor del parámetro de shell $!, que siempre cotendrá el ID de proceso del último trabajo puesto en el fondo.

El script padre continua y luego ejecuta un comando wait con el PID del proceso hijo. Esto hace que el script padre se pause hasta que el script hijo salga, punto en el cual el script padre concluye.

Cuando se ejecutan, los script padre e hijo producen la siguiente salida:

 [me@linuxbox ~]$ async-parent
 Parent: starting...
 Parent: launching child script...
 Parent: child (PID= 6741) launched.
 Parent: continuing...
 Child: child is running...
 Parent: pausing to wait for child to finish...
 Child: child is done. Exiting.
 Parent: child is finished. Continuing...
 Parent: parent is done. Exiting.

Entubados con nombre

En la mayoría de los sistemas como-Unix, es posible crear un tipo especial de archivo llamado un entubado con nombre. Los entubados con nombre se usan para crear una conexión entre dos procesos y pueden usarse igual que otros tipos de archivo. No son muy populares, pero es bueno conocerlos.

Hay una arquitectura de programación común llamada cliente-servidor, que puede hacer uso de un método de comunicación como los entubados con nombre, así como de otros tipos de comunicación entre procesos tales como conexiones de red.

El tipo más ampliamente usado de sistema cliente-servidor es, claramente, la comunicación entre un navegador web y un servidor web. El navegador web actua como cliente, realizando peticiones al servidor y el servidor responde al navegador con páginas web.

Los entubados con nombre se comportan como archivos, pero en realidad forman buffers "el primero en entrar es el primero en salir" (FIFO - first in firt out). Igual que los entubados normales (sin nombre), los datos entran por un extremo y salen por el otro. Con los entubados con nombre, es posible configurar algo como esto:

proceso1 > entubado_con_nombre

y

proceso2 < entubado_con_nombre

y se comportará como si fuera:

proceso1 | proceso2


Configurando un entubado con nombre

Primero, debemos crear un entubado con nombre. Esto se hace usando el comando mkfifo:

 [me@linuxbox ~]$ mkfifo pipe1
 [me@linuxbox ~]$ ls -l pipe1
 prw-r--r-- 1 me me 0 2009-07-17 06:41 pipe1

Aquí hemos usado mkfifo para crear un entubado con nombre llamado pipe1. Usando ls, examinamos el archivo y vemos que la primera letra en el campo atributos es "p", indicando que es un entubado con nombre.


Usando entubados con nombre

Para demostrar como funciona el entubado con nombre, necesitaremos dos ventanas de terminal (o alternativamente, dos consolas virtuales). En el primer terminal, introducimos un comando simple y redirigimos su salida al entubado con nombre:

 [me@linuxbox ~]$ ls -l > pipe1

Tras pulsar la tecla Enter, el comando parecerá que se ha colgado. Esto es porque no está recibiendo nada desde el otro extremo del entubado aún. Cuando ocurre esto, se dice que el entubado está bloqueado. Esta condición se aclarará una vez que apliquemos un proceso al otro extremo y empiece a leer entrada desde el entubado. Usando la segunda ventana de terminal, introducimos este comando:

 [me@linuxbox ~]$ cat < pipe1

y el listado de directorio producido desde la primera ventana de terminal aparece en el segundo terminal como salida desde el comando cat. El comando ls en el primer terminal se completa con éxito una vez que ya no está bloqueado.


Resumiendo

Bien, hemos completado nuestro viaje. Lo único que queda por hacer ahora es practicar, practicar y practicar. Aunque hemos cubierto bastante terreno en nuestra excursión, apenas hemos rozado la superficie de lo que es la línea de comandos. Hay todavía miles de programas de línea de comandos pendientes de descubrir y disfrutar. ¡Comienza excavando en /usr/bin y veras!

Para saber más

No hay comentarios:

Publicar un comentario

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