vaadin / flow

Vaadin Flow is a Java framework binding Vaadin web components to Java. This is part of Vaadin 10+.
Apache License 2.0
620 stars 167 forks source link

Better API for asynchronously loading data. #16697

Open mrgreywater opened 1 year ago

mrgreywater commented 1 year ago

Describe your motivation

Currently loading data asynchronously and then displaying said data requires more lines of code than I'd like. Currently the shortest way I can think of looks like this:

        var ui = UI.getCurrent();
        var future = CompletableFuture.runAsync(() -> {
            var heavyComputationResult = heavyLoading();
            ui.access(() -> {
                label.setText(heavyComputationResult);
            });
        });
        label.addDetachListener(event -> {
            future.cancel(true);
            event.unregisterListener();
        });

With exception handling and session access it gets even more more complicated.

Describe the solution you'd like

Instead I'd prefer something simple such as

        AsyncVaadinTask.run(label, () -> {
            return heavyLoading();
        }).thenAccept(heavyComputationResult -> {
            label.setText(heavyComputationResult);
        });

Additional context

Here is my current implementation of async tasks:

package com.example.application.tools;

import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.internal.CurrentInstance;
import com.vaadin.flow.shared.communication.PushMode;

import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;

public class AsyncVaadinTask {
    private static final ExecutorService executorPool = Executors.newWorkStealingPool(16);

    /**
     * Runs a task asynchronously with the current session state
     * When completed, the value is returned inside an associated vaadin ui thread.
     * Useful for displaying Vaadin UI elements that require long computation times.
     *
     * @param supplier the function to execute asynchronously
     * @param <T>      Type of the Result
     * @return The CompletableFuture with the result
     */
    public static <T> CompletableFuture<T> run(Supplier<T> supplier) {
        var ui = UI.getCurrent();
        if (ui == null) {
            throw new IllegalStateException("No Vaadin UI assigned");
        }
        var future = new CompletableFuture<T>();
        executorPool.submit(() -> {
            var instances = CurrentInstance.setCurrent(ui);
            try {
                var result = supplier.get();
                ui.access(() -> {
                    future.complete(result);
                    push();
                });
            } catch (Exception ex) {
                ui.access(() -> {
                    future.completeExceptionally(ex);
                    push();
                });
            } finally {
                CurrentInstance.restoreInstances(instances);
            }
        });
        return future;
    }

    public static <T> CompletableFuture<T> run(CompletableFuture<T> future) {
        var ui = UI.getCurrent();
        if (ui == null) {
            throw new IllegalStateException("No Vaadin UI assigned");
        }
        var result = new CompletableFuture<T>() {
            @Override
            public boolean cancel(boolean mayInterruptIfRunning) {
                future.cancel(mayInterruptIfRunning);
                return super.cancel(mayInterruptIfRunning);
            }
        };
        future.thenAccept(o -> {
            ui.access(() -> {
                result.complete(o);
                push();
            });
        }).exceptionally(ex -> {
            ui.access(() -> {
                result.completeExceptionally(ex);
                push();
            });
            return null;
        });
        return result;
    }

    private static void push() {
        var ui = UI.getCurrent();
        if (Objects.equals(PushMode.MANUAL, ui.getPushConfiguration().getPushMode())) {
            ui.push();
        }
    }

    /**
     * Runs a task asynchronously with the current session state
     * When completed, the value is returned inside an associated vaadin ui thread.
     * Useful for displaying Vaadin UI elements that require long computation times.
     *
     * @param lifetimeOwner when the lifetime owner is detached, the task is also cancelled
     * @param supplier      the function to execute asynchronously
     * @param <T>           Type of the Result
     * @return The CompletableFuture with the result
     */
    public static <T> CompletableFuture<T> run(Component lifetimeOwner, Supplier<T> supplier) {
        var task = run(supplier);
        lifetimeOwner.addDetachListener(event -> {
            task.cancel(true);
            event.unregisterListener();
        });
        return task;
    }

    /**
     * Attaches a CompletableFuture to a component
     * When completed, the value is returned inside an associated vaadin ui thread.
     * Useful for displaying Vaadin UI elements after a CompleteableFuture is finished.
     *
     * @param lifetimeOwner when the lifetime owner is detached, the task is also cancelled
     * @param future      the future that is executed asynchronously
     * @param <T>           Type of the Result
     * @return The CompletableFuture with the result
     */
    public static <T> CompletableFuture<T> run(Component lifetimeOwner, CompletableFuture<T> future) {
        var task = run(future);
        lifetimeOwner.addDetachListener(event -> {
            task.cancel(true);
            event.unregisterListener();
        });
        return task;
    }
}
TatuLund commented 1 year ago

There are many options to do this. Very often async operations start by clicking some button, that triggers fetching data etc. Thus one handy approach to reduce the boiler plate is to create AsyncButton that wraps the async operation. I have an example of such thing here:

https://gist.github.com/TatuLund/ea689f270cdb2483a80c4088e0e77106

    public AsyncButtonView() {
        Grid<String> grid = new Grid<>();
        grid.addColumn(item -> item.toString()).setHeader("Strings");

        // Button takes executor and two callbacks, the task and the update callbacks, 
        // the first one is supplied to background thread and the later one is run in access
        AsyncButton<List<String>> button = new AsyncButton<>("Click", executor,
                () -> getItems(), items -> grid.setItems(items));

        button.addClickListener(e -> {
            Notification.show("Loading ...");
        });

        add(button, grid);
    }

    public List<String> getItems() {
        try {
            // Simulating a long task
            Thread.sleep(5000);
        } catch (InterruptedException e) {
        }
        return IntStream.range(0, 10000)
                .mapToObj(i -> "String " + random.nextInt(10000))
                .collect(Collectors.toList());
    }
TatuLund commented 1 year ago

There is another piece of prior art on the same topic here: https://vaadin.com/directory/component/async-manager

        AsyncManager.register(this, asyncTask -> {
            Thread.sleep(2000);
            asyncTask.push(() -> add(new Label("ASYNC TASK: 2 seconds has passed")));
        });
mrgreywater commented 1 year ago

@TatuLund Thanks for your research. As far as the AsyncButton, it only has very limited usage. It only works for actions that are started with a button, in automatic push mode and the implementation doesn't seem to handle cancellation. Most of the usage I have for async are not triggered by a button, but when loading certain components.

As for the AsyncManager, I feel it has a better interface. It's outdated but implements a cleaner way to use async computations than Vaadin has out of the box right now.

Legioth commented 1 year ago

I'm not saying it shouldn't be further simplified, but there's already a much shorter way in the core APIs as long as you accept that cancelation is triggered only by UI detach but not by detaching the target label:

var future = CompletableFuture.supplyAsync(() -> heavyLoading());
future.thenAccept(UI.getCurrent().accessLater(label::setText, () -> future.cancel(true)));
Legioth commented 1 year ago

Without detach handling, there's an even shorter approach with a simple helper method that creates and Executor around UI::access:

CompletableFuture.supplyAsync(() -> heavyLoading())
    .thenAcceptAsync(wrapper::setText, CurrentUIAccessExecutor.get());

CurrentUIAccessExecutor.get() can be implemented like this (plus some error handling):

public static Executor get() {
    UI ui = UI.getCurrent();
    return task -> ui.access(task::run);            
}
mrgreywater commented 1 year ago

@Legioth

var future = CompletableFuture.supplyAsync(() -> heavyLoading());
future.thenAccept(UI.getCurrent().accessLater(label::setText, () -> future.cancel(true)));

as long as you accept that cancelation is triggered only by UI detach but not by detaching the target label

That's a big if. So it only triggers when the Browser Tab is closed, and not if the user just navigates somewhere else. Also while it looks simpler at the first glance, it's still a bit of boilerplate code that's just crammed into a single line. Further It doesn't address the exception handling. So you'd have to add an additional .exceptionally with a further UI.getCurrent().access(() -> {}) I just feel it's a lot of code to do a simple thing.

As for the executor variant, it's still missing exception handling, and now doesn't cancel the task at all, which I think is a must have if the task is heavy enough to require running in a different thread.

Legioth commented 1 year ago

If you care about cancelation, then you should note that your initial example doesn't do anything to stop the computation running in the background thread. CompletableFuture::cancel has this documentation for the mayInterruptIfRunning parameter:

this value has no effect in this implementation because interrupts are not used to control processing.

mrgreywater commented 1 year ago

Thanks for the heads-up, I guess a proper implementation would have to use a executor that's interruptable and then inherit from CompletableFuture overwrite cancel to actually support mayInterruptIfRunning. Or use FutureTask with it's own thread.

edit: AsyncManager does the latter: https://github.com/fluorumlabs/async-manager/blob/598fa8addfd8b1ec44693556f12e2e6bbdedbfe8/src/main/java/org/vaadin/flow/helper/AsyncTask.java#L191

For me this just further exemplifies this isn't trivial and a reliable API to do this would be helpful.