Esta página aborda sistemas de build baseados em tarefas, como eles funcionam e algumas das complicações que podem ocorrer com esses sistemas. Depois dos scripts de shell, os sistemas de build baseados em tarefas são a próxima evolução lógica da criação.
Noções básicas sobre sistemas de build baseados em tarefas
Em um sistema de build 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 em uso hoje, como Ant, Maven, Gradle, Grunt e Rake, são baseados em tarefas. Em vez de scripts de shell, a maioria dos sistemas de build modernos exige que os engenheiros criem arquivos de build que descrevam como realizar 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 é gravado em XML e define alguns metadados simples sobre o build
além de uma lista de tarefas (as <target> tags no XML). O Ant usa a palavra
target para representar uma tarefa e a palavra task para se referir a
comandos. Cada tarefa executa uma lista de comandos possíveis definidos pelo Ant,
que aqui incluem a criação e 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 das quais ela
depende usando o atributo depends. Essas dependências formam um gráfico acíclico,
como mostrado na Figura 1.
Figura 1. Um gráfico acíclico mostrando dependências
Os usuários realizam builds fornecendo tarefas para a ferramenta de linha de comando do Ant. Por exemplo,
quando um usuário digita ant dist, o Ant realiza as seguintes etapas:
- Carrega um arquivo chamado
build.xmlno diretório atual e o analisa para criar a estrutura de gráfico mostrada na Figura 1. - Procura a tarefa chamada
distque foi fornecida na linha de comando e descobre que ela tem uma dependência da tarefa chamadacompile. - Procura a tarefa chamada
compilee descobre que ela tem uma dependência de a tarefa chamadainit. - Procura a tarefa chamada
inite descobre que ela não tem dependências. - Executa os comandos definidos na tarefa
init. - Executa os comandos definidos na tarefa
compile, considerando que todas as dependências dessa tarefa foram executadas. - 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.shmkdir 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 ao fazer isso. Podemos
criar novos arquivos de build em outros diretórios e vinculá-los. Podemos facilmente
adicionar novas tarefas que dependem de tarefas atuais de maneiras arbitrárias e complexas. Só
precisamos transmitir o nome de uma única tarefa para a ferramenta de linha de comando ant, e ela
determina tudo o que precisa ser executado.
O Ant é um software antigo, lançado originalmente em 2000. Outras ferramentas, como Maven e Gradle, melhoraram o Ant nos anos seguintes e o substituíram essencialmente, adicionando recursos como gerenciamento automático de dependências externas e uma sintaxe mais limpa sem XML. Mas 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 fornecem ferramentas para executar essas tarefas e gerenciar dependências entre elas.
O lado sombrio dos sistemas de build baseados em tarefas
Como essas ferramentas permitem que os engenheiros definam qualquer script como uma tarefa, elas são extremamente poderosas, permitindo que você faça praticamente tudo o que imaginar com elas. Mas 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 se tornam mais complexos. O problema com esses 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, a performance é afetada, já que precisa ser muito conservadora na forma como agenda e executa as etapas de build. E não há como o sistema confirmar se cada script está fazendo o que deveria, então os scripts tendem a aumentar em complexidade e acabam sendo outra coisa que precisa de depuração.
Dificuldade de paralelizar etapas de build
As estações de trabalho de desenvolvimento modernas são bastante poderosas, com vários núcleos que são capazes de executar várias etapas de build em paralelo. Mas os sistemas baseados em tarefas geralmente não conseguem paralelizar a execução de tarefas, mesmo quando parece que deveriam. Suponha que a tarefa A dependa das tarefas B e C. Como as tarefas B e C não têm dependência uma da outra, é seguro executá-las ao mesmo tempo para que o sistema possa chegar à tarefa A mais rapidamente? Talvez, se elas não tocarem em nenhum dos mesmos recursos. Mas talvez não. Talvez ambas usem o mesmo arquivo para acompanhar os status e a execução delas ao mesmo tempo cause um conflito. Não há como o sistema saber, então ele precisa arriscar esses conflitos (levando a problemas de build 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 grande desperdício de uma máquina de desenvolvedor poderosa e elimina completamente a possibilidade de distribuir o build em várias máquinas.
Dificuldade em realizar builds incrementais
Um bom sistema de build permite que os engenheiros realizem builds incrementais confiáveis, de modo que uma pequena mudança não exija que todo o codebase seja reconstruído do zero. Isso é especialmente importante se o sistema de build for lento e não conseguir parallelizar as etapas de build pelos motivos mencionados. Mas, infelizmente, os sistemas de build baseados em tarefas também têm dificuldades aqui. Como as tarefas podem fazer qualquer coisa, não há como verificar se elas já foram concluídas. Muitas tarefas simplesmente usam um conjunto de arquivos de origem e executam um compilador para criar um conjunto de binários. Portanto, elas não precisam ser executadas novamente se os arquivos de origem subjacentes não tiverem sido alterados. Mas, sem informações adicionais, o sistema não pode dizer 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 possa ser diferente em cada execução. Para garantir a correção, o sistema normalmente precisa executar novamente cada tarefa 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 geralmente é 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 para mudanças sem analisar as origens de entrada. Os engenheiros geralmente 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 adquirem o hábito de executar a limpeza antes de cada build para obter um estado novo, anulando completamente o propósito de ter um build incremental em primeiro lugar. Descobrir quando uma tarefa precisa ser executada novamente é surpreendentemente sutil e é um trabalho melhor tratado por máquinas do que por humanos.
Dificuldade em 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. Embora muitas vezes recebam menos escrutínio, os scripts de build são códigos como o sistema que está sendo criado e são lugares fáceis para bugs se esconderem. 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 ele a muda 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 necessária 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 faz uma suposição acidental sobre a máquina que executa 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 fazer o download de um arquivo da Internet ou adicionar um carimbo de data/hora a um build. Agora, as pessoas recebem resultados potencialmente diferentes a cada vez que executam o build, o que significa que os engenheiros nem sempre poderão reproduzir e corrigir as falhas uns dos outros ou falhas que ocorrem em um sistema de build automatizado.
- Tarefas com várias dependências podem criar condições de corrida. Se a tarefa A depende das tarefas B e 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 primeiro.
Não há uma maneira de uso geral para resolver esses problemas de performance, correção ou capacidade de manutenção na estrutura baseada em tarefas apresentada aqui. Enquanto os engenheiros puderem escrever código arbitrário que é executado durante o build, o sistema não terá informações suficientes para sempre executar builds de maneira rápida e correta. Para resolver o problema, precisamos tirar algum poder das mãos dos engenheiros e colocá-lo de volta nas mãos do sistema e reconceituar o papel do sistema não como execução de tarefas, mas como produção de artefatos.
Essa abordagem levou à criação de sistemas de build baseados em artefatos, como Blaze e Bazel.
