grunt – Ein Javascript Build Tool

Momentan beschäftige ich mich erstmals mit einer reinen Javascript Web App, die komplett aus statischen HTML- und JS-Dateien besteht und über eine REST-Schnittstelle mit (dynamischen) Daten versorgt wird. Als Technologien verwende ich dabei so interessante Sachen, wie [highlight1]angularJS[/highlight1], [highlight1]jQuery Mobile[/highlight1] und [highlight1]Jasmine[/highlight1]. Alles super spannend.

Aber wie verwaltet man so eine App im professionellen Einsatz, wenn man an Maven als Build-, Dependency Management- und Deployment-Tool gewohnt ist? Und im Jenkins (Continous Integration) soll es natürlich ebenfalls integrierbar sein. Und regelmässige Unit-Tests sollen auch ausgeführt werden. Und automatisch verteilte Builds, die ins automatische Deployment einlaufen.

Einfach mal die HTML- und JS-Dateien manuell ins Apache-Verzeichnis zu kopieren, kommt jedenfalls nicht in Frage.

Da wir die Webapp sowieso per Javascript entwickeln, hatte ich den Anspruch, auch ein Build-Tool aus dem Javascript-Bereich zu verwenden. Eine (leere) Hülle als Maven-Projekt in dem es keine Java-Klassen gibt, wäre zwar auch möglich gewesen, aber erstens hatte mich meine Entwicklerehre gepackt und zweitens habe ich schon die Erfahrung gemacht, dass es zwar manche Javascript-Tools als Maven-Plugin gibt, aber bei weitem nicht alle, vieles ist veraltet, wird kaum gepflegt und verhält sich eher komisch.

Also Javascript.

Im letzten Jahr hat dort [highlight1]gruntJS[/highlight1] einen Siegeszug erster Güte hingelegt. Erst im März 2012 veröffentlicht und eigentlich immer noch im Beta-Stadium hat es schon eine Fülle an Plugins hervorgebracht. Zusammen mit bower von Twitter als Tool für Dependency Management ist es ausserdem von Google in deren Build-Tool yeoman integriert worden, das ebenfalls äusserst vielversprechend aussieht. Auf yeoman gehe ich in einem späteren Artikel mal ein.

Wenn man sich mit gruntJS näher beschäftigt, fällt auf, dass es sehr ähnlich zu einem ant-Projekt konfiguriert wird, d.h. das Build ist task-basiert und genauso wie bei ant, hat man am anfang (fast) nichts. Zwar bringt grunt einige sehr sinnvolle Tasks eingebaut mit (z.B. minify, concat, qunit und noch ein paar mehr), aber die gilt es erstmal sinnvoll zu konfigurieren.

Der wesentlichste Vorteil gegenüber ant und auch gegenüber Maven ist für mich allerdings, dass die Konfigurationsdatei KEIN XML ist, sondern eine richtige Programmiersprache verwendet wird: Javascript. Man kann also alle Features von Javascript (und Node.js auf das grunt aufsetzt) verwenden. Keine unleserlichen XML-Konstrukte mehr, die niemand versteht. Ausserdem kann man fehlende Features sehr einfach zusätzlich entwickeln, ohne gleich ein komplettes Plugin entwickeln zu müssen, indem man einfach entsprechende Funktionalität entwickelt. Ein neues grunt-Plugin zu entwickeln, ist aber wenn man sich mit dem System mal auseinandergesetzt hat, extrem einfach.

Ein weiterer Vorteil ist für mich, dass es auf [highlight1]node.js[/highlight1] aufsetzt. Im Umfeld des “Javascript auf dem Server”-Tools passiert momentan ziemlich viel, das die Webentwicklung die nächsten Jahre sehr beeinflussen wird. Eine hochgradig dynamische Entwickler-Community, von der man viel lernen und mitnehmen kann. Abhängigkeiten – wie z.B. weitere grunt-Plugins die man benutzen möchte – können deshalb ziemlich einfach mit npm (Node Package Manager) nachinstalliert werden. Und weitere Tools wie z.B. Testacular (Unit Test Runner) zu integrieren ist einfach ein klacks.

Wenn es etwas zu kritisieren gibt, ist es momentan leider die Dokumentation. Zwar findet man an vielen Stellen grundlegende Einführungen wie die grunt.js/gruntfile.js aussehen muss, aber eine komplette, übersichtliche API-Dokumentation findet man kaum. Die Doku im github Projekt von grunt, enthält zwar einige Beispiele und auch eine API-Dokumentation, aber das sieht doch noch sehr unübersichtlich aus. Aber vermutlich wird das mit der Zeit noch kommen.

Als (weitere) Beispielimplementierung hänge ich mal meine grunt.js (basiert noch auf gruntJS 0.3!) und die package.json hier dran, die wir derzeit für unser Projekt benutzen. Die Projektstruktur haben wir (aus Gewohnheit) ähnlich einem maven-Projekt aufgebaut. Die wesentlichen Tasks sind:
[fancy_list]

  • web – startet einen kleinen Webserver, der das Projekt ausliefert. Bei Änderungen am Quellcode wird automatisch jsLint ausgeführt und (geplant) auch die Unit-Tests. Nützlich vor allem auch, wenn die Webapp REST-Requests an das Backend macht. Die sperrt der Browser nämlich, wenn man die index.html vom Dateisystem öffnet
  • install – erzeugt ein deploy-fähiges Build-Artefakt im target-Ordner. Dabei werden per requireJS alle Javacscript-Dateien automatisch in der richtigen Reihenfolge zusammenkopiert. Ausserdem kann man optional noch eine Systemumgebung angeben, dessen Konfigurationsdatei verwendet wird (z.B. Testserver, Produktionsserver, etc.)
  • deploy – führt ein Install aus und zusätzlich wird die Anwendung in einen konfigurierbaren Deployment-Ordner kopiert
  • release – erhöht die angegebene Versionsnummer der Anwendung (in der package.json), nützlich für ein möglichst automatisches Release-Management im Jenkins. (hier fehlt noch ein automatisches Einchecken der geänderten package.json)
  • test – führt die Unit-Tests aus (leider noch nicht fertig, verwendet werden soll grunt-testacular, den unfertigen Code habe ich aus der grunt.js entfernt)

[/fancy_list]

Das ist natürlich nur ein Beispiel, ich weiß nicht mal ob es ein besonders gutes ist. Als Anregung aber sicher zu gebrauchen. Verbesserungsvorschläge sind natürlich gern willkommen. 

/*global module:false*/
module.exports = function (grunt) {
	"use strict";

	grunt.initConfig({
		pkg:'<json:package.json>',
		meta:{
			banner:'/*! <%= pkg.title || pkg.name %> - v<%= pkg.version %> - ' +
				'<%= grunt.template.today("yyyy-mm-dd") %>\n' +
				'<%= pkg.homepage ? "* " + pkg.homepage + "\n" : "" %>' +
				'* Copyright (c) <%= grunt.template.today("yyyy") %> */'
		},
		clean:{
			mainjs: ['<%=pkg.folders.build %>/main.js'],
			requirejs: ['<%=pkg.folders.build %>/requirejs'],
			all: ['<%=pkg.folders.build %>']
		},
		lint:{
			src:'<%=pkg.folders.jsSource %>'+ '**/*.js',
			grunt:['grunt.js']
		},
		watch:{
			files: ['<%=pkg.folders.jsSource %>' + '**/*.js'],
			tasks:'lint'
		},
		jshint:{
			options:{
				curly: true,
				eqeqeq: true,
				immed: true,
				latedef: true,
				newcap: true,
				noarg: true,
				sub: true,
				undef: true,
				boss: true,
				eqnull: true,
				unused: true,
				browser: true,
				strict: true,
				jquery: true
			},
			globals:{
				angular:true,
				moment:true,
				console: true,
				define: true,
				require: true
			}
		},
		targethtml: {
			local: {
				src: '<%=pkg.folders.wwwRoot %>' + 'index.html',
				dest: '<%= pkg.folders.build + pkg.name + "-" + pkg.version %>'  + '/index.html'
			},
			release: {
				src: '<%=pkg.folders.wwwRoot %>' + 'index.html',
				dest: '<%= pkg.folders.build + pkg.name + "-" + pkg.version %>' + '/index.html'
			}
		},
		copy: {
			css: {
				files: {
					'<%= pkg.folders.build + pkg.name + "-" + pkg.version %>/css/': [
						'<%= pkg.folders.wwwRoot%>css/**'
					]
				}
			},
			partials: {
				files: {
					'<%= pkg.folders.build + pkg.name + "-" + pkg.version %>/partials/': [
						'<%= pkg.folders.wwwRoot%>partials/**'
					]
				}
			},
			images: {
				files: {
					'<%=pkg.folders.build + pkg.name + "-" + pkg.version %>/images/': [
						'<%= pkg.folders.wwwRoot%>images/**'
					]
				}
			},
			requirejs: {
				files: {
					'<%=pkg.folders.build %>/requirejs/': [
						'<%= pkg.folders.wwwRoot%>js/**',
						'<%= pkg.folders.wwwRoot%>external-libs/**'
					]
				}
			},
			mainjs: {
				files: {
					'<%=pkg.folders.build + pkg.name + "-" + pkg.version %>/js/': [
						'<%= pkg.folders.build%>/main.js'
					]
				}
			},
			config: {
				files: {
					'<%=pkg.folders.build %>/requirejs/js/config/config.js': [
						'<%= pkg.folders.build%>/requirejs/js/config/<%=configDatei%>'
					]
				}
			},
			deploy: {
				files: {
					'<%=deployOrdner %>': [
						'<%= pkg.folders.build + pkg.name + "-" + pkg.version + ".tar.gz"%>'
					]
				}
			}
		},
		compress: {
			tgz: {
				files: {
					'<%=pkg.folders.build + pkg.name + "-" + pkg.version + ".tar.gz"%>':'<%=pkg.folders.build + pkg.name + "-" + pkg.version + "/**"%>'
				}
			}
		},
		requirejs: {
			compile: {
				options: {
					baseUrl: "target/requirejs/js",
					name:"../external-libs/almond-0.1.1",
					include: "main",
					mainConfigFile: "target/requirejs/js/main.js",
					out:"target/main.js",
					optimize: "none"
				}
			}
		},
		server: {
			port: 8000,
			base: 'src/main'
		},
	});

	grunt.registerTask('default', 'lint');

	//Deployment-Tasks
	grunt.registerTask("install", "Erstellt Build-Artefakt im Build-Ordner für die angegebene Umgebung (Test, Prod, etc.), wenn keine Umgebung angegeben wird, wird die Default-Umgebung aus dem Source-Code verwendet",
		function(systemUmgebung) {
			grunt.task.run("lint");
			grunt.task.run("clean:all");
			grunt.task.run("copy:requirejs");

			if (systemUmgebung) {
				grunt.config("configDatei", "config_" + systemUmgebung + ".js");
				grunt.task.run("copy:config");
			}

			grunt.task.run("requirejs");
			grunt.task.run("copy:css");
			grunt.task.run("copy:partials");
			grunt.task.run("copy:images");
			grunt.task.run("copy:mainjs");
			grunt.task.run("clean:mainjs");
			grunt.task.run("clean:requirejs");
			grunt.task.run("targethtml:release");
			grunt.task.run("compress");
		}
	);

	grunt.registerTask("deploy", "Erstellt Build-Artefakt und kopiert es in den konfigurierten Deployment-Ordner für die angegebene Umgebung (Test, Prod, etc.), wenn keine Umgebung angegeben wird, wird die Default-Umgebung aus dem Source-Code verwendet",
		function(systemUmgebung) {
			var deployFolder;
			if (systemUmgebung) {
				grunt.task.run("install:" + systemUmgebung);
			} else {
				grunt.task.run("install");
			}

			if (systemUmgebung) {
				deployFolder = grunt.config("pkg.folders.deploy") + systemUmgebung + "/";
			} else {
				deployFolder = grunt.config("pkg.folders.deploy") + "/";
			}

			grunt.config("deployOrdner", deployFolder);
			grunt.task.run("copy:deploy");
		}
	);

	grunt.registerTask("release", "Führt ein Release durch: Erhöht die angegebene Versionsnummer in der package.json um 1 und checkt die dadurch geänderte package.json mit svn ein (falls installiert)",
		function(versionNumber) {
			var pkg, versions;
			if (!versionNumber) {
				grunt.fatal("Die Angabe welche Versionsnummer erhöht werden soll, ist zwingend notwendig ('major', 'minor' oder 'patch')");
			}

			if (versionNumber !== 'major' && versionNumber !== 'minor' && versionNumber !== 'patch') {
				grunt.fatal("Die zu erhöhende Versionsnummer muss 'major', 'minor' oder 'patch' sein: " + versionNumber);
			}

			pkg = grunt.file.readJSON('./package.json');
			grunt.log.writeln("Bisherige Version: " + pkg.version);

			versions = pkg.version.split(".");

			if (versionNumber === 'major') {
				versions[0] = parseInt(versions[0], 10) + 1;
			} else if (versionNumber === 'minor') {
				versions[1] = parseInt(versions[1], 10) + 1;
			} else if (versionNumber === 'patch') {
				versions[2] = parseInt(versions[2], 10) + 1;
			}

			pkg.version = versions.join(".");

			grunt.log.writeln("Neue Version: " + pkg.version);
			grunt.file.write('./package.json', JSON.stringify(pkg, undefined, '\t'));

		}
	);

	//Tasks für lokale Entwicklung
	grunt.registerTask('compile', 'lint test');
	grunt.registerTask('test', 'testacularServer');
	grunt.registerTask('web', 'server watch'); 


	//Plugins aktivieren
	grunt.loadNpmTasks('grunt-contrib-clean');
	grunt.loadNpmTasks('grunt-contrib-copy');
	grunt.loadNpmTasks('grunt-targethtml');
	grunt.loadNpmTasks('grunt-testacular');
	grunt.loadNpmTasks('grunt-requirejs');
	grunt.loadNpmTasks('grunt-contrib-compress');
};
{
	"name": "Projekt",
	"homepage": "http://www.google.com",
	"version": "1.0.0",
	"devDependencies": {
		"grunt-contrib-clean": "0.3.x",
		"grunt-contrib-copy": "0.3.x",
		"grunt-contrib-compress": "0.3.x",
		"grunt-targethtml": "0.1.x",
		"grunt-testacular": "0.3.x",
		"grunt-requirejs": "0.3.x",
		"grunt-exec": "0.3.x"
	},
	"engines": {
		"node": "0.8.x"
	},
	"folders": {
		"build": "target/",
		"wwwRoot": "src/main/",
		"jsSource": "src/main/js/",
		"externalLibs": "src/main/external-libs/",
		"testRoot": "src/test/",
		"deploy": "/home/svn/deployment/"
	}
}

Leave a Reply

Your email address will not be published. Required fields are marked *

*