Опыт декларативного подхода в компании Major League Soccer.


Автор оригинала Peggy Rayzis
Оригинал статьи


image


Мое убеждение – лучший код это его отсутствие. Больше кода влечет больше багов и времени на поддержку. У нас в Major League Soccer небольшая команда, и нам близок этот принцип. Мы стремимся к повсеместной оптимизации: от повторного использования кода до облегчения бремени поддержки.


Статья рассказывает, как мы переложили заботу о получении данных на Apollo, и это сэкономило около 5000 строк кода. Переход на Apollo не только уменьшил размер приложения, но добавил декларативности, поскольку компоненты запрашивают только нужные данные.


Что я подразумеваю под декларативностью и почему это хорошо? Декларативное программирование сосредоточено на конечной цели. Напротив, императивный подход концентрируется на шагах по ее достижению. React сам по себе декларативный.


Получение данных с помощью Redux


Рассмотрим простой компонент Article:


import React from 'react';
import { View, Text } from 'react-native';

export default ({ title, body }) => (
  <View>
    <Text>{title}</Text>
    <Text>{body}</Text>
  </View>
);

Пусть компонент <Article/> отображается внутри <MatchDetail/>, который подключен к Redux и принимает ID футбольного матча в props. Без GraphQL получение данных для рендеринга выглядело бы так:


  1. При монтировании <MatchDetail/> вызывается action creator, чтобы запросить данные о матче по ID. Action creator отправляет сообщение в Redux о начале запроса к серверу.
  2. Делается запрос, приходит ответ. Данные нормализуются нужным образом.
  3. Когда данные оформлены как надо, в Redux отправляется сообщение об окончании запроса.
  4. Redux обрабатывает сообщение и с помощью редьюсера обновляет дерево состояния.
  5. Компонент <MatchDetail/> получает все данные о футбольном матче и отбирает нужные данные для рендеринга вложенного компонента.

Так много шагов, чтобы получить данные для <Article/>! Без клиентской библиотеки GraphQL код очень императивный – нацелен на то, как получить данные. Что если компоненту <Article/> не нужны все данные футбольного матча? Тогда можно создать дополнительную точку назначения (endpoint) и отдельный action creator для запроса, но этот подход быстро приводит к трудно поддерживаемому коду.


Посмотрим, как это делается в GraphQL:


  1. Визуальный компонент <MatchDetail/> подключен к GraphQl через компонент-контейнер (HOC), который делает следующий запрос:

query Article($id: Float!) {
  match(id: $id) {
    article {
      title
      body
    }
  }
}

…и это все! Когда клиент GraphQL получает данные с сервера, он превращает их в свойства компонента <Article/>. Намного декларативнее, поскольку акцент на том, какие данные нужны для рендеринга компонента.


Приятно переложить запросы данных на GraphQL, используя клиентскую библиотеку Relay или Apollo. Если начать думать по GraphQL, появляется сосредоточенность на том, какие свойства нужны для рендеринга компонента, а не как их получить.


В какой-то момент придется задуматься над вопросом «как», но теперь это задача сервера, а общая сложность кардинально уменьшилась. Если вы не знакомы с серверной архитектурой GraphQL, взгляните на библиотеку graphql-tools из проекта Apollo. Она позволяет модульно собирать схему данных. Для краткости далее рассматривается только клиентская часть.


Хотя статья о сокращении кода Redux, полностью обойтись без него невозможно. Apollo сам использует Redux, поэтому нет причин отказываться от иммутабельности и ценных возможностей Redux DevTools на вроде отладки с проходом по шкале времени. При первоначальной настройке можно подключить Apollo к существующему хранилищу Redux и тем самым обеспечить единый источник данных. Настроенное хранилище подают в компонент <ApolloProvider/>, который является оберткой над приложением. Выглядит знакомо? Этот компонент заменяет существующий <Provider/> из react-redux. Дополнительно в свойствах <ApolloProvider/> указывают экземпляр ApolloClient.


Прежде чем кромсать код Redux, познакомимся c отличной возможностью постепенно внедрять GraphQL. Не обязательно рефакторить приложение одним коммитом. Apollo можно подключить к существующему хранилищу Redux и переделывать редьюсеры один за другим. То же самое справедливо для бекенда: в сложном приложении GraphQL прекрасно существется бок о бок с REST API пока не заменит все функции REST. Но стоит предупредить: попробовав GraphQL, трудно не влюбиться в него до горячего желания переписать приложение целиком.


Наши требования


Мы тщательно оценили, несколько Apollo отвечает требованиям проекта. Вот что имело значение для принятия решения:


  • Данные из разных источников. Футбольный матч включает данные из четырех источников: описание из REST API, статистика из MySQL, медиа-данные из видео-API, информация о социальной активности из Redis. Изначально серверный плагин собирал все данные в один объект match для отправки клиенту. Плагин работал почти как слой GraphQL! Как только мы это осознали, сразу поняли – наше приложение станет прекрасным кандидатом для внедрения GraphQL.
  • Обновления близко к реальному времени. В ходе матча обновления приходят каждую минуту. До Apollo обновления шли через веб-сокеты и попадали в редьюсер. Это не было плохо, но и не было изящно, поскольку объект match отправлялся целиком, чтобы не делать сложную сериализацию. В Apollo для тех же целей можно легко настроить интервал синхронных запросов для каждого компонента в зависимости от состояния игры.
  • Простая пагинация. Наша страница расписаний содержит список матчей с бесконечной прокруткой, и нам нужна пагинация без головной боли. Конечно, можно сделать для этого редьюсер. Но зачем делать самим, если функция fetchMore в Apollo берет все заботы на себя?

Apollo не только соответствует нашим текущим требованиям, но покрывает перспективные задачи, особенно с учетом планов улучшить персонализацию. Хотя сейчас мы только получаем данные с сервера, но в будущем могут потребоваться мутации (mutation) для сохранения данных о любимых командах пользователей. Мы можем добавить мгновенные комментарии или общение между пользователями. Если синхронных запросов окажется недостаточно, нам помогут подписки (subscriptions) Apollo.


Через Redux к Apollo


Настал долгожданный момент! Изначально хотелось показать в статье примеры кода до и после изменений, но было бы трудно сравнивать варианты, особенно тем, кто только познакомились с Apollo. Вместо этого я подсчитаю удаленные нами фрагменты, а затем оттолкнусь от знакомых понятий Redux и покажу, как можно делать компоненты-контейнеры в Apollo.


Что было удалено


  • Редьюсер футбольных матчей (~300 строк кода).
  • Action creator-ы для запроса данных (~800 строк).
  • Action creator-ы и бизнес-логика для обновления данных через сокеты (~750 строк).
  • Action creator-ы для локального хранилища (~1000 строк). Тут подсчет немного не корректный, поскольку мы пока отказались от работы приложения в оффлайне, но при необходимости вернем такую возможность – просто настроим fetchPolicy в Apollo и добавим соответствующий редьюсер для пакета redux-persist.
  • Компонеты-контейнеры Redux, отделяющие логику Redux от компонентов-шаблонов (~1000 строк).
  • Тесты всего вышеперечисленного (~1000 строк).

connect() > graphql()


Если вы знаете, что делать с connect, то компонент высшего порядка graphql из Apollo будет понятен! Известно, что connect возвращает функцию, которая берет и подключает компонент к хранилищу Redux. Аналогично graphql возвращает функцию, которая берет и подключает компонент к Apollo. Посмотрим в действии!


import React, { Component } from 'react';
import { graphql } from 'react-apollo';
import { MatchSummary, NoDataSummary } from '@mls-digital/react-components';
import MatchSummaryQuery from './match-summary.graphql';

// here we're using the graphql HOC as a decorator, but you can use it as a function too!
@graphql(MatchSummaryQuery, {
    options: ({ id, season, shouldPoll }) => {
        return {
            variables: {
                id,
                season,
            },
            pollInterval: shouldPoll ? 1000 * 60 : undefined,
        };
    }
})
class MatchSummaryContainer extends Component {
    render() {
        const { data: { loading, match } } = this.props;

        if (loading && !match) {
            return <NoDataSummary />;
        }

        return <MatchSummary {...match} />;
    }
}

export default MatchSummaryContainer;

Первый аргумент функции graphqlMatchSummaryQuery. Это ссылка на запрос, в котором указаны получаемые на сервере данные. Мы использует лоадер webpack, чтобы преобразовать запрос в AST для GraphQL. Без использования webpack запрос нужно оформлять в виде тегированной шаблонной строки с функцией gql из Apollo. Вот пример запроса, извлекающего нужные данные:


query MatchSummary($id: String!, $season: String) {
  match(id: $id) {
    stats {
      scores {
        home {
          score
          isWinner: is_winner
        }
        away {
          score
          isWinner: is_winner
        }
      }
    }
    home {
      id: opta_id
      record(season: $season)
    }
    away {
      id: opta_id
      record(season: $season)
    }
  }
}

Отлично, запрос имеется! Чтобы его выполнить, понадобятся значения переменных $id и $season. Где их взять? Для этого служит второй аргумент функции graphql – объект с параметрами.


Параметры объекта настраивают поведение компонента-контейнера. Наиболее важен параметр options. В нем функция, которая при вызове получает свойства (props) компонента-контейнера. Как показано в примере, функция возвращает объект с ключом variables, который задает переменные запроса, и ключом pollInterval, который настраивает периодическое повторение запроса. Обратите внимание, как свойства компонента-контейнера id и season используются, чтобы задать одноименные переменные запроса. Если функция становится громоздкой, мы выносим ее из декоратора в переменную с названием mapPropsToOptions.


mapStateToProps() > mapResultsToProps()


В контейнерах Redux обычно задается функция mapStateToProps. Она сопоставляет состояние приложения со свойствами (props) компонента, благодаря чему данные доступны в компоненте. Apollo позволяет задавать аналогичную функцию. Объект с конфигурационными параметрами, указанный при вызове graphql, может иметь еще один ключ – props В нем также задается функция, которая принимает свойства компонента, включая добавленные самим Apollo, и обрабатывает их прежде чем передать в компонент. Можно задать безымянную функцию, но мы предпочитаем выносить ее с именем mapResultsToProps.


Для чего обрабатывать свойства в этом случае? Результат запроса GraphQL содержится в свойстве data. Иногда нужно уменьшить уровень вложенности, например:


import React, { Component } from 'react';
import { graphql } from 'react-apollo';
import { MatchSummary, NoDataSummary } from '@mls-digital/react-components';
import MatchSummaryQuery from './match-summary.graphql';

const mapResultsToProps = ({ data }) => {
    if (!data.match)
        return {
            loading: data.loading,
        };

    const { stats, home, away } = data.match;

    return {
        loading: data.loading,
        home: {
            ...home,
            results: stats.scores.home,
        },
        away: {
            ...away,
            results: stats.scores.away,
        },
    };
};

const mapPropsToOptions = ({ id, season, shouldPoll }) => {
    return {
        variables: {
            id,
            season,
        },
        pollInterval: shouldPoll ? 1000 * 60 : undefined,
    };
};

@graphql(MatchSummaryQuery, {
    props: mapResultsToProps,
    options: mapPropsToOptions,
})
class MatchSummaryContainer extends Component {
    render() {
        const { loading, ...matchSummaryProps } = this.props;

        if (loading && !matchSummaryProps.home) {
            return <NoDataSummary />;
        }

        return <MatchSummary {...matchSummaryProps} />;
    }
}

export default MatchSummaryContainer;

Объект data содержит не только результаты запроса. В нем присутствует свойство data.loading, показывающее, что ответ еще не поступил. Это позволяет выводить другой компонент на замену, например мы используем для этого <NoDataSummary />.


compose()


Функция compose не уникальна для Redux и включена в Apollo для удобства. Она полезна, когда на одном компоненте нужно использовать несколько декораторов graphql. С помощью compose можно даже скомбинировать graphql и connect из Redux. Вот как мы используем compose для показа различных эпизодов матча.


import React, { Component } from 'react';
import { compose, graphql } from 'react-apollo';
import { NoDataExtension } from '@mls-digital/react-components';
import PostGameExtension from './post-game';
import PreGameExtension from './pre-game';
import PostGameQuery from './post-game.graphql';
import PreGameQuery from './pre-game.graphql';

@compose(
  graphql(PreGameQuery, {
    skip: ({ gameStatus }) => gameStatus !== 'pre',
    props: ({ data }) => ({
      preGameLoading: data.loading,
      preGameProps: data.match,
    }),
  }),
  graphql(PostGameQuery, {
    skip: ({ gameStatus }) => gameStatus !== 'post',
    props: ({ data }) => ({
      postGameLoading: data.loading,
      postGameProps: data.match,
    }),
  }),
)
export default class MatchExtensionContainer extends Component {
  render() {
    const {
      preGameLoading,
      postGameLoading,
      gameStatus,
      preGameProps,
      postGameProps,
      ...rest
    } = this.props;

    if (preGameLoading || postGameLoading)
      return <NoDataExtension gameStatus={gameStatus} />;

    return gameStatus === 'post'
      ? <PostGameExtension {...postGameProps} {...rest} />;
      : <PreGameExtension {...preGameProps} {...rest} />;
  }
}

Функция compose хорошо подходит, когда для компонента возможен набор состояний. Но что если в зависимости от состояния нужно выполнить тот или иной запрос? Поможет параметр skip, как показано в примере выше. В нем указывается функция, получающая свойства компонента (props). Если свойства не соответствует заданным критериям, skip возвращает false, и запрос отменяется. compose + skip = love


Вот так опыт работы с Redux позволяет быстро освоить Apollo. Его API опирается на многие принципы Redux и в то же время уменьшает объем кода для достижения сходных результатов.


...


Надеюсь, вам окажется полезен опыт переход на Apollo в компании Major League Soccer. При выборе любых библиотек наилучшее решение по управлению загрузкой данных зависит от требований проекта. Если у вас возникли вопросы, меня можно найти в Twitter

Поделиться с друзьями
-->

Комментарии (1)


  1. vintage
    02.08.2017 11:01