javascript

Длинные уши асинхронности

  • среда, 25 октября 2017 г. в 03:13:03
https://habrahabr.ru/company/qiwi/blog/340840/
  • Программирование
  • ReactJS
  • JavaScript
  • Блог компании QIWI



Разработчики React тяготеют к функциональному подходу, но с появлением MobX, появилась возможность работать с состоянием в более-менее привычном ООП-стиле. Mobx старается не навязывать какую либо архитектуру, позволяя работать с реактивным состоянием, как с обычными объектами. При этом он делает автоматическое связывание вычислений, когда достаточно написать C = A + B, чтобы при обновлении A, обновился и C.


В HelloWorld это выглядит просто, но если мы добавим fetch, отображение статусов загрузки и обработку ошибок, мы увидим, что получается много копипаста, а в код начинают просачиваться хелперы вроде when, fromPromise или lazyObservable. И уже не получается писать код так, как будто нет асинхронности. Я хочу разобрать некоторые подобные примеры в MobX и попытаться улучшить его базовую концепцию, развив идею псевдосинхронности.


Загрузка данных


Рассмотрим простейший список дел на MobX и React.


const {action, observable, computed} = mobx;
const {observer} = mobxReact;
const {Component} = React;

let tid = 0
class Todo {
    id = ++tid;
    @observable title;
    @observable finished = false;
    constructor(title) {
        this.title = title;
    }
}

function fetchSomeTodos(genError) {
    return new Promise((resolve) => setTimeout(() => {
      resolve([
        new Todo('Get Coffee'),
        new Todo('Write simpler code')
      ])
    }, 500))
}

class TodoList {
    @observable todos = [];
    @computed get unfinishedTodoCount() {
        return this.todos.filter(todo => !todo.finished).length;
    }
    @action fetchTodos() {
        fetchSomeTodos()
          .then(todos => { this.todos = todos })
    }
}

const TodoView = observer(({todo}) => {
   return <li>
        <input
            type="checkbox"
            checked={todo.finished}
            onClick={() => todo.finished = !todo.finished}
        />{todo.title}
    </li>
})
TodoView.displayName = 'TodoView'

@observer class TodoListView extends Component {
    componentDidMount() {
        this.props.todoList.fetchTodos()
    }
    render() {
        const {todoList} = this.props
        return <div>
            <ul>
                {todoList.todos.map(todo =>
                    <TodoView todo={todo} key={todo.id} />
                )}
            </ul>
            Tasks left: {todoList.unfinishedTodoCount}
        </div>
    }
}

const store = new TodoList()
ReactDOM.render(<TodoListView todoList={store} />, document.getElementById('mount'))

fiddle


В простом случае компонент через componentWillMount должен сам начать загрузку данных. Каждый раз, создавая новый компонент, используюший todoList, программисту надо держать в голове, что todoList.todos надо загрузить. Если этого не сделать, то кто даст гарантию, что кто-то там наверху уже загрузил эти данные?


Можно, конечно, лучше разделить состояние и UI без componentWillMount для целей загрузки. О чем и говорит автор MobX Michel Weststrate в статье How to decouple state and UI. При открытии страницы все данные, необходимые для ее рендеринга, запрашиваются с сервера. А ответственность по инициализации этой загрузки автор предлагает перенести на роутер.


import { createHistory } from 'history';
import { Router } from 'director';

export function startRouter(store) {
    // update state on url change
    const router = new Router({
        "/document/:documentId": (id) => store.showDocument(id),
        "/document/": () => store.showOverview()
    }).configure({
        notfound: () => store.showOverview(),
        html5history: true
    }).init()
}

Такой подход порождает проблему — роутер должен знать, что конкретно из данных требуется компонентам, которые будут на открываемой странице. Вызов метода store.showOverview в этом месте кода нарушает инкапсуляцию. Что будет, если в ходе рефакторинга на страницу добавили новый компонент, которому надо что-то получить с сервера, а в роутер не добавили загрузку? Ошибиться здесь легко, так как детали работы со стором размазаны по разным местам приложения.


Вызов fetchTodos() не обязательно должен быть в componentWillMount. Он может быть замаскирован за HOC, за роутером, за вызовом onClick в какой-нибудь кнопке, даже напрямую вызываться в index.js, как в примере с redux-saga:


...
import rootSaga from './sagas'
const store = configureStore(window.__INITIAL_STATE__)
store.runSaga(rootSaga)
...

Где store.runSaga(rootSaga) сразу запускает загрузку всех необходимых для работы приложения данных.


Суть одна — в коде будет место, где программист должен инициировать загрузку. И это место будет вне модели или того что вместо неё (например саги), хотя по смыслу сам факт вызова инициализации — лишь внутренняя деталь работы с сетью. Если убрать асинхронность, то она становится ненужной. Причем загрузка в подобных решениях происходит не по факту обращения компонента к этим данным, а заранее.


Обработка ошибок при асинхронной загрузке


В MobX ошибки и статус загрузки сами собой никак не попадут на интерфейс. Чтобы их отобразить, нам для каждой загружаемой сущности надо создать свойство error в сторе. В каждом компоненте с todoList.todos необходимо cделать обработку этого свойства, которая в большинстве случаев будет одинаковой — показать надпись или stack trace в dev-режиме. Если программист забудет их обработать — пользователь не увидит ничего, даже надписи «Что-то пошло не так».


class TodoList {
    @observable todos = []
    @observable error: ?Error
    @observable pending = false
    @action fetchTodos(genError) {
        this.pending = true
        this.error = null
        fetchSomeTodos(genError)
           .then(todos => { this.todos = todos; this.pending = false })
           .catch(error => { this.error = error; this.pending = false })
    }
}
@observer class TodoListView extends Component {
    componentWillMount() {
        this.props.todoList.fetchTodos()
    }
    render() {
        const {todoList} = this.props
        return <div>
            {todoList.pending ? 'Loading...' : null}
            {todoList.error ? todoList.error.message : null}
            ...
        </div>
    }
}

fiddle


Используем fromPromise


Шаблонного кода в предыдущем примере много, как в сторе, так и в компоненте. Для уменьшения копипаста можно использовать хелпер fromPromise из mobx-utils, который вместе со значением отдает статус загрузки этого значения. Вот пример демонстрации его работы:


class TodoList {
    @observable todoContainer

    constructor() {
      this.fetchTodos()
    }
    // ...
    @action fetchTodos(genError) {
        this.todoContainer = fromPromise(fetchSomeTodos(genError))
    }
}

const StatusView = ({fetchResult}) => {
  switch(fetchResult.state) {
     case "pending": return <div>Loading...</div>
     case "rejected": return <div>Ooops... {JSON.stringify(fetchResult.value)}</div>
  }
}

const TodoListView = observer(({todoList}) => {
    const todoContainer = todoList.todoContainer
    return <div>
        {todoContainer.state === 'fulfilled'
            ? ...
            : <StatusView fetchResult={todoContainer}/>
        }
        ...
    </div>
})

fiddle


У нас уже есть свойство todoContainer, которое содержит значение и статус. Обработать в компоненте его уже проще. В примере выше вызов fetchTodos делается в конструкторе стора TodoList. В отличие от примера с роутингом, это позволяет лучше инкапсулировать детали реализации, не выставляя fetchTodos наружу. Метод fetchTodos остается приватной деталью реализации TodoList.


Минусы такого подхода:


  1. Нарушается ленивость загрузки, new TodoList() отсылает запрос к серверу
  2. В компоненте все равно надо вставлять проверки на состояние загрузки и показывать соответствующее сообщение.
  3. Ладно если только в компоненте. В реальном приложении источников данных может быть много и не все они напрямую прокидываются в компонент, некоторые преобразуются через вычисляемые (computed) значения. В каждом таком значении надо постоянно проверять статус до каких-либо действий с данными. Как в методе unfinishedTodoCount из примера выше

class TodoList {
    //...
    @computed get unfinishedTodoCount() {
        return this.todoContainer.value
            ? this.todoContainer.value.filter(todo => !todo.finished).length
            : []
    }
    //...
}

Используем lazyObservable


Чтобы загрузка из последнего примера происходила лениво, по факту рендеринга компонента (а не в new TodoList) можно обернуть fromPromise в хелпер lazyObservable из mobx-utils. Загрузка начнется, после того, как в компоненте выполнится todoContainer.current().


class TodoList {
    constructor() {
        this.todoContainer = lazyObservable(sink => sink(fromPromise(fetchSomeTodos())))
    }

    @computed get unfinishedTodoCount() {
        const todos = this.todoContainer.current()
        return todos && todos.status === 'fulfilled'
            ? todos.filter(todo => !todo.finished).length
            : []
    }
}

const StatusView = ({fetchResult}) => {
    if (!fetchResult || fetchResult.state === 'pending') return <div>Loading...</div>
    if (fetchResult.state === 'rejected') return <div>{fetchResult.value}</div>
    return null
}

const TodoListView = observer(({todoList}) => {
    const todoContainer = todoList.todoContainer
    const todos = todoContainer.current()
    return <div>
        {todos && todos.state === 'fulfilled'
            ? <div>
                <ul>
                {todos.value.map(todo =>
                    <TodoView todo={todo} key={todo.id} />
                )}
                </ul>
                Tasks left: {todoList.unfinishedTodoCount}
            </div>
            : <StatusView fetchResult={todos}/>
        }
        <button onClick={() => todoContainer.refresh()}>Fetch</button>
    </div>
})

fiddle


Хелпер lazyObservable решает проблему ленивости, но не спасает от шаблонного кода в компоненте. Да и конструкция lazyObservable(sink => sink(fromPromise(fetchSomeTodos()))) уже не так просто выглядит как fetchSomeTodos().then(todos => this.todos = todos) в первой версии списка.


Альтернатива


Помните идею «пишем так, как будто нет асинхронности». Что если пойти дальше MobX? Может кто-то уже это сделал?


Пока, на мой взгляд, дальше всех продвинулся mol_atom. Эта библиотека является частью фреймворка mol от vintage. Здесь, на хабре, автор написал много статей о нем и о принципах его работы (например, Объектное Реактивное Программирование или ОРП). Mol интереснен своими оригинальными идеями, которых нет нигде больше. Проблема в том, что у него полностью своя экосистема. Нельзя взять mol_atom и начать использовать в проекте с реактом, вебпаком и т. д. Поэтому пришлось написать свою реализацию, lom_atom. По сути это адаптация mol_atom, заточенная для использования с реактом.


Ленивая актуализация


Рассмотрим аналогичный пример с todo-листом на lom. Для начала посмотрим на стор с компонентом.


/** @jsx lom_h */
//...
class TodoList {
    @force $: TodoList
    @mem set todos(next: Todo[] | Error) {}
    @mem get todos() {
        fetchSomeTodos()
           .then(todos => { this.$.todos = todos })
           .catch(error => { this.$.todos = error })
        throw new mem.Wait()
    }
    // ...
}

function TodoListView({todoList}) {
  return <div>
    <ul>
      {todoList.todos.map(todo =>
         <TodoView todo={todo} key={todo.id} />
      )}
    </ul>
    Tasks left: {todoList.unfinishedTodoCount}
  </div>
}

fiddle


Происходит тут следующее.


  1. Рендерится TodoListView.
  2. Этот компонент обратится к todoList.todos, сработает get todos() и выполнится код, загружающий данные с сервера.
  3. Данные еще не пришли, а компонент надо показать прямо сейчас. Тут мы можем либо возвратить какое-то значение по умолчанию либо, как в примере, бросить исключение: throw new mem.Wait().
  4. Декоратор mem его перехватывает и todos в TodoListView приходит прокси.
  5. При обращении к любому его свойству бросается исключение внутри TodoListView.
  6. Так как переопределенный createElement оборачивает этот компонент, а обертка эта перехватывает исключения, то будет показан ErrorableView, который задается настройкой библиотеки.
  7. Когда данные приходят с сервера, выполняется this.$.todos = todos (this.$ — означает запись в кэш, минуя вызов set todos() {}).

ErrorableView может быть такого содержания:


function ErrorableView({error}: {error: Error}) {
    return <div>
        {error instanceof mem.Wait
            ? <div>
                Loading...
            </div>
            : <div>
                <h3>Fatal error !</h3>
                <div>{error.message}</div>
                <pre>
                    {error.stack.toString()}
                </pre>
            </div>
        }
    </div>
}

Неважно какой компонент и какие данные в нем используются, поведение по умолчанию для всех одинаково: при любом исключении показывается либо крутилка (в случе mem.Wait), либо текст ошибки. Такое поведение сильно экономит код и нервы, но иногда его надо переопределить. Для этого можно задать кастомный ErrorableView:


function TodoListErrorableView({error}: Error) {
  return <div>{error instanceof mem.Wait ? 'pending...' : error.message}</div>
}
//...
TodoListView.onError = TodoListErrorableView

fiddle


Можно просто перехватить исключение внутри TodoListView, обернув в try/catch todoList.todos. Исключение, бросаемое в компоненте, роняет только его, рисуя ErrorableView.


function TodoView({todo}) {
    if (todo.id === 2) throw new Error('oops')
    return <li>...</li>
}

fiddle


В этом примере мы увидим Fatal error только на месте второго todo.


Такой подход на исключениях дает следующие преимущества:


  1. Любое исключение будет обработано автоматически (нет больше this.error в TodoList) и пользователь увидит сообщение об ошибке.
  2. Исключения не ломают всё приложение, а только компонент, где оно произошло.
  3. Статусы загрузки обрабатываются автоматически, аналогично исключениям (нет больше this.status в TodoList).
  4. Идея настолько простая, что для превращения асинхронного кода в псевдосинхронный не нужно хелперов вроде fromPromise или lazyObservable. Все асинхронные операции инкапсулированы в обработчике get todos().
  5. Код выглядит практически синхронным (кроме fetch, но над ним можно сделать обертку, позволяющую записать его в псевдосинхронном виде).

По сравнению с MobX бойлерплейта стало гораздо меньше. Каждая строчка — это строчка бизнес-логики.


Неблокирующая загрузка


А что будет, если в одном компоненте отобразить несколько загружаемых сущностей, то есть кроме todos, например, есть еще users.


class TodoList {
    @force $: TodoList
    @mem set users(next: {name: string}[] | Error) {}
    @mem get users() {
        fetchSomeUsers()
           .then(users => { this.$.users = users })
           .catch(error => { this.$.users = error })
        throw new mem.Wait()
    }
    //...
}

function TodoListView({todoList}) {
  const {todos, users} = todoList
  //...
  todos.map(...)
  users.map(...)
}

fiddle


Если при первом рендере TodoListView todos и users не будут загружены, вместо них в компонент придут прокси-объекты. То есть когда мы пишем const {todos, users} = todoList, выполняются get todos() и get users(), инициируется их параллельная загрузка, бросается mem.Wait, mem оборачивает исключение в прокси. В компоненте, при обращении к свойствам todos.map или к users.map, выбросится исключение mem.Wait и отрендерится ErrorableView. После загрузки компонент еще раз отрендерится, но уже с реальными данными в todos и users.


Это то, что в mol называется синхронный код, но неблокирующие запросы.


У такого подхода правда есть и минус — необходимо сперва вытащить из todoList todos и users и только потом с ними работать, иначе будет последовательная загрузка и оптимизации не получится.


Управление кэшем


Примеры выше довольно простые. Декоратор mem это такой умный кэш, то есть если todos один раз загрузились, то во второй раз mem отдаст их из кэша.


Раз есть кэш, значит должна быть возможность писать в кэш, минуя обработчик set todos. Значит есть проблема инвалидации кэша. Нужен способ автоматически сбрасывать значение, если зависимость изменилась, также нужно уметь вручную сбрасывать значение, если надо по нажатию кнопки перевытянуть данные и т. д.


Очистка при изменении зависимости и обновление компонента решаются аналогично MobX. А проблема ручного управления кэшем решена через декоратор force. Его работу демонстрирует следующий пример:


class TodoList {
    @force forced: TodoList
    // ..
}
function TodoListView({todoList}) {
  return <div>
    ...
    <button onClick={() => todoList.forced.todos}>Reset</button>
  </div>

fiddle


При нажатии кнопки Reset запрашивается todoList.forced.todos, который безусловно выполняет get todos и заново заполняет кэш. При присвоении значения к todoList.forced.todos значение же запишется в кэш, минуя обработчик set todos.


Помните выше был код с this.$.todos = todos?


/** @jsx lom_h */
//...
class TodoList {
    @force $: TodoList
    @mem set todos(next: Todo[] | Error) {}
    @mem get todos() {
        fetchSomeTodos()
           .then(todos => { this.$.todos = todos })
           .catch(error => { this.$.todos = error })
        throw new mem.Wait()
    }
    // ...
}

Запись в кэш — это приватная деталь get todos. Когда fetch в нем получит данные, то запишет их в кэш напрямую, минуя вызов set todos. Извне запись в todoList.$.todos не допускается. А вот сброс кэша (чтение todoList.$.todos) вполне может быть инициирован извне, что бы повторить запрос.


То, как это сейчас выглядит с force, не самое интуитивно понятное решение, но оно не привносит хелперов в код, оно практически не искажает интерфейс свойств класса (не надо все делать методами), то есть остается ненавязчивым. И очень просто решает целый класс задач, которые неизбежно возникают в MobX-подобных подходах. Тут главное понять некоторые правила:


  • Чтение todoList.todos берет из кэша.
  • Если хотим сбросить значение кэша, делаем чтение из todoList.$.todos.
  • Если хотим записать новое значение и чтобы при этом выполнился set todos (в нем может быть сохранение данных в разные апи, валидация), делаем todoList.todos = newTodos.
  • Если хотим записать значение напрямую в кэш, не выполняя set todos, делаем todoList.$.todos. Это можно делать только внутри get/set todos.

Словари


В lom_atom нет observable-оберток свойств-объектов и массивов, как в MobX. Но есть простой key-value словарь. Например, если к каждому todo понадобилось отдельно подгружать описание по todoId, вместо свойства можно использовать метод, где первый аргумент — ключ на который кэшируется описание, второй — само описание.


class TodoList {
    // ...
    @force forced: TodoList
    @mem.key description(todoId: number, todoDescription?: Description | Error) {
        if (todoDescription !== undefined) return todoDescription // set mode
        fetchTodoDescription(todoId)
            .then(description => this.forced.description(todoId, description))
            .catch(error => this.forced.description(todoId, error))

        throw new mem.Wait()
    }
}
function TodoListView({todoList}) {
  return <div>
    <ul>
      {todoList.todos.map(todo =>
         <TodoView
            todo={todo}
            desc={todoList.description(todo.id)}
            reset={() => todoList.forced.description(todo.id)}
            key={todo.id} />
      )}
    </ul>
    // ...
  </div>
}

fiddle


Если выполнить todoList.description(todo.id), то метод сработает как геттер, аналогично get todos.
Так как метод один, а функции 2 — get/set, то внутри есть ветвление:


if (todoDescription !== undefined) return todoDescription // set mode

То есть если todoDescription !== undefined, значит метод вызван как сеттер: todoList.description(todo.id, todo). Ключ может быть любым сериализуемым типом, объекты и массивы будут сериализованы в ключи с некоторой потерей производительности.


Почему MobX?


Зачем я в начале завел разговор о MobX? Дело в том, что обычно в бизнес-требованиях ничего нет про асинхронность — это приватные детали реализации работы с данными, от неё пытаются всячески абстрагироваться — через потоки, промисы, async/await, волокна и т. д. Причем в вебе выигрывают абстракции проще и менее навязчивее. Например, async/await менее навязчив, по сравнению с промисами, так как это конструкция языка, работает привычный try/catch, не надо передавать функции в then/catch. Иными словами, код на async/await выглядит больше похожим на код без асинхронности.


Как антипод этого подхода, можно упомянуть RxJS. Здесь уже надо окунаться в функциональное программирование, привносить в язык тяжеловесную библиотеку и изучать её API. Вы выстраиваете поток простых вычислений, вставляя их в огромное количество точек расширения библиотеки, или заменяете все операции на функции. Если бы еще RxJS был в стандарте языка, однако наряду с ним есть most, pull-stream, beacon, ramda и многие другие в схожем стиле. И каждый привносит свою спецификацию для реализации ФП, сменить которую уже не получится без переписывания бизнес-логики.


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


Почему не MobX?


Актуализация данных, обработка статусов и ошибок в компонентах — это тоже просачивающаяся асинхронность: инфраструктура, которая в большинстве случаев имеет косвенное отношение к предметной области. Приложения без fetch на MobX выглядят просто, однако стоит добавить этот необходимый слой, как уши асинхронности начинают торчать из каждого сервиса или более-менее сложного компонента. Либо у нас шаблонный код, либо хелперы, захламляющие бизнес логику и ухудшающие чистоту идеи «пишем так, как будто нет асинхронности». Структура данных усложняется, вместе с самими данными в компоненты просачиваются детали реализации канала связи: ошибки и статусы загрузки данных.


Как альтернатива MobX, lom_atom пытается решить эти проблемы в основе, без привнесения хелперов. Для адаптации к компонентамам реакта используется reactive-di (по смыслу аналогичен mobx-react). О нем я рассказывал в своей первой статье, как о попытке развить идею контекстов реакта, получив более гибкие в настройке компоненты, переиспользуемую альтернативу HOC и лучшую интеграцию компонент с flow-типами и, в перспективе, дешевый SOLID.


Итог


Надеюсь, я смог показать на примере атомов, как небольшая доработка базовой концепции может существенно упростить код в типовых задачах для веба и избавить компоненты от знания деталей получения данных. И это небольшая часть того, что может ОРП. На мой взгляд, это целая область программирования со своими паттернами, достоинствами и недостатками. А такие вещи как mol_atom, MobX, delegated-properties в Kotlin это первые попытки нащупать контуры этой области. Если кому-то что-либо известно о подобных подходах в других языках и экосистемах — пишите в комментах, это может быть интересно.