Przewiń do treści 

I cięcie!

Opublikowany:
Autor:
Comandeer
Kategorie:
JavaScript
Eksperymenty

Ostatnio dziwnie popularny zrobił się temat anulowania pobierania danych przez fetch. Wydaje mi się jednak, że umyka przy tym pewna istotna kwestia: to rozwiązanie powinno działać ze wszystkimi asynchronicznymi API.

Sygnał przerwania

Bardzo szybko po wprowadzeniu Promise do ES2015 i wysypie pierwszych Web API wykorzystujących nowy sposób obsługi asynchroniczności pojawiła się też potrzeba przerywania asynchronicznych zadań (i to jeszcze przed oficjalnym ogłoszeniem samego ES2015!). W tym celu próbowano stworzyć uniwersalne rozwiązanie, które miało stać się częścią standardu ECMAScript. Dyskusje szybko jednak utknęły w martwym punkcie, a palący problem pozostał. Z tego też powodu WHATWG wzięło sprawy w swoje ręce i wprowadziło rozwiązanie na poziomie DOM-u – AbortController.

Oczywistą wadą takiego obrotu sprawy jest fakt, że rozwiązanie to nie jest dostępne w Node.js. Tam wciąż nie ma sposobu na eleganckie przerywanie zadań asynchronicznych.

Jak można zauważyć w specyfikacji, rozwiązanie to jest opisane w sposób jak najbardziej ogólny. Sprawia to, że można je użyć do API, które powstaną w przyszłości. Na chwilę obecną obsługa wbudowana jest tylko w fetch, ale nic nie stoi na przeszkodzie, by wykorzystać AbortController w naszym własnym kodzie.

Zanim jednak do tego przejdziemy, przyjrzyjmy się chwilę, jak w ogóle AbortController działa:

const abortController = new AbortController(); // 1
const abortSignal = abortController.signal; // 2

fetch( 'http://example.com', {
	signal: abortSignal // 3
} ).catch( ( { message } ) => { // 5
	console.log( message );
} );

abortController.abort(); // 4

Na samym początku tworzymy instancję interfejsu DOM-owego AbortController (1) i wyciągamy z niej własność signal (2). Następnie wywołujemy fetch i przekazujemy własność signal jako jedną z opcji fetcha (3). Żeby przerwać ściąganie zasobu wystarczy teraz wywołać metodę abortController.abort (4). To sprawi, że obiecanka stworzono przez fetch zostanie automatycznie odrzucona i uruchomi się block catch (5).

Sama własność signal również jest ciekawa i to tak naprawdę ona jest głównym aktorem tego dramatu. Jest to instancja interfejsu DOM-owego AbortSignal. Posiada ona własność aborted, przechowującą informację, czy użytkownik już wywołał metodę abortController.abort, a dodatkowo można jej przypiąć listenera do zdarzenia abort, które zachodzi, gdy metoda abortController.abort jest wywoływana. Innymi słowy: AbortController jest tak naprawdę publicznym interfejsem dla AbortSignal.

Przerywalna funkcja

Wyobraźmy sobie, że tworzymy asynchroniczną funkcję, która wykonuje naprawdę skomplikowane obliczenia (np. przetwarza sporą tablicę w partiach). Dla uproszczenia w przykładzie funkcja będzie symulować ciężką pracę po prostu czekając 5 sekund przed zwróceniem wyniku:

function calculate() {
	return new Promise( ( resolve, reject ) => {
		setTimeout( ()=> {
			resolve( 1 );
		}, 5000 );
	} );
}

calculate().then( ( result ) => {
	console.log( result );
} );

Jednak czasami użytkownik może chcieć przerwać taką kosztowną operację. Wypada zatem mu to umożliwić! Dodajmy zatem przycisk, który będzie rozpoczynał i anulował obliczanie

<button id="calculate">Calculate</button>

<script type="module">
	document.querySelector( '#calculate' ).addEventListener( 'click', async ( { target } ) => { // 1
		target.innerText = 'Stop calculation';

		const result = await calculate(); // 2

		alert( result ); // 3

		target.innerText = 'Calculate';
	} );

	function calculate() {
		return new Promise( ( resolve, reject ) => {
			setTimeout( ()=> {
				resolve( 1 );
			}, 5000 );
		} );
	}
</script>

W powyższym kodzie przypinamy do przycisku asynchroniczny listener dla zdarzenia click, a następnie wywołujemy naszą funkcję do obliczeń (2). Po pięciu sekundach pojawi się okienko z wynikiem (3).

script[type=module] powoduje, że cały kod JS zawarty w tym elemencie jest uruchamiany w strict mode. Osobiście uważam to za bardziej eleganckie rozwiązanie niż 'use strict';.

Dodajmy zatem obsługę dla przerywania asynchronicznego zadania:

{ // 1
	let abortController = null; // 2

	document.querySelector( '#calculate' ).addEventListener( 'click', async ( { target } ) => {
		if ( abortController ) {
			abortController.abort(); // 5

			abortController = null;
			target.innerText = 'Calculate';

			return;
		}

		abortController = new AbortController(); // 3
		target.innerText = 'Stop calculation';

		try {
			const result = await calculate( abortController.signal ); // 4

			alert( result );
		} catch {
			alert( 'WHY DID YOU DO THAT?!!' ); // 9
		} finally { // 10
			abortController = null;
			target.innerText = 'Calculate';
		}
	} );

	function calculate( abortSignal ) {
		return new Promise( ( resolve, reject ) => {
			const timeout = setTimeout( ()=> {
				resolve( 1 );
			}, 5000 );

			abortSignal.addEventListener( 'abort', () => { // 6
				const error = new DOMException( 'Calculation aborted by user', 'AbortError' );

				clearTimeout( timeout ); // 7
				reject( error ); // 8
			} );
		} );
	}
}

Kod się dość rozrósł. Ale spokojnie, nie zrobił się specjalnie trudniejszy! Całość została zamknięta w bloku (1), co – przy wykorzystaniu zmiennych o zasięgu blokowym – jest odpowiednikiem IIFE. Dzięki temu nasza zmienna abortController (2) nie wypłynie do globalnego scope. Na chwilę obecną ta zmienna ma wartość null. Zmienia się to w momencie kliknięcia na przycisk. Przypisujemy jej wtedy jako wartość nową instancję AbortController (3). Następnie przekazujemy jej własność signal bezpośrednio do naszej funkcji calculate (4). Teraz, gdy użytkownik kliknie w przycisk przed upływem pięciu sekund, kliknięcie spowoduje wywołanie abortController.abort (5). To z kolei wywoła zdarzenie abort na abortController.signal, którego nasłuchujemy wewnątrz calculate (6). Wówczas usuwamy tykający timer (7) i odrzucamy obiecankę z odpowiednim błędem (8). Typ tego błędu ten wynika bezpośrednio ze specyfikacji DOM. Błąd ten z kolei powoduje wykonanie się czynności z bloków catch (9) i finally (10).

Więcej o nowej składni bloków try/catch przeczytać można na MDN.

Wypada się także przygotować na sytuację taką, jak poniższa:

const abortController = new AbortController();

abortController.abort();
calculate( abortController.signal );

W tym wypadku zdarzenie abort nie zajdzie, bo zaszło przed przekazaniem sygnału do funkcji calculate. Dlatego wypada ją nieco przebudować:

function calculate( abortSignal ) {
	return new Promise( ( resolve, reject ) => {
		const error = new DOMException( 'Calculation aborted by user', 'AbortError' ); // 1

		if ( abortSignal.aborted ) { // 2
			return reject( error );
		}

		const timeout = setTimeout( ()=> {
			resolve( 1 );
		}, 5000 );

		abortSignal.addEventListener( 'abort', () => {
			clearTimeout( timeout );
			reject( error );
		} );
	} );
}

Błąd został przeniesiony na samą górę (1), tak, aby można go było użyć w dwóch różnych miejscach kodu (chociaż bardziej eleganckim rozwiązaniem byłaby pewnie fabryka błędów – jakkolwiek to dziwnie nie brzmi). Dodatkowo pojawiła się tzw. klauzula strażnicza, sprawdzająca wartość abortSignal.aborted (2). Jeśli jest ona równa true, wówczas calculate odrzuca obiecankę z odpowiednim błędem, bez wykonywania żadnych innych czynności.

I tym sposobem stworzyliśmy w pełni przerywalną funkcję asynchroniczną! Demo jest dostępne online. Miłego przerywania!

Komentarze

Przejdź do komentarzy bezpośrednio na Githubie.

Dawne komentarze

Ten blog wcześniej korzystał z systemu komentarzy Disqus. Jednakże pożegnaliśmy się i postanowiłem, że zaimportuję do nowej wersji stare komentarze z niego. Cóż, jego system eksportu na wiele nie pozwala…

  1. Opublikowany:
    Autor:
    piecioshka

    Świetny wpis! Sympatycznie opisany AbortController :)

  2. Opublikowany:
    Autor:
    lukaszpolowczyk

    A to da się uzyskać tworząc custom event, nie?
    W sensie, AbortController nie ma żadnych unikalnych właściwości.

    1. Opublikowany:
      Autor:
      Comandeer

      W przypadku własnego API faktycznie dałoby się to zastąpić jakimś eventem. Nie da się tego zrobić jednak w przypadku natywnych API, gdzie zwykły event nie spowodowałby przerwania operacji asynchronicznej. Poza tym AbortSignal ma odpowiednią semantykę, a custom event – niekoniecznie.

  3. Opublikowany:
    Autor:
    PanLydka

    A najmniej "nieelegancka" metoda w NodeJS? :)

    1. Opublikowany:
      Autor:
      Comandeer

      process.exit() :P

      A tak na serio, to w sumie nie wiem. Niby są jakieś porty AbortControllera na npm, niemniej nie wiem, na ile to faktycznie działa (w sensie nie tylko odrzuca obiecankę, ale przerywa równocześnie asynchroniczną operację w tle).

      1. Opublikowany:
        Autor:
        PanLydka

        Backendu nie piszę, ale nodeJS tak się spopularyzował, że powstają naprawdę wymagające i poważne aplikacje, systemy itd. Brak jakiejś ludzkiej formy przerwania zadania asynchronicznego... wydaje się to aż niemożliwe, szczególnie że przypuszczam, że na backendzie jest to jeszcze częstsza czynność, niż na froncie.

  4. Opublikowany:
    Autor:
    bartoce

    Jeśli jednym z powodów tej 'dziwnej popularności' jest mój ostani artykuł to warto było go napisać :D Fajnie, że podjąłeś temat! Jak zwykle dobry art.

  5. Opublikowany:
    Autor:
    denisnowakow

    Czy ja dobrze zrozumiałem, że cała koncepcja polega na tym, żeby wywołać reject() używając mechanizmu domknięcia? Jeśli tak, to status promisa dałoby się zmienić synchronicznie, czyli szybciej, wystarczy zapisać funkcję reject lub resume do jakiejś zmiennej z zewnętrznej przestrzeni zmiennych i wywołać ją w odpowiednim momencie.

    1. Opublikowany:
      Autor:
      Comandeer

      Nie, to zupełnie nie dotyczy domknięć. Chodzi o to, że AbortController kończy obiecankę, równocześnie anulując to, co dzieje się w tle (np. dla fetcha przerwie żądanie HTTP). W przypadku własnego API co prawda i tak trzeba sobie to zaimplementować samemu, ale AbortController pozwala przerwać obiecankę bez potrzeby wyciekania jej wewnętrznej implementacji (to obiecanka decyduje, w jaki sposób przerwać dane operacje asynchroniczne, nie kod, który chce jej przerwania).

      1. Opublikowany:
        Autor:
        denisnowakow

        W takim razie funkcję, wywoływaną zwrotnie podczas wydarzenia 'abort', można zapisać do zmiennej z zewnętrznej przestrzeni zmiennych i wywoływać synchronicznie. W ten sposób obietnica przejdzie w stan rejected nawet wtedy, gdy funkcja wywołująca resolved() jest już dodana do kolejki wywołań zwrotnych (callback queue).

        1. Opublikowany:
          Autor:
          Comandeer

          Ale po co? To znowu spowoduje wyciek implementacji obiecanki. Zewnętrzny kod nie powinien wiedzieć, jak operacja asynchroniczna jest zatrzymywana. On po prostu informuje, że dana operacja asynchroniczna nie jest potrzebna i można ją ubić – na tym jego zadanie się kończy.

          1. Opublikowany:
            Autor:
            denisnowakow

            A jaka różnica w jaki sposób informuje? Równie dobrze może "informować" wywołując tą funkcję synchronicznie. Pisałem już, że to będzie szybsze. Wydaje mi się, że to jest istotny powód. Uwzględniając to, że teoretycznie można zapobiec zbędnemu "odpaleniu" się kodu zależnego od rozwiązania obietnicy, wydaje się, że jest to nawet bardziej wydajne, chociaż być może zależy to od sytuacji.

            1. Opublikowany:
              Autor:
              Comandeer

              W takim, że zaproponowany przez Ciebie sposób wymusza przeniesienie odpowiedzialności za operację asynchroniczną do głównego przepływu i lamie zasady enkapsulacji. Po to tworzę obiecankę jako cały osobny przeplyw, żeby reszta programu wiedziała jedynie to, że kiedyś otrzyma wynik i że może taką operację przerwać. To wszystko co program powinien wiedzieć. Cała reszta to odpowiedzialność obiecanki. Problem szybkości zatrzymania jest mało istotny tak po prawdzie.

              Co więcej, mieszanie synchronicznego kodu z asynchronicznym (a już używanie synchronicznego kodu do sterowania asynchronicznym) powoduje niepotrzebny chaos i narzut mentalny.

              1. Opublikowany:
                Autor:
                denisnowakow

                Wywołanie metody abort() też może zależeć od głównego przepływu. Jaka więc różnica co się wywołuje? Metoda raczej też jest wykonywana synchronicznie. A jak już coś wykonujemy to lepiej robić to efektywnie.

                1. Opublikowany:
                  Autor:
                  Comandeer

                  Wydajnośc praktycznie nigdy nie jest najważniejszym wyznacznikiem jakości oprogramowania. O wiele ważniejsze są choćby utrzymywalność czy modularyzacja.

                  A różnica jest spora. Powtórzę po raz kolejny: wywołanie abortController.abort() NIE WYMAGA łamania granic odpowiedzialności pomiędzy głównym przepływem a obiecanką. Jedyne, co robimy, to informujemy obiecankę poprzez zdarzenie na AbortSignal, że ma przerwać operację. W przypadku przeniesienia całej logiki do głównego przepływu, przenosimy złożoność związaną z asynchronicznym zadaniem z powrotem tam, skąd ją usunęliśmy.

                  1. Opublikowany:
                    Autor:
                    denisnowakow

                    Przecież obsługę synchronicznego przerywania obietnic też można przenieść do modułu i nie będzie złamana zasada inkapsulacji. Główny przepływ będzie tylko wiedział jaką wywołać metodę, tak samo jak w przypadku abortController.abort();. Po co czekać na efekty wykonania abortController.abort() i wykonywać callbacki przekazane np. do thenów, których wywołanie miało anulować wykonanie tej metody?

                    1. Opublikowany:
                      Autor:
                      Comandeer

                      Sam fakt, że główny przepływ woła bezpośrednio metodę anulującą operację asynchroniczną, jest złamaniem enkapsulacji. Główny przepływ nie powinien wiedzieć nic o tym, jak jest wykonywana operacja asynchroniczna. Jedyne, co powinien wiedzieć, to to, że takowa się wykonuje. Stąd też nie powinien mieć dostępu bezpośrednio do metody anulującej taką operację.

                      Zresztą to, że zgłaszamy chęć anulowania asynchronicznej aplikacji, nie jest jednoznaczne z _wymuszeniem_ jej zatrzymania. O tym, jak zostanie taka operacja zatrzymana, decyduje obsługujący ją kod. Zresztą po to wgl wprowadzono AbortSignal.

                      No i zostaje jeszcze kwestia spójności. Jeśli natywne APIs korzystają z AbortControllera, nie widzę powodu, dla którego mielibyśmy dla własnego kodu implementować całkowicie inny mechanizm.

                      1. Opublikowany:
                        Autor:
                        denisnowakow

                        Przecież podczas tworzenia obsługi zdarzenia 'abort' (abortSignal.addEventListener( 'abort',...) korzystamy z funkcji reject(), czyli musimy wiedzieć jak jest obsługiwana operacja asynchroniczna. Jaka więc różnica, czy wie o tym abortSignal czy jakiś nasz moduł?

                        Jeżeli asynchroniczna operacja trafiła do kolejki wywołań zwrotnych (message queue, calback queue, task queue, macrotask queue) to już jej nic nie zatrzyma oprócz zablokowania event loopa. Ewentualnie może ona trafić również do kolejki mikrotasków. Możemy jednak szybciej i efektywniej anulować trafienie jej do którejś z tych kolejek, a to może być wystarczający powód, żeby zaimplementować taki mechanizm.

                        1. Opublikowany:
                          Autor:
                          Comandeer

                          Nie, AbortSignal nic o tym nie wie. AbortSignal jedynie jest nośnikiem zdarzenia, cała reszta jest zamknięta w funkcji tworzącej obiecankę.

                          Poza tym obiecanka != operacja asynchroniczna. Promise jest reprezentacją aktualnego stanu takiej operacji. Dobrze to widać po fetch, w którym operacją asynchroniczną jest pobieranie zasobów przez Sieć, a obiecanka jedynie wskazuje, czy pobieranie się zakończyło, czy wciąż trwa. Samo zatrzymanie obiecanki nie zatrzymuje operacji asynchronicznej.

                          1. Opublikowany:
                            Autor:
                            denisnowakow

                            Jak to nie wie?

                            abortSignal.addEventListener( 'abort', () => {
                            clearTimeout( timeout );
                            reject( error );
                            } );

                            Przekazujemy reject() do funkcji wywoływanej przez wydarzenie generowane przez abortSignal, czyli wie on, że coś musimy przekazać.

                            Równie dobrze moglibyśmy wysłać rejecta do naszego modułu.

                            W przedstawionym w artykule przykładzie, najpierw czekamy aż się wykona callback ze zdarzenia 'click', potem aż się wykona callback ze zdarzenia 'abort'. To dwa obroty event loopa. Gdyby podczas kliknięcia odrzucić promise synchronicznie, byłoby szybciej i efektywniej.

                            1. Opublikowany:
                              Autor:
                              Comandeer

                              > Równie dobrze moglibyśmy wysłać rejecta do naszego modułu.

                              Nie, nie moglibyśmy. Powtórzę po raz ostatni: w tym momencie cała logika dotycząca operacji asynchronicznej zamknięta jest WE WŁASNEJ FUNKCJI. A więc odseparowana jest od głównego przepływu. Po to używamy obiecanki i po to wgl deleguje coś do asynchronicznego przepływu, żeby wyciągnąć to z głównego przepływu. Dodatkowo funkcja opakowująca obiecankę ZAWIERA CAŁĄ LOGIKĘ DOTYCZĄCĄ DANEJ OPERACJI. To zapewnia enkapsulację operacji w jednym miejscu zamiast rozbijania jej na cieknące moduły, które byłyby ściśle ze sobą powiązane i dodatkowo WIEDZIAŁYBY O SZCZEGÓŁACH IMPLEMENTACYJNYCH OBIECANKI. Taki kod byłby nie dość, że niezwykle trudny w utrzymaniu, to dodatkowo całkowicie nielogiczny, z trudnym do wytłumaczenia zachowaniem (np. dlaczego reject jest wyciągany gdzieś do osobnego modułu i jest wywoływany poza jakąkolwiek kontrolą obiecanki?).

                              > Przekazujemy reject() do funkcji wywoływanej przez wydarzenie generowane przez abortSignal, czyli wie on, że coś musimy przekazać.

                              Ale to się odbywa wewnątrz funkcji enkapsulującej całą logikę związaną z tą konkretną operacją asynchroniczną. Poza tym AbortSignal nie wie o tym, on wie tyle, że zaszło zdarzenie. Tyle.

                              > W przedstawionym w artykule przykładzie, najpierw czekamy aż się wykona callback ze zdarzenia 'click', potem aż się wykona callback ze zdarzenia 'abort'. To dwa obroty event loopa. Gdyby podczas kliknięcia odrzucić promise synchronicznie, byłoby szybciej i efektywniej.

                              Ale co by to dało? Przecież to bez sensu… Nie ma absolutnie żadnej korzyści z odrzucania Promise'a synchronicznie. Ale za to są liczne wady związane z mieszaniem przepływów synchronicznego i asynchronicznego → https://blog.izs.me/2013/08...
                              Poza tym niekoniecznie, bo część zdarzeń jest synchroniczna.

                              1. Opublikowany:
                                Autor:
                                denisnowakow

                                Jak to nie ma sensu?

                                Załóżmy, że chcemy odrzucić promise.

                                1. Robimy to przy użyciu abortSignal, czyli wywołujemy metodę abort(), która generuje event 'abort' i wysyłamy callbacka wywołującego reject() do kolejki. Jednak zanim następuje pobranie tego callbacka z tej kolejki, może zmienić nam się status promisa na resolved. Niepotrzebnie odpali się kod zależny od rozwiązania się obietnicy, jego wykonanie zajmie czas i zasoby, w tym możliwe, że również zasoby serwera. Następnie wywoła się callback, który miał zapobiec wykonaniu kodu który się już wykonał. Wykonanie tego callbacka też zajmuje czas i zasoby, jednocześnie jest już zupełnie niepotrzebne. Odkłada to w czasie wykonanie innych oczekujących wywołań zwrotnych z kolejki.

                                2. Robimy to synchronicznie. Już, zrobione.

                                1. Opublikowany:
                                  Autor:
                                  Comandeer

                                  To dalej nie rozwiązuje żadnego z problemów, o jakich wspominałem we wcześniejszych komentarzach. Nie widzę sensu w optymalizowaniu pod edge case, jeśli ma to spowodować znaczne pogorszenie jakości kodu aplikacji.

                                  No i jeszcze raz powtórzę: tu nie chodzi o odrzucenie obiecanki, bo to można rozwiązać na miliard innych sposobów. Tutaj odrzucenie obiecanki jest tylko efektem ubocznym ubicia operacji asynchronicznej. Taka operacja może być wykonywana na różne sposoby i nie pozwalać ubić się natychmiast. Sama specyfikacja zakłada, że ubicie następuje w _sensowny_ sposób → https://dom.spec.whatwg.org...

                                  Stąd jeszcze raz powtórzę: jak najszybsze zabicie operacji nie jest istotne. O wiele istotniejsze jest zachowanie wyraźnego podziału odpowiedzialności, niedopuszczenie do ścisłego powiązania poszczególnych fragmentów kodu i zapewnienie, że wewnętrzna implementacja obiecanki i operacji asynchronicznej nie wycieknie na zewnątrz.

                                  1. Opublikowany:
                                    Autor:
                                    denisnowakow

                                    Owszem jeżeli to nie są problemy hipotetyczne, wyimaginowane bądź nieistotne, nie mi o tym sądzić. Możliwe, że zależy to od sytuacji. Nikt przecież nie odwołał odwiecznego dylematu, pisać czystszy kod czy szybszy.

                                    I jakież to są inne sposoby na odrzucenie obietnicy? Bardzo mnie to zaciekawiło.

                                    1. Opublikowany:
                                      Autor:
                                      denisnowakow

                                      Nie zauważyłem, że w promisie jest if ( abortSignal.aborted ).

                                  2. Opublikowany:
                                    Autor:
                                    denisnowakow

                                    Odnośnie lepszej utrzymywalności - nie zawsze jest istotna. Jeżeli "bardzo łatwo utrzymywalny" kod generuje koszty obsługi infrastruktury sieciowej na poziomie 200 000 $ miesięcznie, to lepiej wydać dodatkowe 20 000 $, żeby utrzymywano "ciężko utrzymywalny kod" kod generujący koszty 100 000$. Natomiast jeżeli ktoś robi landing page'a, który odwiedza 100 osób rocznie, pewnie nawet abortSignal będzie niepotrzebny.