001/* 002 * Copyright 2002-2018 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.scheduling.support; 018 019import java.util.ArrayList; 020import java.util.BitSet; 021import java.util.Calendar; 022import java.util.Collections; 023import java.util.Date; 024import java.util.GregorianCalendar; 025import java.util.List; 026import java.util.TimeZone; 027 028import org.springframework.lang.Nullable; 029import org.springframework.util.StringUtils; 030 031/** 032 * Date sequence generator for a 033 * <a href="https://www.manpagez.com/man/5/crontab/">Crontab pattern</a>, 034 * allowing clients to specify a pattern that the sequence matches. 035 * 036 * <p>The pattern is a list of six single space-separated fields: representing 037 * second, minute, hour, day, month, weekday. Month and weekday names can be 038 * given as the first three letters of the English names. 039 * 040 * <p>Example patterns: 041 * <ul> 042 * <li>"0 0 * * * *" = the top of every hour of every day.</li> 043 * <li>"*/10 * * * * *" = every ten seconds.</li> 044 * <li>"0 0 8-10 * * *" = 8, 9 and 10 o'clock of every day.</li> 045 * <li>"0 0 6,19 * * *" = 6:00 AM and 7:00 PM every day.</li> 046 * <li>"0 0/30 8-10 * * *" = 8:00, 8:30, 9:00, 9:30, 10:00 and 10:30 every day.</li> 047 * <li>"0 0 9-17 * * MON-FRI" = on the hour nine-to-five weekdays</li> 048 * <li>"0 0 0 25 12 ?" = every Christmas Day at midnight</li> 049 * </ul> 050 * 051 * @author Dave Syer 052 * @author Juergen Hoeller 053 * @author Ruslan Sibgatullin 054 * @since 3.0 055 * @see CronTrigger 056 */ 057public class CronSequenceGenerator { 058 059 private final String expression; 060 061 @Nullable 062 private final TimeZone timeZone; 063 064 private final BitSet months = new BitSet(12); 065 066 private final BitSet daysOfMonth = new BitSet(31); 067 068 private final BitSet daysOfWeek = new BitSet(7); 069 070 private final BitSet hours = new BitSet(24); 071 072 private final BitSet minutes = new BitSet(60); 073 074 private final BitSet seconds = new BitSet(60); 075 076 077 /** 078 * Construct a {@link CronSequenceGenerator} from the pattern provided, 079 * using the default {@link TimeZone}. 080 * @param expression a space-separated list of time fields 081 * @throws IllegalArgumentException if the pattern cannot be parsed 082 * @see java.util.TimeZone#getDefault() 083 */ 084 public CronSequenceGenerator(String expression) { 085 this(expression, TimeZone.getDefault()); 086 } 087 088 /** 089 * Construct a {@link CronSequenceGenerator} from the pattern provided, 090 * using the specified {@link TimeZone}. 091 * @param expression a space-separated list of time fields 092 * @param timeZone the TimeZone to use for generated trigger times 093 * @throws IllegalArgumentException if the pattern cannot be parsed 094 */ 095 public CronSequenceGenerator(String expression, TimeZone timeZone) { 096 this.expression = expression; 097 this.timeZone = timeZone; 098 parse(expression); 099 } 100 101 private CronSequenceGenerator(String expression, String[] fields) { 102 this.expression = expression; 103 this.timeZone = null; 104 doParse(fields); 105 } 106 107 108 /** 109 * Return the cron pattern that this sequence generator has been built for. 110 */ 111 String getExpression() { 112 return this.expression; 113 } 114 115 116 /** 117 * Get the next {@link Date} in the sequence matching the Cron pattern and 118 * after the value provided. The return value will have a whole number of 119 * seconds, and will be after the input value. 120 * @param date a seed value 121 * @return the next value matching the pattern 122 */ 123 public Date next(Date date) { 124 /* 125 The plan: 126 127 1 Start with whole second (rounding up if necessary) 128 129 2 If seconds match move on, otherwise find the next match: 130 2.1 If next match is in the next minute then roll forwards 131 132 3 If minute matches move on, otherwise find the next match 133 3.1 If next match is in the next hour then roll forwards 134 3.2 Reset the seconds and go to 2 135 136 4 If hour matches move on, otherwise find the next match 137 4.1 If next match is in the next day then roll forwards, 138 4.2 Reset the minutes and seconds and go to 2 139 */ 140 141 Calendar calendar = new GregorianCalendar(); 142 calendar.setTimeZone(this.timeZone); 143 calendar.setTime(date); 144 145 // First, just reset the milliseconds and try to calculate from there... 146 calendar.set(Calendar.MILLISECOND, 0); 147 long originalTimestamp = calendar.getTimeInMillis(); 148 doNext(calendar, calendar.get(Calendar.YEAR)); 149 150 if (calendar.getTimeInMillis() == originalTimestamp) { 151 // We arrived at the original timestamp - round up to the next whole second and try again... 152 calendar.add(Calendar.SECOND, 1); 153 doNext(calendar, calendar.get(Calendar.YEAR)); 154 } 155 156 return calendar.getTime(); 157 } 158 159 private void doNext(Calendar calendar, int dot) { 160 List<Integer> resets = new ArrayList<>(); 161 162 int second = calendar.get(Calendar.SECOND); 163 List<Integer> emptyList = Collections.emptyList(); 164 int updateSecond = findNext(this.seconds, second, calendar, Calendar.SECOND, Calendar.MINUTE, emptyList); 165 if (second == updateSecond) { 166 resets.add(Calendar.SECOND); 167 } 168 169 int minute = calendar.get(Calendar.MINUTE); 170 int updateMinute = findNext(this.minutes, minute, calendar, Calendar.MINUTE, Calendar.HOUR_OF_DAY, resets); 171 if (minute == updateMinute) { 172 resets.add(Calendar.MINUTE); 173 } 174 else { 175 doNext(calendar, dot); 176 } 177 178 int hour = calendar.get(Calendar.HOUR_OF_DAY); 179 int updateHour = findNext(this.hours, hour, calendar, Calendar.HOUR_OF_DAY, Calendar.DAY_OF_WEEK, resets); 180 if (hour == updateHour) { 181 resets.add(Calendar.HOUR_OF_DAY); 182 } 183 else { 184 doNext(calendar, dot); 185 } 186 187 int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK); 188 int dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH); 189 int updateDayOfMonth = findNextDay(calendar, this.daysOfMonth, dayOfMonth, this.daysOfWeek, dayOfWeek, resets); 190 if (dayOfMonth == updateDayOfMonth) { 191 resets.add(Calendar.DAY_OF_MONTH); 192 } 193 else { 194 doNext(calendar, dot); 195 } 196 197 int month = calendar.get(Calendar.MONTH); 198 int updateMonth = findNext(this.months, month, calendar, Calendar.MONTH, Calendar.YEAR, resets); 199 if (month != updateMonth) { 200 if (calendar.get(Calendar.YEAR) - dot > 4) { 201 throw new IllegalArgumentException("Invalid cron expression \"" + this.expression + 202 "\" led to runaway search for next trigger"); 203 } 204 doNext(calendar, dot); 205 } 206 207 } 208 209 private int findNextDay(Calendar calendar, BitSet daysOfMonth, int dayOfMonth, BitSet daysOfWeek, int dayOfWeek, 210 List<Integer> resets) { 211 212 int count = 0; 213 int max = 366; 214 // the DAY_OF_WEEK values in java.util.Calendar start with 1 (Sunday), 215 // but in the cron pattern, they start with 0, so we subtract 1 here 216 while ((!daysOfMonth.get(dayOfMonth) || !daysOfWeek.get(dayOfWeek - 1)) && count++ < max) { 217 calendar.add(Calendar.DAY_OF_MONTH, 1); 218 dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH); 219 dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK); 220 reset(calendar, resets); 221 } 222 if (count >= max) { 223 throw new IllegalArgumentException("Overflow in day for expression \"" + this.expression + "\""); 224 } 225 return dayOfMonth; 226 } 227 228 /** 229 * Search the bits provided for the next set bit after the value provided, 230 * and reset the calendar. 231 * @param bits a {@link BitSet} representing the allowed values of the field 232 * @param value the current value of the field 233 * @param calendar the calendar to increment as we move through the bits 234 * @param field the field to increment in the calendar (@see 235 * {@link Calendar} for the static constants defining valid fields) 236 * @param lowerOrders the Calendar field ids that should be reset (i.e. the 237 * ones of lower significance than the field of interest) 238 * @return the value of the calendar field that is next in the sequence 239 */ 240 private int findNext(BitSet bits, int value, Calendar calendar, int field, int nextField, List<Integer> lowerOrders) { 241 int nextValue = bits.nextSetBit(value); 242 // roll over if needed 243 if (nextValue == -1) { 244 calendar.add(nextField, 1); 245 reset(calendar, Collections.singletonList(field)); 246 nextValue = bits.nextSetBit(0); 247 } 248 if (nextValue != value) { 249 calendar.set(field, nextValue); 250 reset(calendar, lowerOrders); 251 } 252 return nextValue; 253 } 254 255 /** 256 * Reset the calendar setting all the fields provided to zero. 257 */ 258 private void reset(Calendar calendar, List<Integer> fields) { 259 for (int field : fields) { 260 calendar.set(field, field == Calendar.DAY_OF_MONTH ? 1 : 0); 261 } 262 } 263 264 265 // Parsing logic invoked by the constructor 266 267 /** 268 * Parse the given pattern expression. 269 */ 270 private void parse(String expression) throws IllegalArgumentException { 271 String[] fields = StringUtils.tokenizeToStringArray(expression, " "); 272 if (!areValidCronFields(fields)) { 273 throw new IllegalArgumentException(String.format( 274 "Cron expression must consist of 6 fields (found %d in \"%s\")", fields.length, expression)); 275 } 276 doParse(fields); 277 } 278 279 private void doParse(String[] fields) { 280 setNumberHits(this.seconds, fields[0], 0, 60); 281 setNumberHits(this.minutes, fields[1], 0, 60); 282 setNumberHits(this.hours, fields[2], 0, 24); 283 setDaysOfMonth(this.daysOfMonth, fields[3]); 284 setMonths(this.months, fields[4]); 285 setDays(this.daysOfWeek, replaceOrdinals(fields[5], "SUN,MON,TUE,WED,THU,FRI,SAT"), 8); 286 287 if (this.daysOfWeek.get(7)) { 288 // Sunday can be represented as 0 or 7 289 this.daysOfWeek.set(0); 290 this.daysOfWeek.clear(7); 291 } 292 } 293 294 /** 295 * Replace the values in the comma-separated list (case insensitive) 296 * with their index in the list. 297 * @return a new String with the values from the list replaced 298 */ 299 private String replaceOrdinals(String value, String commaSeparatedList) { 300 String[] list = StringUtils.commaDelimitedListToStringArray(commaSeparatedList); 301 for (int i = 0; i < list.length; i++) { 302 String item = list[i].toUpperCase(); 303 value = StringUtils.replace(value.toUpperCase(), item, "" + i); 304 } 305 return value; 306 } 307 308 private void setDaysOfMonth(BitSet bits, String field) { 309 int max = 31; 310 // Days of month start with 1 (in Cron and Calendar) so add one 311 setDays(bits, field, max + 1); 312 // ... and remove it from the front 313 bits.clear(0); 314 } 315 316 private void setDays(BitSet bits, String field, int max) { 317 if (field.contains("?")) { 318 field = "*"; 319 } 320 setNumberHits(bits, field, 0, max); 321 } 322 323 private void setMonths(BitSet bits, String value) { 324 int max = 12; 325 value = replaceOrdinals(value, "FOO,JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC"); 326 BitSet months = new BitSet(13); 327 // Months start with 1 in Cron and 0 in Calendar, so push the values first into a longer bit set 328 setNumberHits(months, value, 1, max + 1); 329 // ... and then rotate it to the front of the months 330 for (int i = 1; i <= max; i++) { 331 if (months.get(i)) { 332 bits.set(i - 1); 333 } 334 } 335 } 336 337 private void setNumberHits(BitSet bits, String value, int min, int max) { 338 String[] fields = StringUtils.delimitedListToStringArray(value, ","); 339 for (String field : fields) { 340 if (!field.contains("/")) { 341 // Not an incrementer so it must be a range (possibly empty) 342 int[] range = getRange(field, min, max); 343 bits.set(range[0], range[1] + 1); 344 } 345 else { 346 String[] split = StringUtils.delimitedListToStringArray(field, "/"); 347 if (split.length > 2) { 348 throw new IllegalArgumentException("Incrementer has more than two fields: '" + 349 field + "' in expression \"" + this.expression + "\""); 350 } 351 int[] range = getRange(split[0], min, max); 352 if (!split[0].contains("-")) { 353 range[1] = max - 1; 354 } 355 int delta = Integer.parseInt(split[1]); 356 if (delta <= 0) { 357 throw new IllegalArgumentException("Incrementer delta must be 1 or higher: '" + 358 field + "' in expression \"" + this.expression + "\""); 359 } 360 for (int i = range[0]; i <= range[1]; i += delta) { 361 bits.set(i); 362 } 363 } 364 } 365 } 366 367 private int[] getRange(String field, int min, int max) { 368 int[] result = new int[2]; 369 if (field.contains("*")) { 370 result[0] = min; 371 result[1] = max - 1; 372 return result; 373 } 374 if (!field.contains("-")) { 375 result[0] = result[1] = Integer.parseInt(field); 376 } 377 else { 378 String[] split = StringUtils.delimitedListToStringArray(field, "-"); 379 if (split.length > 2) { 380 throw new IllegalArgumentException("Range has more than two fields: '" + 381 field + "' in expression \"" + this.expression + "\""); 382 } 383 result[0] = Integer.parseInt(split[0]); 384 result[1] = Integer.parseInt(split[1]); 385 } 386 if (result[0] >= max || result[1] >= max) { 387 throw new IllegalArgumentException("Range exceeds maximum (" + max + "): '" + 388 field + "' in expression \"" + this.expression + "\""); 389 } 390 if (result[0] < min || result[1] < min) { 391 throw new IllegalArgumentException("Range less than minimum (" + min + "): '" + 392 field + "' in expression \"" + this.expression + "\""); 393 } 394 if (result[0] > result[1]) { 395 throw new IllegalArgumentException("Invalid inverted range: '" + field + 396 "' in expression \"" + this.expression + "\""); 397 } 398 return result; 399 } 400 401 402 /** 403 * Determine whether the specified expression represents a valid cron pattern. 404 * @param expression the expression to evaluate 405 * @return {@code true} if the given expression is a valid cron expression 406 * @since 4.3 407 */ 408 public static boolean isValidExpression(@Nullable String expression) { 409 if (expression == null) { 410 return false; 411 } 412 String[] fields = StringUtils.tokenizeToStringArray(expression, " "); 413 if (!areValidCronFields(fields)) { 414 return false; 415 } 416 try { 417 new CronSequenceGenerator(expression, fields); 418 return true; 419 } 420 catch (IllegalArgumentException ex) { 421 return false; 422 } 423 } 424 425 private static boolean areValidCronFields(@Nullable String[] fields) { 426 return (fields != null && fields.length == 6); 427 } 428 429 430 @Override 431 public boolean equals(@Nullable Object other) { 432 if (this == other) { 433 return true; 434 } 435 if (!(other instanceof CronSequenceGenerator)) { 436 return false; 437 } 438 CronSequenceGenerator otherCron = (CronSequenceGenerator) other; 439 return (this.months.equals(otherCron.months) && this.daysOfMonth.equals(otherCron.daysOfMonth) && 440 this.daysOfWeek.equals(otherCron.daysOfWeek) && this.hours.equals(otherCron.hours) && 441 this.minutes.equals(otherCron.minutes) && this.seconds.equals(otherCron.seconds)); 442 } 443 444 @Override 445 public int hashCode() { 446 return (17 * this.months.hashCode() + 29 * this.daysOfMonth.hashCode() + 37 * this.daysOfWeek.hashCode() + 447 41 * this.hours.hashCode() + 53 * this.minutes.hashCode() + 61 * this.seconds.hashCode()); 448 } 449 450 @Override 451 public String toString() { 452 return getClass().getSimpleName() + ": " + this.expression; 453 } 454 455}