Mind Bending

Nesta continuação do assunto de "Java na Mão" resolvi mostrar uma forma de simplificar todo o processo de compilação e geração de pacotes no Java. Para isso vamos usar a ferramenta GNU Make.

No artigo anterior, mostrei como realizar uma série de compilações e empacotamentos usando os utilitários do Java. Entretanto, utilizar a linha de comando constantemente para esse tipo de tarefa é um trabalho árduo.

Java & GNU

Agora que sabemos exatamente como podemos compilar um código Java e gerar seus respectivos pacotes manualmente, podemos escrever um GNU Makefile para automatizar essa tarefa.

Introdução ao Make

Apesar do que muitas pessoas vão te dizer por aí, o GNU Make não é uma ferramenta velha, antiquada, ultrapassada e etc. Na verdade ela é uma excelente ferramente que eu utilizo diariamente para encadear "receitas", controlar interdependências e gerar conteúdo de forma automatizada. Só pra você ter ideia, até na geração deste site eu utilizo o GNU Make.

Vamos rapidamente entender como é estruturado um Makefile. Veja a imagem abaixo:

Java Makefile Ilustrado

Primeira característica básica, todo Makefile é identado por tabs, isso faz parte de sua sintaxe. Segunda parte importante, o Makefile é composto por alvos (targets), dependências (dependencies) e receitas (recipes):

Um alvo (target)
É uma arquivo que você deseja gerar ou um nome predeterminado que representa uma ação.
Uma dependência (dependencie)
É uma lista de arquivos que são necessários para que o alvo seja executado. Também podem incluir uma lista de alvos existentes no Makefile que devem ser executados com sucesso antes da execução do alvo atual.
Uma receita (recipe)
Contém uma lista de passos a ser executado para que um alvo seja gerado. Caso um passo incorra em erro, toda a receita é abortada por padrão.

Agora que entendemos esse passo, vamos compreender sua interface com o usuário. Ao criar uma arquivo Makefile, você pode invocar qualquer alvo utilizando o comando make. Se você deseja, por exemplo, invocar a receita identificada pelo alvo clean, basta invocar o comando make clean.

Pronto, agora você tem uma ideia básica de como um Makefile e o utilitário make funcionam.

Criando seu Makefile

Antes de criar o seu Makefile é necessário compreender que processo você quer automatizar. Nesse caso, é a compilação, execução e geração de pacotes Java.

Vamos primeiro analisar esses processos separadamente.

Compilação
Ao compilar um programa java é utilizado o comando javac e é gerado um arquivo .class (alvo) e para isso é necessário uma arquivo .java (dependência).
Execução
Para executar um programa, é necessário que este já tenha sido compilado (dependência). Em seguida invocamos o comando java apontando o arquivo de extensão .java (dependência).
Gerar Pacote
Para gerar um pacote é necessário que este programa já tenha sido compilado (dependência). Em seguida invocamos o comando jar apontando os arquivos de extensão .java (dependência).

Então podemos delinear que nosso Makefile terá um esqueleto similar ao descrito abaixo:

%.class: %.java
        # Receita para compilar...

%: %.class
        # Receita para executar...

%.jar: %.class
        # Receita para gerar pacote...

Caso alguém esteja se perguntando o que são os sinais %, eles são caracteres coringas que casam com qualquer nome passado. Ou seja, para compilar meu programa que está no arquivo HelloWorld.java eu vou chamar o make da seguinte forma: make HelloWorld.java. Já para executá-lo eu vou invocar o make da seguinte forma: make HelloWorld. Por que a diferença? Pois para executar eu precisava de algo que identificasse o programa mas que não fosse o nome do arquivo (ou entraria em conflito com a primeira receita). Por último, para gerar um .jar do programa HelloWorld eu invocarei o comando da seguinte forma: make HelloWorld.jar.

Agora vamos à parte difícil, escrever as receitas. O GNU Makefile possui uma linguagem funcional própria documentada aqui, mas que lembra um pouco o shell script. As variáveis são definidas normalmente (JAVA=/usr/bin/java) mas referenciadas utilizando o cifrão e o parêntese (echo $(JAVA)).

Para escrever uma receita simples que compila um programa .java basta escrever o seguinte:

JAVAC=/usr/bin/javac

%.class: %.java
        $(JAVAC)  $*.java

Whoa! De onde saiu aquele $*.java? Bem, o Make ele possui várias variáveis especiais que ele chama de Automatic Variables. Elas são geradas a partir do nome do alvo, de suas dependências e etc. Esta ($*), por exemplo, armazena o nome do arquivo alvo sem a extensão definida, neste caso .class. Ou seja, se eu executar um make MyApp.class esta variável possuirá a string MyApp.

Já uma receita simples de execução do programa pode ser escrita da seguinte forma:

JAVA=/usr/bin/java

%: %.class
        $(JAVA) $*

Ou seja, o comando a ser executado (dado que o nome do arquivo é HelloWorld.java) será /usr/bin/java HelloWorld. A esta altura você está se perguntando, "então para compilar meu programa HelloWorld.java vou precisar executar dois passos? make HelloWorld.class && make HelloWorld?". A resposta é não. Como o alvo de execução possui como dependência o arquivo já compilado (.class) o make é inteligente o suficiente para saber que ele mesmo deve copilar o seu java. Ou seja, basta invocar o comando make HelloWorld que o make se encarregará de compilar e executar seu programa.

Complicando Um Pouco As Coisas

Entretanto, no mundo real nossos projetos não serão tão simples como neste exemplo e nem poderemos compilar desta forma, deixando os binários no mesmo diretório do código fonte e etc, conforme explicado anteriormente. Vamos tomar como base o exemplo do artigo anterior e recriar os seguintes diretórios/artigos:

.
├── HelloWorld.java
├── Makefile
├── myjar
│   └── MyJar.java
└── world
    ├── HelloWorld.java
    └── Other.java

Com os seguintes códigos:

// Arquivo /tmp/project/HelloWorld.java

public class HelloWorld
{
    public static void main(String[] args)
    {
        System.out.println("Hello, World!");
    }
}
// Arquivo /tmp/project/myjar/MyJar.java
package myj;

public class MyJar
{
    public static void call()
    {
        System.out.println("Hello, World from my jar!");
    }
}
// Arquivo /tmp/project/world/HelloWorld.java
package world;
import world.Other;
import myj.MyJar;

public class HelloWorld
{
    public static void main(String[] args)
    {
        System.out.println("Hello, World!");
        Other.call();
        MyJar.call();
    }
}
// Arquivo /tmp/project/world/Other.java
package world;
import java.util.Hashtable;

public class Other
{
    public static void call()
    {
         System.out.println("Hello, World from other place!");
    }
}

Notem a interdependência dos códigos world/HelloWorld.java, world/Other.java e myjar/MyJar.java. Notem também que os códigos agora possuem packages e estes devem ser invocados de forma diferente. Para isso vamos utilizar um Makefile mais elaborado:

# Ignore isso...
space:=$(empty) $(empty)

# Binários
JAVAC=/usr/bin/javac
JAVA=/usr/bin/java
JAR=/usr/bin/jar

# Diretórios...
BINDIR=bin
JARDIR=jars

# Adicione qualquer classpath externo que você precise
USERCLASSPATH=.

# Criando classpath dinâmico
TMPCLASSPATH=$(USERCLASSPATH):$(realpath $(BASE)$(BINDIR))
ifneq (,$(wildcard $(jars)/*))
        CLASSPATH=$(TMPCLASSPATH):$(subst $(space),:,$(foreach jar,$(wildcard $(JARDIR)/*.jar),$(realpath $(jar))))
endif

# Flags de compilação
JCFLAGS=-g -d $(BASE)$(BINDIR) -classpath $(CLASSPATH)
# Flags de execução
JFLAGS=-classpath $(CLASSPATH)

%.class: %.java
        $(eval BASE=$(dir $<))
        rm -rf $(BASE)$(BINDIR) && mkdir $(BASE)$(BINDIR)
        $(JAVAC) $(JCFLAGS) $*.java

%: %.class
        echo $*
        cd $(BASE)$(BINDIR) && $(JAVA) $(JFLAGS) $(subst /,.,$*)

%.jar: %.class
        -mkdir -p $(JARDIR)
        $(JAR) cfe $(JARDIR)/$(subst /,-,$*.jar) $(subst /,.,$*) -C $(BASE)$(BINDIR)/ .

clean:
        -find . -type d -name $(BINDIR) | xargs -I{} rm -rf {}
        -rm -rf $(JARDIR)

PHONY: clean

Este Makefile já é um pouco mais complicado, mas ele já faz segmentação de binários e pacotes e controle de dependências e inclusão no classpath.

Um Programa Simples

Primeiro vamos a uma exemplo simples, compilar, executar e gerar um pacote do programa HelloWorld.java:

$ make HelloWorld
rm -rf ./bin && mkdir ./bin
/usr/bin/javac -g -d ./bin -classpath .:/tmp/java/bin: HelloWorld.java
cd ./bin && /usr/bin/java -classpath .:/tmp/java/bin: HelloWorld
Hello, World!

Notem que esta invocação já realiza a limpeza do arquivo de binários (./bin), compila o código e o invoca. Por fim temos a saída: Hello, World!. Agora vamos gerar o pacote:

$ make HelloWorld.jar
rm -rf ./bin && mkdir ./bin
/usr/bin/javac -g -d ./bin -classpath .:/tmp/java/bin: HelloWorld.java
mkdir -p jars
/usr/bin/jar cfe jars/HelloWorld.jar HelloWorld -C ./bin/ .

$ ls -la jars/
total 12
drwxrwxr-x 2 magnun magnun 4096 Jul 29 00:25 .
drwxrwxr-x 6 magnun magnun 4096 Jul 29 00:25 ..
-rw-rw-r-- 1 magnun magnun  821 Jul 29 00:25 HelloWorld.jar

$ java -jar jars/HelloWorld.jar
Hello, World!

Note que esta regra não executa o .jar pois este não necessariamente é executável, pode ser apenas uma biblioteca conforme o próximo exemplo.

Uma Biblioteca

A biblioteca MyJar não deve ser executada, apenas compilada pois esta é dependência para o próximo programa, presente no package world. Veja como é simples a execução e compilação:

$ make myjar/MyJar.jar
rm -rf myjar/bin && mkdir myjar/bin
/usr/bin/javac -g -d myjar/bin -classpath .:: myjar/MyJar.java
mkdir -p jars
/usr/bin/jar cfe jars/myjar-MyJar.jar myjar.MyJar -C myjar/bin/ .

Pronto! Note que:

  • Eu precisei informar o caminho completo (incluindo o diretório raiz do projeto);
  • Que o make já separa os binários em myjar/bin;
  • Que o make gera nomes de pacotes baseado na estrutura de pastas, myjar-MyJar.jar;

Um Programa com Dependências

Agora vamos ao desafio, o programa world/HelloWorld.java, que possui dependências dentro de si (world/Other.java) e dependência de um .jar externo:

$ make world/HelloWorld
rm -rf world/bin && mkdir world/bin
/usr/bin/javac -g -d world/bin -classpath .::/tmp/java/jars/myjar-MyJar.jar world/HelloWorld.java
cd world/bin && /usr/bin/java -classpath .:/tmp/java/world/bin:/tmp/java/jars/myjar-MyJar.jar world.HelloWorld
Hello, World!
Hello, World from other place!
Hello, World from my jar!

Note que o Makefile já populou o classpath corretamente para a invocação do programa java.

Uma Falha

Da forma como este Makefile foi idealizado (100% automático) ele não é capaz de deduzir que o programar world.HelloWorld tem como dependência a biblioteca myjar.MyJar e requer que nos invoquemos sua compilação manualmente. Veja o erro:

$ make clean
find . -type d -name bin | xargs -I{} rm -rf {}
rm -rf jars

$ make world/HelloWorld
rm -rf world/bin && mkdir world/bin
/usr/bin/javac -g -d world/bin -classpath .:: world/HelloWorld.java
world/HelloWorld.java:4: error: package myj does not exist
import myj.MyJar;
          ^
world/HelloWorld.java:12: error: cannot find symbol
        MyJar.call();
        ^
  symbol:   variable MyJar
  location: class HelloWorld
2 errors
make: ** [world/HelloWorld.class] Erro 1

O erro ocorre pois o programa MyJar não foi compilado ainda. Basta executar um make myjar/MyJar.jar e repetir o comando make world/HelloWorld que a execução concluirá com sucesso.

Bonus Track

Vocês se lembram que eu comecei o artigo anterior dizendo que aprendi a compilar em Java apenas para usar o VIM? Então… eu aprendi a usar o Makefile pois este se integra muito com com o VIM também.

Se você estiver programando no VIM e colocar esse Makefile na raiz do seu projeto você pode compilar e quiser executar seu programa sem sair do VIM, basta você invocar o make através do modo de comandos do VIM da seguinte forma: :make world/HelloWorld. Qual a vantagem em relação a invocar o make pelo terminal? A integração entre ambas as ferramentas! O VIM interpreta a saída do make realizar o parsing encontra os erros de compilação e move o seu cursor para as linhas que contêm erros. Veja:

Intrgração VIM & Makefile

Muito interessante não? Mas é claro que você não precisa ficar digitando esse comando constantemente. Para isso você pode criar os seguintes atalhos:

nnoremap <F1> :make %:r.class<CR>
nnoremap <F2> :make %:r<CR>
nnoremap <F3> :make %:r.jar<CR>

Estes mapeamentos configuram as teclas F1 para compilar o programa, a tecla F2 para compilar e executar o programa e a tecla F3 para compilar e empacotar.

Caso você seja como eu, e não goste de mover a mão até a fileira de teclas F1 a F12, faça a seguinte configuração de atalhos:

nnoremap <leader>jc :make %.class<CR>
nnoremap <leader>jr :make %:r<CR>
nnoremap <leader>jj :make %:r.jar<CR>

Sendo estes minemônicos para java compile, java run e java jar.

Por enquanto é só pessoal!

Magnun

Magnun

Engenheiro de telecomunicações por formação, mas trabalha com suporte à infraestrutura GNU/Linux, e nas horas vagas é Programador OpenSource (Python e C) desenhista e escritor do Mind Bending Blog.


Comments

comments powered by Disqus