
В последнее время набирает популярность стандарт 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, которая умеет делать следующее:
- нормализует данные, осуществляя merge
dataиincluded; - конвертирует коллекции объектов из массива в Map вида
id => объект; - сохраняет оригинальную структуру JSON API документа в специальном объекте
meta; - объединяет 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 появились совсем недавно. Со стороны может показаться, что они весьма несложные, но, на самом деле, прежде, чем прийти к подобной реализации, я в течение года успел наступить на множество самых разных и неочевидных граблей и потому уверен, что эти простые и удобные инструменты
могут быть полезны сообществу и сэкономят много времени.
Приглашаю принять участие в дискуссии, а также помочь мне в развитии этих инструментов.
Ссылки
- Спецификация JSON API: http://jsonapi.org/
- Репозиторий json-api-normalizer: https://github.com/yury-dymov/json-api-normalizer
- Репозиторий redux-object: https://github.com/yury-dymov/redux-object
- Пример веб-сервисов на базе JSON API, реализованный на Phoenix Framework: https://phoenix-json-api-example.herokuapp.com/api/test
- Исходный код примера веб-сервисов на базе JSON API: https://github.com/yury-dymov/phoenix-json-api-example
- Пример клиентского приложения на React, использующего JSON API: https://yury-dymov.github.io/json-api-react-redux-example
- Исходный код клиентского приложения на React, первоначальная версия: https://github.com/yury-dymov/json-api-react-redux-example/tree/initial
- Исходный код клиентского приложения на React, финальная версия: https://github.com/yury-dymov/json-api-react-redux-example
ссылка на оригинал статьи https://habrahabr.ru/post/318958/
Добавить комментарий