4 January 2017

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

Website developmentJavaScriptNode.JSAPIReactJS

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, финальная версия
Tags:elixirJavaScriptJSON APInode.jsphoenixreact.jsreduxweb-разработка
Hubs: Website development JavaScript Node.JS API ReactJS
+13
21k 98
Comments 11
Popular right now