From d1ea659ef87724ea8b0df74260be8c74ded950eb Mon Sep 17 00:00:00 2001 From: jansupol Date: Wed, 20 Mar 2024 13:21:30 +0100 Subject: [PATCH] Replace ThreadLocal with ConcurrentQueue in HttpDateFormat Introduce a common facade for SimpleDateFormat and DateTimeFormatter Able to switch to DateTimeFormatter for a small performance boost Signed-off-by: jansupol --- .../message/internal/CookiesParser.java | 4 +- .../jersey/message/internal/DateProvider.java | 4 +- .../message/internal/HttpDateFormat.java | 226 ++++++++++++++++-- .../message/internal/NewCookieProvider.java | 4 +- .../server/internal/inject/FormParamTest.java | 6 +- .../media/multipart/ContentDisposition.java | 6 +- .../tests/api/ContentDispositionTest.java | 6 +- .../api/FormDataContentDispositionTest.java | 6 +- .../jersey/tests/api/HttpHeaderTest.java | 8 +- 9 files changed, 224 insertions(+), 46 deletions(-) diff --git a/core-common/src/main/java/org/glassfish/jersey/message/internal/CookiesParser.java b/core-common/src/main/java/org/glassfish/jersey/message/internal/CookiesParser.java index bcd7e5fba7..14a008d82a 100644 --- a/core-common/src/main/java/org/glassfish/jersey/message/internal/CookiesParser.java +++ b/core-common/src/main/java/org/glassfish/jersey/message/internal/CookiesParser.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2021 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2010, 2024 Oracle and/or its affiliates. All rights reserved. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0, which is available at @@ -168,7 +168,7 @@ public static NewCookie parseNewCookie(String header) { cookie.sameSite = NewCookie.SameSite.valueOf(value.toUpperCase()); } else if (param.startsWith("expires")) { try { - cookie.expiry = HttpDateFormat.readDate(value + ", " + bites[++i]); + cookie.expiry = HttpDateFormat.readDate(value + ", " + bites[++i].trim()); } catch (ParseException e) { LOGGER.log(Level.FINE, LocalizationMessages.ERROR_NEWCOOKIE_EXPIRES(value), e); } diff --git a/core-common/src/main/java/org/glassfish/jersey/message/internal/DateProvider.java b/core-common/src/main/java/org/glassfish/jersey/message/internal/DateProvider.java index 09e7553484..8417fb55eb 100644 --- a/core-common/src/main/java/org/glassfish/jersey/message/internal/DateProvider.java +++ b/core-common/src/main/java/org/glassfish/jersey/message/internal/DateProvider.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2020 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2010, 2024 Oracle and/or its affiliates. All rights reserved. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0, which is available at @@ -43,7 +43,7 @@ public boolean supports(final Class type) { @Override public String toString(final Date header) { throwIllegalArgumentExceptionIfNull(header, LocalizationMessages.DATE_IS_NULL()); - return HttpDateFormat.getPreferredDateFormat().format(header); + return HttpDateFormat.getPreferredDateFormatter().format(header); } @Override diff --git a/core-common/src/main/java/org/glassfish/jersey/message/internal/HttpDateFormat.java b/core-common/src/main/java/org/glassfish/jersey/message/internal/HttpDateFormat.java index 0f76eeddae..9cf2abfd39 100644 --- a/core-common/src/main/java/org/glassfish/jersey/message/internal/HttpDateFormat.java +++ b/core-common/src/main/java/org/glassfish/jersey/message/internal/HttpDateFormat.java @@ -18,12 +18,18 @@ import java.text.ParseException; import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Locale; +import java.util.Queue; import java.util.TimeZone; +import java.util.concurrent.ConcurrentLinkedQueue; /** * Helper class for HTTP specified date formats. @@ -33,6 +39,46 @@ */ public final class HttpDateFormat { + private static final boolean USE_SIMPLE_DATE_FORMAT_OVER_DATE_TIME_FORMATTER = true; + + /** + *

+ * A minimum formatter for converting java {@link Date} and {@link LocalDateTime} to {@code String} and vice-versa. + *

+ *

+ * Works as a facade for implementation backed by {@link SimpleDateFormat} and {@link DateTimeFormatter}. + *

+ */ + public static interface HttpDateFormatter { + /** + * + * @param date + * @return + */ + Date toDate(String date); + + /** + * + * @param date + * @return + */ + LocalDateTime toDateTime(String date); + /** + * Formats a {@link Date} into a date-time string. + * + * @param date the time value to be formatted into a date-time string. + * @return the formatted date-time string. + */ + String format(Date date); + /** + * Formats a {@link LocalDateTime} into a date-time string. + * + * @param dateTime the time value to be formatted into a date-time string. + * @return the formatted date-time string. + */ + String format(LocalDateTime dateTime); + } + private HttpDateFormat() { } /** @@ -50,33 +96,65 @@ private HttpDateFormat() { private static final TimeZone GMT_TIME_ZONE = TimeZone.getTimeZone("GMT"); - private static final ThreadLocal> dateFormats = ThreadLocal.withInitial(() -> createDateFormats()); + private static final List dateFormats = createDateFormats(); + private static final Queue> simpleDateFormats = new ConcurrentLinkedQueue<>(); - private static List createDateFormats() { - final SimpleDateFormat[] formats = new SimpleDateFormat[]{ - new SimpleDateFormat(RFC1123_DATE_FORMAT_PATTERN, Locale.US), - new SimpleDateFormat(RFC1036_DATE_FORMAT_PATTERN, Locale.US), - new SimpleDateFormat(ANSI_C_ASCTIME_DATE_FORMAT_PATTERN, Locale.US) + private static List createDateFormats() { + final HttpDateFormatter[] formats = new HttpDateFormatter[]{ + new HttpDateFormatterFromDateTimeFormatter( + DateTimeFormatter.ofPattern(RFC1123_DATE_FORMAT_PATTERN, Locale.US).withZone(GMT_TIME_ZONE.toZoneId())), + new HttpDateFormatterFromDateTimeFormatter( + DateTimeFormatter.ofPattern(RFC1123_DATE_FORMAT_PATTERN.replace("zzz", "ZZZ"), Locale.US) + .withZone(GMT_TIME_ZONE.toZoneId())), + new HttpDateFormatterFromDateTimeFormatter( + DateTimeFormatter.ofPattern(RFC1036_DATE_FORMAT_PATTERN, Locale.US).withZone(GMT_TIME_ZONE.toZoneId())), + new HttpDateFormatterFromDateTimeFormatter( + DateTimeFormatter.ofPattern(RFC1036_DATE_FORMAT_PATTERN.replace("zzz", "ZZZ"), Locale.US) + .withZone(GMT_TIME_ZONE.toZoneId())), + new HttpDateFormatterFromDateTimeFormatter( + DateTimeFormatter.ofPattern(ANSI_C_ASCTIME_DATE_FORMAT_PATTERN, Locale.US) + .withZone(GMT_TIME_ZONE.toZoneId())) }; - formats[0].setTimeZone(GMT_TIME_ZONE); - formats[1].setTimeZone(GMT_TIME_ZONE); - formats[2].setTimeZone(GMT_TIME_ZONE); + + return Collections.unmodifiableList(Arrays.asList(formats)); + } + + private static List createSimpleDateFormats() { + final HttpDateFormatterFromSimpleDateTimeFormat[] formats = new HttpDateFormatterFromSimpleDateTimeFormat[]{ + new HttpDateFormatterFromSimpleDateTimeFormat(new SimpleDateFormat(RFC1123_DATE_FORMAT_PATTERN, Locale.US)), + new HttpDateFormatterFromSimpleDateTimeFormat(new SimpleDateFormat(RFC1036_DATE_FORMAT_PATTERN, Locale.US)), + new HttpDateFormatterFromSimpleDateTimeFormat(new SimpleDateFormat(ANSI_C_ASCTIME_DATE_FORMAT_PATTERN, Locale.US)) + }; + formats[0].simpleDateFormat.setTimeZone(GMT_TIME_ZONE); + formats[1].simpleDateFormat.setTimeZone(GMT_TIME_ZONE); + formats[2].simpleDateFormat.setTimeZone(GMT_TIME_ZONE); return Collections.unmodifiableList(Arrays.asList(formats)); } /** - * Return an unmodifiable list of HTTP specified date formats to use for - * parsing or formatting {@link Date}. + * Get the preferred HTTP specified date format (RFC 1123). *

- * The list of date formats are scoped to the current thread and may be - * used without requiring to synchronize access to the instances when + * The date format is scoped to the current thread and may be + * used without requiring to synchronize access to the instance when * parsing or formatting. * - * @return the list of data formats. + * @return the preferred of data format. */ - private static List getDateFormats() { - return dateFormats.get(); + public static HttpDateFormatter getPreferredDateFormatter() { + if (USE_SIMPLE_DATE_FORMAT_OVER_DATE_TIME_FORMATTER) { + List list = simpleDateFormats.poll(); + if (list == null) { + list = createSimpleDateFormats(); + } + // returns clone because calling SDF.parse(...) can change time zone + final SimpleDateFormat sdf = (SimpleDateFormat) + ((HttpDateFormatterFromSimpleDateTimeFormat) list.get(0)).simpleDateFormat.clone(); + simpleDateFormats.add(list); + return new HttpDateFormatterFromSimpleDateTimeFormat(sdf); + } else { + return dateFormats.get(0); + } } /** @@ -87,10 +165,20 @@ private static List getDateFormats() { * parsing or formatting. * * @return the preferred of data format. + * @deprecated Use getPreferredDateFormatter instead */ + // Unused in Jersey + @Deprecated(forRemoval = true) public static SimpleDateFormat getPreferredDateFormat() { + List list = simpleDateFormats.poll(); + if (list == null) { + list = createSimpleDateFormats(); + } // returns clone because calling SDF.parse(...) can change time zone - return (SimpleDateFormat) dateFormats.get().get(0).clone(); + final SimpleDateFormat sdf = (SimpleDateFormat) + ((HttpDateFormatterFromSimpleDateTimeFormat) list.get(0)).simpleDateFormat.clone(); + simpleDateFormats.add(list); + return sdf; } /** @@ -102,18 +190,106 @@ public static SimpleDateFormat getPreferredDateFormat() { * @throws java.text.ParseException in case the date string cannot be parsed. */ public static Date readDate(final String date) throws ParseException { - ParseException pe = null; - for (final SimpleDateFormat f : HttpDateFormat.getDateFormats()) { + return USE_SIMPLE_DATE_FORMAT_OVER_DATE_TIME_FORMATTER + ? readDateSDF(date) + : readDateDTF(date); + } + + private static Date readDateDTF(final String date) throws ParseException { + final List list = dateFormats; + return readDate(date, list); + } + + private static Date readDateSDF(final String date) throws ParseException { + List list = simpleDateFormats.poll(); + if (list == null) { + list = createSimpleDateFormats(); + } + final Date ret = readDate(date, list); + simpleDateFormats.add(list); + return ret; + } + + private static Date readDate(final String date, List formatters) throws ParseException { + Exception pe = null; + for (final HttpDateFormatter f : formatters) { try { - Date result = f.parse(date); - // parse can change time zone -> set it back to GMT - f.setTimeZone(GMT_TIME_ZONE); - return result; - } catch (final ParseException e) { + return f.toDate(date); + } catch (final Exception e) { pe = (pe == null) ? e : pe; } } - throw pe; + throw ParseException.class.isInstance(pe) ? (ParseException) pe + : new ParseException(pe.getMessage(), + DateTimeParseException.class.isInstance(pe) ? ((DateTimeParseException) pe).getErrorIndex() : 0); + } + + /** + * Warning! DateTimeFormatter is incompatible with SimpleDateFormat for two digits year, since SimpleDateFormat uses + * 80 years before now and 20 years after, whereas DateTimeFormatter uses years starting with 2000. + */ + private static class HttpDateFormatterFromDateTimeFormatter implements HttpDateFormatter { + private final DateTimeFormatter dateTimeFormatter; + + private HttpDateFormatterFromDateTimeFormatter(DateTimeFormatter dateTimeFormatter) { + this.dateTimeFormatter = dateTimeFormatter; + } + + @Override + public Date toDate(String date) { + return new Date(Instant.from(dateTimeFormatter.parse(date)).toEpochMilli()); + } + + @Override + public LocalDateTime toDateTime(String date) { + return Instant.from(dateTimeFormatter.parse(date)).atZone(GMT_TIME_ZONE.toZoneId()).toLocalDateTime(); + } + + @Override + public String format(Date date) { + return dateTimeFormatter.format(date.toInstant()); + } + + @Override + public String format(LocalDateTime dateTime) { + return dateTimeFormatter.format(dateTime); + } + } + + private static class HttpDateFormatterFromSimpleDateTimeFormat implements HttpDateFormatter { + private final SimpleDateFormat simpleDateFormat; + + private HttpDateFormatterFromSimpleDateTimeFormat(SimpleDateFormat simpleDateFormat) { + this.simpleDateFormat = simpleDateFormat; + } + + @Override + public Date toDate(String date) { + final Date result; + try { + result = simpleDateFormat.parse(date); + } catch (ParseException e) { + throw new RuntimeException(e); + } + // parse can change time zone -> set it back to GMT + simpleDateFormat.setTimeZone(GMT_TIME_ZONE); + return result; + } + + @Override + public LocalDateTime toDateTime(String date) { + return Instant.from(toDate(date).toInstant()).atZone(GMT_TIME_ZONE.toZoneId()).toLocalDateTime(); + } + + @Override + public String format(Date date) { + return simpleDateFormat.format(date); + } + + @Override + public String format(LocalDateTime dateTime) { + return simpleDateFormat.format(Date.from(dateTime.atZone(GMT_TIME_ZONE.toZoneId()).toInstant())); + } } } diff --git a/core-common/src/main/java/org/glassfish/jersey/message/internal/NewCookieProvider.java b/core-common/src/main/java/org/glassfish/jersey/message/internal/NewCookieProvider.java index 8615aeb686..08fbf5a126 100644 --- a/core-common/src/main/java/org/glassfish/jersey/message/internal/NewCookieProvider.java +++ b/core-common/src/main/java/org/glassfish/jersey/message/internal/NewCookieProvider.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2023 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2010, 2024 Oracle and/or its affiliates. All rights reserved. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0, which is available at @@ -81,7 +81,7 @@ public String toString(final NewCookie cookie) { } if (cookie.getExpiry() != null) { b.append(";Expires="); - b.append(HttpDateFormat.getPreferredDateFormat().format(cookie.getExpiry())); + b.append(HttpDateFormat.getPreferredDateFormatter().format(cookie.getExpiry())); } return b.toString(); diff --git a/core-server/src/test/java/org/glassfish/jersey/server/internal/inject/FormParamTest.java b/core-server/src/test/java/org/glassfish/jersey/server/internal/inject/FormParamTest.java index 34f29d6548..07b368e577 100644 --- a/core-server/src/test/java/org/glassfish/jersey/server/internal/inject/FormParamTest.java +++ b/core-server/src/test/java/org/glassfish/jersey/server/internal/inject/FormParamTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2022 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2010, 2024 Oracle and/or its affiliates. All rights reserved. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0, which is available at @@ -402,8 +402,8 @@ public void testFormParamDate() throws ExecutionException, InterruptedException initiateWebApplication(FormResourceDate.class); final String date_RFC1123 = "Sun, 06 Nov 1994 08:49:37 GMT"; - final String date_RFC1036 = "Sunday, 06-Nov-94 08:49:37 GMT"; - final String date_ANSI_C = "Sun Nov 6 08:49:37 1994"; + final String date_RFC1036 = "Sunday, 07-Nov-04 08:49:37 GMT"; + final String date_ANSI_C = "Sun Nov 6 08:49:37 1994"; final Form form = new Form(); form.param("a", date_RFC1123); diff --git a/media/multipart/src/main/java/org/glassfish/jersey/media/multipart/ContentDisposition.java b/media/multipart/src/main/java/org/glassfish/jersey/media/multipart/ContentDisposition.java index 6d77453aa3..f6c7165960 100644 --- a/media/multipart/src/main/java/org/glassfish/jersey/media/multipart/ContentDisposition.java +++ b/media/multipart/src/main/java/org/glassfish/jersey/media/multipart/ContentDisposition.java @@ -202,7 +202,9 @@ protected void addStringParameter(final StringBuilder sb, final String name, fin protected void addDateParameter(final StringBuilder sb, final String name, final Date p) { if (p != null) { - sb.append("; ").append(name).append("=\"").append(HttpDateFormat.getPreferredDateFormat().format(p)).append("\""); + sb.append("; ").append(name).append("=\"") + .append(HttpDateFormat.getPreferredDateFormatter().format(p)) + .append("\""); } } @@ -302,7 +304,7 @@ private Date createDate(final String name) throws ParseException { if (value == null) { return null; } - return HttpDateFormat.getPreferredDateFormat().parse(value); + return HttpDateFormat.getPreferredDateFormatter().toDate(value); } private long createLong(final String name) throws ParseException { diff --git a/tests/e2e/src/test/java/org/glassfish/jersey/tests/api/ContentDispositionTest.java b/tests/e2e/src/test/java/org/glassfish/jersey/tests/api/ContentDispositionTest.java index 8cecb6b1c1..138466ec6e 100644 --- a/tests/e2e/src/test/java/org/glassfish/jersey/tests/api/ContentDispositionTest.java +++ b/tests/e2e/src/test/java/org/glassfish/jersey/tests/api/ContentDispositionTest.java @@ -66,7 +66,7 @@ public void testCreate() { contentDisposition = new ContentDisposition(header); assertNotNull(contentDisposition); assertEquals(contentDispositionType, contentDisposition.getType()); - final String dateString = HttpDateFormat.getPreferredDateFormat().format(date); + final String dateString = HttpDateFormat.getPreferredDateFormatter().format(date); header = contentDispositionType + ";filename=\"test.file\";creation-date=\"" + dateString + "\";modification-date=\"" + dateString + "\";read-date=\"" + dateString + "\";size=1222"; @@ -101,7 +101,7 @@ public void testToString() { final Date date = new Date(); final ContentDisposition contentDisposition = ContentDisposition.type(contentDispositionType).fileName("test.file") .creationDate(date).modificationDate(date).readDate(date).size(1222).build(); - final String dateString = HttpDateFormat.getPreferredDateFormat().format(date); + final String dateString = HttpDateFormat.getPreferredDateFormatter().format(date); final String header = contentDispositionType + "; filename=\"test.file\"; creation-date=\"" + dateString + "\"; modification-date=\"" + dateString + "\"; read-date=\"" + dateString + "\"; size=1222"; assertEquals(header, contentDisposition.toString()); @@ -252,7 +252,7 @@ private void assertFileNameExt( final boolean decode ) throws ParseException { final Date date = new Date(); - final String dateString = HttpDateFormat.getPreferredDateFormat().format(date); + final String dateString = HttpDateFormat.getPreferredDateFormatter().format(date); final String prefixHeader = contentDispositionType + ";filename=\"" + actualFileName + "\";" + "creation-date=\"" + dateString + "\";modification-date=\"" + dateString + "\";read-date=\"" + dateString + "\";size=1222" + ";name=\"testData\";" + "filename*=\""; diff --git a/tests/e2e/src/test/java/org/glassfish/jersey/tests/api/FormDataContentDispositionTest.java b/tests/e2e/src/test/java/org/glassfish/jersey/tests/api/FormDataContentDispositionTest.java index c9318094b2..0595890319 100644 --- a/tests/e2e/src/test/java/org/glassfish/jersey/tests/api/FormDataContentDispositionTest.java +++ b/tests/e2e/src/test/java/org/glassfish/jersey/tests/api/FormDataContentDispositionTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014, 2022 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2014, 2024 Oracle and/or its affiliates. All rights reserved. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0, which is available at @@ -45,7 +45,7 @@ public void testCreate() { .modificationDate(date).readDate(date).size(1222).build(); assertFormDataContentDisposition(contentDisposition, date); try { - final String dateString = HttpDateFormat.getPreferredDateFormat().format(date); + final String dateString = HttpDateFormat.getPreferredDateFormatter().format(date); final String header = contentDispositionType + ";filename=\"test.file\";creation-date=\"" + dateString + "\";modification-date=\"" + dateString + "\";read-date=\"" + dateString + "\";size=1222" + ";name=\"testData\""; @@ -92,7 +92,7 @@ public void testToString() { final FormDataContentDisposition contentDisposition = FormDataContentDisposition.name("testData") .fileName("test.file").creationDate(date).modificationDate(date) .readDate(date).size(1222).build(); - final String dateString = HttpDateFormat.getPreferredDateFormat().format(date); + final String dateString = HttpDateFormat.getPreferredDateFormatter().format(date); final String header = contentDispositionType + "; filename=\"test.file\"; creation-date=\"" + dateString + "\"; modification-date=\"" + dateString + "\"; read-date=\"" + dateString + "\"; size=1222" + "; name=\"testData\""; diff --git a/tests/e2e/src/test/java/org/glassfish/jersey/tests/api/HttpHeaderTest.java b/tests/e2e/src/test/java/org/glassfish/jersey/tests/api/HttpHeaderTest.java index 63305db921..4e1bfd34ec 100644 --- a/tests/e2e/src/test/java/org/glassfish/jersey/tests/api/HttpHeaderTest.java +++ b/tests/e2e/src/test/java/org/glassfish/jersey/tests/api/HttpHeaderTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014, 2022 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2014, 2024 Oracle and/or its affiliates. All rights reserved. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0, which is available at @@ -100,8 +100,8 @@ public void testAcceptableTokenList() throws Exception { @Test public void testDateParsing() throws ParseException { final String date_RFC1123 = "Sun, 06 Nov 1994 08:49:37 GMT"; - final String date_RFC1036 = "Sunday, 06-Nov-94 08:49:37 GMT"; - final String date_ANSI_C = "Sun Nov 6 08:49:37 1994"; + final String date_RFC1036 = "Sunday, 07-Nov-04 08:49:37 GMT"; + final String date_ANSI_C = "Sun Nov 6 08:49:37 1994"; HttpHeaderReader.readDate(date_RFC1123); HttpHeaderReader.readDate(date_RFC1036); @@ -113,7 +113,7 @@ public void testDateFormatting() throws ParseException { final String date_RFC1123 = "Sun, 06 Nov 1994 08:49:37 GMT"; final Date date = HttpHeaderReader.readDate(date_RFC1123); - final String date_formatted = HttpDateFormat.getPreferredDateFormat().format(date); + final String date_formatted = HttpDateFormat.getPreferredDateFormatter().format(date); assertEquals(date_RFC1123, date_formatted); }