diff --git a/README.md b/README.md index 79823d6..280dcd5 100644 --- a/README.md +++ b/README.md @@ -27,18 +27,23 @@ W3C [Date and Time Formats](https://www.w3.org/TR/NOTE-datetime) in Java. Performance plot -| Implementation | Parse | Format | Round-trip | --------------------|--------:|----------:|-----:| -| Google DateTime | 1,020 ns | Not supported | N/A -| JDK Java Time | 1,558 ns | 426 ns | 1,984 ns | -| Ethlo ITU | 88 ns |166 ns |254 ns| +| Implementation | Parse | Format | +-------------------|--------:|----------:| +| Google DateTime | 885,089 | Not supported +| JDK Java Time | 739,180 | 2,406,040 +| Ethlo ITU | 12,437,143 | 12,602,170 -Values in nano-seconds. Lower is better. +Numbers are operations per second (higher is better). Your mileage may vary. The tests are easy to run and are included in the repository. -## Example use +Tests performed on a Dell XPS 9700 +* Intel(R) Core(TM) i7-10750H CPU @ 2.60GHz +* Ubuntu 21.10 +* OpenJDK version 11.0.13 + +## Example usage ```java // Parse a string diff --git a/doc/performance.jpg b/doc/performance.jpg index 62a08f9..72c7447 100644 Binary files a/doc/performance.jpg and b/doc/performance.jpg differ diff --git a/src/main/java/com/ethlo/time/EthloITU.java b/src/main/java/com/ethlo/time/EthloITU.java index d4be14e..d83bbf9 100644 --- a/src/main/java/com/ethlo/time/EthloITU.java +++ b/src/main/java/com/ethlo/time/EthloITU.java @@ -20,17 +20,21 @@ * #L% */ +import static com.ethlo.time.LimitedCharArrayIntegerUtil.indexOfNonDigit; +import static com.ethlo.time.LimitedCharArrayIntegerUtil.parsePositiveInt; + import java.time.DateTimeException; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.Month; import java.time.OffsetDateTime; import java.time.Year; import java.time.YearMonth; import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.time.temporal.Temporal; import java.util.Arrays; import java.util.Date; +import java.util.Objects; public class EthloITU extends AbstractRfc3339 implements W3cDateTimeUtil { @@ -49,18 +53,81 @@ public class EthloITU extends AbstractRfc3339 implements W3cDateTimeUtil private final Java8Rfc3339 delegate = new Java8Rfc3339(); @Override - public OffsetDateTime parseDateTime(String s) + public OffsetDateTime parseDateTime(String text) + { + Objects.requireNonNull(text, "text"); + final char[] chars = text.toCharArray(); + + final int year = getYear(chars); + + assertPositionContains(chars, 4, DATE_SEPARATOR); + final int month = getMonth(chars); + + assertPositionContains(chars, 7, DATE_SEPARATOR); + final int day = getDay(chars); + + // *** Time starts ***// + + // HOURS + assertPositionContains(chars, 10, SEPARATOR_UPPER, SEPARATOR_LOWER, SEPARATOR_SPACE); + final int hour = getHour(chars); + + // MINUTES + assertPositionContains(chars, 13, TIME_SEPARATOR); + final int minute = getMinute(chars); + + // SECONDS or TIMEZONE + return handleTime(chars, year, month, day, hour, minute); + } + + private int getHour(final char[] chars) { - final Temporal t = doParseLenient(s, OffsetDateTime.class); - if (t == null) + return parsePositiveInt(chars, 11, 13); + } + + private int getMinute(final char[] chars) + { + return parsePositiveInt(chars, 14, 16); + } + + private int getDay(final char[] chars) + { + return parsePositiveInt(chars, 8, 10); + } + + private OffsetDateTime handleTime(char[] chars, int year, int month, int day, int hour, int minute) + { + switch (chars[16]) { - return null; + // We have more granularity, keep going + case TIME_SEPARATOR: + return seconds(year, month, day, hour, minute, chars); + + case PLUS: + case MINUS: + case ZULU_UPPER: + case ZULU_LOWER: + final ZoneOffset zoneOffset = parseTz(chars, 16); + return OffsetDateTime.of(year, month, day, hour, minute, 0, 0, zoneOffset); + + default: + assertPositionContains(chars, 16, TIME_SEPARATOR, PLUS, MINUS, ZULU_UPPER); } - else if (t instanceof OffsetDateTime) + throw new DateTimeException(new String(chars)); + } + + private void assertPositionContains(char[] chars, int offset, char expected) + { + if (offset >= chars.length) { - return (OffsetDateTime) t; + throw new DateTimeException("Abrupt end of input: " + new String(chars)); + } + + if (chars[offset] != expected) + { + throw new DateTimeException("Expected character " + expected + + " at position " + (offset + 1) + " '" + new String(chars) + "'"); } - throw new DateTimeException("Invalid RFC-3339 date-time: " + s); } private void assertPositionContains(char[] chars, int offset, char... expected) @@ -101,8 +168,8 @@ private ZoneOffset parseTz(char[] chars, int offset) } final char sign = chars[offset]; - int hours = LimitedCharArrayIntegerUtil.parsePositiveInt(chars, offset + 1, offset + 3); - int minutes = LimitedCharArrayIntegerUtil.parsePositiveInt(chars, offset + 4, offset + 4 + 2); + int hours = parsePositiveInt(chars, offset + 1, offset + 3); + int minutes = parsePositiveInt(chars, offset + 4, offset + 4 + 2); if (sign == MINUS) { hours = -hours; @@ -144,25 +211,25 @@ public String format(OffsetDateTime date, Field lastIncluded) public String formatUtc(OffsetDateTime date, Field lastIncluded, int fractionDigits) { assertMaxFractionDigits(fractionDigits); - final LocalDateTime utc = LocalDateTime.ofInstant(date.toInstant(), ZoneOffset.UTC); + final ZonedDateTime utc = date.atZoneSameInstant(ZoneOffset.UTC); final char[] buffer = new char[31]; if (handleDatePart(lastIncluded, buffer, utc.getYear(), 0, 4, Field.YEAR)) { - return finish(buffer, 4); + return finish(buffer, Field.YEAR.getRequiredLength()); } buffer[4] = DATE_SEPARATOR; if (handleDatePart(lastIncluded, buffer, utc.getMonthValue(), 5, 2, Field.MONTH)) { - return finish(buffer, 7); + return finish(buffer, Field.MONTH.getRequiredLength()); } buffer[7] = DATE_SEPARATOR; if (handleDatePart(lastIncluded, buffer, utc.getDayOfMonth(), 8, 2, Field.DAY)) { - return finish(buffer, 10); + return finish(buffer, Field.DAY.getRequiredLength()); } // T separator @@ -173,7 +240,7 @@ public String formatUtc(OffsetDateTime date, Field lastIncluded, int fractionDig buffer[13] = TIME_SEPARATOR; if (handleDatePart(lastIncluded, buffer, utc.getMinute(), 14, 2, Field.MINUTE)) { - return finish(buffer, 16); + return finish(buffer, Field.MINUTE.getRequiredLength()); } buffer[16] = TIME_SEPARATOR; LimitedCharArrayIntegerUtil.toString(utc.getSecond(), buffer, 17, 2); @@ -301,7 +368,7 @@ public Temporal doParseLenient(String s, Class type) // Date portion // YEAR - final int year = LimitedCharArrayIntegerUtil.parsePositiveInt(chars, 0, 4); + final int year = getYear(chars); if (maxRequired == Field.YEAR || chars.length == 4) { return Year.of(year); @@ -309,7 +376,7 @@ public Temporal doParseLenient(String s, Class type) // MONTH assertPositionContains(chars, 4, DATE_SEPARATOR); - final int month = LimitedCharArrayIntegerUtil.parsePositiveInt(chars, 5, 7); + final int month = getMonth(chars); if (maxRequired == Field.MONTH || chars.length == 7) { return YearMonth.of(year, month); @@ -317,49 +384,41 @@ public Temporal doParseLenient(String s, Class type) // DAY assertPositionContains(chars, 7, DATE_SEPARATOR); - final int day = LimitedCharArrayIntegerUtil.parsePositiveInt(chars, 8, 10); + final int day = getDay(chars); if (maxRequired == Field.DAY || chars.length == 10) { return LocalDate.of(year, month, day); } - // *** Time starts ***// - // HOURS assertPositionContains(chars, 10, SEPARATOR_UPPER, SEPARATOR_LOWER, SEPARATOR_SPACE); - final int hour = LimitedCharArrayIntegerUtil.parsePositiveInt(chars, 11, 13); + final int hour = getHour(chars); // MINUTES assertPositionContains(chars, 13, TIME_SEPARATOR); - final int minute = LimitedCharArrayIntegerUtil.parsePositiveInt(chars, 14, 16); + final int minute = getMinute(chars); if (maxRequired == Field.MINUTE || chars.length == 16) { return LocalDate.of(year, month, day); } // SECONDS or TIMEZONE - switch (chars[16]) - { - // We have more granularity, keep going - case TIME_SEPARATOR: - return seconds(year, month, day, hour, minute, chars); + return handleTime(chars, year, month, day, hour, minute); + } - case PLUS: - case MINUS: - case ZULU_UPPER: - case ZULU_LOWER: - final ZoneOffset zoneOffset = parseTz(chars, 16); - return OffsetDateTime.of(year, month, day, hour, minute, 0, 0, zoneOffset); + private int getMonth(final char[] chars) + { + return parsePositiveInt(chars, 5, 7); + } - default: - assertPositionContains(chars, 16, TIME_SEPARATOR, PLUS, MINUS, ZULU_UPPER); - } - throw new DateTimeException(new String(chars)); + private int getYear(final char[] chars) + { + return parsePositiveInt(chars, 0, 4); } private OffsetDateTime seconds(int year, int month, int day, int hour, int minute, char[] chars) { - final int second = LimitedCharArrayIntegerUtil.parsePositiveInt(chars, 17, 19); + final int second = getSecond(chars); // From here the specification is more lenient final int remaining = chars.length - 19; @@ -376,7 +435,7 @@ private OffsetDateTime seconds(int year, int month, int day, int hour, int minut else if (remaining >= 1 && chars[19] == FRACTION_SEPARATOR) { // We have fractional seconds - final int idx = LimitedCharArrayIntegerUtil.indexOfNonDigit(chars, 20); + final int idx = indexOfNonDigit(chars, 20); if (idx != -1) { // We have an end of fractions @@ -419,10 +478,15 @@ else if (remaining == 0) return OffsetDateTime.of(year, month, day, hour, minute, second, fractions, offset); } + private int getSecond(final char[] chars) + { + return parsePositiveInt(chars, 17, 19); + } + private int getFractions(final char[] chars, final int idx, final int len) { final int fractions; - fractions = LimitedCharArrayIntegerUtil.parsePositiveInt(chars, 20, idx); + fractions = parsePositiveInt(chars, 20, idx); switch (len) { case 1: diff --git a/src/main/java/com/ethlo/time/Field.java b/src/main/java/com/ethlo/time/Field.java index 881adce..ba450eb 100644 --- a/src/main/java/com/ethlo/time/Field.java +++ b/src/main/java/com/ethlo/time/Field.java @@ -29,7 +29,14 @@ public enum Field { // 2000-12-31T16:11:34.123456 - YEAR, MONTH, DAY, MINUTE, SECOND; + YEAR(4), MONTH(7), DAY(10), MINUTE(16), SECOND(19); + + private final int requiredLength; + + Field(int requiredLength) + { + this.requiredLength = requiredLength; + } public static Field valueOf(Class type) { @@ -52,4 +59,9 @@ else if (OffsetDateTime.class.equals(type)) throw new IllegalArgumentException("Type " + type.getSimpleName() + " is not supported"); } + + public int getRequiredLength() + { + return requiredLength; + } } diff --git a/src/main/java/com/ethlo/time/Java8Rfc3339.java b/src/main/java/com/ethlo/time/Java8Rfc3339.java index aff202c..c06a894 100644 --- a/src/main/java/com/ethlo/time/Java8Rfc3339.java +++ b/src/main/java/com/ethlo/time/Java8Rfc3339.java @@ -38,9 +38,9 @@ */ public class Java8Rfc3339 extends AbstractRfc3339 { - private SimpleDateFormat[] formats = new SimpleDateFormat[MAX_FRACTION_DIGITS]; + private final SimpleDateFormat[] formats = new SimpleDateFormat[MAX_FRACTION_DIGITS]; - private DateTimeFormatter rfc3339baseFormatter = new DateTimeFormatterBuilder() + private final DateTimeFormatter rfc3339baseFormatter = new DateTimeFormatterBuilder() .appendValue(ChronoField.YEAR, 4) .appendLiteral('-') .appendValue(ChronoField.MONTH_OF_YEAR, 2) @@ -53,7 +53,8 @@ public class Java8Rfc3339 extends AbstractRfc3339 .appendLiteral(':') .appendValue(ChronoField.SECOND_OF_MINUTE, 2) .toFormatter(); - private DateTimeFormatter rfc3339formatParser = new DateTimeFormatterBuilder() + + private final DateTimeFormatter rfc3339formatParser = new DateTimeFormatterBuilder() .appendValue(ChronoField.YEAR, 4) .appendLiteral('-') .appendValue(ChronoField.MONTH_OF_YEAR, 2) @@ -83,14 +84,13 @@ public class Java8Rfc3339 extends AbstractRfc3339 .optionalStart() .appendOffset("+HH:MM", "z") .optionalEnd() - .toFormatter(); public Java8Rfc3339() { for (int i = 1; i < MAX_FRACTION_DIGITS; i++) { - this.formats[i] = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss." + repeat('S', i) + "XXX"); + this.formats[i] = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss." + repeat(i) + "XXX"); } } @@ -125,18 +125,13 @@ public String formatUtc(Date date) @Override public OffsetDateTime parseDateTime(final String s) { - if (s == null || s.isEmpty()) - { - return null; - } - return OffsetDateTime.from(rfc3339formatParser.parse(s)); } - private String repeat(char c, int repeats) + private String repeat(int repeats) { final char[] chars = new char[repeats]; - Arrays.fill(chars, c); + Arrays.fill(chars, 'S'); return new String(chars); } diff --git a/src/main/java/com/ethlo/time/LimitedCharArrayIntegerUtil.java b/src/main/java/com/ethlo/time/LimitedCharArrayIntegerUtil.java index 2471f93..880f959 100644 --- a/src/main/java/com/ethlo/time/LimitedCharArrayIntegerUtil.java +++ b/src/main/java/com/ethlo/time/LimitedCharArrayIntegerUtil.java @@ -57,7 +57,7 @@ public static int parsePositiveInt(char[] strNum, int startInclusive, int endExc int result = 0; for (int i = startInclusive; i < endExclusive; i++) { - if (!isDigit(strNum[i])) + if (isNotDigit(strNum[i])) { throw new DateTimeException("Character " + strNum[i] + " is not a digit"); } @@ -81,7 +81,7 @@ private static void toString(final int val, final char[] buf, final int offset, final int length = Math.min(TABLE_WIDTH, charLength); final int padPrefixLen = charLength - length; final int start = charLength > TABLE_WIDTH ? TABLE_WIDTH : TABLE_WIDTH - charLength; - final int targetOffset = offset + (Math.max(padPrefixLen, 0)); + final int targetOffset = offset + padPrefixLen; final int srcPos = (value * TABLE_WIDTH) + (charLength < TABLE_WIDTH ? start : 0); copy(INT_CONVERSION_CACHE, srcPos, buf, targetOffset, length); if (padPrefixLen > 0) @@ -89,28 +89,30 @@ private static void toString(final int val, final char[] buf, final int offset, zeroFill(buf, offset, padPrefixLen); } } - - int charPos = offset + MAX_INT_WIDTH; - value = -value; - int div; - int rem; - while (value <= -10) + else { - div = value / 10; - rem = -(value - 10 * div); - buf[charPos--] = DIGITS[rem]; - value = div; - } - buf[charPos] = DIGITS[-value]; + int charPos = offset + MAX_INT_WIDTH; + value = -value; + int div; + int rem; + while (value <= -10) + { + div = value / 10; + rem = -(value - 10 * div); + buf[charPos--] = DIGITS[rem]; + value = div; + } + buf[charPos] = DIGITS[-value]; - int l = ((MAX_INT_WIDTH + offset) - charPos) + 1; - while (l < charLength) - { - buf[--charPos] = ZERO; - l++; + int l = ((MAX_INT_WIDTH + offset) - charPos) + 1; + while (l < charLength) + { + buf[--charPos] = ZERO; + l++; + } + final int srcPos = charPos; + copy(buf, srcPos, offset, charLength); } - final int srcPos = charPos; - copy(buf, srcPos, offset, charLength); } private static void zeroFill(char[] buf, int offset, int padPrefixLen) @@ -136,7 +138,7 @@ public static int indexOfNonDigit(char[] chars, int offset) { for (int i = offset; i < chars.length; i++) { - if (!isDigit(chars[i])) + if (isNotDigit(chars[i])) { return i; } @@ -144,9 +146,9 @@ public static int indexOfNonDigit(char[] chars, int offset) return -1; } - private static boolean isDigit(char c) + private static boolean isNotDigit(char c) { - return (c >= ZERO && c <= '9'); + return (c < ZERO || c > '9'); } private static int digit(char c) diff --git a/src/test/java/com/ethlo/time/AbstractTest.java b/src/test/java/com/ethlo/time/AbstractTest.java index 47992f7..688282c 100644 --- a/src/test/java/com/ethlo/time/AbstractTest.java +++ b/src/test/java/com/ethlo/time/AbstractTest.java @@ -22,12 +22,15 @@ import org.junit.jupiter.api.BeforeEach; +import java.text.DecimalFormat; + public abstract class AbstractTest { protected Rfc3339Parser parser; protected Rfc3339Formatter formatter; protected abstract Rfc3339Parser getParser(); + protected abstract Rfc3339Formatter getFormatter(); protected abstract long getRuns(); @@ -45,18 +48,27 @@ protected final void unsupported(final Chronograph chronograph, String msg) }); } - protected final void perform(final Chronograph chronograph, final Runnable func, final String msg) + protected final void perform(final Runnable func, final String msg) { // Warm-up - for (int i = 0; i < getRuns(); i++) + for (int i = 0; i < getRuns() * 2; i++) { func.run(); } // Benchmark - for (int i = 0; i < getRuns(); i++) + final Chronograph c = Chronograph.create(); + c.timed(msg, () -> { - chronograph.timed(msg, func); - } + for (int i = 0; i < getRuns(); i++) + { + func.run(); + } + }); + System.out.println(c.prettyPrint()); + + final double ns = c.getTotalTime().toNanos(); + System.out.printf("%s - %.2f nanoseconds per operation%n", msg, (ns / getRuns())); + System.out.printf("%s - %s operations per second%n", msg, new DecimalFormat("###,###,###").format(getRuns() * 1_000_000_000 / ns)); } } diff --git a/src/test/java/com/ethlo/time/BenchmarkTest.java b/src/test/java/com/ethlo/time/BenchmarkTest.java index 8ac4c85..2b459cf 100644 --- a/src/test/java/com/ethlo/time/BenchmarkTest.java +++ b/src/test/java/com/ethlo/time/BenchmarkTest.java @@ -20,14 +20,9 @@ * #L% */ -import static org.assertj.core.api.Assertions.assertThat; - import java.time.Duration; import java.time.OffsetDateTime; import java.time.ZoneOffset; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.Map; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Test; @@ -35,37 +30,24 @@ public abstract class BenchmarkTest extends AbstractTest { private final OffsetDateTime d = OffsetDateTime.of(2017, 12, 21, 15, 27, 39, 987, ZoneOffset.UTC); - - private static final Chronograph chronograph = Chronograph.create(CaptureConfig.minInterval(Duration.ofMillis(1))); + private static final Chronograph chronograph = Chronograph.create(CaptureConfig.minInterval(Duration.ofMillis(25))); @Override protected long getRuns() { - return 1_000_000; + return 10_000_000; } @Test public void testParsePerformance() { - final Map formats = new LinkedHashMap<>(); - for (String f : Arrays.asList( - "2017-12-21T15:27:39.987Z", - "2017-12-21T15:27:39.98Z", - "2017-12-21T15:27:39.9Z", - "2017-12-21T15:27:39Z" - )) - { - formats.put(f, OffsetDateTime.parse(f)); - } - final String name = parser.getClass().getSimpleName() + " - parse"; - perform(chronograph, () -> - { - for (Map.Entry e : formats.entrySet()) - { - assertThat(parser.parseDateTime(e.getKey())).isEqualTo(e.getValue()); - } - }, name); + perform(() -> parser.parseDateTime("2017-12-21T12:20:45.987Z"), name); + } + + protected Chronograph getChronograph() + { + return chronograph; } @Test @@ -76,11 +58,11 @@ public void testParseLenient() if (parser instanceof W3cDateTimeUtil) { final W3cDateTimeUtil w3cUtil = (W3cDateTimeUtil) parser; - perform(chronograph, () -> w3cUtil.parseLenient(s), name); + perform(() -> w3cUtil.parseLenient(s), name); } else { - unsupported(chronograph, name); + unsupported(getChronograph(), name); } } @@ -90,17 +72,17 @@ public void testFormatPerformance() final String name = parser.getClass().getSimpleName() + " - formatUtc"; if (formatter != null) { - perform(chronograph, () -> formatter.formatUtc(d), name); + perform(() -> formatter.formatUtc(d), name); } else { - unsupported(chronograph, name); + unsupported(getChronograph(), name); } } @AfterAll static void printStats() { - System.out.println(chronograph.prettyPrint()); +// System.out.println(Report.prettyPrint(getChronograph().getTaskData(), OutputConfig.EXTENDED.percentiles(90, 95, 99, 99.5), TableTheme.RED_HERRING)); } } diff --git a/src/test/java/com/ethlo/time/CorrectnessTest.java b/src/test/java/com/ethlo/time/CorrectnessTest.java index 2b8cf0c..e9b67f6 100644 --- a/src/test/java/com/ethlo/time/CorrectnessTest.java +++ b/src/test/java/com/ethlo/time/CorrectnessTest.java @@ -215,17 +215,13 @@ public void testFormat5() @Test public void testParseEmptyString() { - final String s = ""; - final OffsetDateTime date = parser.parseDateTime(s); - assertThat(date).isNull(); + assertThrows(DateTimeException.class, () -> parser.parseDateTime("")); } @Test public void testParseNull() { - final String s = null; - final OffsetDateTime date = parser.parseDateTime(s); - assertThat(date).isNull(); + assertThrows(NullPointerException.class, () -> parser.parseDateTime(null)); } @Test diff --git a/src/test/java/com/ethlo/time/EthloITUBenchmarkTest.java b/src/test/java/com/ethlo/time/EthloITUBenchmarkTest.java index a336ebe..917cc39 100644 --- a/src/test/java/com/ethlo/time/EthloITUBenchmarkTest.java +++ b/src/test/java/com/ethlo/time/EthloITUBenchmarkTest.java @@ -33,4 +33,16 @@ protected Rfc3339Formatter getFormatter() { return new EthloITU(); } + + @Override + protected Chronograph getChronograph() + { + return null; + } + + @Override + protected long getRuns() + { + return 100_000_000; + } } diff --git a/src/test/java/com/ethlo/time/W3cCorrectnessTest.java b/src/test/java/com/ethlo/time/W3cCorrectnessTest.java index 4fc6280..d0dfebf 100644 --- a/src/test/java/com/ethlo/time/W3cCorrectnessTest.java +++ b/src/test/java/com/ethlo/time/W3cCorrectnessTest.java @@ -21,6 +21,7 @@ */ import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.time.DateTimeException; import java.time.LocalDate; @@ -44,9 +45,7 @@ public class W3cCorrectnessTest extends AbstractTest @Test public void testParseEmptyString() { - final String s = ""; - final OffsetDateTime date = parser.parseDateTime(s); - assertThat(date).isNull(); + assertThrows(DateTimeException.class, () -> parser.parseDateTime("")); } @Test @@ -119,9 +118,7 @@ public void testParseBestEffort1DigitMinute() @Test public void testParseNull() { - final String s = null; - final OffsetDateTime date = parser.parseDateTime(s); - assertThat(date).isNull(); + assertThrows(NullPointerException.class, () -> parser.parseDateTime(null)); } @Override