Sesión 04: Laboratorio Bash Scripts
- Antes de empezar os recomiendo tener siempre a mano el manual de bash:
man bash
. - Otras dos referencias que os serán extraordinariamente útiles son:
- El libro The Linux Programming Interface de Michael Kerrisk (https://man7.org/tlpi/)
- El documento Bash Guide for Beginners de Machtelt Garrels (https://tldp.org/LDP/Bash-Beginners-Guide/html/)
Primer script de Bash
- Vamos a construir un script suficientemente interesante como para ver los aspectos fundamentales de lo que queremos que aprendáis.
- La palabra script (guión en español) es la palabra que habitualmente se usa para hablar de un programa en un lenguaje interpretado.
- Los programas en los lenguajes interpretados son “textos” que son directamente leídos por un intérprete (otro programa) para ejecutar línea a línea las sentencias que van apareciendo.
- En un lenguaje compilado los programas son traducidos a código máquina que directamtente puede ser ejecutado por la propia CPU (o por otro programa que es una CPU abstracta como es el caso de la Java Virtual Machine de Java).
- Bueno, volvamos a Bash.
- El programa que interpretará nuestros scripts es
/bin/bash
, es decir el propio programa Bash que se ejecuta cuando accedéis a una máquina Linux comotriqui
. - Como ya viene siendo habitual, a mi me gusta empezar cualquier programa con un código que no haga nada pero que sea sintácticamente correcto y podamos ejecutarlo sin problemas.
- Vamos con un script que escribe “Hola mundo” en la salida estándar. Creamos (con nuestro editor de texto plano favorito) un fichero al que vamos a llamar
hola.sh
(por convención usamos la extensión.sh
pero técnicamente no es necesario, podríamos usar cualquier extensión sensata o directamente ninguna):
|
|
- Salvamos el fichero y lo ejecutamos desde nuestra línea de comandos:
|
|
- Ya hemos aprendido algo, para poder ejecutar un script de Bash es necesario darle permisos de ejecución.
- Para ello usaremos el comando
chmod +x hola.sh
y…
|
|
- Perfecto. Analicemos el programa.
- La primera línea es una línea que caracteriza todos los scripts e indica qué intérprete se va a usar para procesar script, en nuestro caso el intérprete Bash (
/bin/bash
). - Es la línea con la que comenzará cualquier script de Bash:
#!/bin/bash
- Si alguien quiere desgranar, el símbolo
#
es un inicio de comentario que el intérprete ignorará, y!
indica que se va a ejecutar el programa que sigue (/bin/bash
) el cuál va a ser alimentado por su entrada estándar con el contenido del propio fichero (hola.sh
). - De alguna forma es como hacer esto:
|
|
- Mola :)
- La segunda línea es, como cabe esperar, un comando de Unix, en este caso es la ejecución del programa
echo
pasándole como argumentosHola
ymundo
. - En otras palabras, las sentencias del lenguaje Bash pasan a ser todos los programas que tenga instalados en mi máquina.
- Cuidado porque esto nos da una potencia desmesurada como veremos a continuación.
Script de predicción del tiempo
- En internet hay un servicio Web para tener una predicción del tiempo (clima):
https://wttr.in/
(programa implementado en Go por Igor Chubin, puedes mirar los detalles en https://github.com/chubin/wttr.in). - Puedes probarlo tú mismo desde la consola, para ello necesitas tener instalado el programa
curl
(en Ubuntu lo puedes instalar usandosudo apt-get install curl
, en triqui ya está instalado). - El programa
curl
es un cliente de HTTP que se conecta a un servidor Web y saca por la salida estándar lo que ese servicio devuelve. En nuestro caso:
|
|
- Bien, la idea es hacer un script que descargue la predicción del tiempo para un LUGAR dado como argumento del script y que lo almacene en un fichero de texto cuyo mombre incluirá la fecha y el nombre del lugar.
- Además queremos que el programa responda correctamente al argumento
-h
para que de algo de ayuda al usuario. - Además, si el fichero ya existe no deberá realizarse la descarga de datos.
- Además, si hay algún error tendremos que emitir mensajes adecuados para el usuario del script.
- Mola :)
- Vayamos paso a paso.
Argumentos de un Script
- Los argumentos de un script se recogen en parámetros posicionales:
$0
,$1
,$2
, etc. - Además hay dos parámetros especiales que expandem a cuántos argumentos hay (
$#
) y a todos los argumentos separados por espacios ($*
). - Cread el script
arg.sh
para probar lo que acabmos de decir:
|
|
- Y ahora hacemos algunas pruebas para deducir cómo funcionan los parámetros posicionales:
|
|
- Pues ahora ya sabemos cómo vamos a leer el argumento LUGAR o las opciones como
-h
.
Fecha de hoy
- Por otro lado vamos a necesitar extraer la fecha en la que estamos ejecutando el script para usarla como nombre del fichero.
- En Unix existe un programa que te devuelve la fecha en la salida estándar:
date
- En concreto se puede especificar un formato concreto (ver
man date
):
|
|
- Os propongo un reto intermedio: crear un fichero vacío con nombre la fecha de hoy y extensión
.txt
. - Os ayudo introduciendo un programa que permite crear un fichero vacío:
touch
(el programar realmente modifica la fecha de actualización de un fichero preo si el fichero no existe entonces lo crea). - Mola :)
- Pensadlo por un rato: crear un fichero vacío con nombre la fecha de hoy + extensión
.txt
. - Vamos con ello.
- La única forma de hacerlo es usando command substitution:
$(...)
. - La expresión
$(command)
de Bash se expande a la salida estandar delcomando
. - Cuando uso la palabra expandir quiero decir que donde tú escribas
$(command)
Bash lo va a cambiar por la salida estándar decommand
. - Por ejemplo:
|
|
- Bien, pues ahora podemos crear ese fichero, pero lo vamos a hacer usando una variable intermedia (variable de entorno, llamada a veces parámetro en Bash, no confundir con argumento):
|
|
- Está vacío el fichero, esa era la idea. Lo borramos que no moleste:
|
|
Primera versión de tiempo.sh
- Pues vamos con nuestra primera versión del script
tiempo.sh
: consultamos el sitio Web con curl y almacenamos la salida en un fichero usando como lugarmadrid
sin usar argumentos de momento:
|
|
- Ejecutamos:
|
|
- Feo… Yo personalmente no quiero ver esa información de descarga que escribe
curl
en la salida de error. - Tenemos dos formas de solucionarlo, una es enviando la salida de error a la basura (el fichero
/dev/null
es la trituradora), así:
|
|
- La otra, más pro, leyendo el manual de
curl
y viendo que hay un flag--silent
:
|
|
- Más guapo.
- El resultado:
|
|
- Y puedes ver el contenido. ¿Con qué comando? (respuesta:
cat madrid-2023-12-15.txt
, en tu caso con tu fecha, claro).
Segunda versión de tiempo.sh
- Admitamos el lugar como el argumento del script (es decir
$1
). El script nos quedaría así (observa que he cambiado madrid por$1
para que Bash expanda el argumento en los sitios adecuados):
|
|
- Y directamente podemos probar con
barcelona
:
|
|
- De nuevo puedes comprobar el contenido del nuevo fichero para asegurarte de que todo ha ido bien y que se ha descargado la predicción de Barcelona.
Tercera versión de tiempo.sh
- En este tercer paso vamos a procesar los argumentos para que nuestro script ayude al usuario.
- El “manual” de invocación será
tiempo.sh [ -h | LUGAR ]
: si se invoca con-h
sale una ayuda apropiada por la salida estándar, si se invoca con un lugar se conecta al servidor usando ese lugar como referencia y si se invoca sin argumentos o con más de un argumento, también sale la ayuda pero por la salida de error y el script tiene que terminar mal. - Vamos a por ello.
- Bash tiene varias construcciones de flujo, las habituales de cualquier lenguaje de programación imperativo:
if
,case
,for
,while
, etc. (consúlta el manual!). - Empecemos con el
if
, según la sintaxis del manual, la construcciónif
se define así:
|
|
- Donde pone
list
se puede entender que va un comando de Bash. - Si el primer comando, el que va después del
if
va bien (si exit status es 0) se ejecutan los comandos delthen
, si no se ejecutan los comandos delelse
. - Empecemos en pseudocódigo (lo siguiente NO es sintácticamente correcto):
|
|
El programa test
- Bien, fácil :)
- ¿Cómo comprobamos que
$#
ES IGUAL A 1? - Realmente necesitamos un programa cuyo exit status sea 0 cuando esa condición sea cierta.
- Existe un programa maravilloso y extraordinariamente importante en Unix para comprobar condiciones, condiciones sobre datos y sobre ficheros.
- Ese programa se llama
test
: ya estás tardando en ejecutarman test
. - Con test puedes comparar datos.
- Por ejemplo, según el manual,
test $# -eq 1
tiene un exit status 0 cuando el valor de$#
es igual a 1 (-eq
indica equal). - Puedes comprobar si dos strings son iguales con, por ejemplo,
test "$1" = "-h"
(observa que comparar números es con-eq
y comparar enteros es=
). - Luego lo vemos más tarde pero puedes comprobar si un fichero existe o no (opción
-e
), si es legible o no (opción-r
), si es ejecutable o no (opción-x
), etc. - Pues con esto podemos refinar nuestro pseudocódigo (sigue sin ser correcto pero se acerca):
|
|
- El mensaje de ayuda podemos ya escribirlo, serán varias líneas, algo como (observa el uso de
$0
para responder al usuario con su propia invocación del script):
|
|
- Bien, pues adelante. El script finalmente queda así:
|
|
- Es importante observar el uso de la redirección
1>&2
. Su significado es redirige la salida estánda a la salida de error (mira el manual de Bash para ver más opcione de redirección). - Puedes probar el script con las siguientes llamadas y analiza los resultados (no aparecen aquí, tendrás que probarlo tú misma):
|
|
- Observa cómo redirigimos la salida estándar y la salida de error a la basura para distinguir el uso de
-h
y el uso no esperado del comando (sin argumentos o con varios argumentos).
El programa [
- ¿Puedes probar a ejecutar
which [
? - ¿¡Hay un programa que se llama
[
!? Ciertamente. - Y… sorpresa, ese programa es exáctamente el mismo programa
test
- Sólo hay algo que cambia: el programa
[
comprueba que su último argumento es]
. - Pero entonces, si
[
es el programartest
entonces es posible escribir esta llamada:
|
|
- De esta forma:
|
|
- O esta otra llamada (ya veremos lo que significa):
|
|
- De esta forma:
|
|
- Es decir, el programa
[
está ahí para poder escribir tests de forma más bonita.
Cuarta versión de tiempo.sh
- Ya casi estamos terminando el script, sólo falta comprobar que el fichero no está ya generado. Si lo está no será necesario descargar la predicción del tiempo.
- Usaremos
test
de nuevo (test
es siempre nuestro amigo ;)). - El siguiente comando dentro del script comprobará si el fichero existe o no:
|
|
- Vamos a refactorizar un poquito (refactorizar es cambiar el programa para que haga lo mismo pero teniendo un código mejor, en este caso más legible).
- Vamos a incluir una nueva variable
LUGAR
:
|
|
- Y una nueva variable
NOMBREFICHERO
:
|
|
- Bien, pues adelante, el script quedaría así, de nuevo en pseudocódigo ante la duda de qué hacer si el fichero existe:
|
|
- Algo que hay que saber es que si tienes una condición de test, la puedes invertir usando
!
. Esto dice la sintaxis del manual detest
:
|
|
- Por lo tanto, podríamos escribir la comprobación de esta forma:
|
|
- Precioso, este es el script tras este cuarto paso:
|
|
case
- Ahora toca dar un paso sencillo para aprender cómo funciona otra construcción del lenguaje Bash: vamos a convertir el
if
de segundo nivel en uncase
.
|
|
- Aspectos interesantes de
case
: se usa la palabra reservadain
, cada caso se empieza por una alternativa de texto con el que encajar separados por|
(nada que ver los pipes) y terminada en)
, y la lista de comandos termina con un;;
(es como unbreak
de Java). - El
case
se cierra conesac
(que escase
al revés, igual quefi
esif
al revés, muy idiomático :)).
Versión final de tiempo.sh
- Nuestra última versión va a ser una refactorización para embellecer el script usando funciones de Bash.
- Vamos a factorizar el código de descarga y, más útil aún, el código que informa sobre cómo se usa el script.
- En Bash una función se define usando la siguiente sintaxis:
|
|
- Donde
list
son varios comandos simples o compuestos. - Observar que las funciones no tienen argumentos, los argumentos formales de la función van a ser
$1
,$2
, etc. - Una vez que se define una función, ésta es indistinguible de un programa, es decir, se invoca de esta forma:
|
|
- Donde a1, a2, etc. son los argumentos con los que se invoca la función (así los argumentos formales dentro de la función
$1
,$2
, etc. se expanden a dichos valores a1, a2, etc.) - Vamos a definir la función
descargar_wttr
:
|
|
- La función es silenciosa (no imprime nada) pero su exit status es 0 si todo va bien 1 si el fichero ya existe.
- Ahora podemos escribir el siguiente
if
:
|
|
- Observar que la función se usa como si fuera un programa, por ejemplo podríamos escribir:
|
|
- En ese caso el
$1
en el cuerpo de la función expande al argumentovalencia
. - Observar el uso de el símbolo de exclamación
!
en este caso para invertir el exit status de un comando. - La segunda función va a ser la que se encarga de informar de cómo se usa el script, vamos a llamarla
uso
:
|
|
- Aquí podemos ver que la palabra reservada **
function
es opcional y cambiarla por()
despues del nombre. - Además, cuando no hay
return
el exit status de la función es el exit status del último comando ejecutado. - Todo junto ahora:
|
|
- Observa que la salida de una invocación de una función puede redirigirse al completo.
Iteraciones
- Para terminar vamos a jugar con las iteraciones con un
for
. - Vamos a hacer dos ejercicios, el primero es imprimir todos los argumentos de un script usando un
for
. - Y a su vez lo vamos a hacer de dos formas diferentes. La primera es con una variable que vamos a iterar entre todos los valores de
$*
(todos los argumentos):
|
|
- La segunda forma es usando el comando interno de Bash
shift
. Según el manual:
|
|
- Eso significa que si hacemos un
shift
el parámetro$2
se convierte en$1
,$3
se convierte en$2
, etc. Además el contador de parámetros$#
se decrementa. - Vamos a usar ese comando interno y una expresión aritmética (las operaciones aritméticas en Bash se escriben entre dobles paréntesis y si se quiere expandir su resultado se usa un
$((...))
, pruebaecho $((3 + 1))
). - El script
arg.sh
queda de esta forma:
|
|
Ejercicio propuesto
- Elaborar una Versión de
tiempo.sh
para permitir varios lugares como argumentos en el script y que se genere un fichero para cada uno de ellos. - El “manual” de invocación será
tiempo.sh [ -h | LUGAR... ]
(observar los puntos suspensivos indicando que pueden aparecer varios lugares.
Famous Last Words
- Si quieres buscar inspiración te recomiendo que eches un ojo a los scripts de arranque del sistema que hay en los directorios
/etc/init.d
o/etc/rc*
. Todos ellos están llenos de increibles ideas. - Recuerda que los operadores de composición
&&
y||
son muy útiles para evitar escribir código más verboso conif
s anidados. Por ejemplo, este código:
|
|
- Es equivalente a este otro:
|
|
- Y este otro:
|
|
- Es equivalente a
|
|