| « Quartz: 3 Beispiele | Tutorial: CSVCheck (2) » |
Time Warp mit dem Quartz Scheduler
Was verbindet Newsletter, Hotfolder und die Defragmentierung von Festplatten? Eigentlich nichts, aber alle drei benötigen regelmäßige Wiederholungen.
Wenn man sich etwas umsieht, entdeckt man ziemlich viele Anwendungsfälle, bei denen etwas wiederholt werden muss: Automatisch aus Datenbanken generierte Statistiken, automatisierte Downloads von FTP-Servern, Batch-Processing von eintreffenden Dateien usw.
Um das mit Java in den Griff zu bekommen, gibt es ein ideales Tool: den Quartz Enterprise Job Scheduler von OpenSymphony. Der Name lässt vermuten, dass Quartz für JavaEE gedacht ist, aber in JavaSE funktioniert es genauso, und damit sind durch Zeitgeber gesteuerte Desktop-Anwendungen in Reichweite!
Fortsetzung:
Zu Quartz gibt es ein offizielles Tutorial (engl.), das ich durch eine kurze Step-by-Step Einführung ergänzen möchte. Ziel: Eine Desktop-Anwendung mit verschiedenen Jobs und Triggern, die in einer MySQL-Datenbank gespeichert werden.
Das hat gleich mehrere Vorteile: Zunächst die dauerhafte Sicherung der zu erledigenden Aufgaben, auch wenn das Programm zwischendurch mal nicht laufen sollte. Quartz kann verpasste Aufgaben in einem definierten Zeitraum nachholen. Hinzu kommt die Möglichkeit, die Aufgaben und deren Ausführungszeiten (Trigger) direkt über die Datenbank zu administrieren, z.B. mit einem in PHP programmierten Web-Frontend.
Aber eins nach dem anderen...
It's just a jump to the left.
Fangen wir mit den Grundvoraussetzungen an. Für diese Einführung wird folgendes benötigt:
- Eine Java IDE, z.B. Netbeans oder Eclipse. Ich verwende ersteres.
- Die Quartz Libraries, hier in Version 1.4.5.
- Eine Datenbank mit passendem JDBC-Treiber. Für MySQL gibt es den JDBC Driver (Connector/J). Alternativen wären Cloudscape, DB2, HSQLDB, Informix, Oracle, Postgres u.a.m.
And then a step to the right.
Die Quartz Libraries (alle JAR-Dateien im lib-Ordner) und den MySQL-Treiber (mysql-connector-java-5.0.7-bin.jar) nehme ich zunächst in mein Netbeans-Projekt auf. Anschließend muss die Datenbank vorbereitet werden. Quartz liefert eine Reihe von SQL-Skripts mit (in quartz-1.4.5/docs/dbTables), darunter auch eines für MySQL. Ich habe eine neue Datenbank „launchpad“ erzeugt, das Skript darin gestartet und einen neuen User angelegt. Diese Informationen benötigt natürlich auch das Quartz-Programm. Dazu kopiert man am besten die Datei quartz-1.4.5/docs/config/example_quartz.properties direkt in das src-Verzeichnis des Java-Projekts und benennt sie in quartz.properties um. Man findet die Datei anschließend in Netbeans unter Source Packages/default package und kann sie öffnen und anpassen. Ich habe folgendes geändert:
Code:
org.quartz.jobStore.class = org.quartz.impl.jdbcjobstore.JobStoreTX | |
org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.StdJDBCDelegate | |
org.quartz.jobStore.useProperties = true | |
org.quartz.jobStore.dataSource = myDS | |
org.quartz.jobStore.tablePrefix = qrtz_ | |
org.quartz.jobStore.isClustered = false | |
| |
org.quartz.dataSource.myDS.driver = com.mysql.jdbc.Driver | |
org.quartz.dataSource.myDS.URL = jdbc:mysql://localhost/launchpad | |
org.quartz.dataSource.myDS.user = launchpad | |
org.quartz.dataSource.myDS.password = launch | |
org.quartz.dataSource.myDS.maxConnections = 5 |
Damit sind die Vorbereitungen abgeschlossen und die eigentliche Programmierung kann beginnen!
With your hands on your hips.
Ich erstelle zunächst eine neue JFrame-Klasse für das Programmfenster und nenne sie Hauptfenster.java. Sie erhält zwei Methoden zum Starten und Beenden des Quartz Schedulers:
Code:
private void startQuartz () { | |
try { | |
sched =StdSchedulerFactory.getDefaultScheduler (); | |
sched.start (); | |
} catch (SchedulerException ex) { | |
ex.printStackTrace (); | |
} | |
} | |
| |
private void stopQuartz () { | |
try { | |
sched.shutdown (); | |
} catch (SchedulerException ex) { | |
ex.printStackTrace (); | |
} | |
} |
Dann noch drei Import-Anweisungen:
Code:
import org.quartz.Scheduler; | |
import org.quartz.SchedulerException; | |
import org.quartz.impl.StdSchedulerFactory; |
Und schließlich den Aufruf der beiden oben genannten Methoden: Im Konstruktor startQuartz(); und in einem WindowClosing-Eventhandler stopQuartz();
Hier die Hauptfenster-Klasse, die bereits eine vollständige Quartz-Anwendung mit Datenbank-Anbindung darstellt:
Code:
/* | |
* Hauptfenster.java | |
* | |
*/ | |
| |
package quartztutorial.gui; | |
| |
import org.quartz.Scheduler; | |
import org.quartz.SchedulerException; | |
import org.quartz.impl.StdSchedulerFactory; | |
| |
/** | |
* | |
* @author Jean-Rene | |
*/ | |
public class Hauptfenster extends javax.swing.JFrame { | |
| |
Scheduler sched; | |
| |
/** Creates new form Hauptfenster */ | |
public Hauptfenster () { | |
initComponents (); | |
startQuartz (); | |
} | |
| |
/** This method is called from within the constructor to | |
* initialize the form. | |
* WARNING: Do NOT modify this code. The content of this method is | |
* always regenerated by the Form Editor. | |
*/ | |
// [lt]editor-fold defaultstate="collapsed" desc=" Generated Code "[gt] | |
private void initComponents() { | |
| |
setDefaultCloseOperation(javax.swing.WindowConstants.EXIT_ON_CLOSE); | |
addWindowListener(new java.awt.event.WindowAdapter() { | |
public void windowClosing(java.awt.event.WindowEvent evt) { | |
formWindowClosing(evt); | |
} | |
}); | |
| |
javax.swing.GroupLayout layout = new javax.swing.GroupLayout(getContentPane()); | |
getContentPane().setLayout(layout); | |
layout.setHorizontalGroup( | |
layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING) | |
.addGap(0, 400, Short.MAX_VALUE) | |
); | |
layout.setVerticalGroup( | |
layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING) | |
.addGap(0, 300, Short.MAX_VALUE) | |
); | |
pack(); | |
}// [lt]/editor-fold[gt] | |
| |
private void formWindowClosing (java.awt.event.WindowEvent evt) { | |
stopQuartz (); | |
} | |
| |
/** | |
* @param args the command line arguments | |
*/ | |
public static void main (String args[]) { | |
java.awt.EventQueue.invokeLater (new Runnable () { | |
public void run () { | |
new Hauptfenster ().setVisible (true); | |
} | |
}); | |
} | |
| |
// Variables declaration - do not modify | |
// End of variables declaration | |
| |
private void startQuartz () { | |
try { | |
sched =StdSchedulerFactory.getDefaultScheduler (); | |
sched.start (); | |
} catch (SchedulerException ex) { | |
ex.printStackTrace (); | |
} | |
} | |
| |
private void stopQuartz () { | |
try { | |
sched.shutdown (); | |
} catch (SchedulerException ex) { | |
ex.printStackTrace (); | |
} | |
} | |
| |
} |
Beim Programmstart kann in der Konsolenausgabe verfolgt werden, ob alles klappt. Eine Startsequenz sieht z.B. so aus:
Code:
20.09.2007 19:03:17 org.quartz.impl.jdbcjobstore.JobStoreSupport initialize | |
INFO: Using thread monitor-based data access locking (synchronization). | |
20.09.2007 19:03:17 org.quartz.impl.jdbcjobstore.JobStoreSupport cleanVolatileTriggerAndJobs | |
INFO: Removed 0 Volatile Trigger(s). | |
20.09.2007 19:03:17 org.quartz.impl.jdbcjobstore.JobStoreSupport cleanVolatileTriggerAndJobs | |
INFO: Removed 0 Volatile Job(s). | |
20.09.2007 19:03:17 org.quartz.impl.jdbcjobstore.JobStoreSupport recoverJobs | |
INFO: Freed 0 triggers from 'acquired' / 'blocked' state. | |
20.09.2007 19:03:17 org.quartz.impl.jdbcjobstore.JobStoreSupport recoverJobs | |
INFO: Recovering 0 jobs that were in-progress at the time of the last shut-down. | |
20.09.2007 19:03:17 org.quartz.impl.jdbcjobstore.JobStoreSupport recoverJobs | |
INFO: Recovery complete. | |
20.09.2007 19:03:17 org.quartz.impl.jdbcjobstore.JobStoreSupport recoverJobs | |
INFO: Removed 0 'complete' triggers. | |
20.09.2007 19:03:17 org.quartz.impl.jdbcjobstore.JobStoreSupport recoverJobs | |
INFO: Removed 0 stale fired job entries. | |
20.09.2007 19:03:17 org.quartz.impl.jdbcjobstore.JobStoreTX initialize | |
INFO: JobStoreTX initialized. | |
20.09.2007 19:03:17 org.quartz.impl.StdSchedulerFactory instantiate | |
INFO: Quartz scheduler 'Sched1' initialized from default resource file in Quartz package: 'quartz.properties' | |
20.09.2007 19:03:17 org.quartz.impl.StdSchedulerFactory instantiate | |
INFO: Quartz scheduler version: 1.4.5 | |
20.09.2007 19:03:17 org.quartz.core.QuartzScheduler start | |
INFO: Scheduler Sched1_$_1 started. |
Beim Schließen des Programmfensters folgt
Code:
20.09.2007 19:03:19 org.quartz.core.QuartzScheduler shutdown | |
INFO: Scheduler Sched1_$_1 shutting down. | |
20.09.2007 19:03:19 org.quartz.core.QuartzScheduler pause | |
INFO: Scheduler Sched1_$_1 paused. | |
20.09.2007 19:03:19 org.quartz.core.QuartzScheduler shutdown | |
INFO: Scheduler Sched1_$_1 shutdown complete. |
Und damit können wir uns direkt auf die Konfiguration einzelner Jobs und deren Zeitpläne stürzen.
You bring your knees in tight.
Dazu benötigt man drei Dinge:
- Ein JobDetail-Objekt, das definiert, was zu tun ist.
- Eine JobDataMap, die die Parameter des Jobs festlegt.
- Einen (oder mehrere) Trigger für die Ausführungszeitpunkte.
Ein paar Standard-Aktionen sind in Quartz schon enthalten, daher ist es sehr einfach, z.B. einen Befehl ans Betriebssystem abzugeben. Das erledigt man am besten mit einem NativeJob. Er heißt übrigens so, weil er die Befehle abhängig vom jeweiligen Betriebssystem weitergibt.
Code:
JobDetail nativeJobDetail = new JobDetail ("StartProgram", sched.DEFAULT_GROUP, NativeJob.class); |
Leider haben die Entwickler die Klasse NativeJob nur für die Konfiguration
org.quartz.jobStore.useProperties = false
geschrieben, wir haben diesen Wert aber eben auf „true“ gesetzt. Und zwar mit Absicht – damit werden die Job-Konfigurationen nämlich nicht als serialisierte Objekte, sondern als Strings gespeichert. Und damit sind spätere Änderungen an eigenen Jobklassen mit deutlich weniger Problemen verbunden. Sogar im offiziellen Tutorial wird empfohlen, hier „true“ einzustellen.
Damit brauchen wir eine Variante der Klasse NativeJob, die mit der obigen Einstellung klar kommt. Ich habe diese Variante SystemJob genannt, hier ist sie:
Code:
/* | |
* SystemJob.java | |
* | |
*/ | |
| |
package jobs; | |
| |
import org.apache.commons.logging.Log; | |
import org.apache.commons.logging.LogFactory; | |
import org.quartz.JobDataMap; | |
import org.quartz.JobExecutionContext; | |
import org.quartz.JobExecutionException; | |
import org.quartz.jobs.NativeJob; | |
| |
| |
public class SystemJob extends NativeJob { | |
| |
public void execute (JobExecutionContext context) throws JobExecutionException { | |
| |
JobDataMap data = context.getJobDetail ().getJobDataMap (); | |
String command = data.getString (PROP_COMMAND); | |
String parameters = data.getString (PROP_PARAMETERS); | |
| |
if (parameters == null) { | |
parameters = ""; | |
} | |
| |
boolean wait = false; | |
if(data.containsKey (PROP_WAIT_FOR_PROCESS)) { | |
wait = data.getBooleanFromString (PROP_WAIT_FOR_PROCESS); | |
} | |
runNativeCommand (command, parameters, wait); | |
} | |
| |
private static Log getLog () { | |
return LogFactory.getLog (NativeJob.class); | |
} | |
| |
private void runNativeCommand (String command, String parameters, boolean wait) | |
throws JobExecutionException { | |
| |
String[] cmd = null; | |
String[] args = new String[2]; | |
args[0] = command; | |
args[1] = parameters; | |
| |
try { | |
//with this variable will be done the swithcing | |
String osName = System.getProperty ("os.name"); | |
| |
//only will work with Windows NT | |
if (osName.equals ("Windows NT")) { | |
if (cmd == null) cmd = new String[args.length + 2]; | |
cmd[0] = "cmd.exe"; | |
cmd[1] = "/C"; | |
for (int i = 0; i [lt] args.length; i++) | |
cmd[i + 2] = args[i]; | |
} | |
//only will work with Windows 95 | |
else if (osName.equals ("Windows 95")) { | |
if (cmd == null) cmd = new String[args.length + 2]; | |
cmd[0] = "command.com"; | |
cmd[1] = "/C"; | |
for (int i = 0; i [lt] args.length; i++) | |
cmd[i + 2] = args[i]; | |
} | |
//only will work with Windows 2000 | |
else if (osName.equals ("Windows 2000")) { | |
if (cmd == null) cmd = new String[args.length + 2]; | |
cmd[0] = "cmd.exe"; | |
cmd[1] = "/C"; | |
| |
for (int i = 0; i [lt] args.length; i++) | |
cmd[i + 2] = args[i]; | |
} | |
//only will work with Windows XP | |
else if (osName.equals ("Windows XP")) { | |
if (cmd == null) cmd = new String[args.length + 2]; | |
cmd[0] = "cmd.exe"; | |
cmd[1] = "/C"; | |
| |
for (int i = 0; i [lt] args.length; i++) | |
cmd[i + 2] = args[i]; | |
} | |
//only will work with Linux | |
else if (osName.equals ("Linux")) { | |
if (cmd == null) cmd = new String[args.length]; | |
cmd = args; | |
} | |
//will work with the rest | |
else { | |
if (cmd == null) cmd = new String[args.length]; | |
cmd = args; | |
} | |
| |
Runtime rt = Runtime.getRuntime (); | |
// Executes the command | |
getLog ().info ("About to run" + cmd[0] + cmd[1]); | |
Process proc = rt.exec (cmd); | |
if(wait) | |
proc.waitFor (); | |
// any error message? | |
| |
| |
} catch (Exception x) { | |
System.out.println ("error happened in native job"); | |
throw new JobExecutionException ("Error launching native command: ", x, false); | |
} | |
} | |
| |
} |
Um den neuen Job zu definieren, heißt es nun:
Code:
JobDetail nativeJobDetail = new JobDetail ("FileZilla starten", sched.DEFAULT_GROUP, SystemJob.class); |
Damit haben wir einen neuen Job erzeugt, ihm den Namen „FileZilla starten“ gegeben, einer Gruppe zugeordnet und die auszuführende Klasse definiert. Alle drei Angaben sind Pflicht! Jeder Job muss einen eindeutigen Namen haben und einer Gruppe angehören.
Als nächstes folgt die Konfiguration des Jobs:
Code:
nativeJobDetail.getJobDataMap ().put ("command", "C:/Programme/FileZilla/FileZilla.exe"); | |
nativeJobDetail.getJobDataMap ().put ("parameters", ""); | |
nativeJobDetail.getJobDataMap ().putAsString ("waitForProcess", false); |
Ich möchte hier einfach das Programm FileZilla ohne weitere Parameter starten.
Man beachte die Anweisung „putAsString“ in der letzten Zeile: Boolean-Werte müssen zwingend so definiert werden, wenn man in den quartz.properties „org.quartz.jobStore.useProperties = true“ eingestellt hat. Das steht nicht im offiziellen Tutorial!
So, damit ist für Quartz klar, was zu tun ist, aber noch nicht wann. Dafür brauchen wir einen Trigger, der zu definierten Zeiten dem Job ein Startsignal gibt. Übrigens kann man für jeden Job beliebig viele Trigger anlegen.
Die Zeiteinstellung kann auf zwei Weisen erfolgen: Entweder mit einem SimpleTrigger für einfache Wiederholungen in festen Abständen, oder mit einem CronTrigger, der sehr spezielle Einstellungen ermöglicht.
Ein SimpleTrigger sieht z.B. so aus:
Code:
SimpleTrigger nativeTrigger = new SimpleTrigger ("Trigger für StartProgramm", sched.DEFAULT_GROUP, new Date (), null, SimpleTrigger.REPEAT_INDEFINITELY, 60L*1000L); |
Der Trigger erhält einen Namen, eine Gruppenzuordnung, einen Startzeitpunkt („new Date()“ bedeutet „jetzt“), einen Endzeitpunkt („null“ heißt „nie“), die Anzahl der Wiederholungen (hier: unendlich) und das Zeitintervall in Millisekunden (hier: jede volle Minute).
Nun müssen wir nur noch Job und Trigger miteinander verbinden und starten:
Code:
sched.scheduleJob (nativeJobDetail, nativeTrigger); |
... und schon freuen wir uns, dass jede Minute ein Programm gestartet wird, bis das Java-Programmfenster wieder geschlossen wird.
Let's do the time warp again
Na ja, vielleicht fällt uns ja auch noch eine sinnvollere Anwendung ein...
Hier das Ganze als Methode „createJob“:
Code:
private void createJob () { | |
try { | |
String[] job=sched.getJobNames (sched.DEFAULT_GROUP); | |
if(job.length==0) { | |
JobDetail nativeJobDetail = new JobDetail ("FileZilla starten", | |
sched.DEFAULT_GROUP, | |
SystemJob.class); | |
nativeJobDetail.getJobDataMap ().put ("command", "C:/Programme/FileZilla/FileZilla.exe"); | |
nativeJobDetail.getJobDataMap ().put ("parameters", ""); | |
nativeJobDetail.getJobDataMap ().putAsString ("waitForProcess", false); | |
| |
SimpleTrigger nativeTrigger = new SimpleTrigger ("Trigger für StartProgramm", | |
sched.DEFAULT_GROUP, | |
new Date (), | |
null, | |
SimpleTrigger.REPEAT_INDEFINITELY, | |
60L*1000L); | |
| |
sched.scheduleJob (nativeJobDetail, nativeTrigger); | |
} | |
} catch (SchedulerException ex) { | |
ex.printStackTrace (); | |
} | |
} |
Ich habe eine kurze Abfrage vorgeschaltet, die prüft, ob es schon irgendwelche Jobs gibt. Die „createJob“-Methode kann jetzt einfach aus dem Konstruktor heraus gestartet werden.
Code:
public Hauptfenster () { | |
initComponents (); | |
startQuartz (); | |
createJob (); | |
} |
Der Import-Block müsste jetzt so ausssehen, damit alles funktioniert:
Code:
import java.util.Date; | |
import jobs.SystemJob; | |
import org.quartz.JobDetail; | |
import org.quartz.Scheduler; | |
import org.quartz.SchedulerException; | |
import org.quartz.SimpleTrigger; | |
import org.quartz.impl.StdSchedulerFactory; |
Jetzt ist es ganz interessant, mit einem Tool wie HeidiSQL die Tabellen und Einträge in der Datenbank „launchpad“ anzusehen. In der Tabelle „qrtz_job_details“ steht der Job mit allen Parametern, die Konfiguration findet sich im BLOB-Feld JOB_DATA.
Wie geht’s weiter? Am besten mit dem offiziellen Tutorial und Ideen für eigene Jobs: FTP-Verzeichnisse überwachen, PHP-Skripts starten und deren Output per Mail senden, Batchfiles starten u.v.m. Viel Erfolg!
Zum Weiterlesen:
Quartz User Forum
1 Kommentar
Würde mich freuen über einen review Beitrag!
Sprich, wie es momentan aussieht und evtl noch paar Tips und Tricks?
gA