Как стать автором
Обновить

Комментарии 34

Не мешайте человеку изобретать Лисп. :-)

НЛО прилетело и опубликовало эту надпись здесь

Пока не лисп. Сейчас уже у вас есть декларативное дерево, которое исполняется некоторым примитивным интерпретатором. Не хватает только макросов. :-)

НЛО прилетело и опубликовало эту надпись здесь
На этом примере происходит знакомство с принципами работы function-tree, а не способов последовательного запуска асинхронных функций.

К слову, ваш вариант не является полноценным эквивалентом, т.к. в случае с function-tree функция bar может быть синхронной и не возвращать промисов.
If the awaited expression isn’t a promise, its casted into a promise.
Именно. А промисы резолвятся не синхронно, а в микротаске.
Сомнительное увеличение читабельности потока исполнения. Та же «co» выглядит в этом смысле значительно прозрачнее.

Вообще, подобные «решения» (святая война с каллбэками) решают проблему читабельности потока исполнения путём навязывания деления логики приложения на компактные атомистичные функции с ёмкими и понятными названиями, и кроме «красоты» кода ничего дополнительно, по-сути, не дают. Но оказывается, что если разделить логику на атомистичные функции с понятными названиями, то уже и не нужны никакие DSL для их композиции — хватает встроенных возможностей JS.

Christian:


it requires a very high level of discipline to uphold that dividing of logic which can be difficult to do in teams. With function-tree you are forced to divide your logic, in a good way. Also "built-in JS" features does not ensure testability, function-tree does because you put all "globals" on the context. And last but not least… you can not have a debugger that understands "built-in JS features"
НЛО прилетело и опубликовало эту надпись здесь
Большой action creator действительно спорная часть статьи. Кристиан пытался показать весь поток при наступлении определённого события. Обычно в redux так не принято. Но в «правильном» redux не будет такого единого места где можно увидеть весь поток со всеми возможными путями, его придётся восстанавливать в голове каждый раз возвращаясь к этому коду.
Если есть идеи как этот поток можно выразить лучше, пожалуйста дайте пример. Возможны мы просто не умеем готовить редакс :)
К слову, Кристиан утверждает: «The example was typical for what I have seen in projects. Maybe we should change that example if it is not valid. I can understand if people use like Saga or something similar, but a lot of people do not.»
Но тестирование будет даваться нам с трудом, так как axios находится вне нашего контроля. Перепишем функцию, чтобы она принимала axios в качестве аргумента

Откройте для себя sinonjs и будет вам счастье.


Тестировать асинхронный код, подставляя ему axios (window, jquery, что угодно), в действительно сложнее, чем кажется. Проблема в том, что в какой-то момент вы где-то можете начать передавать заголовки, вызывать каким-то нестандартным способом по той или иной причине (конкретно для axios — может понадобится создать экземпляр-наследник), таким образом в своих тестах вы перепишите половину функционала этого самого axios. С тем же sinon все становится гораздо проще, главное — не запутаться когда что ответить.


Вдобавок, в какой-то момент вы начнете тянуть достаточно большой контекст, например, вам понадобится тестировать по stab'ам, фикстурам, мокапам, окажется, что проверять нужно на конкретный момент времени, тогда придется тянуть Date и так далее.


Мало того, что вы вроде как выровняли и украсили свой код, сделав его полностью чистым, а на практике передвинули проблемы из угла в другой угол, так все еще начинает жутко тормозить: даже нативные функции пробрасываете через контексты, постоянно вызваете Object.assign, таскаете огромные хеш-таблицы (объекты) данных (к слову, оно не течет ли часом?), а потом удивляетесь, что это у меня хром отожрал 5 гб памяти на 3 вкладки и вините DOM в его тормознутости, когда без него хватает чему работать нерасторопно.


Хотя идея неплохая, присмотреться стоит.

Вы правы. Примеры в статье, к сожалению, немного гипертрофированы. В реальном приложении я бы обернул ajax запросы в сервис с понятными методами. Это позволит тестировать экшены предоставив мок сервис в контекст в тестовой среде.
Боевой же сервис (который и будет устанавливать нестандартнык заголовки) скорее надо тестировать с помощью интерсептеров.

По производительности, возможно, не самое лучшее решение. Однако обработка управления потока на клик пользователя врядли будет узким местом приложения.

А вот по опыту cerebral мы получаем множество положительных отзывов о итоговой читаемости. О том как легко становиться понимать приложение в целом. В итоге некоторое даже стали пытаться использовать cerebral в nodejs, например, для работы с firebase и т.п.
Мы знаем про redux-saga. Возможно стоило добавить его к сравнению в статье. Однако внимательный читатель встретит аргументы касательно методов используемых в саге. Хотелось бы более развёрнутого комментария.
В целом подход function-tree очень интересный — простое описание что отчего зависит здорово ускоряет разработку.
Но мне кажется декларативный подход ограничен, он обычно упирается в какой-нибудь сложный кейс, и чтобы его решить приходится вносить обработку такого кейса в библиотеку либо серьезно костылить в приложении. В мире реакта это нам регулярно демонстрирует react-router.

Тем более в мире data-flow. В сложном приложении двумя результатами не обойтись (success и error), нужна еще как минимум отмена, причем с выбором как отмена должна влиять на родительские процессы или на параллельные. Все это универсально в декларативном стиле не опишешь, всегда будет конкретный кейс который выбьется из возможностей библиотеки.

Плюс саги как раз в том что можно классически (императивно) описывать поток какой-либо функциональности (так же как она выполняется, упорядоченно и в одном файле) с наличием if, ловлей ошибок, отменами, каналами и тд.
В статье вы приводите аргументы чем function-tree лучше цепочек обещаний. Но саги это не цепочки обещаний. Они основаны на генераторах, а что в них класть — обещания или что-то еще это уже второй вопрос. Природа генераторов позволяет описывать поток «плоско»(как async/await) и так же отлично решает вопрос с тестированием без мокирования сайд-эффектов.

Мне понравилась реализация и плюшки function-tree, но саги, думаю, более гибкие
Также это https://github.com/redux-observable/redux-observable
> Но тестирование будет даваться нам с трудом, так как axios находится вне нашего контроля.
Почему оно должно быть с трудом? Все unit-тесты основаны на mock-объектах, не важно, где и как их подменять. Главное, чтобы такая возможность была. В NodeJS, например, она уже есть из коробки в виде модулей и require.

> Однако есть одно преимущество.
> Всё что происходит при вызове функции loadData определено так как оно выполняется, упорядоченно и в одном файле.
Создайте папку.

> Трудно понять, как эти части связаны друг с другом, потому что вы не можете видеть какие функции приводят к вызову другой функции.
Создайте части по отдельности, а их композицию опишите в одном месте.

>Все функции выполняемые с помощью function-tree принимают один аргумент. context
Т.е. god-object или service-locator, оба антипаттерны.

> Вы можете передать полезную нагрузку, передавая объект к методу пути.

Goto метки, проходили.

> Многие библиотеки для тестирования позволяют вам создавать заглушки для глобальных зависимостей. Но нет причин делать это для function-tree, потому что функции используют только то, что доступно через аргумент контекста.

И не просто так, DI через конструктор хорош, но зачастую избыточен. А уж тем более, Service Locator.

> И Rxjs, и Обещания управляют контролем исполнения. Но ни один из них не имеет декларативного условного определения путей исполнения. Вам придётся разносить потоки, писать выражения if и switch или выбрасывать ошибки.
Это все делается, чтобы не придумывать названия меткам goto. И чтобы не создавать вручную бесконечные нечитаемые деревья.

А в целом, не нравится JS, ну не пишите на нем, зачем придумывать свой DSL поверх него? Проще транслятор написать или использовать тысячи имеющихся.

Christian:


  1. Also Sinon was mentioned as a mocking tool. I can see the point, but personally I think it is better to mock arguments than global dependencies. Cause you do not need a mocking tool, you just mock the context as any argument to a normal function
  2. I do not agree that a folder with files gives the same readability as one file where you see the order of functions execution. Maybe a misunderstanding?
  3. With Elm/Redux-loop/Cyclejs you can not compose them in one place. The reason being that a state change is what returns a new side effect. Which means that for every side effect that causes a state change you have to return a new side effect. This can not be composed in one file as you define state changes one place and side effects an other place
  4. Yeah, this is totally true in general. But this context is created for each function tree. It is no more god-like than creating a class where the context is "this", in my opinion
  5. As I understand GOTO labels can jump wherever in the code. Function tree is stricter in that regard, it is not about "jumping", it is about choosing "the next possible step"
  6. I would argue that using mocking tools to mock global deps is not better than mocking an argument to a function. Mostly because most tools we mock returns promises, which means you do not need to mock the tool itself, you just mock the promise
  7. Decoupling is exactly what reduces readability. You need somewhere to compose it all together so you do not have to build up the mental image of how it runs. If the tree is endless I agree, but I have never met an endless tree I have met a lot of decoupled code that has been hard to compose a mental image of though


Have to say thanks for great comments if you have a chance But I think maybe one thing that goes missing a bit here is that you can achieve all the traits of what function-tree tries to give you with discipline. But that is very hard to achieve in code in general and especially in teams. Function-tree just helps you achieve these attributes that produces readable code. And to boot it is able to visualize your code in a way that is not possible with plain JS, using its debugger.

But yeah, great comments

Прошу прощения, что без перевода. Но лучше так, чем оставлять без ответа.

1. Что такое глобальные зависимости? Я вот знаю в браузере window с детками и в NodeJS global, ну можно еще process и console. Для всего остальное существуют модули, импорты и экспорты, хоть requirejs с commonjs, хоть ES-modules, хоть AMD.
Если не нравится слово mock, есть другое слово DI через импорт модуля. Нужно это уже осознать, и не беспокоиться, что JS — не Java и не Haskell, что в JS принято считать единицей — модуль, а не класс или функцию.
2. Здесь, видимо, переводчик халтурит, я не говорил, что композицию нужно делать с помощью папок. Переводите нормально, или бросьте эту затею.
3. Не знаю, что не может автор. Нет side-эффектов, потому что все инъектируется через импорт, кроме разве что тех из 1 пункта. Вот их мокать трудно, но нужно очень редко.
4. Я классы не защищал, в JS лучше писать микро-функции и передавать туда только ту часть state, которая ей нужна. И уж точно не выдумывать контекст для еще большего state.
5. Сначала создать проблему, потом героически ее решать)
6. Как раз с обещаниями тестировать еще проще, достаточно проверять expectations в конце цепочки вызовов. Однако, не вижу ничего страшного и в подмене обещаний, я вот люблю подменять их тупо на синхронную версию в тестах.
7. Нам не нужно никакое дерево с контекстом, чтобы разделить код на куски, а композицию описать в одном месте.

В JS есть проблемы, иногда асинхронность, иногда tree-shaking, с синтаксисом и с типами, но никак не проблема управления потоком синхронного кода.
  1. Здесь, видимо, переводчик халтурит, я не говорил, что композицию нужно делать с помощью папок. Переводите нормально, или бросьте эту затею.

Предложите перевод для Вашей фразы "Создайте папку", более адекватный чем "Create a folder". А то я прямо в растерянности. :)
При этом к фразе был приложен контекст из оригинальной статьи, аналогичный процитированному вами куску перевода.


Если вам будет удобно, можете обратиться к автору напрямую в нашем discord-чате. Если нет, то продолжу переводить. Нам важен фидбек и я его передам.

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

Этот комментарий оставляю здесь для возможности комментирования после прочтения (завтра)
function getA() {
  return Promise.resolve('a');
}
function getB(a) {
  if (c) throw new CError(); // работа с ошибками через оператор throw, предусмотренный в языке специально для таких случаев
  return Promise.resolve(a + 'b');
}

co(function* () {
  try {  // настоящий try-catch
    a = yield getA();  // ключевое слово yield наглядно показывает, что выполнение прерывается и мы ждем завершения асинхронной операции
    console.log(yield getB(a));
  } catch (e) {
    console.log('error');
  }
}); // возвращает промис, что позволяет при необходимости легко скомбинировать его с дополнительным кодом и добавить then/catch, или даже вызвать изнутри другого генератора

function getA() {
  return Promise.resolve({result: 'a'}); // нельзя просто вернуть результат, каждый раз необходимо придумывать бессмысленное название поля для каждой конкретной функции. Ненужные поля продолжают копиться внутри context грудой хлама и гулять по всем функциям.
}
function getB(a) {
  if (c) return path.error(new CError()); // необходимость вручную вызывать path.error => возможность пропустить необработанную ошибку
  return Promise.resolve({result: a + 'b'});
}

exec([
  getA, // непонятно, синхронная функция или асинхронная, невозможно делегировать исполнение другому генератору через yield*, только через функцию-костыль
  {
    error: () => console.log('error'),
    success: [
      getB, {
        error: () => console.log('error'),  // не предусмотрено слияние веток исполнения; при возможности использовать всплытие мне не пришлось бы писать два раза одно и то же
        success: (context) => console.log(context.result)
      }
    ]
  }
]); // изящный танец скобочек в конце; в примере, приведенном в тексте статьи, это выглядело еще более впечатляюще:
/*        
          }
        ]
      }
    ]
  }
] 
*/
Да, и getB получает не только аргумент, необходимый и достаточный для его работы, а целую пачку данных, включая path, весь context и провайдеры, и должен сам отбирать из этого всего то, что ему нужно. Мелочь, но не особо приятно. В принципе идея интересная, но тупиковая, хотя это очень интересный, «умный» тупик.

обычно это выглядит так:


function getB({ input: { smthImportantForMe } }){
}

Да, возможно, контекст не самое красивое решение, но он работает.
См. также:
https://youtu.be/vpc80c5iC6k?list=PLglJM3BYAMPH2zuz1nbKHQyeawE4SN0Cd&t=643

// работа с ошибками через оператор throw, предусмотренный в языке специально для таких случаев

Я не хочу exception. Неполучение данных для меня не исключение, а норма в условиях http. Использование try catch в качестве условия ветвления логики до добра не доводит.
Цитата из статьи:


Часто потоки мыслятся как: "сделай это или бросай всё, если произойдёт ошибка". Но не в случае с веб-приложениями. Есть много причин, почему вы решите пойти вниз по различным путям выполнения.



// ключевое слово yield наглядно показывает, что выполнение прерывается и мы ждем завершения асинхронной операции

Только зачем мне это при чтении потока?


exec([
  foo,
  bar // наглядно показывает, что bar выполнится только после foo. И не важно синхронные они или нет.
])

exec([
  foo,
  [ // параллельное выполнение bar и baz после foo
    bar,
    baz
  ],
  theEnd // выполнится только после bar и baz. Аналог Promise.all([bar, baz]).then(theEnd)
]) // exec кстати тоже возвращает Промис который резолвится объединённой полезной нагрузкой
// Полезно для тестирования, но не рекомендуется для композиции, во избежание дробления. Дерево является полным и самодостаточным описанием потока. Нет необходимости оборачивать его в try catch.



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

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




// необходимость вручную вызывать path.error => возможность пропустить необработанную ошибку

Использование try catch для отлова ошибки не возбраняется. Но обработать её надо там же где она возникла, не полагаясь на вызывающую сторону, если только вы не хотите восстанавливать поток по крупицам в своей голове.


function getA ({someService, path}) {
  try {
    return path.success({ importantThing: someService.dangerousGetSmth() })
  } catch (e) {
    return path.error({ importantThingError: e.toString() })
  }
}

На самом деле лучше если обработка ошибки будет обработана в сервисе


function getA ({someService, path}) {
  return someService.saveGetSmth().then(
    entity => path.success({ importantThing: entity }),
    error => path.error({ importantThingError: error }),
  )
}

А ещё ошибки бывают разные:


exec([
  getEntity, {
    sucess: [processEntity],
    notFound: [showError('entityNotFound'), showCreateEntityDialog], // 404
    unuathorized: [showError('unuathorized'), showRequestPermissionsDialog]
    serviceFailed: [showError('serviceFailed'), showTryLaterNotification] // 503
  }
])



// непонятно, синхронная функция или асинхронная, невозможно делегировать исполнение другому генератору через yield*, только через функцию-костыль

Синхронность функции — деталь реализации. Из описания дерева и так явно следует порядок исполнения.




// не предусмотрено слияние веток исполнения; при возможности использовать всплытие мне не пришлось бы писать два раза одно и то же

Я не знаю, что вам помешало вынести ветку обработки ошибки в отдельную функцию или даже цепочку. Дерево описывается в js синтаксе.



// chains/processErrors.js

export default [
  logErrorToConsole,
  showNotificationToUser,
  logErrorToGoogleAnalytics
]

// chains/getSmth.js
import getA from '../actions/getA'
import getB from '../actions/getB'
import processErrors from './processErrors'

export default [
  getA, {
    success: [
      getB, {
        success: [({input}) => console.log(input.result)],
        error: [...processErrors, showRetryGetBDialog]
      }
    ],
    error: [...processErrors, showRetryGetADialog]
  }
] // и скобочки не танцуют :(
Что касается try-catch

Я не хочу exception. Неполучение данных для меня не исключение, а норма
Если неполучение данных — не исключение, то что же тогда можно считать исключением? Исключение — это вовсе не «Сделай код и бросай, если произойдет ошибка» Это не то чтобы норма, но вполне штатная ситуация, которую можно и нужно обрабатывать, именно в этом весь смысл оператора catch. Неполучение данных от сервера, невалидный JSON, ошибка чтения файла — все это классические примеры исключений. JSON.parse, fs.readFileSync и многие другие встроенные синхронные функции именно так и обозначают, что что-то пошло не так. Если считать, что использование исключений неуместно в этом случае, логично было бы посчитать, что оно неуместно в принципе, потому что ситуаций, когда можно просто все бросить, в мире веба не существует. Мы в любом случае должны как-то уведомить пользователя о том, что операция не получилась, если это Web UI, или сообщить клиенту, что запрос не обработан и произошла internal error, если у нас API. Если исключения воспринимать как нечто из ряда вон выходящее и повод дальше ничего не делать, то тогда использовать исключения вообще не следует. Такой подход имеет право на существование, многие ругают try-catch и считают его «завуалированным GOTO». На мой взгляд, это некоторая софистика, при желании «завуалированным GOTO» можно назвать вообще любую конструкцию в языке. Так что лучше подойти к вопросу с практической точки зрения.

1. Всплытие — хорошая вещь. Это логичная вещь. Позволять ошибке всплывать — это не значит «полагаться на вызывающую сторону». Это значит соблюдать инкапсуляцию, решать стоящую перед функцией задачу. Допустим, у нас функция foo получает данные пользователя и вызывает их отрисовку в UI. Функция bar делает запрос к API, парсит результат функцией baz и возвращает его. Допустим, функция baz у нас выкинула исключение. Оно имеет смысловую нагрузку, а конкретно — оно означает, что функция baz не смогла распарсить входные данные. Из этого следует, что и функция bar не справилась со своей задачей — не смогла получить и вернуть валидный результат. Ошибка всплывает дальше. Foo перехватывает ее, с ее точки зрения ошибка тоже имеет конкретный смысл — валидные данные не получены, нужно уведомить пользователя, что произошла ошибка, foo вызывает отрисовку соответствующего события в UI. Каждая функция берет на себя ответственность только за те исключения, которые может обработать.

2. Можно что-то сделать с uncaught exceptions. Человек не идеален и не всегда может просчитать все возможные пути исполнения программы. Если мы пропустим возможность получения ошибки, например, в функции baz, то функция foo в любом случае перехватит ее и уведомит пользователя о том, что случилась ошибка. В function-tree нам придется либо допустить, что мы могли о чем-то забыть, и возможна ситуация, когда произойдет ошибка и просто ничего не случится, потому что мы забыли вызвать path.error, либо вместо:
function() {

}

писать везде
function({path}) {
  try {
  
  } catch(e) {
    path.error({aRandomKey: e});
  }
}

ну просто на всякий случай.

3. Исключение — наиболее простой и универсальный способ сообщить о том, что что-то пошло не так. Он традиционный и широко применяется, встроенные методы языка и Node.JS, к счастью, используют именно throw, а не path.error({anotherOneRandomKey: e}). Поэтому если моя функция должна, например, спарсить JSON, как-то обработать его и что-то пошло не так, она просто выкинет ошибку, см. п. 1:

function() {
  let parsedJSON = JSON.parse(json); // ошибка тут означает ошибку всей функции, в этом и смысл всплытия.
  //...
}


function() {
  try {
    let parsedJSON = JSON.parse(json);
    //...
  } catch(e) {
    return path.error({andAnotherOneRandomKey: e});  // фактически ошибка все равно будет обрабатываться дальше снаружи, просто это потребовало больше букв, по-другому называется и выглядит более солидно (запутанно).
  }
}
Что касается нотации

Все субъективно, но лично мне кажется, что

function* () {
  let a = yield foo();
  let b = yield bar(a);
  return yield baz(b);
}


объективно более читаемо, чем

[
  foo, 
  {
    success: [
      bar,
      {
        success: [
        baz
        ]
      }
    ]
  }
]                  


И если операций у меня здесь будет не 3, а 50, то мы получим чудовищный лапшичный код, растягивающий экран на километры вправо, и вернемся обратно в callback hell, только теперь с блэкджеком и промисами.

Не спорю. Первое читаемее второго. Только они не эквивалентны.
В function-tree это будет выглядеть:


[
  foo,
  bar,
  baz
]
Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории