En esta página, se abordan los sistemas de compilación basados en tareas, cómo funcionan y algunas de las complicaciones que pueden surgir con ellos. Después de las secuencias de comandos de shell, los sistemas de compilación basados en tareas son la siguiente evolución lógica de la compilación.
Comprende los sistemas de compilación basados en tareas
En un sistema de compilación basado en tareas, la unidad fundamental de trabajo es la tarea. Cada tarea es un script que puede ejecutar cualquier tipo de lógica, y las tareas especifican otras tareas como dependencias que deben ejecutarse antes que ellas. La mayoría de los principales sistemas de compilación que se usan hoy en día, como Ant, Maven, Gradle, Grunt y Rake, se basan en tareas. En lugar de secuencias de comandos de shell, la mayoría de los sistemas de compilación modernos requieren que los ingenieros creen archivos de compilación que describan cómo realizar la compilación.
Veamos este ejemplo del manual de Ant:
<project name="MyProject" default="dist" basedir=".">
<description>
simple example build file
</description>
<!-- set global properties for this build -->
<property name="src" location="src"/>
<property name="build" location="build"/>
<property name="dist" location="dist"/>
<target name="init">
<!-- Create the time stamp -->
<tstamp/>
<!-- Create the build directory structure used by compile -->
<mkdir dir="${build}"/>
</target>
<target name="compile" depends="init"
description="compile the source">
<!-- Compile the Java code from ${src} into ${build} -->
<javac srcdir="${src}" destdir="${build}"/>
</target>
<target name="dist" depends="compile"
description="generate the distribution">
<!-- Create the distribution directory -->
<mkdir dir="${dist}/lib"/>
<!-- Put everything in ${build} into the MyProject-${DSTAMP}.jar file -->
<jar jarfile="${dist}/lib/MyProject-${DSTAMP}.jar" basedir="${build}"/>
</target>
<target name="clean"
description="clean up">
<!-- Delete the ${build} and ${dist} directory trees -->
<delete dir="${build}"/>
<delete dir="${dist}"/>
</target>
</project>
El archivo de compilación se escribe en XML y define algunos metadatos simples sobre la compilación, junto con una lista de tareas (las etiquetas <target>
en el XML). (Ant usa la palabra destino para representar una tarea y usa la palabra tarea para referirse a comandos). Cada tarea ejecuta una lista de comandos posibles definidos por Ant, que aquí incluyen la creación y eliminación de directorios, la ejecución de javac
y la creación de un archivo JAR. Los complementos proporcionados por el usuario pueden extender este conjunto de comandos para abarcar cualquier tipo de lógica. Cada tarea también puede definir las tareas de las que depende a través del atributo depends. Estas dependencias forman un grafo acíclico, como se muestra en la Figura 1.
Figura 1: Un gráfico acíclico que muestra las dependencias
Los usuarios realizan compilaciones proporcionando tareas a la herramienta de línea de comandos de Ant. Por ejemplo, cuando un usuario escribe ant dist
, Ant realiza los siguientes pasos:
- Carga un archivo llamado
build.xml
en el directorio actual y lo analiza para crear la estructura de gráfico que se muestra en la Figura 1. - Busca la tarea llamada
dist
que se proporcionó en la línea de comandos y descubre que tiene una dependencia de la tarea llamadacompile
. - Busca la tarea llamada
compile
y descubre que tiene una dependencia de la tarea llamadainit
. - Busca la tarea llamada
init
y descubre que no tiene dependencias. - Ejecuta los comandos definidos en la tarea
init
. - Ejecuta los comandos definidos en la tarea
compile
, dado que se ejecutaron todas las dependencias de esa tarea. - Ejecuta los comandos definidos en la tarea
dist
, dado que se ejecutaron todas las dependencias de esa tarea.
Al final, el código que ejecuta Ant cuando se ejecuta la tarea dist
es equivalente a la siguiente secuencia de comandos de shell:
./createTimestamp.sh
mkdir build/
javac src/* -d build/
mkdir -p dist/lib/
jar cf dist/lib/MyProject-$(date --iso-8601).jar build/*
Cuando se quita la sintaxis, el archivo de compilación y la secuencia de comandos de compilación no son tan diferentes. Sin embargo, ya obtuvimos muchos beneficios al hacerlo. Podemos crear nuevos archivos de compilación en otros directorios y vincularlos. Podemos agregar fácilmente nuevas tareas que dependen de tareas existentes de formas arbitrarias y complejas. Solo necesitamos pasar el nombre de una sola tarea a la herramienta de línea de comandos ant
, y esta determinará todo lo que se debe ejecutar.
Ant es un software antiguo que se lanzó originalmente en el año 2000. Otras herramientas, como Maven y Gradle, mejoraron Ant en los años transcurridos y, básicamente, lo reemplazaron agregando funciones como la administración automática de dependencias externas y una sintaxis más clara sin XML. Sin embargo, la naturaleza de estos sistemas más nuevos sigue siendo la misma: permiten a los ingenieros escribir secuencias de comandos de compilación de una manera modular y basada en principios como tareas, y proporcionan herramientas para ejecutar esas tareas y administrar las dependencias entre ellas.
El lado oscuro de los sistemas de compilación basados en tareas
Como estas herramientas permiten que los ingenieros definan cualquier secuencia de comandos como una tarea, son muy potentes y te permiten hacer casi todo lo que se te ocurra. Sin embargo, ese poder tiene desventajas, y los sistemas de compilación basados en tareas pueden volverse difíciles de usar a medida que sus secuencias de comandos de compilación se vuelven más complejas. El problema con estos sistemas es que, en realidad, terminan dándoles demasiado poder a los ingenieros y no suficiente al sistema. Debido a que el sistema no tiene idea de lo que hacen los scripts, el rendimiento se ve afectado, ya que debe ser muy conservador en la forma en que programa y ejecuta los pasos de compilación. Además, el sistema no puede confirmar que cada secuencia de comandos haga lo que debería, por lo que tienden a volverse más complejas y terminan siendo otro elemento que necesita depuración.
Dificultad para paralelizar los pasos de compilación
Las estaciones de trabajo de desarrollo modernas son bastante potentes, con varios núcleos capaces de ejecutar varios pasos de compilación en paralelo. Sin embargo, los sistemas basados en tareas a menudo no pueden paralelizar la ejecución de tareas, incluso cuando parece que deberían poder hacerlo. Supongamos que la tarea A depende de las tareas B y C. Como las tareas B y C no dependen entre sí, ¿es seguro ejecutarlas al mismo tiempo para que el sistema pueda llegar más rápido a la tarea A? Quizás, si no usan los mismos recursos. Pero tal vez no, ya que es posible que ambos usen el mismo archivo para hacer un seguimiento de sus estados y que ejecutarlos al mismo tiempo cause un conflicto. En general, el sistema no tiene forma de saberlo, por lo que debe arriesgarse a estos conflictos (lo que genera problemas de compilación poco frecuentes, pero muy difíciles de depurar) o restringir toda la compilación para que se ejecute en un solo subproceso en un solo proceso. Esto puede ser un gran desperdicio de una potente máquina para desarrolladores y descarta por completo la posibilidad de distribuir la compilación en varias máquinas.
Dificultad para realizar compilaciones incrementales
Un buen sistema de compilación permite que los ingenieros realicen compilaciones incrementales confiables, de modo que un pequeño cambio no requiera que se vuelva a compilar toda la base de código desde cero. Esto es especialmente importante si el sistema de compilación es lento y no puede paralelizar los pasos de compilación por los motivos mencionados anteriormente. Pero, lamentablemente, los sistemas de compilación basados en tareas también tienen dificultades en este punto. Como las tareas pueden hacer cualquier cosa, no hay una forma general de verificar si ya se realizaron. Muchas tareas simplemente toman un conjunto de archivos fuente y ejecutan un compilador para crear un conjunto de archivos binarios; por lo tanto, no es necesario volver a ejecutarlas si los archivos fuente subyacentes no cambiaron. Sin embargo, sin información adicional, el sistema no puede afirmarlo con certeza. Tal vez la tarea descargue un archivo que podría haber cambiado o tal vez escriba una marca de tiempo que podría ser diferente en cada ejecución. Para garantizar la corrección, el sistema suele volver a ejecutar cada tarea durante cada compilación. Algunos sistemas de compilación intentan habilitar compilaciones incrementales permitiendo que los ingenieros especifiquen las condiciones en las que se debe volver a ejecutar una tarea. A veces, esto es factible, pero, a menudo, es un problema mucho más complejo de lo que parece. Por ejemplo, en lenguajes como C++ que permiten que otros archivos incluyan archivos directamente, es imposible determinar el conjunto completo de archivos que se deben supervisar para detectar cambios sin analizar las fuentes de entrada. A menudo, los ingenieros terminan tomando atajos, y estos pueden generar problemas frustrantes y poco comunes en los que se reutiliza el resultado de una tarea incluso cuando no debería hacerse. Cuando esto sucede con frecuencia, los ingenieros adquieren el hábito de ejecutar una compilación limpia antes de cada compilación para obtener un estado nuevo, lo que anula por completo el propósito de tener una compilación incremental en primer lugar. Determinar cuándo se debe volver a ejecutar una tarea es sorprendentemente sutil y es un trabajo que las máquinas pueden realizar mejor que los humanos.
Dificultad para mantener y depurar secuencias de comandos
Por último, las secuencias de comandos de compilación impuestas por los sistemas de compilación basados en tareas suelen ser difíciles de usar. Aunque a menudo reciben menos escrutinio, los scripts de compilación son código al igual que el sistema que se está compilando, y son lugares fáciles para que se oculten los errores. A continuación, se muestran algunos ejemplos de errores que son muy comunes cuando se trabaja con un sistema de compilación basado en tareas:
- La tarea A depende de la tarea B para producir un archivo en particular como resultado. El propietario de la tarea B no se da cuenta de que otras tareas dependen de ella, por lo que la cambia para que genere resultados en una ubicación diferente. Esto no se puede detectar hasta que alguien intenta ejecutar la tarea A y descubre que falla.
- La tarea A depende de la tarea B, que depende de la tarea C, que produce un archivo específico como resultado que necesita la tarea A. El propietario de la tarea B decide que ya no necesita depender de la tarea C, lo que provoca que la tarea A falle, aunque a la tarea B no le importa la tarea C.
- El desarrollador de una tarea nueva accidentalmente hace una suposición sobre la máquina que ejecuta la tarea, como la ubicación de una herramienta o el valor de variables de entorno particulares. La tarea funciona en su máquina, pero falla cada vez que otro desarrollador la intenta.
- Una tarea contiene un componente no determinístico, como descargar un archivo de Internet o agregar una marca de tiempo a una compilación. Ahora, las personas obtienen resultados potencialmente diferentes cada vez que ejecutan la compilación, lo que significa que los ingenieros no siempre podrán reproducir y corregir los errores de los demás o los errores que se producen en un sistema de compilación automatizado.
- Las tareas con varias dependencias pueden crear condiciones de carrera. Si la tarea A depende de la tarea B y de la tarea C, y ambas modifican el mismo archivo, la tarea A obtiene un resultado diferente según cuál de las tareas B y C finalice primero.
No hay una forma de propósito general para resolver estos problemas de rendimiento, corrección o capacidad de mantenimiento dentro del marco basado en tareas que se describe aquí. Mientras los ingenieros puedan escribir código arbitrario que se ejecute durante la compilación, el sistema no tendrá suficiente información para poder ejecutar compilaciones de forma rápida y correcta. Para resolver el problema, necesitamos quitarles algo de poder a los ingenieros y devolvérselo al sistema, y reconceptualizar el rol del sistema no como la ejecución de tareas, sino como la producción de artefactos.
Este enfoque llevó a la creación de sistemas de compilación basados en artefactos, como Blaze y Bazel.