json-api-normalizer: легкий способ подружить Redux и JSON API

в 10:28, , рубрики: api, Elixir, javascript, JSON API, node.js, phoenix, react.js, ReactJS, redux, web-разработка, Разработка веб-сайтов

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/comonents/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;

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

Живое демо доступно тут — https://yury-dymov.github.io/json-api-react-redux-example

Заключение

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

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

Ссылки

  1. Спецификация JSON API: http://jsonapi.org/
  2. Репозиторий json-api-normalizer: https://github.com/yury-dymov/json-api-normalizer
  3. Репозиторий redux-object: https://github.com/yury-dymov/redux-object
  4. Пример веб-сервисов на базе JSON API, реализованный на Phoenix Framework: https://phoenix-json-api-example.herokuapp.com/api/test
  5. Исходный код примера веб-сервисов на базе JSON API: https://github.com/yury-dymov/phoenix-json-api-example
  6. Пример клиентского приложения на React, использующего JSON API: https://yury-dymov.github.io/json-api-react-redux-example
  7. Исходный код клиентского приложения на React, первоначальная версия: https://github.com/yury-dymov/json-api-react-redux-example/tree/initial
  8. Исходный код клиентского приложения на React, финальная версия: https://github.com/yury-dymov/json-api-react-redux-example

Автор: yury-dymov

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js