JSON API + redux


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



JSON API vs типичные веб-сервисы


Мне очень нравится JSON API, так как он сразу предоставляет данные в нормализированном виде, сохраняя иерархию, а также из коробки поддерживает pagination, sorting и filtering.


Типичный веб-сервис


{
  "id": "123",
  "author": {
    "id": "1",
    "name": "Paul"
  },
  "title": "My awesome blog post",
  "comments": [
    {
      "id": "324",
      "text": "Great job, Bro!",
      "commenter": {
        "id": "2",
        "name": "Nicole"
      }
    }
  ]
}

JSON API


{
  "data": [{
     "type": "post",
     "id": "123",
     "attributes": {
        "id": 123,
        "title": "My awesome blog post"
     },
     "relationships": {
        "author": {
          "type": "user",
          "id": "1"
        },
        "comments": {
          "type":  "comment",
          "id": "324"
        }
     }     
  }],
  "included": [{
    "type": "user",
    "id": "1",
    "attributes": {
      "id": 1,
      "name": "Paul"
    }
  }, {
    "type": "user",
    "id": "2",
    "attributes": {
      "id": 2,
      "name": "Nicole"
    }
  }, {
    "type": "comment",
    "id": "324",
    "attributes": {
      "id": 324,
      "text": "Great job, Bro!"
    }, 
    "relationships": {
      "commenter": {
        "type": "user",
        "id": "2"
      }
    }
  }]
 }

Основной недостаток JSON API при сравнении с традиционными API — это его "болтливость", но так ли это плохо?


Тип До сжатия (байт) После сжатия (байт)
Традиционный JSON 264 170
JSON API 771 293

После gzip разница в размерах становится существенно меньше, а так как речь идет о структурированных данных небольшого объема, с точки зрения производительности тоже все будет хорошо.


При желании можно придумать синтетический тест, где размер данных в представлении JSON API будет меньше, чем в традиционном JSON: возьмем пачку объектов, которые ссылаются на другой объект, например, посты блога и их автора, и тогда в JSON API объект "автор" появится лишь раз, в то время как в традиционном JSON он будет включен для каждого поста.


Теперь о достоинствах: структура данных, возвращаемая JSON API, всегда будет плоской и нормализированной, то есть у каждого объекта будет не более одного уровня вложенности. Подобное представление не только позволяет избегать дублирования объектов, но и отлично соответствует лучшим практикам работы с данными в redux. Наконец, в JSON API изначально встроена типизация объектов, поэтому на стороне клиента не нужно определять "схемы", как это требует normalizr. Эта фича позволяет упростить работу с данными на клиенте, в чем мы скоро сможем убедиться.


Замечание: здесь и далее redux можно заменить на многие другие state management библиотеки, но согласно последнему опросу State of JavaScript in 2016, redux по популярности сильно опережает любое другое существующее решение, поэтому redux и state management в JS для меня — это почти одно и то же.


JSON API и redux


JSON API из коробки весьма неплох для интеграции с redux, однако, есть несколько вещей, которые можно сделать лучше.


В частности, для приложения разделение данных на data и included может иметь смысл, ведь иногда бывает необходимо разделять, какие именно данные мы попросили, а какие мы получили "впридачу". Однако, хранить данные в store следует однородно, иначе мы рискуем иметь несколько копий одних и тех же объектов в разных местах, что противоречит лучшим практикам redux.


Также JSON API возвращает нам коллекцию объектов в виде массива, а в redux гораздо удобнее работать с ними как с Map.


Для решения этих проблем я разработал библиотеку json-api-normalizer, которая умеет делать следующее:


  1. нормализует данные, осуществляя merge data и included;
  2. конвертирует коллекции объектов из массива в Map вида id => объект;
  3. сохраняет оригинальную структуру JSON API документа в специальном объекте meta;
  4. объединяет one-to-many отношения в один объект.

Остановимся немного подробнее на пунктах 3 и 4.


Redux, как правило, инкрементально накапливает данные в store, что улучшает производительность и упрощает реализацию offline режима. Однако, если мы работаем с одними и теми же объектами данных, не всегда можно однозначно сказать, какие именно данные следует взять из store для того или иного экрана. json-api-normalizer для каждого запроса хранит в специальном объекте meta структуру JSON API документа, что позволяет однозначно получить только те данные из store, которые нам нужны.


json-api-normalizer конвертирует описание отношений


{
  "relationships": {
    "comments": [{
      "type": "comment",
      "id": "1",
    }, {
      "type": "comment",
      "id": "2",    
    }, {
      "type": "comment",
      "id": "3",    
    }]
  }
}

в следущий вид


{
  "relationships": {
    "comments": {
      "type": "comment",
      "id": "1,2,3"
    }
  }
}

Такое представление более удобно при обновлении redux state через merge, так как в этом случае не приходится решать сложную проблему удаления одного из объектов коллекции и ссылок на него: в процессе merge мы заменим одну строку с "id" другой, и задача будет решена в один шаг. Вероятно, это решение будет оптимальным не для всех сценариев, поэтому буду рад pull request'ам, которые с помощью опций позволят переопределить существующую реализацию.


Практический пример


1. Скачиваем заготовку


В качестве источника JSON API документов я написал простое веб-приложение на Phoenix Framework. Я не буду подробно останавливаться на его реализации, но рекомендую посмотреть на исходный код, чтобы убедиться, как легко делать подобные веб-сервисы.


В качестве клиента я написал небольшое приложение на React.


С этой заготовкой мы и будем работать. Сделайте git clone этой ветки.


git clone https://github.com/yury-dymov/json-api-react-redux-example.git --branch initial

И у вас будут:


  • React и ReactDOM
  • Redux и Redux DevTools
  • Webpack
  • Eslint
  • Babel
  • Точка входа в веб-приложение, два компонента, настроенная сборка, рабочий eslint конфиг и инициализация redux store
  • Стили всех компонентов, которые будут использованы в приложении.

Все это сконфигурировано и работает "из коробки".


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


npm run webpack-dev-server

и откройте в браузере http://localhost:8050.


2. Интегрируемся с API


Сначала напишем redux middleware, который будет взаимодействовать с API. Именно здесь логично использовать json-api-normalizer, чтобы не заниматься нормализацией данных во многих redux action и повторять один и тот же код.


src/redux/middleware/api.js


import fetch from 'isomorphic-fetch';
import normalize from 'json-api-normalizer';

const API_ROOT = 'https://phoenix-json-api-example.herokuapp.com/api';

export const API_DATA_REQUEST = 'API_DATA_REQUEST';
export const API_DATA_SUCCESS = 'API_DATA_SUCCESS';
export const API_DATA_FAILURE = 'API_DATA_FAILURE';

function callApi(endpoint, options = {}) {
  const fullUrl = (endpoint.indexOf(API_ROOT) === -1) ? API_ROOT + endpoint : endpoint;

  return fetch(fullUrl, options)
    .then(response => response.json()
      .then((json) => {
        if (!response.ok) {
          return Promise.reject(json);
        }

        return Object.assign({}, normalize(json, { endpoint }));
      }),
    );
}

export const CALL_API = Symbol('Call API');

export default function (store) {
  return function nxt(next) {
    return function call(action) {
      const callAPI = action[CALL_API];

      if (typeof callAPI === 'undefined') {
        return next(action);
      }

      let { endpoint } = callAPI;
      const { options } = callAPI;

      if (typeof endpoint === 'function') {
        endpoint = endpoint(store.getState());
      }

      if (typeof endpoint !== 'string') {
        throw new Error('Specify a string endpoint URL.');
      }

      const actionWith = (data) => {
        const finalAction = Object.assign({}, action, data);
        delete finalAction[CALL_API];
        return finalAction;
      };

      next(actionWith({ type: API_DATA_REQUEST, endpoint }));

      return callApi(endpoint, options || {})
        .then(
          response => next(actionWith({ response, type: API_DATA_SUCCESS, endpoint })),
          error => next(actionWith({ type: API_DATA_FAILURE, error: error.message || 'Something bad happened' })),
        );
    };
  };
}

Здесь и происходит вся "магия": после получения данных в middleware мы трансформируем их с помощью json-api-normalizer и передаем их дальше по цепочке.


Замечание: если немного "допилить" обработчик ошибок, то этот код вполне сгодится и для production.


Добавим middleware в конфигурацию store:


src/redux/configureStore.js


...
+++ import api from './middleware/api';

export default function (initialState = {}) {
  const store = createStore(rootReducer, initialState, compose(
--- applyMiddleware(thunk),  
+++ applyMiddleware(thunk, api),
    DevTools.instrument(),
...    

Теперь создадим первый action:


src/redux/actions/post.js


import { CALL_API } from '../middleware/api';

export function test() {
  return {
    [CALL_API]: {
      endpoint: '/test',
    },
  };
}

Напишем reducer:


src/redux/reducers/data.js


import merge from 'lodash/merge';
import { API_DATA_REQUEST, API_DATA_SUCCESS } from '../middleware/api';

const initialState = {
  meta: {},
};

export default function (state = initialState, action) {
  switch (action.type) {
    case API_DATA_SUCCESS:
      return merge(
        {},
        state,
        merge({}, action.response, { meta: { [action.endpoint]: { loading: false } } }),
      );
    case API_DATA_REQUEST:
      return merge({}, state, { meta: { [action.endpoint]: { loading: true } } });
    default:
      return state;
  }
}

Добавим наш reducer в конфигурацию redux store:


src/redux/reducers/data.js


import { combineReducers } from 'redux';
import data from './data';

export default combineReducers({
  data,
});

Model слой готов! Теперь можно связать бизнес-логику с UI.


src/components/Content.jsx


import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import Button from 'react-bootstrap-button-loader';
import { test } from '../../redux/actions/test';

const propTypes = {
  dispatch: PropTypes.func.isRequired,
  loading: PropTypes.bool,
};

function Content({ loading = false, dispatch }) {
  function fetchData() {
    dispatch(test());
  }

  return (
    <div>
      <Button loading={loading} onClick={() => { fetchData(); }}>Fetch Data from API</Button>
    </div>
  );
}

Content.propTypes = propTypes;

function mapStateToProps() {
  return {};
}

export default connect(mapStateToProps)(Content);

Откроем страницу в браузере и нажмем на кнопку — благодаря Browser DevTools и Redux DevTools можно увидеть, что наше приложение получает данные в формате JSON API, конвертирует их в более удобное представление и сохраняет их в redux store. Отлично! Настало время отобразить эти данные в UI.


3. Используем данные


Библиотека redux-object превращает данные из redux-store в JavaScript объект. Для этого ей необходимо передать адрес редусера, тип объекта и id, и дальше она все сделаем сама.


import build, { fetchFromMeta } from 'redux-object';

console.log(build(state.data, 'post', '1')); // ---> post
console.log(fetchFromMeta(state.data, '/posts')); // ---> array of posts

Все связи превратятся в JavaScript property с поддержкой lazy loading, то есть объект-потомок будет загружен только тогда, когда он понадобится.


const post = build(state.data, 'post', '1'); // ---> post object; `author` and `comments` properties are not loaded

post.author; // ---> user object

Добавим несколько новых компонентов UI, чтобы отобразить данные на странице.


Замечание: я умышленно опускаю работу со стилями, чтобы не отвлекать внимание от основной темы статьи.


Для начала нам нужно вытащить данные из store и через функцию connect передать их в компоненты:


src/components/Content.jsx


import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import Button from 'react-bootstrap-button-loader';
import build from 'redux-object';
import { test } from '../../redux/actions/test';
import Question from '../Question';

const propTypes = {
  dispatch: PropTypes.func.isRequired,
  questions: PropTypes.array.isRequired,
  loading: PropTypes.bool,
};

function Content({ loading = false, dispatch, questions }) {
  function fetchData() {
    dispatch(test());
  }

  const qWidgets = questions.map(q => <Question key={q.id} question={q} />);

  return (
    <div>
      <Button loading={loading} onClick={() => { fetchData(); }}>Fetch Data from API</Button>
      {qWidgets}
    </div>
  );
}

Content.propTypes = propTypes;

function mapStateToProps(state) {
  if (state.data.meta['/test']) {
    const questions = (state.data.meta['/test'].data || []).map(object => build(state.data, 'question', object.id));
    const loading = state.data.meta['/test'].loading;

    return { questions, loading };
  }

  return { questions: [] };
}

export default connect(mapStateToProps)(Content);

Здесь мы берем данные из метаданных запроса '/test', вытаскиваем айдишники и строим по ним объекты типа "Question", которые и передадим компоненту в коллекции "questions".


src/components/Question/package.json
{
  "name": "Question",
  "version": "0.0.0",
  "private": true,
  "main": "./Question"
}

src/components/Question/Question.jsx


import React, { PropTypes } from 'react';
import Post from '../Post';

const propTypes = {
  question: PropTypes.object.isRequired,
};

function Question({ question }) {
  const postWidgets = question.posts.map(post => <Post key={post.id} post={post} />);

  return (
    <div className="question">
      {question.text}
      {postWidgets}
    </div>
  );
}

Question.propTypes = propTypes;

export default Question;

Отображаем вопросы и ответы на них.


src/components/Post/package.json
{
  "name": "Post",
  "version": "0.0.0",
  "private": true,
  "main": "./Post"
}

src/components/Post/Post.jsx


import React, { PropTypes } from 'react';
import Comment from '../Comment';
import User from '../User';

const propTypes = {
  post: PropTypes.object.isRequired,
};

function Post({ post }) {
  const commentWidgets = post.comments.map(c => <Comment key={c.id} comment={c} />);

  return (
    <div className="post">
      <User user={post.author} />
      {post.text}
      {commentWidgets}
    </div>
  );
}

Post.propTypes = propTypes;

export default Post;

Здесь мы отображаем автора ответа и комментарии.


src/components/User/package.json
{
  "name": "User",
  "version": "0.0.0",
  "private": true,
  "main": "./User"
}

src/components/User/User.jsx


import React, { PropTypes } from 'react';

const propTypes = {
  user: PropTypes.object.isRequired,
};

function User({ user }) {
  return <span className="user">{user.name}: </span>;
}

User.propTypes = propTypes;

export default User;

src/components/Comment/package.json
{
  "name": "Comment",
  "version": "0.0.0",
  "private": true,
  "main": "./Comment"
}

src/components/Comment/Comment.jsx


import React, { PropTypes } from 'react';
import User from '../User';

const propTypes = {
  comment: PropTypes.object.isRequired,
};

function Comment({ comment }) {
  return (
    <div className="comment">
      <User user={comment.author} />
      {comment.text}
    </div>
  );
}

Comment.propTypes = propTypes;

export default Comment;

Вот и все! Если что-то не работает, можно сравнить ваш код с мастер-веткой моего проекта


Живое демо доступно тут


Заключение


Библиотеки json-api-normalizer и redux-object появились совсем недавно. Со стороны может показаться, что они весьма несложные, но, на самом деле, прежде, чем прийти к подобной реализации, я в течение года успел наступить на множество самых разных и неочевидных граблей и потому уверен, что эти простые и удобные инструменты могут быть полезны сообществу и сэкономят много времени.


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


Ссылки


  1. Спецификация JSON API
  2. Репозиторий json-api-normalizer
  3. Репозиторий redux-object
  4. Пример веб-сервисов на базе JSON API, реализованный на Phoenix Framework
  5. Исходный код примера веб-сервисов на базе JSON API
  6. Пример клиентского приложения на React, использующего JSON API
  7. Исходный код клиентского приложения на React, первоначальная версия
  8. Исходный код клиентского приложения на React, финальная версия
Поделиться с друзьями
-->

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


  1. gonzazoid
    04.01.2017 17:43
    +1

    Спасибо огромное за статью!

    Работаю над похожей темой, но на typescript, без реакта и на swagger-е. Пожалуй пощупаю и JSON API, Ваш опыт заинтересовал. Подскажите пожалуйста, я бегло пробежался по документации но не увидел — как дела у JSON API с валидацией данных? Есть механизмы?


    1. yury-dymov
      04.01.2017 18:12
      +2

      Спасибо за теплые слова!


      Я не уверен, что на 100% правильно понял вопрос, но постараюсь ответить. JSON API не является "движком", на котором вы реализуете API. Это лишь набор правил, которые описывают:


      • как конвертировать данные в формат JSON API
      • как должны выглядеть URI веб-сервисов и формат CRUD запросов
      • формат предоставления URL, чтобы клиент мог автоматически распознать pagination объекты и знать, как запросить следующую порцию.

      То есть, мы имеем дело исключительно с форматом представления данных и форматом методов работы с ними, — некоторая надстройка над традиционным JSON'ом, которая ничего не знает и не должна знать о вашей бизнес-логике, но содержит мета-информацию (типы объектов, ссылки для pagination и т.п.).


      Конвертацию из JSON API в объекты на стороне клиента делает моя библиотека (тут валидация, вероятно, не нужна), на стороне сервера делает какая-нибудь другая библиотека, которая будет у каждого стека своя. Далее уже вы валидируете объекты после конвертации как обычно. Если вам прислали "кривой" запрос, то ловите исключение парсера в контроллере и возвращаете ту ошибку, которая вам кажется логичнее.


  1. justboris
    04.01.2017 22:42
    +1

    Не хватает более подробного объяснения, что не так с Normalizr/Denormalizr, который сейчас считается стандартным решением


    1. yury-dymov
      04.01.2017 23:20

      Да, справедливо


      Для работы с normalizr необходимо явно указать Scheme ваших данных, что излишне для работы с JSON API (я имею в виду именно стандарт http://jsonapi.org, а не произвольный API в формате JSON), так как сам документ содержит в себе все необходимые метаданные. Normalizr просто не подходит для работы с JSON API. Вероятно, их можно интегрировать, но трудозатраты на это едва ли оправданы. Также normalizr заточен на работу с данными, в то время, как JSON API функционально богаче и может содержать мета-данные и ссылки для pagination, sorting, filtering.


  1. justboris
    04.01.2017 22:45
    +1

    А еще из листинга стоит убрать файлы package.json Полезной информации там нет, без них код читать будет проще


    1. yury-dymov
      04.01.2017 23:24

      Убрал в спойлеры


  1. bsideup
    04.01.2017 22:46
    +2

    return fetch(fullUrl, options)
        .then(response => response.json()
          .then((json) => {

    Вот тут по спеке Promise-ов можно избавиться от вложенности:


    You can pass a lambda to then and if it returns a promise, an equivalent Promise will be exposed to the subsequent then in the method chain

    return fetch(fullUrl, options)
        .then(response => response.json())
        .then((json) => {

    А ещё можно вообще избавиться от Promise-ов и начать использовать RxJS с React-ом:
    https://habrahabr.ru/post/309226/


    1. yury-dymov
      04.01.2017 23:12
      +1

      Спасибо за комментарий, этот код я скопи-пастил с real-world-example Дэна Абрамова.


      Мне нужно было проиллюстрировать, что json-api-normalizer позволяет интегрировать JSON API и redux одной строкой, что я и сделал :) Так что вполне допускаю, что именно этот кусок можно было написать лучше


  1. EJIqpEP
    05.01.2017 10:58

    Исходя из моего опыта правил JSONAPI недостаточно обычно и постоянно возникают вопросы «А как реализовать то или то»и никакая библиотека не покрывает нужны целиком. Например, фильтрация и сортировка в JSONAPI не описана. Пишите как хотите. И все библиотеки реализовывают их по разному. В redux я использовал redux-json-api, но его пришлось выкинуть так как количество нереализованных, но нужных фич очень большой. Простите, но сейчас 2017 год и не JSONAPI, который был еще 6 лет назад набирает популярность, а нормальные решения для API. Попробуйте например Graphql. Вы просто не захотите больше никогда видеть JSONAPI после него.
    Вкратце плюсы Grapql например: валидация из коробки, чистый код, легкое взаимодействие с redux, нормальное описание сущностей, вебморда чтоб тестить все запросы.
    Там в одном из комментариев также писали, что используют Swagger. Тот же самый ответ. Зачем использовать Swagger в 2017? Используйте решения у которых все запросы отображаются по умолчанию в вебморде как только вы их создаете, а не их необходимо описывать. Неужели вам недостаточно что вы пишите код и вы еще хотите описывать каким то DSL какие данные кормить запросам?


    1. yury-dymov
      05.01.2017 11:10

      JSON API — это формат представления данных, настройка над JSON. Именно поэтому я отказался от redux-json-api и написал свое решение — я сам хочу управлять стейтом, как мне удобно для своего проекта, а не городить костыли и redux middleware, чтобы реализовывать бизнес-логику.


      GraphQL — это решение "под ключ", их, наверное, не очень корректно сравнивать. Я пробовал использовать GraphQL в рамках Proof of Concept и для нужд моего проекта он подошел плохо, по крайней мере, он создавал больше проблем, чем их решал. Я бы мог поспорить с каждым из ваших аргументов, которые вы описали, как плюсы, кроме вебморды, чтобы тестить запросы. Количество кода он не уменьшает — вы его просто переносите на клиент в виде Query, объем трафика вы ощутимо снижаете только при работе с огромными объектами с 200 полями, и, если вы не фейсбук, то у вас их скорее всего не будет. При этом вы ломаете транзакционность, так как тащите объект в store по частям и не факт, что полученный объект будет соответствовать тому, что у вас в бекенде. Хотите сохранить транзакционность? Ломаете кеш. Главный плюс GraphQL следующий — вы стандартизируете разработку API и взаимодействие с ним, если у вас большая команда, то вы снижаете свои издержки на коммуникации и "подумать". Но это не killer feature.


      1. Dreyk
        05.01.2017 12:38
        +1

        тоже пытался перейти на GraphQL, но для этого надо переписать половину проекта, а даже если начинать с ним новый проект — надо менять подход в голове (а я еще не отошел от смены подхода после перехода на сам react :D)