Asynchroniczne generatory i pętla for-await

Słowem wstępu

Asynchroniczność to nieodłączny element języka JavaScript, bez niego tworzenie nowoczesnych, dynamicznych aplikacji byłoby praktycznie niemożliwe, ponieważ pozwala wykonywać operacje czekając na ich rezultat, nie blokując tym samym działania całej aplikacji.
Generatory natomiast, to stosunkowo nowa funkcjonalność tego języka, dodana w specyfikacji ES2015. Nie cieszą się jednak dużą popularnością i mogą wydawać się trochę "magiczne".

Jako przykład użycia tych funkcjonalności posłuży nam stworzenie prostej aplikacji, która będzie symulować proces migracji bazy danych, gdzie wszystkie skrypty muszą wykonać się po kolei, a wyłapany błąd zakończy jej pracę, aby zachować poprawną strukturę bazy.

Zanim zaczniemy...

  • Skryptami migracji na potrzeby przykładu będą proste funkcje asynchroniczne
  • Funkcja może nie wykonać się poprawnie np. z powodu timeoutu, błędu w pliku migracji czy nawet w samym kodzie - musimy wtedy przerwać migrację, aby zachować spójność danych

Zaczynamy!

Poniższa funkcja zwróci Promise i będzie symulować wykonywanie się skryptu migracji.
Przyjęliśmy, że każda operacja trwa 1 sekundę i możemy określić czy ma wykonać się poprawnie czy nie (podając parametr rejected).

const createMigration = (name, rejected = false) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (rejected) {
        return reject(`[fail] ${name}`);
      }

      resolve(`[success] ${name}`);
    }, 1000);
  });
};

Następnie stwórzmy kilka migracji

const migrations = [
  () => createMigration('initial'),
  () => createMigration('addTable'),
  () => createMigration('addSomeField', true), // ta migracja nie wykona się poprawnie
  () => createMigration('removeSomeField'),
];

Dlaczego w tablicy migrations każdy z elementów to funkcja zwracająca Promise, a nie po prostu Promise jak poniżej?

const migrations = [
  createMigration('initial'),
  createMigration('addTable'),
  createMigration('addSomeField', true),
  createMigration('removeSomeField'),
];

Dla powyższego, interpreter JS ustawi status wszystkich Promise' ów na pending w momencie wykonywania się kodu linia po linii (mówiąc prosto: uruchomi je).   W efekcie czego, nie będziemy mieć kontroli nad tym, który kiedy się wykona i co najważniejsze, czy zrobi to poprawnie.

Nasz generator

async function* runMigrations() {
  for(migration of migrations) {
    const result = await migration();
    yield result;
  }
}

To o czym warto wspomnieć przy tym kodzie:

  • function* () {...} - to składnia funkcji generatora, wyróżnia go * po słowie function, która zwraca nam obiekt generatora, posiadający m.in. asynchroniczny iterator, funkcję next() i wartość podczas danej iteracji
  • Słowo kluczowe yield poniekąd zastępuje return, ale różni się tym, że można go użyć wielokrotnie. Ustawia wartość obiektu generatora dla kolejnego wywołania metody next()
  • Użyta została także składania async/await pozwalająca "zaczekać" na wykonanie asynchronicznych operacji wewnątrz funkcji

Użycie generatora

const executeAllMigrations = async () => {
  try {
    for await (const result of runMigrations()) {
      console.log(result);
    }
    console.log('Done all migrations')
  } catch (error) {
    console.log(error);
  }
}
  • Składania try/catch pozwala złapać błędy podczas wykonywania poszczególnych migracji
  • Pętla for-await iteruje po obiekcie generatora, za pomocą asynchronicznego iteratora

Uruchomienie migracji

executeAllMigrations();

Efekt?

Widzimy, że jeden ze skryptów migracji nie działa poprawnie i cały proces został zatrzymany zgodnie z oczekiwaniami - nie ujrzeliśmy w konsoli Done all migrations.
Naprawmy to i sprawdźmy czy zadziała.

const migrations = [
  () => createMigration('initial'),
  () => createMigration('addTable'),
  () => createMigration('addSomeField'),
  () => createMigration('removeSomeField'),
];

Jak widać poniżej wszystko wykonało się zgodnie z planem.

To, na co chciałbym jeszcze zwrócić uwagę, to dokładny czas logów. Ustaliliśmy timeout na sekundę i w takim odstępie czasu wykonują się nasze operacje jedna po drugiej.

Wsparcie

  • Generatory są częścią specyfikacji ES2015
  • Składania async/await dostępna jest od wersji ES2017
  • w wersjach Node < 10 wymagane jest wykonanie skryptu z flagą --harmony-async-iteration

Podsumowanie

Używanie generatorów jest czasami bardzo przydatne, a jak widać wcale nie tak skomplikowane jakby się mogło wydawać.
Można ich użyć tak, jak w tym wpisie, do wykonywania asynchronicznych operacji jedna po drugiej albo np. do przetwarzania jakiś podzielonych danych, które są stronicowane.
Warto wiedzieć jak działają i znać ich możliwości.

Autorem tekstu jest Piotr Sołtysiak.

Zdjęcie: Irvan Smith na Unsplash.