Sistemas de build baseados em tarefas

Esta página aborda sistemas de compilação baseados em tarefas, como eles funcionam e algumas das complicações que podem ocorrer com sistemas baseados em tarefas. Depois dos scripts de shell, os sistemas de compilação baseados em tarefas são a próxima evolução lógica da criação.

Noções básicas sobre sistemas de compilação baseados em tarefas

Em um sistema de compilação baseado em tarefas, a unidade fundamental de trabalho é a tarefa. Cada tarefa é um script que pode executar qualquer tipo de lógica, e as tarefas especificam outras tarefas como dependências que precisam ser executadas antes delas. A maioria dos principais sistemas de build usados atualmente, como Ant, Maven, Gradle, Grunt e Rake, é baseada em tarefas. Em vez de scripts de shell, a maioria dos sistemas de build modernos exige que os engenheiros criem arquivos que descrevem como executar o build.

Confira este exemplo do manual do 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>

O arquivo de build é escrito em XML e define alguns metadados simples sobre o build com uma lista de tarefas (as tags <target> no XML). O Ant usa a palavra target para representar uma tarefa e usa a palavra task para se referir a comandos. Cada tarefa executa uma lista de possíveis comandos definidos pelo Ant, que aqui inclui criar e excluir diretórios, executar javac e criar um arquivo JAR. Esse conjunto de comandos pode ser estendido por plug-ins fornecidos pelo usuário para abranger qualquer tipo de lógica. Cada tarefa também pode definir as tarefas de que depende por meio do atributo dependente. Essas dependências formam um gráfico acíclico, como mostrado na Figura 1.

Gráfico acrílico mostrando dependências

Figura 1. Gráfico acíclico mostrando dependências

Os usuários executam versões fornecendo tarefas para a ferramenta de linha de comando do Ant. Por exemplo, quando um usuário digita ant dist, o Ant segue estas etapas:

  1. Carrega um arquivo chamado build.xml no diretório atual e o analisa para criar a estrutura gráfica mostrada na Figura 1.
  2. Procura a tarefa chamada dist que foi fornecida na linha de comando e descobre que ela depende da tarefa chamada compile.
  3. Procura a tarefa chamada compile e descobre que ela depende da tarefa init.
  4. Procura a tarefa chamada init e descobre que ela não tem dependências.
  5. Executa os comandos definidos na tarefa init.
  6. Executa os comandos definidos na tarefa compile, considerando que todas as dependências dessa tarefa foram executadas.
  7. Executa os comandos definidos na tarefa dist, considerando que todas as dependências dessa tarefa foram executadas.

No final, o código executado pelo Ant ao executar a tarefa dist é equivalente ao seguinte script de shell:

./createTimestamp.sh
mkdir build/
javac src/* -d build/
mkdir -p dist/lib/
jar cf dist/lib/MyProject-$(date --iso-8601).jar build/*

Quando a sintaxe é removida, o arquivo de build e o script de build não são muito diferentes. Mas já ganhamos muito com isso. Podemos criar novos arquivos de build em outros diretórios e vinculá-los. É fácil adicionar novas tarefas que dependem das atuais de maneiras arbitrárias e complexas. Precisamos transmitir apenas o nome de uma única tarefa para a ferramenta de linha de comando ant, e ela determina tudo o que precisa ser executado.

Ant é um software antigo, lançado originalmente em 2000. Outras ferramentas, como Maven e Gradle, melhoraram o Ant nos anos seguintes e o substituiram adicionando recursos como gerenciamento automático de dependências externas e uma sintaxe mais limpa, sem nenhum XML. No entanto, a natureza desses sistemas mais recentes permanece a mesma: eles permitem que os engenheiros escrevam scripts de build de maneira modular e com princípios, como tarefas, e forneçam ferramentas para executar essas tarefas e gerenciar dependências entre elas.

O lado sombrio dos sistemas de compilação baseados em tarefas

Como essas ferramentas basicamente permitem que os engenheiros definam qualquer script como uma tarefa, elas são extremamente eficientes, permitindo que você faça praticamente tudo o que imaginar com elas. No entanto, esse poder tem desvantagens, e os sistemas de build baseados em tarefas podem se tornar difíceis de trabalhar à medida que os scripts de build ficam mais complexos. O problema é que esses sistemas acabam conferindo muito poder aos engenheiros e não suficiente para o sistema. Como o sistema não tem ideia do que os scripts estão fazendo, o desempenho fica prejudicado, já que ele precisa programar e executar as etapas de build de maneira muito conservadora. E não há como o sistema confirmar se cada script está fazendo o que deveria. Portanto, os scripts tendem a crescer em complexidade e acabam sendo outra coisa que precisa de depuração.

Dificuldade de carregar as etapas do build em paralelo

As estações de trabalho de desenvolvimento modernas são bastante potentes, com vários núcleos capazes de executar várias etapas de compilação em paralelo. Porém, sistemas baseados em tarefas geralmente não podem carregar a execução de tarefas em paralelo, mesmo quando parece que deveriam ser capazes. Suponha que a tarefa A depende das tarefas B e C. Como as tarefas B e C não têm dependência uma na outra, é seguro executá-las ao mesmo tempo para que o sistema possa chegar mais rapidamente à tarefa A? Talvez, se não mexerem em nenhum dos mesmos recursos. Mas talvez não. Talvez ambos usem o mesmo arquivo para rastrear os status e executá-los ao mesmo tempo cause um conflito. Em geral, não há como o sistema saber, então ele precisa arriscar esses conflitos, o que leva a problemas de build raros, mas muito difíceis de depurar, ou tem que restringir todo o build para ser executado em uma única linha de execução em um único processo. Isso pode ser um grande desperdício de uma máquina de desenvolvedor poderosa e eliminar completamente a possibilidade de distribuir o build em várias máquinas.

Dificuldade para executar builds incrementais

Um bom sistema de build permite que os engenheiros executem builds incrementais confiáveis para que uma pequena mudança não exija que toda a base de código seja recriada do zero. Isso é especialmente importante se o sistema de compilação for lento e não conseguir carregar as etapas de compilação em paralelo pelos motivos mencionados acima. Mas, infelizmente, os sistemas de build baseados em tarefas também enfrentam dificuldades. Como as tarefas podem fazer qualquer coisa, em geral, não há como verificar se elas já foram feitas. Muitas tarefas simplesmente usam um conjunto de arquivos de origem e executam um compilador para criar um conjunto de binários. Assim, elas não precisam ser executadas novamente se os arquivos de origem subjacentes não tiverem sido modificados. Porém, sem outras informações, o sistema não pode afirmar isso com certeza. Talvez a tarefa faça o download de um arquivo que possa ter sido alterado ou talvez grave um carimbo de data/hora que pode ser diferente a cada execução. Para garantir a correção, o sistema normalmente precisa executar novamente todas as tarefas durante cada build. Alguns sistemas de build tentam ativar builds incrementais permitindo que os engenheiros especifiquem as condições em que uma tarefa precisa ser executada novamente. Às vezes, isso é viável, mas muitas vezes é um problema muito mais complicado do que parece. Por exemplo, em linguagens como C++ que permitem que arquivos sejam incluídos diretamente por outros arquivos, é impossível determinar todo o conjunto de arquivos que precisam ser monitorados em busca de mudanças sem analisar as origens de entrada. Os engenheiros geralmente usam atalhos, e eles podem levar a problemas raros e frustrantes, em que o resultado de uma tarefa é reutilizado mesmo quando não deveria ser. Quando isso acontece com frequência, os engenheiros adotam o hábito de executar limpos antes de cada build para receber um novo estado, anulando completamente o propósito de ter um build incremental. Descobrir quando uma tarefa precisa ser executada novamente é surpreendentemente sutil e é um trabalho mais bem realizado por máquinas do que por humanos.

Dificuldade para manter e depurar scripts

Por fim, os scripts de build impostos por sistemas de build baseados em tarefas geralmente são difíceis de trabalhar. Eles geralmente recebem menos verificações, mas os scripts de build são códigos como o sistema que está sendo criado e são lugares fáceis de ocultar pelos bugs. Confira alguns exemplos de bugs muito comuns ao trabalhar com um sistema de build baseado em tarefas:

  • A tarefa A depende da tarefa B para produzir um arquivo específico como saída. O proprietário da tarefa B não percebe que outras tarefas dependem dela, então faz a mudança para produzir a saída em um local diferente. Isso não pode ser detectado até que alguém tente executar a tarefa A e descubra que ela falha.
  • A tarefa A depende da tarefa B, que depende da tarefa C, que está produzindo um arquivo específico como saída que é necessário para a tarefa A. O proprietário da tarefa B decide que não precisa mais depender da tarefa C, o que faz com que a tarefa A falhe, mesmo que a tarefa B não se importe com a tarefa C.
  • O desenvolvedor de uma nova tarefa acidentalmente supõe que a máquina que está executando a tarefa, como o local de uma ferramenta ou o valor de variáveis de ambiente específicas. A tarefa funciona na máquina dele, mas falha sempre que outro desenvolvedor a tenta.
  • Uma tarefa contém um componente não determinístico, como o download de um arquivo da Internet ou a adição de um carimbo de data/hora a um build. Agora, as pessoas recebem resultados potencialmente diferentes sempre que executam a compilação, o que significa que os engenheiros nem sempre podem reproduzir e corrigir as falhas ou falhas uns dos outros que ocorrem em um sistema de compilação automatizado.
  • Tarefas com várias dependências podem criar disputas. Se a tarefa A depender da tarefa B e C, e as tarefas B e C modificarem o mesmo arquivo, a tarefa A vai receber um resultado diferente, dependendo de qual das tarefas B e C termina primeiro.

Não há uma maneira de uso geral de resolver esses problemas de desempenho, precisão ou capacidade de manutenção no framework baseado em tarefas apresentado aqui. Desde que os engenheiros possam escrever um código arbitrário que seja executado durante a criação, o sistema não poderá ter informações suficientes para sempre executar as versões de maneira rápida e correta. Para resolver o problema, precisamos tirar energia das mãos dos engenheiros e colocá-la de volta nas mãos do sistema e reconcelicar o papel do sistema não como tarefas em execução, mas como produção de artefatos.

Essa abordagem levou à criação de sistemas de compilação baseados em artefatos, como Blaze e Bazel.