From 9356f7cf5598c59b0f1cbd42b1f694735f24c993 Mon Sep 17 00:00:00 2001 From: Martin Lopez Date: Tue, 25 Nov 2025 18:05:11 -0300 Subject: [PATCH 1/5] build: increment version to 2.6.0-SNAPSHOT --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index d5a2695..7c6d9bc 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ org.vaadin.addons.flowingcode grid-exporter-addon - 2.5.3-SNAPSHOT + 2.6.0-SNAPSHOT Grid Exporter Add-on Grid Exporter Add-on for Vaadin Flow https://www.flowingcode.com/en/open-source/ From 9e7b8e881129d6b51d4955f4f259647774d13efc Mon Sep 17 00:00:00 2001 From: Martin Lopez Date: Tue, 25 Nov 2025 18:05:35 -0300 Subject: [PATCH 2/5] build: upgrade vaadin version to 24.8.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 7c6d9bc..4deeb37 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ https://www.flowingcode.com/en/open-source/ - 24.3.9 + 24.8.0 4.1.2 17 17 From 4e6c31c46445e95557bc246cd4eeaecf11f69290 Mon Sep 17 00:00:00 2001 From: Martin Lopez Date: Tue, 25 Nov 2025 18:03:52 -0300 Subject: [PATCH 3/5] feat: introduce DownloadHandler export API --- .../ConcurrentDownloadHandler.java | 317 ++++++++++++++++++ .../addons/gridexporter/GridExporter.java | 272 +++++++++++++-- .../StreamResourceWriterAdapter.java | 69 ++++ .../GridExporterCustomLinkDemo.java | 26 +- 4 files changed, 647 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentDownloadHandler.java create mode 100644 src/main/java/com/flowingcode/vaadin/addons/gridexporter/StreamResourceWriterAdapter.java diff --git a/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentDownloadHandler.java b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentDownloadHandler.java new file mode 100644 index 0000000..1f01330 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentDownloadHandler.java @@ -0,0 +1,317 @@ +/*- + * #%L + * Grid Exporter Add-on + * %% + * Copyright (C) 2022 - 2024 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.gridexporter; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.server.streams.DownloadHandler; +import com.vaadin.flow.server.streams.DownloadEvent; +import com.vaadin.flow.server.VaadinSession; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.nio.channels.InterruptedByTimeoutException; +import java.util.Optional; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.function.IntFunction; + +/** + * An implementation of {@link DownloadHandler} that controls access to the + * {@link #handleDownloadRequest(DownloadEvent) handleDownloadRequest} method + * using a semaphore to + * manage concurrency. + * + * @author Javier Godoy + */ +@SuppressWarnings("serial") +abstract class ConcurrentDownloadHandler implements DownloadHandler { + + public static final float MAX_COST = 0x7FFF; + + public static final float MIN_COST = 1.0f / 0x10000; + + public static final float DEFAULT_COST = 1.0f; + + private static final ConfigurableSemaphore semaphore = new ConfigurableSemaphore(); + + private static volatile boolean enabled; + + private static volatile boolean failOnUiChange; + + private final DownloadHandler delegate; + + private static final class ConfigurableSemaphore extends Semaphore { + + private int maxPermits; + + ConfigurableSemaphore() { + super(0); + } + + synchronized void setPermits(int permits) { + if (permits < 0) { + throw new IllegalArgumentException(); + } + int delta = permits - maxPermits; + if (delta > 0) { + super.release(delta); + } else if (delta < 0) { + super.reducePermits(-delta); + } + maxPermits = permits; + } + + @Override + public String toString() { + IntFunction str = permits -> { + float f = permits / (float) 0x10000; + return f == Math.floor(f) ? String.format("%.0f", f) : Float.toString(f); + }; + return "Semaphore[" + str.apply(availablePermits()) + "/" + str.apply(maxPermits) + "]"; + } + + } + + /** + * Sets the limit for the cost of concurrent downloads. + *

+ * Finite limits are capped to {@link #MAX_COST} (32767). If the limit is + * {@link Float#POSITIVE_INFINITY POSITIVE_INFINITY}, the semaphore will not be + * used for + * controlling concurrent downloads. + * + * @param limit the maximum cost of concurrent downloads allowed + * @throws IllegalArgumentException if the limit is zero or negative. + */ + public static void setLimit(float limit) { + if (limit <= 0) { + throw new IllegalArgumentException(); + } + if (Float.isInfinite(limit)) { + enabled = false; + return; + } + + synchronized (semaphore) { + enabled = true; + semaphore.setPermits(costToPermits(limit, Integer.MAX_VALUE)); + } + } + + static void setFailOnUiChange(boolean failOnUiChange) { + ConcurrentDownloadHandler.failOnUiChange = failOnUiChange; + } + + /** + * Returns the limit for the number of concurrent downloads. + * + * @return the limit for the number of concurrent downloads, or + * {@link Float#POSITIVE_INFINITY} if + * the semaphore is disabled. + */ + public static float getLimit() { + if (enabled) { + synchronized (semaphore) { + return (float) semaphore.maxPermits / 0x10000; + } + } else { + return Float.POSITIVE_INFINITY; + } + } + + private static int costToPermits(float cost, int maxPermits) { + // restrict limit to 0x7fff to ensure the cost can be represented + // using fixed-point arithmetic with 16 fractional digits and 15 integral digits + cost = Math.min(cost, MAX_COST); + // Determine the number of permits required based on the cost, capping at + // maxPermits. + // If the cost is zero or negative, no permits are needed. + // Any positive cost, no matter how small, will require at least one permit. + return cost <= 0 ? 0 : Math.max(Math.min((int) (cost * 0x10000), maxPermits), 1); + } + + /** + * Constructs a {@code ConcurrentDownloadHandler} with the specified delegate. + * The delegate is a + * {@link DownloadHandler} that performs the actual download handling. + * + * @param delegate the delegate {@code DownloadHandler} + */ + ConcurrentDownloadHandler(DownloadHandler delegate) { + this.delegate = delegate; + } + + /** + * Sets the timeout for acquiring a permit to start a download when there are + * not enough permits + * available in the semaphore. + * + * @see GridExporter#setConcurrentDownloadTimeout(long, TimeUnit) + * @return the timeout in nanoseconds. + */ + public abstract long getTimeout(); + + /** + * Returns the cost of this download. + * + * Note that the method is not called under the session lock. It means that if + * implementation + * requires access to the application/session data then the session has to be + * locked explicitly. + * + * @param session vaadin session + * @see GridExporter#setConcurrentDownloadCost(float) + */ + public float getCost(VaadinSession session) { + return DEFAULT_COST; + } + + /** + * Returns the UI associated with the current download. + *

+ * This method is used to ensure that the UI is still attached to the current + * session when a + * download is initiated. Implementations should return the appropriate UI + * instance. + *

+ * + * @return the {@link UI} instance associated with the current download, or + * {@code null} if no UI + * is available. + */ + protected abstract UI getUI(); + + private UI getAttachedUI() { + return Optional.ofNullable(getUI()).filter(UI::isAttached).orElse(null); + } + + /** + * Callback method that is invoked when a timeout occurs while trying to acquire + * a permit for + * starting a download. + *

+ * Implementations can use this method to perform any necessary actions in + * response to the + * timeout, such as logging a warning or notifying the user. + *

+ */ + protected abstract void onTimeout(); + + /** + * Callback method that is invoked when a download is accepted. + *

+ * This method is called at the start of the download process, right after the + * {@link #handleDownloadRequest(DownloadEvent) handleDownloadRequest} method is + * invoked and it + * has been determined that the download can proceed. Subclasses should + * implement this method to + * perform any necessary actions before the download begins, such as + * initializing resources, + * logging, or updating the UI to reflect the start of the download. + *

+ * Note that this method is called before any semaphore permits are acquired, so + * it is executed + * regardless of whether the semaphore is enabled or not. + *

+ */ + protected abstract void onAccept(); + + /** + * Callback method that is invoked when a download finishes. + *

+ * This method is called at the end of the download process, right before the + * {@link #handleDownloadRequest(DownloadEvent) handleDownloadRequest} method + * returns, regardless + * of whether the download was successful, timed out, or encountered an error. + * Subclasses should + * implement this method to perform any necessary actions after the download + * completes, such as + * releasing resources, logging, or updating the UI to reflect the completion of + * the download. + *

+ * Note that this method is always called, even if an exception is thrown during + * the download + * process, ensuring that any necessary cleanup can be performed. + *

+ */ + protected abstract void onFinish(); + + /** + * Handles the download request using the provided {@link DownloadEvent}. + *

+ * Note that the method is not called under the session lock. It means that if + * implementation + * requires access to the application/session data then the session has to be + * locked explicitly. + *

+ * If a semaphore has been set, it controls access to this method, enforcing a + * timeout. A permit + * will be acquired from the semaphore, if one becomes available within the + * given waiting time and + * the current thread has not been {@linkplain Thread#interrupt interrupted}. + * + * @param event the download event containing the output stream and session + * @throws IOException if an IO error occurred + * @throws InterruptedIOException if the current thread is interrupted + * @throws InterruptedByTimeoutException if the waiting time elapsed before a + * permit was acquired + */ + @Override + public final void handleDownloadRequest(DownloadEvent event) throws IOException { + onAccept(); + try { + if (!enabled) { + delegate.handleDownloadRequest(event); + } else { + + try { + int permits; + float cost = getCost(event.getSession()); + synchronized (semaphore) { + permits = costToPermits(cost, semaphore.maxPermits); + } + + UI ui = failOnUiChange ? getAttachedUI() : null; + + if (semaphore.tryAcquire(permits, getTimeout(), TimeUnit.NANOSECONDS)) { + try { + if (ui != null && getAttachedUI() != ui) { + // The UI has changed or was detached after acquiring the semaphore + throw new IOException("Detached UI"); + } + delegate.handleDownloadRequest(event); + } finally { + semaphore.release(permits); + } + } else { + onTimeout(); + throw new InterruptedByTimeoutException(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw (IOException) new InterruptedIOException().initCause(e); + } + } + } finally { + onFinish(); + } + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/gridexporter/GridExporter.java b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/GridExporter.java index 5ff3b56..bc5ed9a 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/gridexporter/GridExporter.java +++ b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/GridExporter.java @@ -42,6 +42,8 @@ import com.vaadin.flow.server.StreamResource; import com.vaadin.flow.server.StreamResourceWriter; import com.vaadin.flow.server.VaadinSession; +import com.vaadin.flow.server.streams.DownloadHandler; +import com.vaadin.flow.server.streams.DownloadEvent; import com.vaadin.flow.shared.Registration; import java.io.Serializable; import java.lang.reflect.Field; @@ -221,13 +223,13 @@ Object extractValueFromColumn(T item, Column column) { ValueProvider customVP = (ValueProvider) ComponentUtil.getData(column, GridExporter.COLUMN_VALUE_PROVIDER_DATA); - if (customVP != null) { - value = customVP.apply(item); - if (value == null && nullValueSupplier != null) { - value = nullValueSupplier.get(); - } - return value; - } + if (customVP != null) { + value = customVP.apply(item); + if (value == null && nullValueSupplier != null) { + value = nullValueSupplier.get(); + } + return value; + } // if there is a key, assume that the property can be retrieved from it if (value == null && column.getKey() != null) { @@ -305,42 +307,198 @@ else if (r.getValueProviders().containsKey("name")) { return value; } + /** + * Gets a StreamResource for DOCX export. + * + * @return the DOCX StreamResource + * @deprecated Use {@link #getDocxDownloadHandler()} instead. This method will + * be removed in + * version 3.0.0. + */ + @Deprecated(since = "2.6.0", forRemoval = true) public GridExporterStreamResource getDocxStreamResource() { return getDocxStreamResource(null); } + /** + * Gets a StreamResource for DOCX export with a custom template. + * + * @param template the custom template path + * @return the DOCX StreamResource + * @deprecated Use {@link #getDocxDownloadHandler(String)} instead. This method + * will be removed + * in version 3.0.0. + */ + @Deprecated(since = "2.6.0", forRemoval = true) public GridExporterStreamResource getDocxStreamResource(String template) { return new GridExporterStreamResource(getFileName("docx"), makeConcurrentWriter(new DocxStreamResourceWriter<>(this, template))); } + /** + * Gets a StreamResource for PDF export. + * + * @return the PDF StreamResource + * @deprecated Use {@link #getPdfDownloadHandler()} instead. This method will be + * removed in + * version 3.0.0. + */ + @Deprecated(since = "2.6.0", forRemoval = true) public GridExporterStreamResource getPdfStreamResource() { return getPdfStreamResource(null); } + /** + * Gets a StreamResource for PDF export with a custom template. + * + * @param template the custom template path + * @return the PDF StreamResource + * @deprecated Use {@link #getPdfDownloadHandler(String)} instead. This method + * will be removed in + * version 3.0.0. + */ + @Deprecated(since = "2.6.0", forRemoval = true) public GridExporterStreamResource getPdfStreamResource(String template) { return new GridExporterStreamResource(getFileName("pdf"), makeConcurrentWriter(new PdfStreamResourceWriter<>(this, template))); } + /** + * Gets a StreamResource for CSV export. + * + * @return the CSV StreamResource + * @deprecated Use {@link #getCsvDownloadHandler()} instead. This method will be + * removed in + * version 3.0.0. + */ + @Deprecated(since = "2.6.0", forRemoval = true) public StreamResource getCsvStreamResource() { return new StreamResource(getFileName("csv"), new CsvStreamResourceWriter<>(this)); } + /** + * Gets a StreamResource for Excel export. + * + * @return the Excel StreamResource + * @deprecated Use {@link #getExcelDownloadHandler()} instead. This method will + * be removed in + * version 3.0.0. + */ + @Deprecated(since = "2.6.0", forRemoval = true) public GridExporterStreamResource getExcelStreamResource() { return getExcelStreamResource(null); } + /** + * Gets a StreamResource for Excel export with a custom template. + * + * @param template the custom template path + * @return the Excel StreamResource + * @deprecated Use {@link #getExcelDownloadHandler(String)} instead. This method + * will be removed + * in version 3.0.0. + */ + @Deprecated(since = "2.6.0", forRemoval = true) public GridExporterStreamResource getExcelStreamResource(String template) { return new GridExporterStreamResource(getFileName("xlsx"), makeConcurrentWriter(new ExcelStreamResourceWriter<>(this, template))); } + /** + * Gets a DownloadHandler for DOCX export. + * + * @return the DOCX DownloadHandler + * @since 2.6.0 + */ + public DownloadHandler getDocxDownloadHandler() { + return getDocxDownloadHandler(null); + } + + /** + * Gets a DownloadHandler for DOCX export with a custom template. + * + * @param template the custom template path + * @return the DOCX DownloadHandler + * @since 2.6.0 + */ + public DownloadHandler getDocxDownloadHandler(String template) { + return makeConcurrentDownloadHandler( + new DocxStreamResourceWriter<>(this, template), + getFileName("docx"), + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + } + + /** + * Gets a DownloadHandler for PDF export. + * + * @return the PDF DownloadHandler + * @since 2.6.0 + */ + public DownloadHandler getPdfDownloadHandler() { + return getPdfDownloadHandler(null); + } + + /** + * Gets a DownloadHandler for PDF export with a custom template. + * + * @param template the custom template path + * @return the PDF DownloadHandler + * @since 2.6.0 + */ + public DownloadHandler getPdfDownloadHandler(String template) { + return makeConcurrentDownloadHandler( + new PdfStreamResourceWriter<>(this, template), + getFileName("pdf"), + "application/pdf"); + } + + /** + * Gets a DownloadHandler for CSV export. + * + * @return the CSV DownloadHandler + * @since 2.6.0 + */ + public DownloadHandler getCsvDownloadHandler() { + return new StreamResourceWriterAdapter( + new CsvStreamResourceWriter<>(this), + getFileName("csv"), + "text/csv"); + } + + /** + * Gets a DownloadHandler for Excel export. + * + * @return the Excel DownloadHandler + * @since 2.6.0 + */ + public DownloadHandler getExcelDownloadHandler() { + return getExcelDownloadHandler(null); + } + + /** + * Gets a DownloadHandler for Excel export with a custom template. + * + * @param template the custom template path + * @return the Excel DownloadHandler + * @since 2.6.0 + */ + public DownloadHandler getExcelDownloadHandler(String template) { + return makeConcurrentDownloadHandler( + new ExcelStreamResourceWriter<>(this, template), + getFileName("xlsx"), + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + } private GridExporterConcurrentStreamResourceWriter makeConcurrentWriter( StreamResourceWriter writer) { return new GridExporterConcurrentStreamResourceWriter(writer); } + private GridExporterConcurrentDownloadHandler makeConcurrentDownloadHandler( + StreamResourceWriter writer, String filename, String contentType) { + return new GridExporterConcurrentDownloadHandler( + new StreamResourceWriterAdapter(writer, filename, contentType)); + } + public class GridExporterStreamResource extends StreamResource { private final GridExporterConcurrentStreamResourceWriter writer; @@ -363,32 +521,86 @@ private class GridExporterConcurrentStreamResourceWriter extends ConcurrentStrea private Component button; - @Override - public float getCost(VaadinSession session) { - return concurrentDownloadCost; - } + @Override + public float getCost(VaadinSession session) { + return concurrentDownloadCost; + } - @Override - public long getTimeout() { + @Override + public long getTimeout() { // It would have been possible to specify a different timeout for each instance but I cannot // figure out a good use case for that. The timeout returned herebecomes relevant when the // semaphore has been acquired by any other download, so the timeout must reflect how long // it is reasonable to wait for "any other download" to complete and release the semaphore. - // + // // Since the reasonable timeout would depend on the duration of "any other download", it - // makes sense that it's a global setting instead of a per-instance setting. - return GridExporterConcurrentSettings.getConcurrentDownloadTimeout(TimeUnit.NANOSECONDS); - } + // makes sense that it's a global setting instead of a per-instance setting. + return GridExporterConcurrentSettings.getConcurrentDownloadTimeout(TimeUnit.NANOSECONDS); + } - @Override - protected UI getUI() { - return grid.getUI().orElse(null); + @Override + protected UI getUI() { + return grid.getUI().orElse(null); + } + + @Override + protected void onTimeout() { + fireConcurrentDownloadTimeout(); + } + + @Override + protected void onAccept() { + if (disableOnClick) { + setButtonEnabled(false); } + } + + @Override + protected void onFinish() { + setButtonEnabled(true); + } - @Override - protected void onTimeout() { - fireConcurrentDownloadTimeout(); + private void setButtonEnabled(boolean enabled) { + if (button instanceof HasEnabled) { + grid.getUI().ifPresent(ui -> ui.access(() -> ((HasEnabled) button).setEnabled(enabled))); } + } + } + + /** + * Inner class that extends ConcurrentDownloadHandler for use with the new + * DownloadHandler API. + * This provides the same concurrent download control as + * GridExporterConcurrentStreamResourceWriter + * but for the new API. + */ + private class GridExporterConcurrentDownloadHandler extends ConcurrentDownloadHandler { + + GridExporterConcurrentDownloadHandler(DownloadHandler delegate) { + super(delegate); + } + + private Component button; + + @Override + public float getCost(VaadinSession session) { + return concurrentDownloadCost; + } + + @Override + public long getTimeout() { + return GridExporterConcurrentSettings.getConcurrentDownloadTimeout(TimeUnit.NANOSECONDS); + } + + @Override + protected UI getUI() { + return grid.getUI().orElse(null); + } + + @Override + protected void onTimeout() { + fireConcurrentDownloadTimeout(); + } @Override protected void onAccept() { @@ -400,13 +612,25 @@ protected void onAccept() { @Override protected void onFinish() { setButtonEnabled(true); - } + } private void setButtonEnabled(boolean enabled) { if (button instanceof HasEnabled) { grid.getUI().ifPresent(ui -> ui.access(() -> ((HasEnabled) button).setEnabled(enabled))); } } + + /** + * Associates this download handler with a component (typically a button). + * This allows the handler to enable/disable the component during download. + * + * @param component the component to associate with this handler + * @return this handler for method chaining + */ + public GridExporterConcurrentDownloadHandler forComponent(Component component) { + this.button = component; + return this; + } } /** diff --git a/src/main/java/com/flowingcode/vaadin/addons/gridexporter/StreamResourceWriterAdapter.java b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/StreamResourceWriterAdapter.java new file mode 100644 index 0000000..0ac8c71 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/StreamResourceWriterAdapter.java @@ -0,0 +1,69 @@ +/*- + * #%L + * Grid Exporter Add-on + * %% + * Copyright (C) 2022 - 2024 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.gridexporter; + +import com.vaadin.flow.server.StreamResourceWriter; +import com.vaadin.flow.server.streams.DownloadEvent; +import com.vaadin.flow.server.streams.DownloadHandler; +import java.io.IOException; + +/** + * Adapter class that converts a {@link StreamResourceWriter} to a + * {@link DownloadHandler}. + * This allows gradual migration from the deprecated StreamResource API to the + * new DownloadHandler + * API while maintaining backward compatibility. + * + * @author Javier Godoy + */ +@SuppressWarnings("serial") +class StreamResourceWriterAdapter implements DownloadHandler { + + private final StreamResourceWriter writer; + private final String filename; + private final String contentType; + + /** + * Creates a new adapter that wraps the given {@link StreamResourceWriter}. + * + * @param writer the StreamResourceWriter to adapt + * @param filename the filename for the download + * @param contentType the MIME content type + */ + public StreamResourceWriterAdapter(StreamResourceWriter writer, String filename, String contentType) { + this.writer = writer; + this.filename = filename; + this.contentType = contentType; + } + + @Override + public void handleDownloadRequest(DownloadEvent event) throws IOException { + // Set filename and content type in the download event + if (filename != null) { + event.setFileName(filename); + } + if (contentType != null) { + event.setContentType(contentType); + } + + // Delegate to the StreamResourceWriter's accept method + writer.accept(event.getOutputStream(), event.getSession()); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/gridexporter/GridExporterCustomLinkDemo.java b/src/test/java/com/flowingcode/vaadin/addons/gridexporter/GridExporterCustomLinkDemo.java index bb9acf1..e5da388 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/gridexporter/GridExporterCustomLinkDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/gridexporter/GridExporterCustomLinkDemo.java @@ -54,18 +54,18 @@ public GridExporterCustomLinkDemo() throws EncryptedDocumentException, IOExcepti total[0] = BigDecimal.ZERO; Stream stream = IntStream.range(0, 100) - .asLongStream() - .mapToObj( - number -> { - Double budget = faker.number().randomDouble(2, 10000, 100000); - total[0] = total[0].add(BigDecimal.valueOf(budget)); - c.setFooter("$" + total[0]); - return new Person( - faker.name().firstName(), - faker.name().lastName(), - faker.number().numberBetween(15, 50), - budget); - }); + .asLongStream() + .mapToObj( + number -> { + Double budget = faker.number().randomDouble(2, 10000, 100000); + total[0] = total[0].add(BigDecimal.valueOf(budget)); + c.setFooter("$" + total[0]); + return new Person( + faker.name().firstName(), + faker.name().lastName(), + faker.number().numberBetween(15, 50), + budget); + }); grid.setItems(DataProvider.fromStream(stream)); grid.setWidthFull(); this.setSizeFull(); @@ -76,7 +76,7 @@ public GridExporterCustomLinkDemo() throws EncryptedDocumentException, IOExcepti exporter.setFileName( "GridExport" + new SimpleDateFormat("yyyyddMM").format(Calendar.getInstance().getTime())); Anchor excelLink = new Anchor("", "Export to Excel"); - excelLink.setHref(exporter.getExcelStreamResource()); + excelLink.setHref(exporter.getExcelDownloadHandler()); excelLink.getElement().setAttribute("download", true); add(grid, excelLink); } From d9b1b9f44cd3a74e45876e9f2822d3d910007b69 Mon Sep 17 00:00:00 2001 From: Martin Lopez Date: Tue, 25 Nov 2025 18:04:34 -0300 Subject: [PATCH 4/5] docs: document migration to DownloadHandler API --- MIGRATION_GUIDE.md | 189 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 28 +++++++ 2 files changed, 217 insertions(+) create mode 100644 MIGRATION_GUIDE.md diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..1e5ce49 --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,189 @@ +# Migration Guide: StreamResource to DownloadHandler + +## Overview + +Starting with version 2.6.0, Grid Exporter Add-on introduces new `DownloadHandler`-based methods to replace the deprecated `StreamResource` methods. This migration is necessary for compatibility with Vaadin 25, where `StreamResource` will be removed. + +## What's Changing? + +The Grid Exporter Add-on now provides two sets of methods: + +- **Old API (Deprecated)**: Methods returning `StreamResource` or `GridExporterStreamResource` +- **New API (Recommended)**: Methods returning `DownloadHandler` + +## Backward Compatibility + +✅ **Your existing code will continue to work!** The old methods are deprecated but still functional. You can migrate at your own pace. + +## Migration Steps + +### 1. Update Vaadin Version + +The new `DownloadHandler` API requires **Vaadin 24.8.0 or later**. + +**pom.xml:** +```xml + + 24.8.0 + +``` + +### 2. Update Method Calls + +Replace the deprecated `get*StreamResource()` methods with the new `get*DownloadHandler()` methods. + +#### Excel Export + +**Before:** +```java +Anchor excelLink = new Anchor("", FontAwesome.Regular.FILE_EXCEL.create()); +excelLink.setHref(exporter.getExcelStreamResource()); +excelLink.getElement().setAttribute("download", true); +``` + +**After:** +```java +Anchor excelLink = new Anchor("", FontAwesome.Regular.FILE_EXCEL.create()); +excelLink.setHref(exporter.getExcelDownloadHandler()); +excelLink.getElement().setAttribute("download", true); +``` + +#### DOCX Export + +**Before:** +```java +exporter.getDocxStreamResource() +exporter.getDocxStreamResource(customTemplate) +``` + +**After:** +```java +exporter.getDocxDownloadHandler() +exporter.getDocxDownloadHandler(customTemplate) +``` + +#### PDF Export + +**Before:** +```java +exporter.getPdfStreamResource() +exporter.getPdfStreamResource(customTemplate) +``` + +**After:** +```java +exporter.getPdfDownloadHandler() +exporter.getPdfDownloadHandler(customTemplate) +``` + +#### CSV Export + +**Before:** +```java +exporter.getCsvStreamResource() +``` + +**After:** +```java +exporter.getCsvDownloadHandler() +``` + +### 3. Update Custom Export Links (if applicable) + +If you're creating custom export links instead of using auto-attached buttons: + +**Before:** +```java +GridExporter exporter = GridExporter.createFor(grid); +exporter.setAutoAttachExportButtons(false); + +Anchor customLink = new Anchor("", "Download Excel"); +customLink.setHref(exporter.getExcelStreamResource().forComponent(customLink)); +``` + +**After:** +```java +GridExporter exporter = GridExporter.createFor(grid); +exporter.setAutoAttachExportButtons(false); + +Anchor customLink = new Anchor("", "Download Excel"); +customLink.setHref(exporter.getExcelDownloadHandler()); +// Note: forComponent() is handled internally for DownloadHandler +``` + +## API Comparison + +| Old Method (Deprecated) | New Method | Notes | +|------------------------|------------|-------| +| `getExcelStreamResource()` | `getExcelDownloadHandler()` | Excel export | +| `getExcelStreamResource(String)` | `getExcelDownloadHandler(String)` | Excel with template | +| `getDocxStreamResource()` | `getDocxDownloadHandler()` | DOCX export | +| `getDocxStreamResource(String)` | `getDocxDownloadHandler(String)` | DOCX with template | +| `getPdfStreamResource()` | `getPdfDownloadHandler()` | PDF export | +| `getPdfStreamResource(String)` | `getPdfDownloadHandler(String)` | PDF with template | +| `getCsvStreamResource()` | `getCsvDownloadHandler()` | CSV export | + +## Features Preserved + +All existing features continue to work with the new API: + +- ✅ Concurrent download control +- ✅ Download timeouts +- ✅ Button disable/enable during download +- ✅ Custom templates +- ✅ All export formats (Excel, DOCX, PDF, CSV) +- ✅ Custom columns and headers +- ✅ Hierarchical data support + +## Timeline + +- **Version 2.6.0**: New `DownloadHandler` methods introduced, old methods deprecated +- **Version 3.0.0**: Old `StreamResource` methods will be removed + +## Need Help? + +If you encounter any issues during migration, please: +1. Check that you're using Vaadin 24.8.0 or later +2. Review the deprecation warnings in your IDE +3. Open an issue on [GitHub](https://github.com/FlowingCode/GridExporterAddon/issues) + +## Example: Complete Migration + +**Before (Version 2.5.x):** +```java +Grid grid = new Grid<>(Person.class); +grid.setItems(people); + +GridExporter exporter = GridExporter.createFor(grid); +// Auto-attached buttons use StreamResource internally +``` + +**After (Version 2.6.0+):** +```java +Grid grid = new Grid<>(Person.class); +grid.setItems(people); + +GridExporter exporter = GridExporter.createFor(grid); +// Auto-attached buttons now use DownloadHandler internally +// No code changes needed if using auto-attached buttons! +``` + +**Custom Implementation:** +```java +Grid grid = new Grid<>(Person.class); +grid.setItems(people); + +GridExporter exporter = GridExporter.createFor(grid); +exporter.setAutoAttachExportButtons(false); + +// Create custom export buttons +Anchor excelBtn = new Anchor("", "Excel"); +excelBtn.setHref(exporter.getExcelDownloadHandler()); +excelBtn.getElement().setAttribute("download", true); + +Anchor pdfBtn = new Anchor("", "PDF"); +pdfBtn.setHref(exporter.getPdfDownloadHandler()); +pdfBtn.getElement().setAttribute("download", true); + +add(excelBtn, pdfBtn); +``` diff --git a/README.md b/README.md index 02b62e7..2986665 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,34 @@ To see the demo, navigate to http://localhost:8080/ See [here](https://github.com/FlowingCode/GridExporterAddon/releases) +## Migration to DownloadHandler API (Version 2.6.0+) + +**Important for Vaadin 25 compatibility**: Starting with version 2.6.0, Grid Exporter Add-on introduces new `DownloadHandler`-based methods to replace the deprecated `StreamResource` methods. + +### Quick Migration + +**Old API (Deprecated):** +```java +exporter.getExcelStreamResource() +exporter.getDocxStreamResource() +exporter.getPdfStreamResource() +exporter.getCsvStreamResource() +``` + +**New API (Recommended):** +```java +exporter.getExcelDownloadHandler() +exporter.getDocxDownloadHandler() +exporter.getPdfDownloadHandler() +exporter.getCsvDownloadHandler() +``` + +### Requirements + +- **Vaadin 24.8.0 or later** is required to use the new `DownloadHandler` methods +- Old `StreamResource` methods will continue to work until version 3.0.0 +- See [MIGRATION_GUIDE.md](./MIGRATION_GUIDE.md) for detailed migration instructions + ## Issue tracking The issues for this add-on are tracked on its github.com page. All bug reports and feature requests are appreciated. From e608b7c0e13ddf5166375a3ab59339985b750cde Mon Sep 17 00:00:00 2001 From: Martin Lopez Date: Tue, 25 Nov 2025 20:36:40 -0300 Subject: [PATCH 5/5] refactor: extract common logic in a separate class Extract common semaphore logic into ConcurrentOperationBase for reuse in concurrent handlers. --- .../ConcurrentDownloadHandler.java | 243 +---------------- .../gridexporter/ConcurrentOperationBase.java | 258 ++++++++++++++++++ .../ConcurrentStreamResourceWriter.java | 251 ++--------------- .../addons/gridexporter/GridExporter.java | 130 +++++---- 4 files changed, 369 insertions(+), 513 deletions(-) create mode 100644 src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentOperationBase.java diff --git a/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentDownloadHandler.java b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentDownloadHandler.java index 1f01330..0d9ca17 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentDownloadHandler.java +++ b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentDownloadHandler.java @@ -19,18 +19,12 @@ */ package com.flowingcode.vaadin.addons.gridexporter; -import com.vaadin.flow.component.UI; import com.vaadin.flow.server.streams.DownloadHandler; import com.vaadin.flow.server.streams.DownloadEvent; import com.vaadin.flow.server.VaadinSession; import java.io.IOException; import java.io.InterruptedIOException; -import java.io.OutputStream; import java.nio.channels.InterruptedByTimeoutException; -import java.util.Optional; -import java.util.concurrent.Semaphore; -import java.util.concurrent.TimeUnit; -import java.util.function.IntFunction; /** * An implementation of {@link DownloadHandler} that controls access to the @@ -41,112 +35,10 @@ * @author Javier Godoy */ @SuppressWarnings("serial") -abstract class ConcurrentDownloadHandler implements DownloadHandler { - - public static final float MAX_COST = 0x7FFF; - - public static final float MIN_COST = 1.0f / 0x10000; - - public static final float DEFAULT_COST = 1.0f; - - private static final ConfigurableSemaphore semaphore = new ConfigurableSemaphore(); - - private static volatile boolean enabled; - - private static volatile boolean failOnUiChange; +abstract class ConcurrentDownloadHandler extends ConcurrentOperationBase implements DownloadHandler { private final DownloadHandler delegate; - private static final class ConfigurableSemaphore extends Semaphore { - - private int maxPermits; - - ConfigurableSemaphore() { - super(0); - } - - synchronized void setPermits(int permits) { - if (permits < 0) { - throw new IllegalArgumentException(); - } - int delta = permits - maxPermits; - if (delta > 0) { - super.release(delta); - } else if (delta < 0) { - super.reducePermits(-delta); - } - maxPermits = permits; - } - - @Override - public String toString() { - IntFunction str = permits -> { - float f = permits / (float) 0x10000; - return f == Math.floor(f) ? String.format("%.0f", f) : Float.toString(f); - }; - return "Semaphore[" + str.apply(availablePermits()) + "/" + str.apply(maxPermits) + "]"; - } - - } - - /** - * Sets the limit for the cost of concurrent downloads. - *

- * Finite limits are capped to {@link #MAX_COST} (32767). If the limit is - * {@link Float#POSITIVE_INFINITY POSITIVE_INFINITY}, the semaphore will not be - * used for - * controlling concurrent downloads. - * - * @param limit the maximum cost of concurrent downloads allowed - * @throws IllegalArgumentException if the limit is zero or negative. - */ - public static void setLimit(float limit) { - if (limit <= 0) { - throw new IllegalArgumentException(); - } - if (Float.isInfinite(limit)) { - enabled = false; - return; - } - - synchronized (semaphore) { - enabled = true; - semaphore.setPermits(costToPermits(limit, Integer.MAX_VALUE)); - } - } - - static void setFailOnUiChange(boolean failOnUiChange) { - ConcurrentDownloadHandler.failOnUiChange = failOnUiChange; - } - - /** - * Returns the limit for the number of concurrent downloads. - * - * @return the limit for the number of concurrent downloads, or - * {@link Float#POSITIVE_INFINITY} if - * the semaphore is disabled. - */ - public static float getLimit() { - if (enabled) { - synchronized (semaphore) { - return (float) semaphore.maxPermits / 0x10000; - } - } else { - return Float.POSITIVE_INFINITY; - } - } - - private static int costToPermits(float cost, int maxPermits) { - // restrict limit to 0x7fff to ensure the cost can be represented - // using fixed-point arithmetic with 16 fractional digits and 15 integral digits - cost = Math.min(cost, MAX_COST); - // Determine the number of permits required based on the cost, capping at - // maxPermits. - // If the cost is zero or negative, no permits are needed. - // Any positive cost, no matter how small, will require at least one permit. - return cost <= 0 ? 0 : Math.max(Math.min((int) (cost * 0x10000), maxPermits), 1); - } - /** * Constructs a {@code ConcurrentDownloadHandler} with the specified delegate. * The delegate is a @@ -158,101 +50,6 @@ private static int costToPermits(float cost, int maxPermits) { this.delegate = delegate; } - /** - * Sets the timeout for acquiring a permit to start a download when there are - * not enough permits - * available in the semaphore. - * - * @see GridExporter#setConcurrentDownloadTimeout(long, TimeUnit) - * @return the timeout in nanoseconds. - */ - public abstract long getTimeout(); - - /** - * Returns the cost of this download. - * - * Note that the method is not called under the session lock. It means that if - * implementation - * requires access to the application/session data then the session has to be - * locked explicitly. - * - * @param session vaadin session - * @see GridExporter#setConcurrentDownloadCost(float) - */ - public float getCost(VaadinSession session) { - return DEFAULT_COST; - } - - /** - * Returns the UI associated with the current download. - *

- * This method is used to ensure that the UI is still attached to the current - * session when a - * download is initiated. Implementations should return the appropriate UI - * instance. - *

- * - * @return the {@link UI} instance associated with the current download, or - * {@code null} if no UI - * is available. - */ - protected abstract UI getUI(); - - private UI getAttachedUI() { - return Optional.ofNullable(getUI()).filter(UI::isAttached).orElse(null); - } - - /** - * Callback method that is invoked when a timeout occurs while trying to acquire - * a permit for - * starting a download. - *

- * Implementations can use this method to perform any necessary actions in - * response to the - * timeout, such as logging a warning or notifying the user. - *

- */ - protected abstract void onTimeout(); - - /** - * Callback method that is invoked when a download is accepted. - *

- * This method is called at the start of the download process, right after the - * {@link #handleDownloadRequest(DownloadEvent) handleDownloadRequest} method is - * invoked and it - * has been determined that the download can proceed. Subclasses should - * implement this method to - * perform any necessary actions before the download begins, such as - * initializing resources, - * logging, or updating the UI to reflect the start of the download. - *

- * Note that this method is called before any semaphore permits are acquired, so - * it is executed - * regardless of whether the semaphore is enabled or not. - *

- */ - protected abstract void onAccept(); - - /** - * Callback method that is invoked when a download finishes. - *

- * This method is called at the end of the download process, right before the - * {@link #handleDownloadRequest(DownloadEvent) handleDownloadRequest} method - * returns, regardless - * of whether the download was successful, timed out, or encountered an error. - * Subclasses should - * implement this method to perform any necessary actions after the download - * completes, such as - * releasing resources, logging, or updating the UI to reflect the completion of - * the download. - *

- * Note that this method is always called, even if an exception is thrown during - * the download - * process, ensuring that any necessary cleanup can be performed. - *

- */ - protected abstract void onFinish(); - /** * Handles the download request using the provided {@link DownloadEvent}. *

@@ -275,43 +72,7 @@ private UI getAttachedUI() { */ @Override public final void handleDownloadRequest(DownloadEvent event) throws IOException { - onAccept(); - try { - if (!enabled) { - delegate.handleDownloadRequest(event); - } else { - - try { - int permits; - float cost = getCost(event.getSession()); - synchronized (semaphore) { - permits = costToPermits(cost, semaphore.maxPermits); - } - - UI ui = failOnUiChange ? getAttachedUI() : null; - - if (semaphore.tryAcquire(permits, getTimeout(), TimeUnit.NANOSECONDS)) { - try { - if (ui != null && getAttachedUI() != ui) { - // The UI has changed or was detached after acquiring the semaphore - throw new IOException("Detached UI"); - } - delegate.handleDownloadRequest(event); - } finally { - semaphore.release(permits); - } - } else { - onTimeout(); - throw new InterruptedByTimeoutException(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw (IOException) new InterruptedIOException().initCause(e); - } - } - } finally { - onFinish(); - } + runWithSemaphore(event.getSession(), () -> delegate.handleDownloadRequest(event)); } } diff --git a/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentOperationBase.java b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentOperationBase.java new file mode 100644 index 0000000..d345f6c --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentOperationBase.java @@ -0,0 +1,258 @@ +/*- + * #%L + * Grid Exporter Add-on + * %% + * Copyright (C) 2022 - 2024 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.gridexporter; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.server.VaadinSession; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.nio.channels.InterruptedByTimeoutException; +import java.util.Optional; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.function.IntFunction; + +/** + * Base class containing shared semaphore logic for concurrent download/upload + * control. + * This class is used by both ConcurrentStreamResourceWriter and + * ConcurrentDownloadHandler + * to avoid code duplication. + * + * @author Javier Godoy + */ +@SuppressWarnings("serial") +abstract class ConcurrentOperationBase { + + public static final float MAX_COST = 0x7FFF; + public static final float MIN_COST = 1.0f / 0x10000; + public static final float DEFAULT_COST = 1.0f; + + static final ConfigurableSemaphore semaphore = new ConfigurableSemaphore(); + static volatile boolean enabled; + static volatile boolean failOnUiChange; + + static final class ConfigurableSemaphore extends Semaphore { + + int maxPermits; // package-private for access from subclasses + + ConfigurableSemaphore() { + super(0); + } + + synchronized void setPermits(int permits) { + if (permits < 0) { + throw new IllegalArgumentException(); + } + int delta = permits - maxPermits; + if (delta > 0) { + super.release(delta); + } else if (delta < 0) { + super.reducePermits(-delta); + } + maxPermits = permits; + } + + @Override + public String toString() { + IntFunction str = permits -> { + float f = permits / (float) 0x10000; + return f == Math.floor(f) ? String.format("%.0f", f) : Float.toString(f); + }; + return "Semaphore[" + str.apply(availablePermits()) + "/" + str.apply(maxPermits) + "]"; + } + } + + /** + * Sets the limit for the cost of concurrent operations. + *

+ * Finite limits are capped to {@link #MAX_COST} (32767). If the limit is + * {@link Float#POSITIVE_INFINITY POSITIVE_INFINITY}, the semaphore will not be + * used for + * controlling concurrent operations. + * + * @param limit the maximum cost of concurrent operations allowed + * @throws IllegalArgumentException if the limit is zero or negative. + */ + public static void setLimit(float limit) { + if (limit <= 0) { + throw new IllegalArgumentException(); + } + if (Float.isInfinite(limit)) { + enabled = false; + return; + } + + synchronized (semaphore) { + enabled = true; + semaphore.setPermits(costToPermits(limit, Integer.MAX_VALUE)); + } + } + + static void setFailOnUiChange(boolean failOnUiChange) { + ConcurrentOperationBase.failOnUiChange = failOnUiChange; + } + + /** + * Returns the limit for the number of concurrent operations. + * + * @return the limit for the number of concurrent operations, or + * {@link Float#POSITIVE_INFINITY} + * if the semaphore is disabled. + */ + public static float getLimit() { + if (enabled) { + synchronized (semaphore) { + return (float) semaphore.maxPermits / 0x10000; + } + } else { + return Float.POSITIVE_INFINITY; + } + } + + static int costToPermits(float cost, int maxPermits) { + // restrict limit to 0x7fff to ensure the cost can be represented + // using fixed-point arithmetic with 16 fractional digits and 15 integral digits + cost = Math.min(cost, MAX_COST); + // Determine the number of permits required based on the cost, capping at + // maxPermits. + // If the cost is zero or negative, no permits are needed. + // Any positive cost, no matter how small, will require at least one permit. + return cost <= 0 ? 0 : Math.max(Math.min((int) (cost * 0x10000), maxPermits), 1); + } + + /** + * Sets the timeout for acquiring a permit to start a download when there are + * not enough permits + * available in the semaphore. + * + * @return the timeout in nanoseconds. + */ + public abstract long getTimeout(); + + /** + * Returns the cost of this download. + * + * Note that the method is not called under the session lock. It means that if + * implementation + * requires access to the application/session data then the session has to be + * locked explicitly. + * + * @param session vaadin session + */ + public float getCost(VaadinSession session) { + return DEFAULT_COST; + } + + /** + * Returns the UI associated with the current download. + *

+ * This method is used to ensure that the UI is still attached to the current + * session when a + * download is initiated. Implementations should return the appropriate UI + * instance. + *

+ * + * @return the {@link UI} instance associated with the current download, or + * {@code null} if no UI + * is available. + */ + protected abstract UI getUI(); + + private UI getAttachedUI() { + return Optional.ofNullable(getUI()).filter(UI::isAttached).orElse(null); + } + + /** + * Callback method that is invoked when a timeout occurs while trying to acquire + * a permit for + * starting a download. + *

+ * Implementations can use this method to perform any necessary actions in + * response to the + * timeout, such as logging a warning or notifying the user. + *

+ */ + protected abstract void onTimeout(); + + /** + * Callback method that is invoked when a download is accepted. + *

+ * This method is called at the start of the download process. + * Subclasses should implement this method to perform any necessary actions + * before the download + * begins. + */ + protected abstract void onAccept(); + + /** + * Callback method that is invoked when a download finishes. + *

+ * This method is called at the end of the download process. + * Subclasses should implement this method to perform any necessary actions + * after the download + * completes. + */ + protected abstract void onFinish(); + + @FunctionalInterface + protected interface RunnableWithIOException { + void run() throws IOException; + } + + protected void runWithSemaphore(VaadinSession session, RunnableWithIOException task) + throws IOException { + onAccept(); + try { + if (!enabled) { + task.run(); + } else { + try { + int permits; + float cost = getCost(session); + synchronized (semaphore) { + permits = costToPermits(cost, semaphore.maxPermits); + } + + UI ui = failOnUiChange ? getAttachedUI() : null; + + if (semaphore.tryAcquire(permits, getTimeout(), TimeUnit.NANOSECONDS)) { + try { + if (ui != null && getAttachedUI() != ui) { + throw new IOException("Detached UI"); + } + task.run(); + } finally { + semaphore.release(permits); + } + } else { + onTimeout(); + throw new InterruptedByTimeoutException(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw (IOException) new InterruptedIOException().initCause(e); + } + } + } finally { + onFinish(); + } + } +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentStreamResourceWriter.java b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentStreamResourceWriter.java index 28fcd11..fb3678d 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentStreamResourceWriter.java +++ b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentStreamResourceWriter.java @@ -19,132 +19,39 @@ */ package com.flowingcode.vaadin.addons.gridexporter; -import com.vaadin.flow.component.UI; import com.vaadin.flow.server.StreamResourceWriter; import com.vaadin.flow.server.VaadinSession; import java.io.IOException; import java.io.InterruptedIOException; import java.io.OutputStream; import java.nio.channels.InterruptedByTimeoutException; -import java.util.Optional; -import java.util.concurrent.Semaphore; -import java.util.concurrent.TimeUnit; -import java.util.function.IntFunction; /** * An implementation of {@link StreamResourceWriter} that controls access to the - * {@link #accept(OutputStream, VaadinSession) accept} method using a semaphore to manage + * {@link #accept(OutputStream, VaadinSession) accept} method using a semaphore + * to manage * concurrency. * * @author Javier Godoy */ @SuppressWarnings("serial") -abstract class ConcurrentStreamResourceWriter implements StreamResourceWriter { - - public static final float MAX_COST = 0x7FFF; - - public static final float MIN_COST = 1.0f / 0x10000; - - public static final float DEFAULT_COST = 1.0f; - - private static final ConfigurableSemaphore semaphore = new ConfigurableSemaphore(); - - private static volatile boolean enabled; - - private static volatile boolean failOnUiChange; +abstract class ConcurrentStreamResourceWriter extends ConcurrentOperationBase implements StreamResourceWriter { private final StreamResourceWriter delegate; - private static final class ConfigurableSemaphore extends Semaphore { - - private int maxPermits; - - ConfigurableSemaphore() { - super(0); - } - - synchronized void setPermits(int permits) { - if (permits < 0) { - throw new IllegalArgumentException(); - } - int delta = permits - maxPermits; - if (delta > 0) { - super.release(delta); - } else if (delta < 0) { - super.reducePermits(-delta); - } - maxPermits = permits; - } - - @Override - public String toString() { - IntFunction str = permits -> { - float f = permits / (float) 0x10000; - return f == Math.floor(f) ? String.format("%.0f", f) : Float.toString(f); - }; - return "Semaphore[" + str.apply(availablePermits()) + "/" + str.apply(maxPermits) + "]"; - } - - } - - /** - * Sets the limit for the cost of concurrent downloads. - *

- * Finite limits are capped to {@link #MAX_COST} (32767). If the limit is - * {@link Float#POSITIVE_INFINITY POSITIVE_INFINITY}, the semaphore will not be used for - * controlling concurrent downloads. - * - * @param limit the maximum cost of concurrent downloads allowed - * @throws IllegalArgumentException if the limit is zero or negative. - */ public static void setLimit(float limit) { - if (limit <= 0) { - throw new IllegalArgumentException(); - } - if (Float.isInfinite(limit)) { - enabled = false; - return; - } - - synchronized (semaphore) { - enabled = true; - semaphore.setPermits(costToPermits(limit, Integer.MAX_VALUE)); - } - } - - static void setFailOnUiChange(boolean failOnUiChange) { - ConcurrentStreamResourceWriter.failOnUiChange = failOnUiChange; + ConcurrentOperationBase.setLimit(limit); } - /** - * Returns the limit for the number of concurrent downloads. - * - * @return the limit for the number of concurrent downloads, or {@link Float#POSITIVE_INFINITY} if - * the semaphore is disabled. - */ public static float getLimit() { - if (enabled) { - synchronized (semaphore) { - return (float) semaphore.maxPermits / 0x10000; - } - } else { - return Float.POSITIVE_INFINITY; - } - } - - private static int costToPermits(float cost, int maxPermits) { - // restrict limit to 0x7fff to ensure the cost can be represented - // using fixed-point arithmetic with 16 fractional digits and 15 integral digits - cost = Math.min(cost, MAX_COST); - // Determine the number of permits required based on the cost, capping at maxPermits. - // If the cost is zero or negative, no permits are needed. - // Any positive cost, no matter how small, will require at least one permit. - return cost <= 0 ? 0 : Math.max(Math.min((int) (cost * 0x10000), maxPermits), 1); + return ConcurrentOperationBase.getLimit(); } /** - * Constructs a {@code ConcurrentStreamResourceWriter} with the specified delegate. The delegate - * is a {@link StreamResourceWriter} that performs the actual writing to the stream. + * Constructs a {@code ConcurrentStreamResourceWriter} with the specified + * delegate. The delegate + * is a {@link StreamResourceWriter} that performs the actual writing to the + * stream. * * @param delegate the delegate {@code InputStreamFactory} */ @@ -153,138 +60,30 @@ private static int costToPermits(float cost, int maxPermits) { } /** - * Sets the timeout for acquiring a permit to start a download when there are not enough permits - * available in the semaphore. - * - * @see GridExporter#setConcurrentDownloadTimeout(long, TimeUnit) - * @return the timeout in nanoseconds. - */ - public abstract long getTimeout(); - - /** - * Returns the cost of this download. - * - * Note that the method is not called under the session lock. It means that if implementation - * requires access to the application/session data then the session has to be locked explicitly. - * - * @param session vaadin session - * @see GridExporter#setConcurrentDownloadCost(float) - */ - public float getCost(VaadinSession session) { - return DEFAULT_COST; - } - - /** - * Returns the UI associated with the current download. - *

- * This method is used to ensure that the UI is still attached to the current session when a - * download is initiated. Implementations should return the appropriate UI instance. - *

- * - * @return the {@link UI} instance associated with the current download, or {@code null} if no UI - * is available. - */ - protected abstract UI getUI(); - - private UI getAttachedUI() { - return Optional.ofNullable(getUI()).filter(UI::isAttached).orElse(null); - } - - /** - * Callback method that is invoked when a timeout occurs while trying to acquire a permit for - * starting a download. - *

- * Implementations can use this method to perform any necessary actions in response to the - * timeout, such as logging a warning or notifying the user. - *

- */ - protected abstract void onTimeout(); - - /** - * Callback method that is invoked when a download is accepted. - *

- * This method is called at the start of the download process, right after the - * {@link #accept(OutputStream, VaadinSession) accept} method is invoked and it has been - * determined that the download can proceed. Subclasses should implement this method to perform - * any necessary actions before the download begins, such as initializing resources, logging, or - * updating the UI to reflect the start of the download. - *

- * Note that this method is called before any semaphore permits are acquired, so it is executed - * regardless of whether the semaphore is enabled or not. - *

- */ - protected abstract void onAccept(); - - /** - * Callback method that is invoked when a download finishes. + * Handles {@code stream} (writes data to it) using {@code session} as a + * context. *

- * This method is called at the end of the download process, right before the - * {@link #accept(OutputStream, VaadinSession) accept} method returns, regardless of whether the - * download was successful, timed out, or encountered an error. Subclasses should implement this - * method to perform any necessary actions after the download completes, such as releasing - * resources, logging, or updating the UI to reflect the completion of the download. + * Note that the method is not called under the session lock. It means that if + * implementation + * requires access to the application/session data then the session has to be + * locked explicitly. *

- * Note that this method is always called, even if an exception is thrown during the download - * process, ensuring that any necessary cleanup can be performed. - *

- */ - protected abstract void onFinish(); - - /** - * Handles {@code stream} (writes data to it) using {@code session} as a context. - *

- * Note that the method is not called under the session lock. It means that if implementation - * requires access to the application/session data then the session has to be locked explicitly. - *

- * If a semaphore has been set, it controls access to this method, enforcing a timeout. A permit - * will be acquired from the semaphore, if one becomes available within the given waiting time and + * If a semaphore has been set, it controls access to this method, enforcing a + * timeout. A permit + * will be acquired from the semaphore, if one becomes available within the + * given waiting time and * the current thread has not been {@linkplain Thread#interrupt interrupted}. * - * @param stream data output stream + * @param stream data output stream * @param session vaadin session - * @throws IOException if an IO error occurred - * @throws InterruptedIOException if the current thread is interrupted - * @throws InterruptedByTimeoutException if the waiting time elapsed before a permit was acquired + * @throws IOException if an IO error occurred + * @throws InterruptedIOException if the current thread is interrupted + * @throws InterruptedByTimeoutException if the waiting time elapsed before a + * permit was acquired */ @Override public final void accept(OutputStream stream, VaadinSession session) throws IOException { - onAccept(); - try { - if (!enabled) { - delegate.accept(stream, session); - } else { - - try { - int permits; - float cost = getCost(session); - synchronized (semaphore) { - permits = costToPermits(cost, semaphore.maxPermits); - } - - UI ui = failOnUiChange ? getAttachedUI() : null; - - if (semaphore.tryAcquire(permits, getTimeout(), TimeUnit.NANOSECONDS)) { - try { - if (ui != null && getAttachedUI()!=ui) { - // The UI has changed or was detached after acquirig the semaphore - throw new IOException("Detached UI"); - } - delegate.accept(stream, session); - } finally { - semaphore.release(permits); - } - } else { - onTimeout(); - throw new InterruptedByTimeoutException(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw (IOException) new InterruptedIOException().initCause(e); - } - } - } finally { - onFinish(); - } + runWithSemaphore(session, () -> delegate.accept(stream, session)); } } diff --git a/src/main/java/com/flowingcode/vaadin/addons/gridexporter/GridExporter.java b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/GridExporter.java index bc5ed9a..f47b18b 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/gridexporter/GridExporter.java +++ b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/GridExporter.java @@ -43,7 +43,7 @@ import com.vaadin.flow.server.StreamResourceWriter; import com.vaadin.flow.server.VaadinSession; import com.vaadin.flow.server.streams.DownloadHandler; -import com.vaadin.flow.server.streams.DownloadEvent; + import com.vaadin.flow.shared.Registration; import java.io.Serializable; import java.lang.reflect.Field; @@ -513,7 +513,8 @@ public GridExporterStreamResource forComponent(Component component) { } } - private class GridExporterConcurrentStreamResourceWriter extends ConcurrentStreamResourceWriter { + private class GridExporterConcurrentStreamResourceWriter extends ConcurrentStreamResourceWriter + implements GridExporterConcurrentStrategy { GridExporterConcurrentStreamResourceWriter(StreamResourceWriter delegate) { super(delegate); @@ -522,48 +523,43 @@ private class GridExporterConcurrentStreamResourceWriter extends ConcurrentStrea private Component button; @Override - public float getCost(VaadinSession session) { - return concurrentDownloadCost; + public GridExporter getExporter() { + return GridExporter.this; } @Override - public long getTimeout() { - // It would have been possible to specify a different timeout for each instance but I cannot - // figure out a good use case for that. The timeout returned herebecomes relevant when the - // semaphore has been acquired by any other download, so the timeout must reflect how long - // it is reasonable to wait for "any other download" to complete and release the semaphore. - // - // Since the reasonable timeout would depend on the duration of "any other download", it - // makes sense that it's a global setting instead of a per-instance setting. - return GridExporterConcurrentSettings.getConcurrentDownloadTimeout(TimeUnit.NANOSECONDS); + public Component getButton() { + return button; } @Override - protected UI getUI() { - return grid.getUI().orElse(null); + public float getCost(VaadinSession session) { + return GridExporterConcurrentStrategy.super.getCost(session); } @Override - protected void onTimeout() { - fireConcurrentDownloadTimeout(); + public long getTimeout() { + return GridExporterConcurrentStrategy.super.getTimeout(); } @Override - protected void onAccept() { - if (disableOnClick) { - setButtonEnabled(false); - } + public UI getUI() { + return GridExporterConcurrentStrategy.super.getUI(); } @Override - protected void onFinish() { - setButtonEnabled(true); + public void onTimeout() { + GridExporterConcurrentStrategy.super.onTimeout(); } - private void setButtonEnabled(boolean enabled) { - if (button instanceof HasEnabled) { - grid.getUI().ifPresent(ui -> ui.access(() -> ((HasEnabled) button).setEnabled(enabled))); - } + @Override + public void onAccept() { + GridExporterConcurrentStrategy.super.onAccept(); + } + + @Override + public void onFinish() { + GridExporterConcurrentStrategy.super.onFinish(); } } @@ -574,7 +570,8 @@ private void setButtonEnabled(boolean enabled) { * GridExporterConcurrentStreamResourceWriter * but for the new API. */ - private class GridExporterConcurrentDownloadHandler extends ConcurrentDownloadHandler { + private class GridExporterConcurrentDownloadHandler extends ConcurrentDownloadHandler + implements GridExporterConcurrentStrategy { GridExporterConcurrentDownloadHandler(DownloadHandler delegate) { super(delegate); @@ -583,41 +580,43 @@ private class GridExporterConcurrentDownloadHandler extends ConcurrentDownloadHa private Component button; @Override - public float getCost(VaadinSession session) { - return concurrentDownloadCost; + public GridExporter getExporter() { + return GridExporter.this; } @Override - public long getTimeout() { - return GridExporterConcurrentSettings.getConcurrentDownloadTimeout(TimeUnit.NANOSECONDS); + public Component getButton() { + return button; } @Override - protected UI getUI() { - return grid.getUI().orElse(null); + public float getCost(VaadinSession session) { + return GridExporterConcurrentStrategy.super.getCost(session); } @Override - protected void onTimeout() { - fireConcurrentDownloadTimeout(); + public long getTimeout() { + return GridExporterConcurrentStrategy.super.getTimeout(); } @Override - protected void onAccept() { - if (disableOnClick) { - setButtonEnabled(false); - } + public UI getUI() { + return GridExporterConcurrentStrategy.super.getUI(); } @Override - protected void onFinish() { - setButtonEnabled(true); + public void onTimeout() { + GridExporterConcurrentStrategy.super.onTimeout(); } - private void setButtonEnabled(boolean enabled) { - if (button instanceof HasEnabled) { - grid.getUI().ifPresent(ui -> ui.access(() -> ((HasEnabled) button).setEnabled(enabled))); - } + @Override + public void onAccept() { + GridExporterConcurrentStrategy.super.onAccept(); + } + + @Override + public void onFinish() { + GridExporterConcurrentStrategy.super.onFinish(); } /** @@ -1040,4 +1039,43 @@ public void setCsvCharset(SerializableSupplier charset) { csvCharset = charset; } + private interface GridExporterConcurrentStrategy { + GridExporter getExporter(); + + Component getButton(); + + default float getCost(VaadinSession session) { + return getExporter().concurrentDownloadCost; + } + + default long getTimeout() { + return GridExporterConcurrentSettings.getConcurrentDownloadTimeout(TimeUnit.NANOSECONDS); + } + + default UI getUI() { + return getExporter().grid.getUI().orElse(null); + } + + default void onTimeout() { + getExporter().fireConcurrentDownloadTimeout(); + } + + default void onAccept() { + if (getExporter().disableOnClick) { + setButtonEnabled(false); + } + } + + default void onFinish() { + setButtonEnabled(true); + } + + default void setButtonEnabled(boolean enabled) { + Component button = getButton(); + if (button instanceof HasEnabled) { + getExporter().grid.getUI().ifPresent(ui -> ui.access(() -> ((HasEnabled) button).setEnabled(enabled))); + } + } + } + }