Що таке "пекло зворотного дзвінка" і як і чому RX вирішує це?


113

Чи може хтось дати чітке визначення разом із простим прикладом, який пояснює, що таке "пекло зворотного дзвінка" для того, хто не знає JavaScript і node.js?

Коли (в яких налаштуваннях) виникає "пекла проблема зворотного дзвінка"?

Чому це відбувається?

Чи завжди "пекло зворотного дзвінка" пов'язане з асинхронними обчисленнями?

Або "пекельний зворотний виклик" може відбуватися і в одному потоковому додатку?

Я взяв Реактивний курс у Coursera, і Ерік Мейєр сказав в одній зі своїх лекцій, що RX вирішує проблему "зворотного виклику". Я запитав, що таке "пекельний зворотний дзвінок" на форумі Coursera, але я не отримав однозначної відповіді.

Пояснивши "пекло зворотного дзвінка" на простому прикладі, чи можете ви також показати, як RX вирішує "проблему пекло зворотного дзвінка" на тому простому прикладі?

Відповіді:


136

1) Що таке "пекельний зворотний дзвінок" для того, хто не знає JavaScript та node.js?

У цьому іншому запитанні є кілька прикладів зворотного виклику Javascript: Як уникнути тривалого вкладення асинхронних функцій у Node.js

Проблема в Javascript полягає в тому, що єдиний спосіб "заморозити" обчислення і змусити останнє (асинхронно) виконати останнє - це помістити "решту його" всередину зворотного виклику.

Наприклад, скажіть, що я хочу запустити код, який виглядає приблизно так:

x = getData();
y = getMoreData(x);
z = getMoreData(y);
...

Що станеться, якщо зараз я хочу зробити функції getData асинхронними, це означає, що я отримую можливість запустити якийсь інший код, поки я чекаю, коли вони повернуть свої значення? У Javascript єдиним способом було б переписати все, що стосується обчислення асинхронізації, використовуючи стиль проходження продовження :

getData(function(x){
    getMoreData(x, function(y){
        getMoreData(y, function(z){ 
            ...
        });
    });
});

Я не думаю, що мені потрібно когось переконувати, що ця версія гірша, ніж попередня. :-)

2) Коли (в яких налаштуваннях) виникає "пекла проблема зворотного дзвінка"?

Коли у вашому коді багато функцій зворотного дзвінка! Робити з ними стає важче, тим більше їх у коді, і це стає особливо погано, коли вам потрібно робити петлі, пробувати блоки та подібні речі.

Наприклад, наскільки я знаю, в JavaScript єдиний спосіб виконати ряд асинхронних функцій, коли одна запускається після попереднього повернення, використовує рекурсивну функцію. Ви не можете використовувати цикл.

// we would like to write the following
for(var i=0; i<10; i++){
    doSomething(i);
}
blah();

Натомість нам може знадобитися закінчити написання:

function loop(i, onDone){
    if(i >= 10){
        onDone()
    }else{
        doSomething(i, function(){
            loop(i+1, onDone);
        });
     }
}
loop(0, function(){
    blah();
});

//ugh!

Кількість питань, які ми отримуємо тут на StackOverflow, запитуючи, як це зробити, є свідченням того, наскільки це заплутано :)

3) Чому це відбувається?

Це відбувається тому, що в JavaScript єдиний спосіб затримати обчислення, щоб він запустився після повернення асинхронного виклику, - це помістити код затримки всередині функції зворотного виклику. Ви не можете затримувати код, який був написаний у традиційному синхронному стилі, так що у вас є скрізь вкладені зворотні виклики.

4) Або може відбуватися "пекельний зворотний дзвінок" також в одному потоковому додатку?

Асинхронне програмування пов'язане з паралельністю, а однопотокове - з паралелізмом. Два поняття насправді не одне і те ж.

Ви все одно можете мати паралельний код в одному потоковому контексті. Насправді JavaScript, королева пекельного зворотного дзвінка, є однопоточною.

Чим відрізняється паралельність і паралелізм?

5) Ви можете, будь ласка, також показати, як RX вирішує проблему пекла зворотного дзвінка на тому простому прикладі.

Я нічого не знаю, зокрема, про RX, але зазвичай цю проблему вирішують шляхом додавання вбудованої підтримки для асинхронних обчислень на мові програмування. Реалізації можуть відрізнятися і включати: асинхронізацію, генератори, супроводи та callcc.

У Python ми можемо реалізувати той попередній приклад циклу з чимось уздовж рядків:

def myLoop():
    for i in range(10):
        doSomething(i)
        yield

myGen = myLoop()

Це не повний код, але ідея полягає в тому, що "урожай" призупиняє наш цикл, поки хтось не зателефонує до myGen.next (). Важливим є те, що ми все-таки могли записати код за допомогою циклу, не потребуючи вимикання логіки "всередину", як це потрібно було робити в цій рекурсивній loopфункції.


Тож пекельний зворотний виклик може відбуватися лише в налаштуваннях асинхронізації? Якщо мій код повністю синхронічний (тобто немає одночасності), тоді "пекло зворотного дзвінка" не може відбутися, якщо я правильно зрозумів вашу відповідь, чи правильно?
jhegedus

Пекельний зворотний виклик має більше спільного з тим, як дратує кодування за допомогою стилю передачі продовження. Тероретично ви все-таки можете переписати всі свої функції, використовуючи стиль CPS навіть для звичайної програми (у статті wikipedia є кілька прикладів), але, з поважної причини, більшість людей цього не роблять. Зазвичай ми використовуємо стиль передачі продовження лише тоді, коли нас змушують, що стосується програмування async Javascript.
hugomg

btw, я шукаю в реактивних розширеннях, і у мене складається враження, що вони більше схожі на бібліотеку Promise, а не на розширення мови, що вводить синтаксис асинхронізації. Обіцяння допомагають розібратися з введенням зворотного дзвінка та з обробкою винятків, але вони не такі акуратні, як розширення синтаксису. Цикл for все ще дратує коду, і вам все одно потрібно перевести код з синхронного стилю в стиль обіцянки.
hugomg

1
Я повинен уточнити, як RX взагалі робить кращу роботу. RX є декларативним. Ви можете оголосити, як програма буде реагувати на події, коли вони пізніше відбуватимуться, не впливаючи на будь-яку іншу логіку програми. Це дозволяє відокремити основний код циклу від коду обробки подій. Ви можете легко обробляти деталі, такі як замовлення подій асинхронізації, які є кошмаром при використанні змінних стану. Я виявив, що RX була найчистішою реалізацією для виконання нового мережевого запиту після повернення 3 мережевих відповідей або для помилки обробляти всю ланцюг, якщо вона не повертається. Тоді він може скинутись і чекати тих самих 3 подій.
знімки з колонками

Ще один пов'язаний коментар: RX - це в основному монада продовження, яка стосується CPS, якщо я не помиляюся, це також може пояснити, як / чому RX хороший для проблеми зворотного виклику / пекла.
jhegedus

30

Просто дайте відповідь на запитання: чи можете ви, будь ласка, показати, як RX вирішує проблему пекла зворотного дзвінка?

Магія є flatMap. Для прикладу @ hugomg в Rx можна написати наступний код:

def getData() = Observable[X]
getData().flatMap(x -> Observable[Y])
         .flatMap(y -> Observable[Z])
         .map(z -> ...)...

Це як ви пишете кілька синхронних FP-кодів, але насправді ви можете зробити їх асинхронними Scheduler.


26

Щоб вирішити питання про те, як Rx вирішує пекельний зворотний дзвінок :

Спочатку опишемо ще раз пекло зворотного дзвінка.

Уявіть собі випадок, якщо ми повинні зробити http, щоб отримати три ресурси - людину, планету та галактику. Наша мета - знайти галактику, в якій живе людина. Спочатку ми повинні отримати людину, потім планету, потім галактику. Це три зворотні виклики за три асинхронні операції.

getPerson(person => { 
   getPlanet(person, (planet) => {
       getGalaxy(planet, (galaxy) => {
           console.log(galaxy);
       });
   });
});

Кожен зворотний дзвінок вкладений. Кожен внутрішній зворотний дзвінок залежить від свого батька. Це призводить до стилю зворотного виклику "піраміди приреченості" . Код виглядає як знак>.

Щоб вирішити це в RxJs, ви можете зробити щось подібне:

getPerson()
  .map(person => getPlanet(person))
  .map(planet => getGalaxy(planet))
  .mergeAll()
  .subscribe(galaxy => console.log(galaxy));

З оператором mergeMapAKA flatMapви можете зробити це більш лаконічним:

getPerson()
  .mergeMap(person => getPlanet(person))
  .mergeMap(planet => getGalaxy(planet))
  .subscribe(galaxy => console.log(galaxy));

Як бачите, код вирівняний і містить єдину ланцюжок викликів методів. У нас немає «піраміди приреченості».

Таким чином, уникнути зворотного дзвінка.

У випадку, коли вам було цікаво, обіцянки - це ще один спосіб уникнути зворотного дзвінка, але обіцянки прагнуть , не лінуються як спостереження, і (взагалі кажучи) ви не можете їх скасувати так легко.


Я не розробник JS, але це просте пояснення
Омар Бешарі

15

Пекельний зворотний дзвінок - це будь-який код, де використання зворотних викликів функцій у коді асинхронізації стає незрозумілим або важким для дотримання. Як правило, коли існує більше одного рівня непрямості, коду, що використовує зворотні дзвінки, стає важче дотримуватися, складніше перетворювати, і важче перевірити. Запах коду - це декілька рівнів відступу через проходження декількох шарів літералів функції.

Це часто трапляється, коли поведінка має залежність, тобто коли A має відбутися до того, як B повинно відбутися перед C. Тоді ви отримаєте такий код:

a({
    parameter : someParameter,
    callback : function() {
        b({
             parameter : someOtherParameter,
             callback : function({
                 c(yetAnotherParameter)
        })
    }
});

Якщо у вашому коді багато таких поведінкових залежностей, це може швидко набути проблем. Особливо, якщо вона гілки ...

a({
    parameter : someParameter,
    callback : function(status) {
        if (status == states.SUCCESS) {
          b(function(status) {
              if (status == states.SUCCESS) {
                 c(function(status){
                     if (status == states.SUCCESS) {
                         // Not an exaggeration. I have seen
                         // code that looks like this regularly.
                     }
                 });
              }
          });
        } elseif (status == states.PENDING {
          ...
        }
    }
});

Це не зробить. Як ми можемо змусити асинхронний код виконувати у визначеному порядку без необхідності передавати всі ці зворотні виклики?

RX короткий для "реактивних розширень". Я цього не використовував, але Google показує, що це подія на основі подій, яка має сенс. Події - це загальна модель, щоб змусити код виконувати по порядку, не створюючи крихких зв'язків . Ви можете змусити C слухати подію "bFinished", що відбувається лише після того, як B буде викликано прослуховуванням "aFinished". Потім ви можете легко додати додаткові кроки або розширити подібну поведінку, і ви можете легко перевірити, чи виконується ваш код у порядку, просто транслюючи події у вашому тестовому випадку.


1

Пекло зворотного дзвінка означає, що ви знаходитесь всередині зворотного дзвінка всередині іншого зворотного дзвінка, і він переходить до n-го виклику, поки ваші потреби не будуть повноцінними.

Давайте розберемося на прикладі фальшивого виклику ajax, використовуючи встановлений API очікування, припустимо, у нас є API рецепту, нам потрібно завантажити весь рецепт.

<body>
    <script>
        function getRecipe(){
            setTimeout(()=>{
                const recipeId = [83938, 73838, 7638];
                console.log(recipeId);
            }, 1500);
        }
        getRecipe();
    </script>
</body>

У наведеному вище прикладі через 1,5 секунди, коли закінчиться таймер, всередині коду виклику буде виконуватися, іншими словами, через наш підроблений виклик ajax, весь рецепт буде завантажений з сервера. Тепер нам потрібно завантажити конкретні дані рецепту.

<body>
    <script>
        function getRecipe(){
            setTimeout(()=>{
                const recipeId = [83938, 73838, 7638];
                console.log(recipeId);
                setTimeout(id=>{
                    const recipe = {title:'Fresh Apple Juice', publisher:'Suru'};
                    console.log(`${id}: ${recipe.title}`);
                }, 1500, recipeId[2])
            }, 1500);
        }
        getRecipe();
    </script>
</body>

Щоб завантажити конкретні дані рецепту, ми написали код всередині нашого першого зворотного виклику та передали ідентифікатор рецепта.

Тепер скажімо, що нам потрібно завантажити всі рецепти того самого видавця рецепту, id якого - 7638.

<body>
    <script>
        function getRecipe(){
            setTimeout(()=>{
                const recipeId = [83938, 73838, 7638];
                console.log(recipeId);
                setTimeout(id=>{
                    const recipe = {title:'Fresh Apple Juice', publisher:'Suru'};
                    console.log(`${id}: ${recipe.title}`);
                    setTimeout(publisher=>{
                        const recipe2 = {title:'Fresh Apple Pie', publisher:'Suru'};
                        console.log(recipe2);
                    }, 1500, recipe.publisher);
                }, 1500, recipeId[2])
            }, 1500);
        }
        getRecipe();
    </script>
</body>

Щоб повністю заповнити наші потреби, а саме - завантажити всі рецепти назви видавця suru, ми написали код всередині нашого другого дзвінка. Зрозуміло, що ми написали зворотний дзвінок, який називається пеклом зворотного дзвінка.

Якщо ви хочете уникнути пекло зворотного дзвінка, ви можете використовувати Promise, який є функцією js es6, кожна обіцянка приймає зворотний виклик, який викликається, коли обіцянка заповнена повністю. Обіцяння зворотного виклику має два варіанти: він вирішений, або відхилений. Припустимо, ваш виклик API вдалий, ви можете викликати дозвіл на виклик та передавати дані через дозвіл. Ви можете отримати ці дані, використовуючи тоді () . Але якщо ваш API не вдався, ви можете скористатися відхиленням, використовуйте catch, щоб знайти помилку. Пам'ятайте обіцянку завжди використовувати потім для рішучості і улову для відхиляти

Давайте вирішимо попередню пекельну проблему зворотного дзвінка за допомогою обіцянки.

<body>
    <script>

        const getIds = new Promise((resolve, reject)=>{
            setTimeout(()=>{
                const downloadSuccessfull = true;
                const recipeId = [83938, 73838, 7638];
                if(downloadSuccessfull){
                    resolve(recipeId);
                }else{
                    reject('download failed 404');
                }
            }, 1500);
        });

        getIds.then(IDs=>{
            console.log(IDs);
        }).catch(error=>{
            console.log(error);
        });
    </script>
</body>

Тепер скачайте конкретний рецепт:

<body>
    <script>
        const getIds = new Promise((resolve, reject)=>{
            setTimeout(()=>{
                const downloadSuccessfull = true;
                const recipeId = [83938, 73838, 7638];
                if(downloadSuccessfull){
                    resolve(recipeId);
                }else{
                    reject('download failed 404');
                }
            }, 1500);
        });

        const getRecipe = recID => {
            return new Promise((resolve, reject)=>{
                setTimeout(id => {
                    const downloadSuccessfull = true;
                    if (downloadSuccessfull){
                        const recipe = {title:'Fresh Apple Juice', publisher:'Suru'};
                        resolve(`${id}: ${recipe.title}`);
                    }else{
                        reject(`${id}: recipe download failed 404`);
                    }

                }, 1500, recID)
            })
        }
        getIds.then(IDs=>{
            console.log(IDs);
            return getRecipe(IDs[2]);
        }).
        then(recipe =>{
            console.log(recipe);
        })
        .catch(error=>{
            console.log(error);
        });
    </script>
</body>

Тепер ми можемо написати інший метод виклику allRecipeOfAPublisher, як getRecipe, який також поверне обіцянку, і ми можемо написати інший тоді (), щоб отримати обіцянку вирішення для allRecipeOfAPublisher, я сподіваюся, що в цей момент ви можете зробити це самостійно.

Таким чином ми дізналися, як будувати обіцянки та споживати, тепер давайте полегшити використання обіцянки за допомогою функції async / await, яка введена в es8.

<body>
    <script>

        const getIds = new Promise((resolve, reject)=>{
            setTimeout(()=>{
                const downloadSuccessfull = true;
                const recipeId = [83938, 73838, 7638];
                if(downloadSuccessfull){
                    resolve(recipeId);
                }else{
                    reject('download failed 404');
                }
            }, 1500);
        });

        const getRecipe = recID => {
            return new Promise((resolve, reject)=>{
                setTimeout(id => {
                    const downloadSuccessfull = true;
                    if (downloadSuccessfull){
                        const recipe = {title:'Fresh Apple Juice', publisher:'Suru'};
                        resolve(`${id}: ${recipe.title}`);
                    }else{
                        reject(`${id}: recipe download failed 404`);
                    }

                }, 1500, recID)
            })
        }

        async function getRecipesAw(){
            const IDs = await getIds;
            console.log(IDs);
            const recipe = await getRecipe(IDs[2]);
            console.log(recipe);
        }

        getRecipesAw();
    </script>
</body>

У наведеному вище прикладі ми використовували функцію async, оскільки вона буде працювати у фоновому режимі, всередині функції async ми використовували ключове слово очікування перед кожним методом, який повертається або є обіцянкою, тому що чекати на цій позиції, поки ця обіцянка не буде виконана, іншими словами в внизу коди, поки програма getIds не буде завершена або відхилена, програма перестане виконувати коди нижче цього рядка, коли ідентифікатори повернулися, тоді ми знову викликали функцію getRecipe () з ідентифікатором і чекали, використовуючи ключове слово очікування, поки дані не повернуться. Так ось, нарешті, ми оговталися від пекла зворотного дзвінка.

  async function getRecipesAw(){
            const IDs = await getIds;
            console.log(IDs);
            const recipe = await getRecipe(IDs[2]);
            console.log(recipe);
        }

Для використання в режимі очікування нам знадобиться функція асинхронізації, ми можемо повернути обіцянку, тож використовуйте тоді для вирішення обіцянки та поступка для відхилення обіцянки

з наведеного вище прикладу:

 async function getRecipesAw(){
            const IDs = await getIds;
            const recipe = await getRecipe(IDs[2]);
            return recipe;
        }

        getRecipesAw().then(result=>{
            console.log(result);
        }).catch(error=>{
            console.log(error);
        });

0

Одним із способів уникнути пекло зворотного виклику є використання FRP, який є "розширеною версією" RX.

Я почав використовувати FRP нещодавно, тому що знайшов гарну реалізацію його під назвою Sodium( http://sodium.nz/ ).

Типовий код виглядає приблизно так (Scala.js):

def render: Unit => VdomElement = { _ =>
  <.div(
    <.hr,
    <.h2("Note Selector"),
    <.hr,
    <.br,
    noteSelectorTable.comp(),
    NoteCreatorWidget().createNewNoteButton.comp(),
    NoteEditorWidget(selectedNote.updates()).comp(),
    <.hr,
    <.br
  )
}

selectedNote.updates()- це те, Streamщо спрацьовує, якщо selectedNode(що є Cell) змінюється, NodeEditorWidgetто оновлення відповідно.

Отже, залежно від змісту selectedNode Cellпоточного редагування Noteбуде змінено.

Цей код уникає Callback-s повністю, майже, Cacllback-s висуваються на "зовнішній шар" / "поверхню" програми, де логіка обробки стану взаємодіє із зовнішнім світом. Немає зворотних викликів, необхідних для розповсюдження даних у внутрішній логіці обробки стану (яка реалізує стан машини).

Повний вихідний код тут

Фрагмент коду вище відповідає простому прикладу створення / відображення / оновлення:

введіть тут опис зображення

Цей код також надсилає оновлення на сервер, тому зміни до оновлених Суб'єктів автоматично зберігаються на сервері.

Про всі заходи подій забезпечується за допомогою Streams та Cells. Це концепції FRP. Відкликання дзвінків потрібні лише в тому випадку, коли логіка FRP взаємодіє із зовнішнім світом, таким як введення користувача, редагування тексту, натискання кнопки, повернення дзвінка AJAX.

Потік даних явно описується декларативно, використовуючи FRP (реалізований бібліотекою Sodium), тому для опису потоку даних не потрібна логіка обробки подій / зворотного виклику.

FRP (що є більш "суворою" версією RX) - це спосіб описати графік потоку даних, який може містити вузли, що містять стан. Події викликають зміни стану у вузлах, що містять стан (звані Cells).

Натрій - це бібліотека FRP вищого порядку, тобто за допомогою flatMap/ switchпримітиву можна переставляти графік потоку даних під час виконання.

Я рекомендую ознайомитись із книгою Sodium , вона детально пояснює, як FRP позбавляється від усіх зворотних зворотів, які не є істотними для опису логіки потоку даних, пов'язаного з оновленням стану додатків у відповідь на деякі зовнішні стимули.

Використовуючи FRP, потрібно зберігати лише ті зворотні виклики, які описують взаємодію із зовнішнім світом. Іншими словами, потік даних описується функціонально / декларативно, коли використовується FRP-фреймворк (такий як Sodium) або коли використовується "FRP-подібний" фреймворк (наприклад, RX).

Натрій також доступний для Javascript / Typescript.


-3

Якщо у вас немає знань про зворотний дзвінок і зворотний виклик, немає проблем. Найперше, це зворотній дзвінок і передзвоніть пекла. Наприклад: зворотний зворотній дзвінок - це як ми можемо зберігати клас у класі. Як ви чули про це вкладене в мову C, C ++. Введений означає, що клас усередині іншого класу.


Відповідь буде кориснішою, якщо вона містить фрагмент коду, щоб показати, що таке "відкликання зворотного дзвінка" та той самий фрагмент коду з Rx після видалення "пекло зворотного дзвінка"
rafa

-4

Використовуйте jazz.js https://github.com/Javanile/Jazz.js

це спрощується так:

    // запустити послідовне завдання, пов'язане ланцюгом
    jj.script ([
        // перше завдання
        функція (наступна) {
            // в кінці цього процесу «наступний» вкажіть на друге завдання і запустіть його 
            callAsyncProcess1 (наступний);
        },
      // друге завдання
      функція (наступна) {
        // в кінці цього процесу "наступний" вкажіть на завдання завдання та запустіть його 
        callAsyncProcess2 (наступний);
      },
      // завдання тирту
      функція (наступна) {
        // в кінці цього процесу "наступний" пункт (якщо є) 
        callAsyncProcess3 (наступний);
      },
    ]);


вважайте ультра компактним, як цей github.com/Javanile/Jazz.js/wiki/Script-showcase
cicciodarkast
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.