Java-projektin hallinta Mavenilla

Keskitymme tästä eteenpäin käsittelemään Javan perusominaisuuksien sijaan korkeamman tason apuvälineitä Java-ohjelman toteuttamiseen ja hallintaan. Eräs varsin perustavanlaatuinen tarve koskee koodin käännösprosessin ja ulkoisten kirjastojen hallintaa. Tähän tarkoitukseen on olemassa laaja kirjo apuvälineitä. Tässä on valittu tarkasteltavaksi Apache Maven-niminen työkalu, jolla voi määrittää esimerkiksi sen, miten ohjelma käännetään ja mitä ulkoisia kirjastoja ohjelma tarvitsee. Maven lienee yleisin Java-projekteissa käytetty käännöstyökalu, mutta myös esim. Gradle on nykyisin varsin suosittu. Mavenin roolia Java-projektin hallinnassa voisi verrata esim. npm-pakettienhallintaohjelman rooliin Node.js-viitekehystä käyttävässä JavaScript-projektissa. Käsittelemme Mavenia varsin suppeasti pidättäytyen lähinnä kurssin kannalta oleellisimmissa ominaisuuksissa.

Netbeans sisältää Mavenin jo valmiiksi, mutta sen voi asentaa myös erikseen esimerkiksi Mavenin kotisivuilta tai Linuxissa järjestelmän pakettienhallintaohjelmalla. Jos Maven on kunnolla asennettu, pitäisi se pystyä suorittamaan komentorivillä komennolla mvn.

Maven-projektin rakenne

Maven-projekti koostuu pääasiassa kahdesta asiasta: projektin juurihakemistossa sijaitsevasta Maven-projektin ominaisuudet määrittävästä projektitiedostosta nimeltä pom.xml, ja alihakemistossa src sijaitsevasta projektin lähdekoodista. Java-lähdekoodit sijoitetaan oletuksena tarkemmin ottaen alihakemistoon src/main/java, noudattaen muuten tavanomaista tapaa sijoittaa kooditiedostot pakkauksia vastaaviin alihakemistoihin. Esim. pakkauksen com.example kooditiedoston SomeClass.java polku olisi src/main/java/com/example/SomeClass.java.

Kuten tiedostopääte vihjaa, projektitiedosto pom.xml kuvaa projektin ominaisuudet XML-muodossa. Ellei XML ole vielä tuttu käsite, tutustu sen perusominaisuuksiin esim. Wikipedian artikkelista.

Alla on esimerkkinä melko (joskaan ei aivan) minimaalinen Mavenin projektitiotiedosto:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>fi.tuni.prog3</groupId>
  <artifactId>example</artifactId>
  <version>1.0</version>
  <packaging>jar</packaging>
  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
  </properties>
</project>

pom.xml:n täytyy sisältää ainakin seuraavat osat:

  • project-juurielementti (alkaa <project>-alkutagilla ja päättyy </project>-lopputagiin). Alla luetellut elementit sijaitsevat juurielementin sisällä.

  • modelVersion-elementti, jonka arvon tulee nykyisin aina olla 4.0.0.

    • Tämä kuvaa, mitä Mavenin standardia (tai “mallia”) projektitiedosto vastaa. Nykyinen on 4.0.0.

  • groupId-elementti, joka kertoo projektin ryhmän.

    • Maven voi ryhmitellä projekteja tämän perusteella (hieman samoin kuin pakkaukset ryhmittelevät lähdekooditiedostoja).

  • artifactId-elementti, joka kertoo projektin nimen.

  • version-elementti, joka kertoo kuvaa projektin version.

    • Tämä voisi siis kasvaa sitä mukaa, kun ohjelma kehittyy (esim. version 1.0 jälkeen voisi tulla versio 1.1 tai versio 2.0).

Yllä oli lisäksi packaging-elementti, jolla voi määrittää millaiseen muotoon Maven paketoi käännetyn ohjelman; arvo jar tarkoittaa, että ohjelma luokkatiedostot yms. resurssit asetetaan yhteen ns. JAR-tiedostoon (tämä on myös Mavenin oletus). Tästä kohta lisää. Yllä määritetään lisäksi lopussa properties-elementin sisällä, että projektin lähdekooditiedostot käyttävät UTF-8 merkistökoodausta, lähdekoodin tulkitaan noudattavan Javan versiota 17, ja kääntäjä tuottaa Javan version 17 mukaisia luokkatiedostoja. Nämä Javan versioita koskevat arvot tulee tietenkin asettaa oman ympäristön kannalta toimiviksi (ne eivät voi olla käännösympäristön tarjoaman Javan versiota korkeampia; alempia on mahdollista käyttää).

Jos luot Netbeansissa uuden projektin ja valitset Java with Maven/Java Application, luo Netbeans valmiiksi jokseenkin yllä kuvattua muotoa olevan projektitiedoston. Se löytyy vasemmalla Projects-välilehdellä kansion “Project files” alta.

Maven-projektin kääntäminen

Maven-projektin voi kääntää joko Mavenia tukevan IDE:n komennolla tai suoraan itse komentoriviltä. Jotkut IDE:t (kuten Netbeans) tukevat Mavenia suoraan, ja toiset (esim. VS Code) vaativat lisäosan asentamisen.

Maven käsittelee käännöstä oletusarvoisesti monivaiheisena prosessina, johon kuuluu mm. seuraavat vaiheet:

  • compile: Kääntää projektin alihakemistossa src olevat lähdekoodit.

  • test: Suorittaa käännetylle ohjelmalle automaattiset testit (jos sellaisia on määritetty).

  • package: Paketoi käännetyn ohjelman helpommin jaettavaan muotoon, kuten JAR-tiedostoon.

  • install: Tallentaa paketin Mavenin paikalliseen tietovarastoon.

    • Esim. Linuxissa paketti löytyisi yleensä käyttäjän kotihakemiston alihakemistosta .m2/repository. Maven oletusarvoisesti tulostaa tiedon siitä, mihin ja millä nimellä paketti tarkkaan ottaen kopioitiin.

Vaiheita on enemmänkin, mutta emme käsittele niitä tässä tarkemmin.

Maven-projektin voi kääntää komentorivillä muotoa mvn phase olevalla komennolla, missä phase kertoo, mihin käännösvaiheeseen asti haluamme suorittaa käännöksen. Vaiheet seuraavat toinen toistaan järjestyksessä, ja myöhemmän vaiheen suorittaminen tarkoittaa, että suoritetaan myös kaikki sitä edeltävät vaiheet. Esimerkiksi mvn compile kääntää projektin, mvn test sekä kääntää että testaa, ja mvn package sekä kääntää, testaa että paketoi.

Maven asettaa käännetyt luokkatiedostot oletusarvoisesti alihakemistoon target/classes. Kuten tavallisestikin, luokkatiedostot sijoittuvat niille määritettyjen pakkausten mukaiseen hakemistorakenteeseen. Jos projekti paketoidaan esimerkiksi JAR-tiedostoon, asettaa Maven lopputuloksen alihakemiston target juureen. Tuloksena syntyvän tiedoston nimi johdetaan projektitiedostossa määritetyistä tiedoista: se on yleensä muotoa artifactId-version.jar.

Maven-projektin kääntäminen Netbeansin Run/Build project -komennolla vastaa yleensä komentoa mvn install. Koska install on myöhäisempi vaihe kuin package, sekin siten jo esimerkiksi paketoi ohjelman alihakemistoon target.

JAR-tiedostot (alias Java Archive)

JAR-tiedostot ovat eräs Java-kääntäjän tukema mahdollisuus paketoida ohjelma (tai luokkakirjasto) yhteen tiedostoon, jotta ohjelma olisi helpompi jakaa esimerkiksi loppukäyttäjille. JAR-tiedosto sisältää kaikki ohjelman luokkatiedostot, ja mahdollisesti muitakin sen tarvitsemia resursseja. JAR-tiedostot ovat oikeastaan vain erilaista tiedostopäätettä käyttäviä ZIP-tiedostoja, ja niiden sisältöä voi siten sinänsä halutessaan käsitellä tavallisilla ZIP-tiedostojen käsittelyohjelmilla.

Java-kääntäjän ohessa tulee apuohjelma jar, jolla voi halutessaan itse komentorivillä paketoida jo valmiiksi käännettyjä luokkatiedostoja yms. JAR-tiedostoksi. Emme käsittele tätä kuitenkaan tarkemmin, vaan keskitymme Mavenin käyttöön.

Kun ohjelma on paketoitu JAR-tiedostoon, sen voi suorittaa antamalla Java-virtuaalikoneelle lisävalitsimen -jar ja sen perässä suoritettavan JAR-tiedoston nimen: esimerkiksi tapaan java -jar program.jar, jos ohjelma on paketoitu JAR-tiedostoon program.jar.

JAR-tiedostoja käytetään yleisesti luokkakirjastojen levittämiseen. Jos haluamme suorittaa ohjelman, joka tarvitsee vaikkapa JAR-tiedoston library.jar tarjoamia luokkia tai rajapintoja, tulee library.jar asettaa johonkin sellaiseen paikkaan, mistä Java löytää sen, ja tarvittaessa antaa tiedostoa vastaava luokkapolkumääritys. Esimerkiksi java -cp /some/directory/library.jar MyClass suorittaisi luokan MyClass niin, että Java etsii suorituksen aikana tarvittavia luokkia yms. tavanomaisten Javan luokkakirjastojen lisäksi myös hakemistossa /some/directory olevasta JAR-tiedostosta library.jar. Luokkapolkumääritys voi luetella useita polkuja ja JAR-tiedostoja: ne vain erotellaan toisistaan kaksoispisteellä (Linux, Mac) tai puolipisteellä (Windows). Esim. Linuxissa suoritus java -cp /another/directory/:.:/some/directory/library.jar MyClass ohjaisi Java-virtuaalikoneen etsimään tarvittavia luokkatiedostoja hakemistosta /another/directory, nykyhakemistosta ., ja hakemistossa /some/directory olevasta JAR-tiedostosta library.jar.

Ulkoisten luokkakirjastojen nouto Mavenilla

Eräs merkittävä motiivi Mavenin käyttöön on sen kyky noutaa ulkoisia kirjastoja projektin käyttöön internetistä. Javan oma luokkakirjasto on laaja, mutta ei luonnollisestikaan sisällä kaikkia mahdollisesti tarvitsemiamme apuvälineitä. Jos on olemassa jokin ulkoinen (muu kuin Javan omaan luokkakirjastoon sisältyvä) kirjasto, jonka toimintoja haluamme käyttää, on yleensä varsin todennäköistä, että kyseinen kirjasto löytyy Mavenin jopa miljoonia kirjastoja sisältävästä kirjastohakemistosta. Tällöin kirjasto voidaan ottaa projektissa käyttöön yksinkertaisesti lisäämällä pom.xml-tiedostoon kyseistä kirjastoa vastaava riippuvuusmääritys. Kun tämän jälkeen projektin kääntää, noutaa Maven kyseisen kirjaston automaattisesti internetistä ja voimme alkaa käyttämään kyseisen kirjaston luokkia ja rajapintoja koodissamme hyvin samaan tapaan kuin olemme toistaiseksi käyttäneet Javan oman luokkakirjaston luokkia ja rajapintoja.

Alempana on esimerkki siitä, miten riippuvuusmääritys tehdään: projektitiedostoon on lisätty dependencies-elementti, jonka sisällä olevat dependency-elementit luettelevat projektin riippuvuudet. Alla on vain yksi riippuvuus: jdom2-kirjasto, joka tarjoaa apuvälineitä XML-datan käsittelyyn. Riippuvuuden määrittelyssä annetaan groupId, artifactId ja version. Nämä riittävät kuvaamaan Mavenille, mistä luokkakirjastosta ja sen versiosta on tarkkaan ottaen kyse. Tämä samalla ehkä antaa esimerkin siitä, miksi Mavenin projektitiedostoon vaaditaan kyseiset tiedot: jos oma projektimme julkaistaisiin Mavenin keskushakemistossa, jotta muut Mavenin käyttäjät voisivat helposti käyttää sitä ulkoisena luokkakirjastona, toimisivat meidän omalle projektillemme määritetyt groupId, artifactId ja version luokkakirjastomme yksilöivinä riippuvuuden kuvaavina arvoina.

Mavenin keskushakemisto

Herää ehkä kysymys, että kuinka osaamme luoda oikeanlaisen riippuvuusmäärityksen? Kirjaston (jonka olemme löytäneet esim. internet-hakukoneen avulla tms.) dokumentaatiossa on toisinaan kuvattu sitä vastaava Mavenin riippuvuusmääritys. Jos kirjasto ylipäänsä on Mavenin saatavilla, sen tietojen pitäisi myös löytyä Mavenin keskushakemistosta, johon on tarjolla hakusivu https://search.maven.org/. Keskushakemisto esimerkiksi luettelee saatavilla olevien kirjastojen eri versiot (joista yleensä kannattanee valita uusin) sekä näyttää valitsemaame kirjaston versiota vastaavan riippuvuusmäärityksen.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>fi.tuni.prog3</groupId>
  <artifactId>example</artifactId>
  <version>1.0</version>
  <packaging>jar</packaging>
  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.jdom</groupId>
      <artifactId>jdom2</artifactId>
      <version>2.0.6.1</version>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>com.jolira</groupId>
        <artifactId>onejar-maven-plugin</artifactId>
        <version>1.4.4</version>
        <executions>
          <execution>
            <configuration>
              <mainClass>fi.tuni.prog3.example.ExampleMain</mainClass>
              <onejarVersion>0.97</onejarVersion>
              <attachToBuild>true</attachToBuild>
            </configuration>
            <goals>
              <goal>one-jar</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>

Edellä on jdom2-kirjaston käyttöönoton lisäksi yksi toinenkin muutos aiempaan projektitiedostoon: loppuun on lisätty build-elementti, jonka alla on käännöksessä käytettäviä liitännäisiä määrittävä plugins-elementti. Tässä on tarkemmin ottaen yksi liitännäinen (“plugin”): yksi plugin-elementti, joka ottaa käyttöön onejar-liitännäisen. Kyseinen liitännäinen tarjoaa sellaisen mukavan ominaisuuden, että sen myötä Maven paketoi JAR-tiedostoon projektin omien luokkatiedostojen yms. resurssien lisäksi myös sen tarvitsemat ulkoiset luokkakirjastot. Nimittäin Mavenin luomat JAR-tiedostot eivät tavallisesti sisältäisi niitä, ja tällöin ulkoisia kirjastoja käyttävää ohjelmaa ei voisikaan suorittaa suoraan pelkän yhden JAR-tiedoston varassa: sen oheen tarvittaisiin erikseen myös ohjelman tarvitsemat kirjastot. Sen sijaan onejar-liitännäistä käyttämällä saadaan luotua itseriittoinen JAR-tiedosto. Onejaria käyttäessä tuloksena on kaksi JAR-tiedostoa: tavallinen sekä riippuvuudet sisältävä versio. Jälkimmäisen nimeen on lisätty osa “one-jar”. Esimerkiksi edellä annetun projektitiedoston tapauksessa komento mvn package loisi JAR-tiedostot target/example-1.0.jar ja target/example-1.0.one-jar.jar.

Onejar-liitännäisen määrityksessä on yksi kohta, joka pitää yleensä määrittää projektikohtaisesti eri tavalla: mainClass-elementin sisältö. Kyseinen arvo määrittää projektin pääluokan eli sen luokan, jonka määrittämästä main-funktiosta ohjelman suoritus alkaa. Yllä määritettiin, että projektin pääluokka olisi fi.tuni.prog3.example.ExampleMain. Tällainen määritys tarvitaan, jotta JAR-tiedoston sisältämän ohjelman voisi suorittaa tapaan java -jar program.jar ilman erillistä parametria, joka kertoisi mistä luokasta suoritus aloitetaan. Projektin ei toki ole pakko sisältää main-funktiota, jos se on tarkoitettu muiden ohjelmien käyttämäksi luokkakirjastoksi.

Jos käytät onejar-liitännäistä määrittämättä pääluokkaa, tai haluat aloittaa suorituksen jostain muusta saman projektin luokasta, suoritettavan luokan voi määrittää ohjelmaa ajettaessa erillisellä valitsimella -Done-jar.main.class=MainClass, missä MainClass ilmaisee luokan (tarvittaessa pakkauksineen päivineen). Esimerkiksi java -Done-jar.main.class=com.example.SomeClass -jar program.jar aloittaisi suorituksen JAR-tiedoston program.jar sisältämästä pakkaukseen com.example määritetystä luokasta SomeClass.

Ohjelmointidemo (kesto 43:49)