https://github.com/ReactiveX/RxJava

Библиотека RxJava ˜— реализация принципов реактивного программирования для JVM.

Пользователи ожидают данных в реальном времени. Они хотят твиты сейчас. Подтвержение заказа сейчас. Им необходимы цены по состоянию на сейчас. Как разработчик, вы нуждаетесь в самонаводящихся сообщениях. Вы не хотите быть блокированным в ожидании результата. Вы хотите, чтобы результат пришел к вам по готовности. Даже более того, вы хотите работать с результатом по частям: вы не хотите ждать пока загрузится всё перед тем как отобразить первую строку. Мир перешел в режим уведомлений. У разработчиков есть инструменты, чтобы уведомлять. Им нужны инструменты чтобы реагировать на уведомления.

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


* Интуитивность
Действия в Rx описываются в таком же стиле, как и в других библиотеках вдохновленных функциональным программированием (например, Java Streams).
Rx дает возможность использовать функциональные трансформации над потоками событий.


* Расширяемость
RxJava может быть расширена пользовательскими операторами. И хотя Java не позволяет сделать это элегантным образом, RxJava предлагает всю расширяемость доступную в реализациях Rx на любом другом языке.


* Декларативность
Функциональные трансформации обьявлены декларативно.


* Компонуемость
Операторы в Rx легко компонуются, чтобы проводить сложные операции.


* Преобразуемость
Операторы в Rx могут трансформировать типы данных, фильтруя, обрабатывая и расширяя потоки данных при необходимости.


* Когда следует использовать Rx?
Rx применяется для составления и обработки последовательностей событий.

Следует использовать Rx:
- UI события, такие как mouse move, button click
- События вроде изменения свойства, обновления коллекции, «Заказ оформлен», «Регистрация закончена» и т.д.
- Инфраструктурные события (сообщения от системы, WMI или файловых менеджеров)
- Интеграция с событиями от шины сообщений (message bus), сообщениями из WebScoket API
- Интеграция с CEP-системами (StreamInsight, StreamBas)

Возможно использование Rx
- Результат Future или похожего паттерна

Не следует использовать Rx
- Для превращения Iterable в Observable только для того, чтобы работать с ними через библиотеку Rx.




Базовыми строительными блоками реактивного кода являются Observables и Subscribers1. Observable является источником данных, а Subscriber — потребителем.
Порождение данных через Observable всегда происходит в соответствии с одним и тем же порядком действий: Observable «излучает» некоторое количество данных (в том числе, Observable может ничего и не излучать), и завершает свою работу — либо успешно, либо с ошибкой. Для каждого Subscriber, подписанного на Observable, вызывается метод Subscriber.onNext() для каждого элемента потока данных, после которого может быть вызван как Subscriber.onComplete(), так и Subscriber.onError().
Всё это очень похоже на обычный паттерн «Наблюдатель», но есть одно важное отличие: Observables часто не начинают порождать данные до тех пор, пока кто-нибудь явно не подписывается на них2. Другими словами: если дерево падает, а рядом никого нет, значит звук его падения не слышен.


Давайте разберёмся с небольшим примером. Сначала создадим простой Observable:

Observable<String> myObservable = Observable.create(
    new Observable.OnSubscribe<String>() {
        @Override
        public void call(Subscriber<? super String> sub) {
            sub.onNext("Hello, world!");
            sub.onCompleted();
        }
    }
);

Наш Observable порождает строку «Hello, world!», и завершает свою работу. Теперь создадим Subscriber для того, чтобы принять данные и что-нибудь с ними сделать.

Subscriber<String> mySubscriber = new Subscriber<String>() {
    @Override
    public void onNext(String s) { System.out.println(s); }

    @Override
    public void onCompleted() { }

    @Override
    public void onError(Throwable e) { }
};

Всё, что делает Subscriber — печатает строки, переданные ему Observable. Теперь, когда у нас есть myObservable и mySubscriber, мы можем связать их вместе, воспользовавшись методом subscribe():

myObservable.subscribe(mySubscriber);
// Выводит "Hello, world!"

Как только мы подписали mySubscriber на myObservable, myObservable вызывает у mySubscriber методы onNext() и onComplete(), в результате чего mySubscriber выводит в консоль «Hello, world!», и завершает своё выполнение.

Упрощаем код


Вообще говоря, мы написали слишком много кода для такой простой задачи, как вывод «Hello, world!» в консоль. Я специально написал этот код таким образом, чтобы вы могли легко разобраться, что тут к чему. В RxJava есть много более рациональных способов решить подобную задачу.
Во-первых, давайте упростим наш Observable. В RxJava существуют методы создания Observable, подходящих для решения наиболее типовых задач. В нашем случае, Observable.just() порождает один элемент данных, а потом завершает своё выполнение, точно так же как и наш первый вариант3:

Observable<String> myObservable = Observable.just("Hello, world!");

Далее, давайте-ка упростим наш Subscriber. Нас не интересуют методы onCompleted() и onError(), так что мы можем использовать другой базовый класс для определения того, что нужно сделать в onNext():

Action1<String> onNextAction = new Action1<String>() {
    @Override
    public void call(String s) {
        System.out.println(s);
    }
};

Action может быть использован для замены любой части Subscriber: Observable.subscribe() может принять один, два или три Action-параметра, которые будут выполняться вместо onNext(), onError() и onCompete(). То есть, мы можем заменить наш Subscriber вот так:

myObservable.subscribe(onNextAction, onErrorAction, onCompleteAction);

Но, так как нам не нужны onError() и onCompete(), мы можем упростить код ещё больше:

myObservable.subscribe(onNextAction);
// Выводит "Hello, world!"

Теперь давайте избавимся от переменных, прибегнув к цепочечному вызову методов:

Observable.just("Hello, world!")
    .subscribe(new Action1<String>() {
        @Override
        public void call(String s) {
              System.out.println(s);
        }
    });

Ну и, наконец, мы можем воспользоваться лямбдами из Java 8, чтобы упростить код ещё больше:

Observable.just("Hello, world!")
    .subscribe(s -> System.out.println(s));

Если вы пишете под Android (и поэтому не можете использовать Java 8), я очень рекомендую retrolambda, которая поможет упростить очень уж многословный в некоторых местах код.

Трансформация


Давайте попробуем нечто новое.
Например, я хочу добавить свою подпись к «Hello, world!», выводимому в консоль. Как это сделать? Во-первых, мы можем изменить наш Observable:

Observable.just("Hello, world! -Dan")
    .subscribe(s -> System.out.println(s));

Это может сработать, если вы имеете доступ к исходному коду, в котором определяется ваш Observable, но это не всегда будет так — например, когда вы используете чью-то библиотеку. Другая проблема: что, если мы используем наш Observable во многих местах, но хотим добавлять подпись только в некоторых случаях?
Можно попробовать переписать Subscriber:

Observable.just("Hello, world!")
    .subscribe(s -> System.out.println(s + " -Dan"));

Такой вариант тоже является неподходящим, но уже по другим причинам: я хочу, чтобы мои подписчики были настолько легковесными, насколько это возможно, так как я могу запускать их в главном потоке. На более концептуальном уровне, подписчики должны реагировать на поступающие в них данных, а не изменять их.
Было бы здорово, если можно было изменить «Hello, world!» на некотором промежуточном шаге.

Введение в операторы


И такой промежуточный шаг, предназначенный для трансформации данных, есть. Имя ему — операторы, и они могут быть использованы в промежутке между Observable и Subscriber для манипуляции данными. Операторов в RxJava очень много, поэтому для начала лучше будет сосредоточиться лишь на некоторых.
Для нашей конкретной ситуации лучше всего подошёл бы оператор map(), через который можно преобразовывать один элемент данных в другой:

Observable.just("Hello, world!")
    .map(new Func1<String, String>() {
        @Override
        public String call(String s) {
            return s + " -Dan";
        }
    })
    .subscribe(s -> System.out.println(s));

И снова можно прибегнуть к лямбдам:

Observable.just("Hello, world!")
    .map(s -> s + " -Dan")
    .subscribe(s -> System.out.println(s));

Круто, да? Наш оператор map(), грубо говоря, это Observable, который трансформирует поступающий в него элемент данных. Мы можем создать цепочку из такого количества map(), какое посчитаем нужным для того, чтобы придать данным наиболее удобную и простую форму, чтобы облегчить задачу нашему Subscriber.

Ещё о map()


Интересным свойством map() является то, что он не обязан порождать данные того же самого типа, что и исходный Observable.
Допустим, что наш Subscriber должен выводить не порождаемый текст, а его хэш:

Observable.just("Hello, world!")
    .map(new Func1<String, Integer>() {
        @Override
        public Integer call(String s) {
            return s.hashCode();
        }
    })
    .subscribe(i -> System.out.println(Integer.toString(i)));

Интересно: мы начали со строк, а наш Subscriber принимает Integer. Кстати, мы опять забыли о лямбдах:

Observable.just("Hello, world!")
    .map(s -> s.hashCode())
    .subscribe(i -> System.out.println(Integer.toString(i)));

Как я говорил ранее, мы хотим, чтобы наш Subscriber делал как можно меньше работы, поэтому давайте применим ещё один map(), чтобы сконвертировать наш хэш обратно в String:

Observable.just("Hello, world!")
    .map(s -> s.hashCode())
    .map(i -> Integer.toString(i))
    .subscribe(s -> System.out.println(s));

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

Observable.just("Hello, world!")
    .map(s -> s + " -Dan")
    .map(s -> s.hashCode())
    .map(i -> Integer.toString(i))
    .subscribe(s -> System.out.println(s));


Идея №1: Observable и Subscriber могут делать всё, что угодно


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

Идея №2: Observable и Subscriber не зависят от промежуточных шагов, находящихся между ними


Можно вставить сколько угодно вызовов map() в промежутке между Observable и подписанным на него Subscriber. Система является легко компонуемой, и с её помощью очень легко управлять потоком данных. Если операторы работают с корректными входными/выходными данными, можно написать цепочку преобразований бесконечной длины4.

Взгляните на эти ключевые идеи вместе и вы увидите систему с большим потенциалом. Сейчас, правда, у нас есть только один оператор map(), и с ним много не напишешь. Во второй части этой статьи мы рассмотрим большое количество операторов, доступных вам из коробки, когда вы пользуетесь RxJava.



1 Subscriber имплементирует интерфейс Observer, и потому «базовым строительным блоком» назвать можно, скорее, последний, но на практике вы чаще всего будете использовать Subscriber, потому что он имеет несколько дополнительных полезных методов, в том числе и Subscriber.unsubscribe().
2 В RxJava есть «горячие» и «холодные» Observables. Горячий Observable порождает данные постоянно, даже если на него никто не подписан. Холодный Observable, соответственно, порождает данные только если у него есть хотя бы один подписчик (в статье используются именно холодные Observables). Для начальных стадий изучения RxJava эта разница не столь важна.
3 Строго говоря, Observable.just() не является полным аналогом нашего изначального кода, но почему это так происходит, я объясню только в третьей части статьи.
4 Окей, не такой уж и бесконечной, так как в какой-то момент я упрусь в ограничения, налагаемые железом, но вы понимаете, что я хотел сказать.
Большая часть всей мощи RxJava скрывается в её операторах.

Задача


Предположим, что у меня есть такой вот метод:

// Возвращает список url'ов, основываясь на поиске по содержимому веб-страницы
Observable<List<String>> query(String text); 

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

query("Hello, world!")
    .subscribe(urls -> {
        for (String url : urls) {
            System.out.println(url);
        }
    });

Это решение никоим образом нас не удовлетворяет потому, что мы теряем возможность трансформировать поток данных. Если у нас возникнет желание модифицировать каждый url, нам придётся делать всё это в Subscriber, оставляя, таким образом, все наши трюки с map() не у дел.
Можно было бы написать map(), который работал бы с одним списком url'ов, и выдавал бы наружу список измененных url'ов, но в таком случае каждый вызов map() содержал бы в себе for-each. Тоже не очень-то и красиво.

Применим метод Observable.from(), который берёт коллекцию и «испускает» один элемент этой коллекции за другим:

Observable.from("url1", "url2", "url3")
    .subscribe(url -> System.out.println(url));

Похоже на то, что нам нужно, давайте попробуем воспользоваться им в нашей задаче:

query("Hello, world!")
    .subscribe(urls -> {
        Observable.from(urls)
            .subscribe(url -> System.out.println(url));
    });

От цикла мы избавились, но, что получилось в итоге, выглядит как полный бардак: вместо цикла мы получили вложенные друг в друга подписки. И плохо не только то, что код выглядит запутанно и потому его скорее всего будет трудно модифицировать; он конфликтует с некоторыми особенностями RxJava.

flatMap()


Observable.flatMap() принимает на вход данные, излучаемые одним Observable, и возвращает данные, излучаемые другим Observable, подменяя таким образом один Observable на другой. Неожиданный поворот событий, так сказать: вы думали, что получаете один поток данных, а получаете на самом деле другой. Вот как flatMap() поможет нам решить нашу проблему:

query("Hello, world!")
    .flatMap(new Func1<List<String>, Observable<String>>() {
        @Override
        public Observable<String> call(List<String> urls) {
            return Observable.from(urls);
        }
    })
    .subscribe(url -> System.out.println(url));

Я показал полную версию для того, чтобы вам было проще разобраться в происходящем, но, если переписать код с лямбдами, то выглядеть он начинает просто замечательно:

query("Hello, world!")
    .flatMap(urls -> Observable.from(urls))
    .subscribe(url -> System.out.println(url));

Довольно странная штука, если призадуматься. Зачем flatMap() возвращает другой Observable? Ключевой момент тут в том, что новый Observable — это то, что увидит в итоге наш Subscriber. Он не получит List<String>, он получит поток индивидуальных объектов класса String так, как он получил бы от Observable.from().
Между прочим, этот момент показался мне самым сложным, но, как только я его понял и осознал, большая часть того, как работает RxJava, встала в моей голове на свои места.

Подчеркну ещё раз, потому что это важно: flatMap() может вернуть нам любой Observable, какой вы только захотите.
Например, у меня есть второй метод:

// Возвращает заголовок вебсайта, или null, если мы получили ошибку 404
Observable<String> getTitle(String URL);

Вместо того, чтобы печатать url'ы, я теперь хочу печатать заголовок каждого сайта, до которого удалось достучаться. Есть проблемы: мой метод принимает только один url, и он не возвращает String, он возвращает Observable, который возвращает String.
Можно решить обе эти проблемы с flatMap(); сначала мы перейдём от списка url'ов к потоку индивидуальных url'ов, а потом используем getTitle() внутри flatMap() прежде чем передать окончательный результат в Subscriber:

query("Hello, world!")
    .flatMap(urls -> Observable.from(urls))
    .flatMap(new Func1<String, Observable<String>>() {
        @Override
        public Observable<String> call(String url) {
            return getTitle(url);
        }
    })
    .subscribe(title -> System.out.println(title));

И ещё раз упростим всё с помощью лямбд:

query("Hello, world!")
    .flatMap(urls -> Observable.from(urls))
    .flatMap(url -> getTitle(url))
    .subscribe(title -> System.out.println(title));

Здорово, да? Мы объединяем вместе несколько не зависящих друг от друга методов, которые возвращают нам Observables.
Обратите внимание на то, каким образом я объединил вместе два вызова API в одну цепочку. То же самое можно проделать для любого количества обращений к API. Вы наверняка знаете, насколько сложно порой бывает скоординировать работу нескольких вызовов API для того, чтобы получить в итоге некоторый нужный нам результат: сделали запрос, получили результат в функции обратного вызова, уже изнутри неё сделали новый запрос… Брр. А здесь мы взяли и обошли весь этот ад стороной, уложив всю ту же самую логику в один короткий реактивный вызов2.

Изобилие операторов


Пока что мы взглянули лишь на два оператора, но их в RxJava на самом деле гораздо больше. Как ещё можно улучшить наш код?
getTitle() возвращает null, если мы получили ошибку 404. Мы не хотим выводить на экран "null", и мы можем отфильтровать ненужные нам значения:

query("Hello, world!")
    .flatMap(urls -> Observable.from(urls))
    .flatMap(url -> getTitle(url))
    .filter(title -> title != null)
    .subscribe(title -> System.out.println(title));

filter() «испускает» тот же самый элемент потока данных, который он получил, но только если он проходит проверку.
А теперь мы хотим показать только 5 результатов, не больше:

query("Hello, world!")
    .flatMap(urls -> Observable.from(urls))
    .flatMap(url -> getTitle(url))
    .filter(title -> title != null)
    .take(5)
    .subscribe(title -> System.out.println(title));

take() возвращает не больше заданного количества элементов (если в нашем случае получилось меньше 5 элементов, take() просто-напросто завершит свою работу раньше.
Знаете, а давайте-ка будем ещё и сохранять каждый полученный нами заголовок на диск:

query("Hello, world!")
    .flatMap(urls -> Observable.from(urls))
    .flatMap(url -> getTitle(url))
    .filter(title -> title != null)
    .take(5)
    .doOnNext(title -> saveTitle(title))
    .subscribe(title -> System.out.println(title));

doOnNext()позволяет нам добавить некоторое дополнительное действие, происходящее всякий раз, как мы получаем новый элемент данных, в данном случае этим действием будет сохранение заголовка.
Взгляните на то, как легко нам манипулировать потоком данных. Можно добавлять всё новые и новые ингридиенты к вашему рецепту, и не получить в итоге неудобоваримую бурду.
RxJava поставляется с вагоном и маленькой тележкой разнообразных операторов. Такой огромный список может и напугать, но его стоит просмотреть хотя бы для того, чтобы иметь представление о том, что есть в наличии. У вас уйдёт некоторое время для того, чтобы запомнить доступные вам операторы, но, как только вы это сделаете, вы обретёте истинную силу на кончиках ваших пальцев.
Да, кстати, вы также можете писать свои собственные операторы! Эта тема выходит за рамки данного цикла статей, но, скажем так: если вы придумаете свой собственный оператор, вы почти наверняка сможете реализовать его3.


Идея №3: Операторы позволяют вам делать с потоком данных всё, что угодно


Единственное ограничение находится в вас самих.
Можно написать довольно сложную логику манипулирования данными, не используя ничего, кроме цепочек простых операторов. Это и есть функциональное реактивное программирование. Чем чаще вы им пользуетесь, тем сильнее изменяется ваше представление о том, как должен выглядеть программный код.
Также, подумайте о том, как легко было представить наши данные конечному потребителю после того, как мы трансформировали их. Под конец нашего примера мы делали два запроса к API, обрабатывали данные, и заодно сохраняли их на диск. Но наш конечный Subscriber не имеет об этом ни малейшего представления, он всё так же работает с обычным Observable<String>. Инкапсуляция делает код более простым!
В третьей части мы пройдёмся по другим крутым особенностям RxJava, которые связаны с манипуляцией данными в меньшей степени: обработка ошибок и параллелизм.



1 Так, например, обработка ошибок, многопоточность и отмена подписок в RxJava сочетаются с этим кодом чуть менее чем никак. Я затрону эти темы в третьей части.
2 А вот тут вы, возможно, задумались о другой стороне callback hell: обработка ошибок. Я рассмотрю это в третьей части.
3 Если вы хотите написать свои собственные операторы, вам стоит посмотреть вот сюда. Некоторые детали их имплементации, правда, будут довольно сложны для понимания, до прочтения третьей части статьи.

Обработка ошибок


До настоящего момента мы полностью игнорировали такие методы Observable, как onComplete() и onError(). Данные методы вызываются в момент, когда Observable прекращает порождать новые данные — либо потому, что ему нечего больше порождать, либо потому, что произошла ошибка.
Самый первый наш Subscriber следил за onCompleted() и onError(). Давайте сделаем что-нибудь полезное в этих точках:

Observable.just("Hello, world!")
    .map(s -> potentialException(s))
    .map(s -> anotherPotentialException(s))
    .subscribe(new Subscriber<String>() {
        @Override
        public void onNext(String s) { System.out.println(s); }

        @Override
        public void onCompleted() { System.out.println("Completed!"); }

        @Override
        public void onError(Throwable e) { System.out.println("Ouch!"); }
    });



Положим, что potentialException() и anotherPotentialException() могут выбрасывать исключения во время работы. Каждый Observable завершает своё выполнение вызовом onCompleted() или onError. В таком случае, вывод программы будет либо строкой, за которой следует «Completed!», либо вывод будет состоять из одного-единственного «Ouch!» (потому что было выброшено исключение).

Таким образом, у нас есть несколько выводов:

  1. onError() вызывается вне зависимости от того, когда было выброшено исключение.
    Благодаря этому, обработка ошибок становится очень простой: можно просто обрабатывать каждую возникающую ошибку в одной-единственной функции, находящейся в самом конце.
  2. Операторы не обязаны обрабатывать исключения.
    Обработка ошибок, возникающих в любом месте цепочки Observables становится задачей Subscriber, т.к. каждое исключение следует напрямую в onError().
  3. Вы всегда знаете, когда Subscriber прекратил получать новые элементы.
    Знание момента завершения работы помогает вам писать более последовательный код (хотя может произойти и так, что Observable никогда не завершит своё выполнение).

Я считаю подобный подход к обработке ошибок гораздо более простым, в сравнении с традиционным подходом. Если вы пишете код с функциями обратного вызова, то обработка ошибок должна происходить в каждой из них. Это не просто ведёт к тому, что ваш код начинает повторяться во многих местах, но ещё и к тому, что каждая функция обратного вызова теперь должна знать, как ей обрабатывать ошибки, то есть она становится сильно связанной с тем, кто её вызывает.
В случае с RxJava, Observable не должен даже знать о том, что ему делать с ошибками! Это относится и к операторам: они не будут выполняться, если на каком-то из предыдущих этапов у нас произошла критическая ошибка. Вся обработка ошибок находится в Subscriber.

Планировщики


У вас есть Android приложение, которое делает запрос к сети. Запрос может продлиться долго, поэтому вы выносите его в другой поток. Не успеете и оглянуться, как у вас есть проблемы.
Многопоточные Android приложения сложны в написании потому, что вам нужно убедиться, что вы запускаете правильный код в правильном потоке; перепутаете что-нибудь, и приложение упадёт. Классический пример — исключение, которое падает в ответ на вашу попытку модифицировать состояние View не из главного потока.
В RxJava можно легко указать, в каком потоке должны запускаться ваши Observer и Subscriber, воспользовавшись, соответственно, subscribeOn() и observeOn():

myObservableServices.retrieveImage(url)
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(bitmap -> myImageView.setImageBitmap(bitmap));

Просто, правда? Всё, что выполняется до Subscriber, выполняется в отдельном I/O потоке, а манипуляции с View работают уже в главном потоке1.
Интересно здесь то, что subscribeOn() и observeOn() могут быть вызваны на любом Observable, так как они всего-навсего операторы. Не нужно беспокоиться о том, что делает наш Observable(), или следующие за ним операторы — можно просто добавить subscribeOn() и observeOn() в самом конце, для того, чтобы раскидать выполнение задач по нужным потокам.
Если мы пользуемся AsyncTask, или чем-то подобным, нам нужно писать код с учётом того, какие его части должны выполняться параллельно. В случае с RxJava мы просто пишем код — а потом указываем, где нам его выполнять2.

Подписки


Когда вы вызываете Observable.subscribe(), вам в ответ возвращается объект класса Subscription, который представляет собой связь между вашими Observable и Subscriber:

Subscription subscription = Observable.just("Hello, World!")
    .subscribe(s -> System.out.println(s));

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

subscription.unsubscribe();
System.out.println("Unsubscribed=" + subscription.isUnsubscribed());
// Выводит "Unsubscribed=true"

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


Полезное: backpressure).

Официальная wiki-страница.

Comments and questions

Publish comment or question

Copyright 2019 © ELTASK.COM
All rights reserved.