“In every job that must be done, there is an element of fun.”
Mary Poppins
Die Holidays Bibliothek aus dem Beitrag Kalenderspielereien mit Java hat schon seit geraumer Zeit keine Aktualisierung erfahren. Daher sollen einige Methoden hinzuzugefügt werden um den Umgang mit Werktagen zu vereinfachen.
Die bisherige Bibliothek prüft ein gegebenes Datum darauf, ob sich um einen Feiertag handelt. Zusätzlich liefert die Bibliothek die Feiertages eines Jahres als Map.
Die Unterstützung für Arbeitstage soll nicht nur die triviale Prüfung sein, sondern häufig benötigte Berechnungen umfassen. Dazu gehört die Berechnung des nächsten oder des n-ten Werktages nach einem vorgegebenen Datum. Nicht ganz so häufig genutzt aber auch sehr nützlich, der letzte Werktag vor einem bestimmten Datum oder die Anzahl Werktage in einem bestimmten Zeitraum.
Zum Einsatz kommen soll bei diesen Methoden die Java Stream API, damit die verschiedenen Berechnungen kurz und elegant formuliert werden können.
LocalDate begin = LocalDate.of(2021, Month.JANURAY, 1); LocalDate end= LocalDate.of(2022, Month.JANURAY, 1); Holidays holidays = Holidays.in(Locale.GERMANY); int count = holidays.workdaysBetween(begin, end).count(); LocalDate firstWorkdayAfter = holidays.workdaysAfter(begin).findFirst().orElse(null); LocalDate forthWorkdayAfter = holidays.workdaysAfter(begin).skip(3).findFirst().orElse(null);
In diesem Beispiel wird in Zeile 4 ein Stream<LocalDate>
für die Werktage zwischen dem 1.1.2021 und dem 1.1.2022 erzeugt und die Anzahl Elemente gezählt. In Zeile 5 und 6 werden der erste und der vierte Werktag nach dem 1.1.2021 bestimmt, indem das erste bzw. das vierte Element aus dem Stream verwendet wird.
Um die Methoden workdaysBetween
und workdaysAfter
bereitstellen zu können, bedarf es zunächst einen Stream von LocalDate
Instanzen, aus denen Feiertage und Wochenenden herausgefiltert wurden.
private Stream<LocalDate> workdaysBetween(Holidays holidays, LocalDate start, LocalDate end) { return start.datesUntil(end).filter(isWorkday()).filter(not(isHoliday(holidays))); } private Predicate<LocalDate> isWorkday() { return date -> date.getDayOfWeek() != DayOfWeek.SATURDAY && date.getDayOfWeek() != DayOfWeek.SUNDAY; } private Predicate<LocalDate> isHoliday(Holidays holidays) { Map<Integer, Map<LocalDate, String>> holidaysMap = new HashMap<>(); return date -> holidaysMap.computeIfAbsent(date.getYear(), holidays::getHolidays).containsKey(date); }
Eine erste Version der workdaysBetween
Methode ist hier dargestellt. in der Zeile 2 wird mit Hilfe der datesUntil
Methode der Klasse LocalDate
ein Stream
erzeugt und mit Hilfe der Prädikatsmethoden isWorkday
und isHoliday
gefiltert.
Die Methode isWorkday
liefert ein Predicate
, dass false
für die Wochentage Samstag und Sonntag liefert und ansonsten true
. Die Methode isHoliday
ist etwas komplizierter. Ihr Predicate
prüft das aktuelle Datum gegen Feiertage in einer Map
. Da die Maps der Klasse Holidays
nur jeweils ein Jahr enthalten, muss bei einem Jahreswechsel im Stream
über computeIfAbsent
ggf. eine neue Map
berechnet werden.
Eine weitere Möglichkeit um dies zu implementieren ist ein eigenen Spliterator
. Hauptaufgaben des Spliterator
ist das Iterieren über eine Menge von Elementen und das Splitten dieser Menge für eine parallele Verarbeitung.
private static class WorkdaySpliterator implements Spliterator<LocalDate> { private final Holidays holidays; private LocalDate startDate; private LocalDate endDate; private int currentYear; private Map<LocalDate, String> currentHolidays; private WorkdaySpliterator(Holidays holidays, LocalDate startDate, LocalDate endDate) { this.holidays = holidays; this.startDate = startDate; this.endDate = endDate; this.increment = increment; currentYear = startDate.getYear(); currentHolidays = holidays.getHolidays(currentYear); } @Override public boolean tryAdvance(Consumer<? super LocalDate> consumer) { if (startDate.equals(endDate)) { return false; } do { if (notHoliday(startDate)) { consumer.accept(startDate); startDate = startDate.plusDays(1); return true; } startDate = startDate.plusDays(1); } while (!startDate.equals(endDate)); return false; } private boolean notHoliday(LocalDate startDate) { if (startDate.getYear() != currentYear) { currentYear = startDate.getYear(); currentHolidays = holidays.getHolidays(currentYear); } return !currentHolidays.containsKey(startDate); } @Override public long estimateSize() { return startDate.until(endDate, ChronoUnit.DAYS); } @Override public int characteristics() { return Spliterator.DISTINCT | Spliterator.ORDERED | Spliterator.SORTED; } @Override public Comparator<? super LocalDate> getComparator() { return Comparator.naturalOrder(); } }
Das Iterieren übernimmt die tryAdvance
Methode des WorkdaySpliterator
. Das Iterieren endet, wenn das
das startDate
enddate
erreicht hat, ansonsten wird das nächste Datum berechnet, das kein Feiertag ist und der consumer
mit diesem Datum versorgt. Die Prüfung auf das Wochenende findet hier nicht statt. Dieses ist ausgelagert um den gleichen Spliterator
für Berechnungen inklusive und exklusive Samstag zu verwenden.
Die Methode characteristics
wurde implementiert, weil bei diesem Spliterator
eine feste Reihenfolge der Elemente (ORDERED
, SORTED
) besteht und kein Element doppelt (DISTINCT
) vorkommen kann. Wegen der Sortierung muss zusätzlich ein Comparator
angegeben werden. In diesem Fall einfach Comparator.naturalOrder()
.
Da nur bei einem Jahreswechsel eine neue Holiday Map
benötigt wird, prüft dies die notHoliday
Methode und wechselt ggf. die Map
.
Verwendet wird der WorkdaySpliterator
von den Methoden workdaysBetween
und workdaysAfter
.
public static Stream<LocalDate> workdaysBetween(Holidays holidays, LocalDate begin, LocalDate end, boolean parallel) { if (begin.equals(end) || begin.isAfter(end)) { throw new IllegalArgumentException(); } return StreamSupport.stream(new WorkdaySpliterator(holidays, begin, end), parallel) .filter(day -> day.getDayOfWeek() != DayOfWeek.SUNDAY); } public static Stream<LocalDate> workdaysAfter(Holidays holidays, LocalDate start) { return workdaysBetween(holidays, start, LocalDate.MAX, false); }
Die workdaysBetween
Methode erzeugt den Stream für ein Anfangs- und ein Enddatum. Der Stream erhält zusätzliche einen Filter um Sonntage herauszufiltern. Eine Variante dieser Methode workdaysBetweenWithoutSaturday
filtert auch die Samstage heraus. Der vierte Parameter parallel
ermöglicht es zwischen paralleler und serieller Verarbeitung zu wechseln.
Die Methode workdaysAfter
ist eine Spezialisierung von workdaysBetween
mit dem größtmöglichen LocalDate
als Enddatum. Auch hier gibt es die Variante workdaysAfterWithoutSaturday
.
Eine Methode workdaysBefore
kann mit einem invertierten Spliterator
implementiert werden. Statt die Datumwerte hochzuzählen, werden sie heruntergezählt.
Neben dem Iterieren kümmert sich ein Spliterator
um das Splitten der Menge von Elementen. Die Methode trySplit
liefert einen neuen Spliterator
. Dieser Spliterator
kümmert sich um die Hälfte der Menge, während sich der bisherige Spliterator
um die andere Hälfte kümmert.
@Override public Spliterator<LocalDate> trySplit() { if (endDate.equals(LocalDate.MAX)) { return null; } long count = startDate.until(endDate, ChronoUnit.DAYS) / 2; if (count < 10) { return null; } LocalDate newEndDate = endDate; endDate = startDate.plusDays(count); return new WorkdaySpliterator(holidays, endDate, newEndDate, currentYear, currentHolidays); }
Für den Fall, dass sich das Enddatum weit in der Zukunft liegt oder die Anzahl der Tage klein ist, wird kein neuer Spliterator
erzeugt und null
zurückgegeben. Im anderen Fall wird das Enddatum des ursprünglichen Spliterators
und das Startdatum des neuen Spliterator
auf das bisherige mittlere Datum gesetzt.
Die parallele Verarbeitung von Werktagen ist nicht in jedem Fall sinnvoll, aber u.a. für das Zählen von Werktagen nützlich.
LocalDate begin = LocalDate.of(2021, Month.JANURAY, 1); LocalDate end= LocalDate.of(2022, Month.JANURAY, 1); int count1 = Holidays.in(Locale.Germany).workdaysBetween(begin, end, false).count(); int count2 = Holidays.in(Locale.Germany).workdaysBetween(begin, end, true).count();
In diesem Beispiel werden die Werktage im Jahr 2021 gezählt. In Zeile 3 seriell und in der Zeile 4 parallel. Die parallele Version arbeitet etwa 25% schneller.
Damit ist der Beitrag über den WorkdaySpliterator
in der Datumsberechnung auch schon am Ende. Zum Abschluss aber noch ein kurzer Ausblick auf den HolidaySpliterator
, der Streams über Feiertage ermöglicht und seine wohl wichtigste Funktion.
Holidays.in(GermanFederalState.NW).holidaysBetween(begin, end) .filter(day -> -> day.getDayOfWeek() == DayOfWeek.THUERDAY || day.getDayOfWeek() == DayOfWeek.THURSDAY) .forEach(System.out::println);