Sistemas de compilação baseados em tarefas

Informar um problema Ver código-fonte

Esta página aborda os 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 de 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 do 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. Atualmente, a maioria dos principais sistemas de compilação em uso, como Ant, Maven, Gradle, Grunt e Rake, é baseada em tarefas. Em vez de scripts de shell, a maioria dos sistemas de compilação modernos exige que os engenheiros criem arquivos de build que descrevam como executar o build.

Veja este exemplo do Ant manual:

<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, além de uma lista de tarefas (as tags <target> no XML). (Ant usa a palavra target para representar uma tarefa e usa a palavra tarefa para se referir a comandos) Cada tarefa executa uma lista de possíveis comandos definidos pelo Ant, que incluem a criação e a exclusão de diretórios, a execução de javac e a criação de 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 dependentes. Essas dependências formam um gráfico acíclico, como mostrado na Figura 1.

Gráfico de acrílico mostrando dependências

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

Os usuários realizam builds fornecendo tarefas à ferramenta de linha de comando do Ant. Por exemplo, quando um usuário digita ant dist, o Ant realiza as seguintes etapas:

  1. Carrega um arquivo chamado build.xml no diretório atual e o analisa para criar a estrutura do gráfico mostrada na Figura 1.
  2. Procura a tarefa chamada dist que foi fornecida na linha de comando e descobre que ela tem uma dependência na 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, já que todas as dependências dessa tarefa foram executadas.
  7. Executa os comandos definidos na tarefa dist, já 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 compilação 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. Podemos facilmente adicionar novas tarefas que dependem das tarefas existentes de maneiras arbitrárias e complexas. Só precisamos transmitir o nome de uma tarefa para a ferramenta de linha de comando ant, e isso determina tudo que precisa ser executado.

O Ant é um software antigo, originalmente lançado em 2000. Outras ferramentas, como o Maven e o Gradle, melhoraram o Ant nos anos seguintes e, basicamente, substituí-las adicionando recursos como o gerenciamento automático de dependências externas e uma sintaxe mais limpa sem XML. No entanto, a natureza desses sistemas mais recentes permanece a mesma: eles permitem que os engenheiros escrevam scripts de compilação como princípios e modulares 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 permitem basicamente que os engenheiros definam qualquer script como uma tarefa, elas são extremamente eficientes, permitindo que você faça praticamente tudo que puder imaginar. No entanto, esse poder vem de desvantagens, e os sistemas de compilação baseados em tarefas podem se tornar difíceis de trabalhar à medida que os scripts de build ficam mais complexos. O problema desses sistemas é que eles acabam muito poder aos engenheiros e não o suficiente ao sistema. Como o sistema não tem ideia do que os scripts estão fazendo, o desempenho é prejudicado, porque precisa ser muito conservador na forma como ele programa e executa etapas de compilação. O sistema não consegue confirmar se cada script está fazendo o que deveria, então os scripts tendem a crescer em complexidade e acabam sendo outra coisa que precisa de depuração.

Dificuldade de carregar etapas de versão em paralelo

As estações de trabalho modernas de desenvolvimento são bastante poderosas, com vários núcleos que podem executar diversas etapas de versão em paralelo. No entanto, sistemas baseados em tarefas geralmente não podem carregar a execução de tarefas em paralelo mesmo quando parecem precisarem. Suponha que a tarefa A dependa das tarefas B e C. Como as tarefas B e C não têm dependência umas das outras, é seguro executá-las ao mesmo tempo para que o sistema possa chegar mais rapidamente à tarefa A? Talvez, se elas não usarem nenhum dos mesmos recursos. Mas talvez não. Os dois usam o mesmo arquivo para acompanhar os status e executá-los ao mesmo tempo, gerando um conflito. Em geral, o sistema não sabe por isso, é necessário arriscar esses conflitos (o que leva a problemas de compilação raros, mas muito difíceis de depurar) ou restringir todo o build para execução em uma única linha de execução em um único processo. Isso pode ser um enorme desperdício de uma poderosa máquina de desenvolvimento, e isso exclui completamente a possibilidade de distribuição da versão em várias máquinas.

Dificuldade para executar builds incrementais

Um bom sistema de compilação permite que os engenheiros realizem builds incrementais confiáveis, de modo que uma pequena mudança não exija que toda a base de código seja recriada do segundo. Isso é especialmente importante se o sistema de compilação for lento e não for possível carregar as etapas de compilação em paralelo pelos motivos mencionados acima. Infelizmente, os sistemas de compilação baseados em tarefas também têm dificuldade aqui. Como as tarefas podem fazer qualquer coisa, não há como verificar se elas já foram concluídas. Basta que você execute um conjunto de arquivos de origem e execute um compilador para criar um conjunto de binários. Assim, eles não precisarão ser executados novamente se os arquivos de origem subjacentes não tiverem mudado. Mas, sem mais informações, o sistema não consegue dizer isso com certeza. Talvez a tarefa faça o download de um arquivo que poderia ter mudado ou talvez escreva um carimbo de data/hora que pode ser diferente em cada execução. Para garantir a correção, o sistema normalmente precisa executar novamente todas as tarefas durante cada criação. Alguns sistemas de compilação tentam ativar builds incrementais permitindo que os engenheiros especifiquem as condições sob as quais uma tarefa precisa ser executada novamente. Às vezes, isso é viável, mas costuma ser um problema mais complicado do que parece. Por exemplo, em linguagens como C++ que permitem que os arquivos sejam incluídos diretamente por outros arquivos, é impossível determinar todo o conjunto de arquivos que precisam ser observados para mudanças sem analisar as fontes de entrada. Os engenheiros muitas vezes acabam usando atalhos, e esses atalhos podem levar a problemas raros e frustrantes, em que um resultado de tarefa é reutilizado, mesmo quando não deveria. Quando isso acontece com frequência, os engenheiros criam o hábito de executar uma limpeza antes que cada build receba um novo estado, descartando completamente o propósito de ter um build incremental em primeiro lugar. Descobrir quando uma tarefa precisa ser executada novamente é surpreendentemente sutil e é um job mais bem processado por máquinas do que por humanos.

Dificuldade para manter e depurar scripts

Por fim, os scripts de compilação impostos pelos sistemas de compilação baseados em tarefas geralmente são difíceis de trabalhar. Embora muitas vezes recebam menos análise, os scripts de compilação são códigos exatamente como o sistema criado e são lugares fáceis para os bugs se esconderem. Veja alguns exemplos de bugs muito comuns ao trabalhar com um sistema de compilação 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 a mudam para produzir uma saída em um local diferente. Isso só será detectado quando alguém tentar executar a tarefa A e descobrir que ela falhou.
  • A tarefa A depende da tarefa B, que depende da tarefa C, que está produzindo um arquivo específico como saída necessária para a tarefa A. O proprietário da tarefa B decide que ela 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 pressupõe acidentalmente a máquina que executa a tarefa, como a localização de uma ferramenta ou o valor de variáveis de ambiente específicas. A tarefa funciona na máquina, mas falha sempre que é testada por outro desenvolvedor.
  • 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 o build. Isso significa que os engenheiros nem sempre conseguem reproduzir e corrigir as falhas uns dos outros ou que ocorrem em um sistema de compilação automatizado.
  • Tarefas com várias dependências podem criar disputas. Se a tarefa A depende da tarefa B e da tarefa C, e as tarefas B e C modificam o mesmo arquivo, a tarefa A recebe um resultado diferente, dependendo de qual das tarefas B e C termina antes.

Não há uma maneira geral de resolver esses problemas de desempenho, correção ou manutenção no framework baseado em tarefas estabelecido aqui. Desde que os engenheiros possam escrever um código arbitrário que seja executado durante a criação, o sistema não pode ter informações suficientes para sempre executar versões de maneira rápida e correta. Para resolver o problema, precisamos tirar o poder das mãos dos engenheiros e colocá-lo de volta nas mãos do sistema e reformular o papel dele, 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.