Зоны в Dart: операция на открытом сердце для окружения

Привет! Меня зовут Дима, я frontend-разработчик в компании Wrike. Клиентскую часть проекта мы пишем на Dart, однако работать с асинхронными операциями нам приходится не меньше, чем на других технологиях. Зоны — один из удобных инструментов, который Dart для этого предоставляет. Но в Dart-сообществе редко можно встретить о нем полезную информацию, поэтому я решил разобраться и рассказать об этом мощном инструменте подробнее.


lm_ty4f-dyg0v_qh1tymerkhjlo.jpeg

Disclaimer: Весь используемый в статье код только притворяется копипастой. На самом деле я его сильно упростил и избавился от деталей, на которые не стоит обращать внимание в контексте этой статьи. В подготовке материала использовались Dart версии 2.7.2 и AngularDart версии 5.0.0.

О зонах до Dart я почти не слышал. Эта сущность зачастую не используется напрямую и обладает довольно специфическими способностями. При этом, как нам намекает импорт dart: async, они имеют смысл именно при асинхронных операциях.

В Wrike (да и в любом другом проекте на AngularDart) упоминание зон обычно встречается в таких фрагментах:

// Part of AngularDart component class
final NgZone _zone;
final ChangeDetectorRef _detector;
final Element _element;

void onSomeLifecycleHook() {
  _zone.runOutsideAngular(() {
    _element.onMouseMove.where(filterEvent).listen((event) {
      doWork(event);
      _zone.run(_detector.markForCheck);
    });
  });
}

Опытные Dart-разработчики говорят, что это утерянные знания предков. Делай так, и будет по красоте.

Однако если залезть под капот некоторых библиотек, то окажется, что зоны часто используются, чтобы:


  • Упрощать API в утилитах, которые используют асинхронные операции.
  • Исправлять некоторые проседания производительности приложения.
  • Ловить необработанные исключения и фиксить баги (которые иногда порождены самими зонами, но это несущественная деталь).

Источников информации о зонах в сообществе Dart немного, если не считать issues на github. Это документация API, статья с официального сайта языка, в которой хорошо разъясняется кейс с отловом асинхронных ошибок, да немногочисленные доклады, например, с конференции DartUP. Поэтому, самым полноценным справочником становится код.

Здесь предлагаю рассмотреть примеры из библиотек, которые мы используем в проекте:


  • package: intl;
  • package: quiver;
  • package: angular.

Начнем с простого.


Intl и временное переключение локали

Пакет intl — это часть нашего механизма локализации. Принцип работы довольно прост: при старте приложения мы смотрим, какая локаль выбрана по умолчанию, грузим эту локаль и при каждом вызове методов message или plural возвращаем нужный текст для переданного ключа.

Ключи обозначаются так:

class AppIntl {
  static String loginLabel() => Intl.message(
        'Login',
        name: 'AppIntl_loginLabel',
      );
}

Иногда у нас возникает необходимость временно сменить локаль. Например, пользователь вводит период времени, и нам нужно его распарсить с текущего языка. А если не получилось это сделать с текущего, то с какого-то другого. Для этого предусмотрен метод withLocale, который используется примерно так:

// User has 'en' as default locale, but he works from Russia
final fallbackLocale = 'ru';

Future parseText(String userText) async =>
    // Try to parse user text
    await _parseText(userText) ??
    // Try to parse with 'ru' locale if default parsing failed
    await Intl.withLocale(fallbackLocale, () => _parseText(userText));

// This is actual parser
Future _parseText(String userText) {
  // ...
}

Здесь мы пытаемся распарсить пользовательский ввод сначала в локали по-умолчанию, а затем в fallback локали.

Выглядит, будто withLocale меняет текущий язык на указанный, потом выполняет переданный коллбек, затем возвращает все как было. Но!

Метод parseText возвращает Future, потому что нужная локаль может быть еще не загружена, а значит результат нужно подождать. Пока мы ждем, пользователь что-то тронул в интерфейсе, и он начал перерисовываться. Так как текущая локаль в этот момент — русская, то и интерфейс сменил язык на русский. А когда операция закончилась — обратно на английский. Никуда не годится.

Было бы неплохо, чтобы Future сам сообщал, что асинхронная операция закончилась, и он передает управление и результат дальше. Тогда алгоритм в нужный момент мог бы переключить локаль.


1. Что находится под капотом Future

Хорошая новость — он это делает! Мы вызываем какую-то асинхронную функцию и получаем на выходе инстанс Future:

class Future {
  Future() : _zone = Zone.current; // Save current zone on creation

  final Zone _zone;
  // ...
}

При создании инстанса Future запоминает текущую зону. Теперь нам нужно запланировать обработку результата. Конечно, с помощью метода then:

class Future {
  // ...
  Future then(
    FutureOr callback(T value), // ...
  ) {
    // Notify zone about callback for async operation
    callback = Zone.current.registerUnaryCallback(callback);
    final result = Future();
    // Schedule to complete [result] when async operation ends
    _addListener(_FutureListener.then(
      result,
      callback, // ...
    ));
    return result;
  }
}

class _FutureListener {
  // ...
  FutureOr handleValue(S value) =>
      // Call scheduled work inside zone that was saved in [result] Future
      result._zone.runUnary(_callback, value);
}

Интересно! Сначала мы сообщаем текущей зоне, какой коллбек выполнится в будущем. Затем создаем новый Future, планируем для него обработку результата асинхронной операции и возвращаем его. При этом новый Future запоминает зону, в которой был создан — Zone.current. После получения результата с помощью метода runUnary он выполняет коллбек в сохраненной зоне. Зона узнает, когда коллбек был добавлен, и знает, когда его надо выполнить. Значит, на процесс выполнения можно как-то повлиять!


2. Что такое зона, откуда берется текущая зона, и что значит «выполнить внутри зоны»


It’s an execution context. — Brian Ford, zone.js author.

Зона — это объект, который можно описать словом «контекст»: он может нести в себе контекстуальные данные и контекстуальное поведение. Все сущности, которые знают о зонах, так или иначе используют данные или поведение текущей зоны. На примере Future мы уже посмотрели, что если ему надо выполнить коллбек, то он делегирует эту работу зоне с помощью семейства методов run*. А она уже накладывает на выполнение коллбека окончательные особенности и сайд-эффекты.

Текущая зона — это зона, ссылка на которую в данный момент хранится в поле _current. Zone.current — это статический геттер для доступа к _current. В коде это выглядит так:

class Zone {
  static Zone _current = _rootZone; // This is where current zone lives

  static Zone get current => _current;
  // ...
}

От глобальной переменной это поле отличается тем, что его нельзя изменить просто так. Для этого нужно использовать набор собственных методов run* у инстанса зоны: run, runUnary, runBinary. В самом простом случае эти методы временно записывают свою зону в поле _current:

class Zone {
  // ...
  R run(R action()) {
    Zone previous = _current;
    // Place [this] zone in [_current] for a while
    _current = this;
    try {
      return action(); // Then do stuff we wanted
    } finally {
      _current = previous; // Then revert current zone to previous
    }
  }
}

Перед выполнением коллбека в поле _current пишется новая зона, а после выполнения возвращается старая. Выполнить внутри зоны значит заменить зону в поле Zone.current на выбранную на время выполнения функции.

Вот и все! Можно даже согрешить и сказать, что, если бы у поля current был сеттер, то эти два фрагмента кода выполняли бы похожие действия:

class _FutureListener {
  // ...
  FutureOr handleValue(T value) => result._zone.runUnary(_callback, value);
}

class _FutureListener {
  // ...
  FutureOr handleValue(T value) {
    final previousZone = Zone.current;
    Zone.current = result._zone;
    final updatedValue = _callback(value);
    Zone.current = previousZone;
    return updatedValue;
  }
}

Но важная разница все же существует. Методы run* гарантируют, что текущая зона не только изменится на желаемую, но и обязательно вернется к предыдущей, когда коллбек закончит работу. Смена обязательно будет временной. В императивной форме разработчик может забыть о переключении или просто посчитать его ненужным.

Теперь мы понимаем, что с Intl сервисом все должно быть в порядке, если он использует зоны под капотом. Они-то и помогут проконтролировать переключение локали.


3. Как Intl сервис использует зоны

Вернемся к сервису и посмотрим на метод withLocale:

class Intl {
  // ...
  static withLocale(String locale, Function() callback) =>
      // Create new zone with saved locale, then call callback inside it
      runZoned(callback, zoneValues: {#Intl.locale: locale});
}

Сразу что-то новенькое! Давайте по порядку.

Функция runZoned выполняет коллбек внутри зоны. Но мы не предоставляем ссылку на зону, потому что runZoned создает новую зону и тут же выполняет в ней коллбек. А методы run* выполняют коллбек в уже созданной зоне.

Вторая фишка, которую мы видим — это мапка zoneValues. Мощный инструмент, который позволяет сохранить данные в зоне. В zoneValues можно положить данные любого типа по уникальному ключу (в фрагменте используется Symbol).

Теперь посмотрим, где читаются данные:

class Intl {
  // ...
  static String getCurrentLocale() {
    // Get locale from current zone
    var zoneLocale = Zone.current[#Intl.locale];
    return zoneLocale == null ? _defaultLocale : zoneLocale;
  }
  // ...
}

Вуаля! Сначала смотрим, есть ли идентификатор локали в зоне. Далее возвращаем либо его, либо локаль по умолчанию. Если в зоне записана какая-то локаль, то она и будет использоваться.

Чтение данных из зоны происходит с помощью оператора [], который ищет значение в самой зоне и в ее родителях (о них — позже). Но оператор []= не определен — значит данные, присвоенные зоне при создании, изменить нельзя. Из-за этого в методе withLocale и используется runZoned:

class Intl {
  // ...
  static withLocale(String locale, Function() callback) =>
      // Create new zone with saved locale, then call callback inside it
      runZoned(callback, zoneValues: {#Intl.locale: locale});
}

Заранее созданная зона здесь бы не подошла, поэтому мы постоянно создаем новую зону с актуальными данными.

Наконец, вернемся в самое начало:

// User has 'en' as default locale, but he works from Russia
final fallbackLocale = 'ru';

Future parseText(String userText) async =>
    // Try to parse user text
    await _parseText(userText) ??
    // Try to parse with 'ru' locale if default parsing failed
    await Intl.withLocale(fallbackLocale, () => _parseText(userText));

// This is actual parser
Future _parseText(String userText) {
  // ...
}

Теперь мы знаем, что коллбек метода withLocale выполнится в зоне, и в ней будет записана нужная нам локаль. Более того, каждый созданный в этой зоне Future сохранит ссылку на зону и будет выполнять в ней свои коллбеки. Значит локаль будет меняться на указанную каждый раз перед выполнением _parseText и возвращаться обратно сразу после выполнения _parseText. То что надо!

Мы разобрались, как Future может взаимодействовать с зоной. Future, Stream и Timer «пронизаны» такими взаимодействиями с зонами вдоль и поперек. Зоны знают почти о каждом их шаге влево или вправо, поэтому они обладают такими возможностями и мощью. Из следующего примера мы сможем узнать об этом чуть больше.


FakeAsync и быстрое тестирование асинхронного кода

У нас в команде юнит-тесты пишут все фронтенд-разработчики. Иногда нам надо протестировать код, который работает асинхронно. В Dart из коробки есть отличные инструменты тестирования. Например, пакет test, который умеет работать с асинхронными тестами. Когда нам нужно подождать, достаточно вернуть Future из коллбека функции test, и тесты будут ждать вызова функции expect, пока Future не закомплитится:

void main() {
  test('do some testing', () {
    return getAsyncResult().then((result) {
      expect(result, isTrue);
    });
  });
}

Во всем этом великолепии нас не устраивает одно — ждать. Вдруг мы тестируем стрим, в котором использован debounce в секунду, или таймер у операции поставлен на час. В таких случаях стоит «прокидывать» mock извне, но это не всегда возможно.

Мы снова подошли к проблеме, связанной с асинхронными задачами. Только теперь мы знаем, что можем поискать помощи у зоны. Авторы package: quiver уже это сделали и написали утилиту FakeAsync.

Используется она так:

import 'package:quiver/testing/async.dart'; 

void main() {
  test('do some testing', () {
    // Make FakeAsync object and run async code with it
    FakeAsync().run((fakeAsync) {
      getAsyncResult().then((result) {
        expect(result, isTrue);
      });
      // Ask FakeAsync to flush all timers and microtasks
      fakeAsync.flushTimers();
    });
  });
}

Создается объект FakeAsync, с его помощью стартует асинхронная операция, сбрасываются все таймеры и микротаски. А после вся запланированная работа тут же выполняется.

Давайте снова представим себя Пенном и Теллером и разберемся, как эта магия работает.


1. Как изнутри устроен FakeAsync

Идем в метод run и видим это:

class FakeAsync {
  // ...
  dynamic run(callback(FakeAsync self)) {
    // Make new zone if there wasn't any zone created before
    _zone ??= Zone.current.fork(specification: _zoneSpec);
    dynamic result;
    // Call the test callback inside custom zone
    _zone.runGuarded(() {
      result = callback(this);
    });
    return result;
  }
}

Тут ничего сложного — создаем свою зону, выполняем в ней коллбек и возвращаем результат.

И на первой же строке мы знакомимся с двумя новыми деталями — fork и specification.

При старте приложения на Dart у нас всегда уже есть одна зона — root. Она настолько особенная, что доступ к ней есть всегда — через статичный геттер Zone.root. Каждая правильная зона должна быть форком root, поскольку все базовые возможности реализованы именно в root зоне. Помните этот фрагмент run, который должен менять текущую зону?

class Zone {
  // ...
  R run(R action()) {
    Zone previous = _current;
    // Place [this] zone in [_current] for a while
    _current = this;
    try {
      return action(); // Then do stuff we wanted
    } finally {
      _current = previous; // Then revert current zone to previous
    }
  }
}

Это был обман аналогичный школьному правилу «На ноль делить нельзя». На самом деле код должен быть больше похож на этот фрагмент:

class _RootZone implements Zone {
  // Only root zone can change current zone
  // ...
  R _run(Zone self, ZoneDelegate parent, Zone zone, R action()) {
    Zone previous = Zone._current;
    // On this [zone] the .run() method was initially called
    Zone._current = zone;
    try {
      return action(); // Then do stuff we wanted
    } finally {
      Zone._current = previous; // Then revert current zone to previous
    }
  }
}

Вот так под маской обычной зоны скрывался настоящий воротила!

Все другие зоны — это форки root зоны. Они нужны, чтобы добавлять сайд-эффекты в основную работу.


2. Как zoneSpecification влияет на поведение зоны

ZoneSpecification — это второй важный публичный интерфейс зон наравне с zoneValues:

abstract class ZoneSpecification {
  // All this handlers can be added during object creation
  // ...
  HandleUncaughtErrorHandler get handleUncaughtError;
  RunHandler get run;
  RunUnaryHandler get runUnary;
  RunBinaryHandler get runBinary;
  RegisterCallbackHandler get registerCallback;
  RegisterUnaryCallbackHandler get registerUnaryCallback;
  RegisterBinaryCallbackHandler get registerBinaryCallback;
  ErrorCallbackHandler get errorCallback;
  ScheduleMicrotaskHandler get scheduleMicrotask;
  CreateTimerHandler get createTimer;
  CreatePeriodicTimerHandler get createPeriodicTimer;
  PrintHandler get print;
  ForkHandler get fork;
}

Перед вами все возможные способы влиять на поведение зоны и, соответственно, на окружение. Каждый из геттеров спецификации — это функция-хендлер. У каждого хендлера первые три аргумента совпадают, поэтому на примере одной функции можно рассмотреть сразу несколько.

Для этого разберем маленький абстрактный пример — посчитаем, сколько раз код будет просить что-то выполнить в зоне:

// This is the actual type of run handler
typedef RunHandler = R Function(
  Zone self, // Reference to the zone with this specification
  ZoneDelegate parent, // Object for delegating work to [self] parent zone
  Zone zone, // On this zone .run() method was initially called
  R Function() action, // The actual work we want to run in [zone]
);

int _counter = 0;

final zone = Zone.current.fork(
  specification: ZoneSpecification(
    // This will be called within [zone.run(doWork);]
    run: (self, parent, zone, action) {
      // RunHandler
      // Delegate an updated work to parent, so in addition
      // to the work being done, the counter will also increase
      parent.run(zone, () {
        _counter += 1;
        action();
      });
    },
  ),
);

void main() {
  zone.run(doWork);
}

Здесь мы создаем зону со своей спецификацией. Через хендлер run будут проходить все коллбеки, вызванные с помощью метода run. Сайд-эффект хендлера — это инкремент глобального счетчика, так и посчитаем.

При этом важно понимать несколько нюансов.

Когда мы вызываем любой метод у зоны, то он под капотом вызывает соответствующий хендлер из своей спецификации. Метод помогает минимизировать количество аргументов, которые мы передаем функции-хендлеру. Он подставляет первые три.

Форкнутая зона является узлом дерева. Когда мы вызываем метод зоны с какими-либо аргументами, то их надо «пробросить» от узла до самого корня через каждого родителя. Если цепочка оборвется, то работа, на которую мы рассчитываем, может быть не выполнена, поскольку до root зоны ничего не дойдет. Это имеет смысл для отсечения лишней работы, но не в этом примере.

Первый аргумент — это зона, которая владеет хендлером.

Второй аргумент — делегат родительской зоны, который используется для «проброса» дальше. Если мы его не вызовем, то root зона не сможет переключить текущую зону на нужную нам, а другого способа писать в поле _current нет.

Третий аргумент — зона, в которой изначально произошел вызов. Нам иногда нужно это знать, поскольку зона, владеющая хендлером, может быть не последней в иерархии. Когда этот аргумент дойдет до корня, то именно на него и изменится значение текущей зоны в примере.

Все это звучит не очень просто, но обычно достаточно попрактиковаться. Вот еще один маленький пример в форме диаграммы:


pqfq9tcki7wfgqydggxckxrnwn8.png

Здесь показана вымышленная иерархия: по шагам можно посмотреть, что произойдет, если при текущей зоне D выполнить работу в зоне B

Точно также работают первые аргументы у всех других хендлеров, а значит к ним применимы те же паттерны.


3. Какие сайд-эффекты нужны FakeAsync

Теперь вернемся к FakeAsync. Как мы помним, в методе run форкается текущая зона, и с помощью спецификации создается новая. Посмотрим на нее:

class FakeAsync {
  // ...
  ZoneSpecification get _zoneSpec => ZoneSpecification(
        // ...
        scheduleMicrotask: (_, __, ___, Function microtask) {
          _microtasks.add(microtask); // Just save callback
        },
        createTimer: (_, __, ___, Duration duration, Function callback) {
          // Save timer that can immediately provide its callback to us
          var timer = _FakeTimer._(duration, callback, isPeriodic, this);
          _timers.add(timer);
          return timer;
        },
      );
}

Первым видим хендлер scheduleMicrotask. Он вызовется, когда что-то попросит запланировать работу в микротаску, чтобы она выполнилась сразу после текущего потока выполнения. Например, Future обязан выполнять обработку результата в микротаске, поэтому каждый отдельно стоящий Future хоть раз попросит зону это сделать. И не только он: асинхронный Stream тоже активно этим пользуется.

У FakeAsync c планированием микротасок все просто — они без всяких вопросов синхронно сохраняются под капотом на будущее.

Далее хендлер createTimer. У зоны можно вызвать метод createTimer, чтобы, как ни странно, получить объект Timer. Вы спросите: «А как же его собственный конструктор?». А вот так:

abstract class Timer {
  factory Timer(Duration duration, void callback()) {
    // Create timer with current zone
    return Zone.current
        .createTimer(duration, Zone.current.bindCallbackGuarded(callback));
  }
  // ...
  // Create timer from environment
  external static Timer _createTimer(Duration duration, void callback());
}

class _RootZone implements Zone {
  // ...
  Timer createTimer(Duration duration, void f()) {
    return Timer._createTimer(duration, f);
  }
}

Дартовый таймер — это всегда всего лишь обертка над таймером окружения, но зона хочет иметь возможность влиять на процесс его создания. Например, если вам потребуется создать фиктивный таймер, который ничего не отсчитывает. Именно так и делает FakeAsync: он создает _FakeTimer, единственная задача которого — сохранить переданный в него коллбек.

class FakeAsync {
  // ...
  ZoneSpecification get _zoneSpec => ZoneSpecification(
        // ...
        scheduleMicrotask: (_, __, ___, Function microtask) {
          _microtasks.add(microtask); // Just save callback
        },
        createTimer: (_, __, ___, Duration duration, Function callback) {
          // Save timer that can immediately provide its callback to us
          var timer = _FakeTimer._(duration, callback, isPeriodic, this);
          _timers.add(timer);
          return timer;
        },
      );
}

В отличие от нашего примера с хендлером run, здесь делегат на родителя никак не используется. Все потому, что нам не нужно ничего реально планировать. Цепочка разорвалась, никаких таймеров и микротасок в тестовом окружении не будет. Ведь задача зоны FakeAsync — собрать все коллбеки для таймеров и микротасок, которые планируются любыми асинхронными структурами или действиями во время выполнения кода.

Все это для того, чтобы потом в нужном порядке выполнить их синхронно! Это произойдет при вызове метода flushTimers:

class FakeAsync {
  // ...
  void flushTimers() {
    // Call timer callback for every saved timer
    while (_timers.isNotEmpty) {
      final timer = _timers.removeFirst();
      timer._callback(timer);
      // Call every microtask after processing each timer
      _drainMicrotasks();
    }
  }

  void _drainMicrotasks() {
    while (_microtasks.isNotEmpty) {
      final microtask = _microtasks.removeFirst();
      microtask();
    }
  }
}

Метод перебирает запланированные таймеры, синхронно вызывает их коллбеки, затем синхронно перебирает все запланированные ими микротаски и делает это, пока не останется ни одной задачи. Получается, что все асинхронные задачи выполняются синхронно!

Мы разобрались, как создаются зоны, и поговорили про ZoneSpecification. Но осталось еще много не упомянутых хендлеров.

Вот что еще они могут делать:


  • ловить и обрабатывать ошибки (handleUncaughtError, errorCallback);
  • модифицировать коллбек на этапе создания (registerCallback*);
  • создавать повторяющийся таймер (createPeriodicTimer);
  • влиять на стандартное логгирование (print);
  • влиять на процесс форка зоны (fork).

Пожалуй, на сегодня все. В этой статье мы рассмотрели примеры использования зон в двух небольших библиотеках и, надеюсь, поняли их базовые возможности. В следующей статье разберем интересные и не самые очевидные детали на примере AngularDart — фреймворка, который больше других пророс в нашу фронтендерскую жизнь.

Буду рад ответить на вопросы!

© Habrahabr.ru