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

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(advanced, 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(advanced, 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 from api './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
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.