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