5.7 Асинхронная загрузка данных


Большинство веб-приложений работают с данными, которые, как правило, хранятся в базе данных (БД). Что получить эти данные приложения обычно используют AJAX, то есть асинхронные запросы к серверу. Сервер, получив такой запрос, обращается к БД, а затем возвращает данные обратно приложению. Здесь мы подробнее рассмотрим суть асинхронной загрузки данных и её возможную реализацию.




5.7.1 Мотивация


Теперь мы подошли к важной части: взаимодействие сервера и клиента. Наш клиент написан на React, сервер же может быть написан на чём угодно: Java, C#, PHP, Kothlin, JavaScript и т.д. Это здорово, когда над проектом трудятся одновременно две команды: frontend и backend. Но очень частой является ситуация, когда backend разрабатывается медленнее или его в данный момент вообще нет. Такое происходит по ряду причин: мало разработчиков, сложная бизнес-логика, нет средств на разработку и пр. Но клиент делать нужно, мало того он должен работать с какими-то данными, чтобы можно было демонстрировать результаты труда.

Ранее мы уже говорили о фиктивных данных и даже создали их. Далее мы усовершенствуем уже имеющийся код. Главная задача данного раздела: эмулировать сервер, который будет возвращать данные. Данные же будут фейковые, но пользователю будет казаться, что приложение работает с настоящим сервером: будет показываться лоудер загрузки, будут приходить данные и т.д. То есть будет работать весь функционал, будто всё по-настоящему. Также мы добавим возможность переключения приложения с фейковых данных на реальные только с помощью файла config.js.

Что ж, не будем терять времени и приступим к созданию фиктивного сервера!



5.7.2 Взаимодействие клиент-сервер


Давайте повторим базовые стадии взаимодействия клиент-сервер. На основании этого мы будем проектировать наш фейковый сервер.

Итак, у нас есть клиент. Обычно это программа, которая запрашивает данные по определённом протоколу у сервера. Как правило, мы используем протокол HTTPS - это HTTP с шифрованием. Сначала клиент создаёт запрос определённого типа: GET, POST, PUT, DELETE и т.д. Также он может добавить в запрос какие-то данные, например данные формы, если запрос типа POST или PUT. Кроме этого клиент может указать заголовки и параметры запроса. Когда запрос создан, клиент посылает его серверу. Про архитектуру клиент-сервер и протокол HTTP можно почитать в википедии. Обязательно это сделайте, если эти вещи для вас мало понятны или, что ещё хуже, вы слышите о них впервые!

В ваших приложениях вы всегда будете иметь дело с HTTP запросами. Нужно понимать как их строить и какие данные можно положить в запрос, в зависимости от его типа.

После того как запрос попал в сеть, он идёт к серверу. Сервер - это тоже программа, только на другой машине, которая может быть расположена в любой точке земного шара. Как только сервер получил запрос, он начинает его обрабатывать. Сервер получает у запроса всю информацию, необходимую для выборки запрошенных данных. Эта информация включает в себя:

  • URL - он же путь к запрашиваемому ресурсу. По нему сервер понимает, какой его метод обработки запроса должен быть вызван.

  • Заголовки - строки в HTTP-сообщении, содержащие разделённую двоеточием пару параметр-значение. Например X-AuthToken: vuaw672387heHh33298Sei92372wi0rsdg836194 - заголовок, передающий в запросе токен текущего залогиненного пользователя, он же - токен сессии. Заголовки не видны в адресной строке - они не присутствуют в URL.

  • Параметры запроса - пары вида ключ=значение, которые могут быть добавлены в URL после знака ?. Если параметров больше одного, они разделяются между собой символом &. Например:

    URL
        
      https:/health-imperium/appointmens?page=0&pageSize=10&onlyMe=true&startDate=1560256200000
      

    Здесь представлены четыре параметра: номер порции данных page, размер порции данных pageSize, значения фильтра: onlyMe и startDate. Параметры чаще передаются в запросе типа GET.

    • Тело запроса. Если вы передаёте в запросе данные формы, то параметры запроса не подходят. Дело в том, что адресная строка может быть ограничена по длине некоторыми браузерами, а также сервером. Форма может быть очень большого размера. В этом случае следует использовать запрос типа POST или PUT, передавая в теле данные формы. Получив запрос и определив его тип, сервер понимает, что нужно извлечь данные из тела запроса. Сделав это - сервер как правило сохраняет их базу данных. Запрос типа POST используется, когда мы создаём какой-либо объект в БД, а PUT - когда обновляем его.

Хорошей практикой является построение сервера по архитектуре REST. О правилах наименования ресурсов, а также использовании подходящих типов запросов вам расскажет эта замечательная статья. Не поленитесь и изучите её полностью. В ней описаны лучшие практики, которых следует придерживаться при построении качественных приложений.

Как только сервер узнал из запроса всё, что необходимо, он начинает связываться с базой данных. Если запрос был к примеру GET (получить какие-либо данные), сервер достанет из БД необходимые данные, возможно отфильтрует их, а затем отправит клиенту в своём ответе. Если же запрос был типа POST (создать что-то), сервер попытается сохранить данные в БД и, если все прошло удачно, пошлёт клиенту ответ с информацией об успехе. В противном случае пошлёт клиенту ответ с информацией об ошибке.



Стоит упомянуть тот факт, что послав последовательно N запросов, нет гарантии, что ответы придут в таком же порядке. Дело в том, что сервер на обработку этих запросов может тратить разное количество времени. Допустим вы послали запросы под номером 1 и 2. Сначала сервер получает запрос 1 и начинает обрабатывать его. Затем он получает запрос 2 и тоже начинает обрабатывать. Но запрос 1 обрабатывается в 10 раз дольше, например из-за частого обращения в БД или слишком сложного запроса. В такой ситуации запрос 2 обработается быстрее и сервер тут же пошлёт клиенту ответ. Затем сервер обработает запрос 1 и снова пошлёт ответ. В итоге клиент сначала получит ответ 2, а потом 1, что не соответствует порядку отправленных запросов.

Мы же в своём приложении должны считать, что ответы сервера всегда приходят в случайном порядке и проектировать свой код согласно этому предположению. Такой подход убережёт от странных, а порой и тяжело обнаруживаемых ошибок.

Довольно подробно о клиент-серверном взаимодействии написано в документации MDN.



5.7.3 Архитектура


Прежде чем приступать к написанию кода взаимодействия с сервером, необходимо спроектировать хорошую архитектуру. Может быть кто-то из вас знаком или слышал о таком популярном Java-фреймворке как Spring. Мы используем некоторые его термины и архитектурные решения.

Для начала нам нужно спроектировать классы для взаимодействия с сервером. Настоящего сервера у нас нет, поэтому также нужна его симуляция. У нас уже есть компонент <Appointments>, который использует фиктивные данные напрямую из MockData.js. В таком решении есть масса недостатков: отсутствует пагинация, серверная сортировка и лоудер загрузки. По сути мы захардкодили данные. Но нам нужно, чтобы компонент <Appointments> вызывал какой-то API и получал данные асинхронно. Во время загрузки данных можно показывать лоудер, а сами данные можно подгружать порциями определённого размера.

Исходя из вышеперечисленных требований, давайте создадим соответствующую архитектуру классов, способных решать поставленные задачи:




На этом рисунке представлена диаграмма классов. Я создал её с помощью этого популярного онлайн-инструмента. Давайте подробно проанализируем все её элементы:

  • Appointments - это наш уже существующий компонент, который нуждается в API для асинхронной загрузки данных.

  • Уровень сервисов - все классы-сервисы приложения, унаследованные от класса BaseService. Они предоставляют API для асинхронной загрузки данных и будут расположены в папке /services приложения.

    • BaseService - базовый абстрактный класс для всех сервисов. Он инкапсулирует какую-либо библиотеку для выполнения HTTP запросов (например jquery или superagent) и вызывает её в своём методе request(params). Этот метод возвращает объект класса Promise и принимает параметры запроса в объекте opts.

    • AppointmentService - класс-наследник BaseService. Он предлагает API для асинхронной загрузки данных. В нашем случае API представлен методом find(params). Этот метод вызывает унаследованный метод request(params) своего предка и принимает параметры поиска приёмов: размер и номер порции данных, параметры фильтрации.

  • MockServer - фиктивный сервер обрабатывающий запросы и возвращающий фиктивные данные с установленной задержкой, симулирующей время ожидания ответа настоящего сервера.

    • MockServer - класс фиктивного сервера, который выполняет маршрутизацию запроса на соответствующий контроллер. Он предоставляет публичный метод service() для обслуживания запроса.

    • Уровень контроллеров - все классы-контроллеры фиктивного сервера приложения, унаследованные от класса Controller. Контроллеры предоставляют API для своей маршрутизации и получения списка своих обработчиков запроса вместе с соответствующими путями.

      • Controller - базовый абстрактный класс обработчика запросов. Название позаимствовано с фреймворка Spring MVC. Он предоставляет самый важный публичный метод handle(), который выполняет обработку запроса. Все потомки этого класса должны переопределять метод getHandlers(), который возвращает список элементов вида: { path: ‘/appointments’, handler: (vars, params) => {} }, где path - это определённый путь, а handler - соответствующий обработчик. Метод handle() вызывает метод getHandlers() и определяет какой обработчик вызвать, в соответствии с текущим путём. Метод API getPath() используется классом MockServer для маршрутизации запроса на соответствующий контроллер.

      • AppointmentController - конкретный контроллер, расширяющий класс Controller. Он обрабатывает все запросы, связанные с приёмами: список, детали, количество, удаление, обновление, сохранение и пр.

  • Real Server - некий реальный сервер, возвращающий настоящие данные. У нас его нет, поэтому мы создадим фиктивный, симулирующий задержку обработки запроса и возвращающий фиктивные данные.

Как я уже упоминал, архитектурные решения, в частности уровень сервисов и уровень контроллеров взяты из фреймворков Spring и Spring MVC.

Для полного понимания специфики работы наших классов, можно построить диаграмму последовательности, на которой сперва будет отражено взаимодействие с реальным сервером:




Как показывает диаграмма, сначала компонент Appointments вызывает метод find() API класса AppointmentService. Последний сразу возвращает объект типа Promise и выполняет HTTP запрос на сервер, используя метод унаследованный метод request(params). Как только сервер получает запрос в момент времени A, он начинает обработку запроса до момента времени B и возвращает ответ. Как только ответ приходит на клиент, компонент Appointments получает его в методе then() в виде объекта response.

Только что было описано взаимодействие с реальным сервером. Теперь нужно модифицировать диаграмму для работы с фиктивным сервером:




Здесь, с того момента, как класс AppointmentService вызывает метод request(params) реального HTTP запроса не происходит. Вместо этого мы вызываем метод service(request) класса, MockServer. Этот метод сначала выполняет маршрутизацию запроса на контроллер AppointmentController, а затем вызывает его метод handle(request). Далее в методе handle(request) класс AppointmentController находит и вызывает свой обработчик, полностью соответствующий пути запроса. Найденный обработчик в свою очередь вызывает метод getAppointments() фиктивной базы данных MockData.



После того как метод getAppointments() возвращает данные обработчику, последний возвращает их методу handle(request) класса AppointmentController, а тот в свою очередь возвращает их методу service(request) класса MockServer. Здесь стоит остановиться. Дело в том, что данные до этого момента были получены почти мгновенно. А как нам уже известно, настоящему серверу нужно определённое время, чтобы обработать запрос. Нам нужно симулировать это время в фиктивном сервере MockServer. Для этого будем использовать всем известную конструкцию: setTimeout(() => { //отправить ответ }, RESPONSE_DELAY). Мы установим определённую задержку RESPONSE_DELAY и по истечении этого времени вернём ответ классу AppointmentService.



5.7.4 Реализация


Мы построили архитектуру асинхронной загрузки данных. Теперь самое время приступить к реализации всех её элементов. Для лучшего понимания будем двигаться “с конца”, то есть начнём с файла MockData.js, а закончим модификацией компонента <Appointments>. Что ж, давайте приступим!


5.7.4.1 Фиктивный бэкэнд


Давайте модифицируем файл MockData.js в соответствии с нашей новой архитектурой. На диаграмме последовательности вы могли заметить метод getAppointments(). Этот метод мы будем использовать вместо переменной appointments, переместив в него код фильтрации данных из компонента <Appointments>:


Код
    
  import { filter } from 'underscore'

  const USER = 'Иванов Иван Иванович'

  export const appointments = [
    //...данные
  ]

  export function getAppointments (params) {
    const {
        startDate,
        endDate,
        clientName,
        onlyMe,
      } = params

    return filter(appointments, o => {
        return (startDate ? o.date >= startDate : true) &&
        (endDate ? o.date <= endDate : true) &&
        (clientName ? (clientName.length > 2 ? o.clientName.includes(clientName) : true) : true) &&
        (onlyMe ? o.holderName === USER : true)
    })
  }
  

Тем самым мы переместили логику фильтрации данных с клиента на сервер!


Совет!

По возможности лучше перемещать любую логику работы с данными на сервер, а клиент делать максимально простым. Это напрямую связано с ресурсами: сервер, как правило, обладает гораздо большими ресурсами, чем клиент: жесткий диск, процессор, оперативная память - среднестатистическая машина пользователя обычно слабее (часто гораздо слабее) серверной.

Сделав клиент простым, он будет работать очень быстро, доставляя пользователю удовольствие от работы с приложением. Фильтрацию, сортировку и пагинацию, за редким исключением, следует выполнять именно на сервере. Если вы видите, что реализованная вами логика работает не так быстро, как хотелось бы, самое время рассмотреть возможность её переноса на сервер, это может оказаться настоящим спасением.

В идеале, клиент должен получить данные с сервера и сразу показать их. Конечно, нередки ситуации, где нужно что-то посчитать или немного преобразовать исходные данные. Главное здесь следить за тем, чтобы эти преобразования не давали просадки в производительности. Если это происходит, то преобразования следует выполнить на сервере или придумать более подходящий формат данных. Если ничего не помогает, возможно, ошибка кроется в архитектуре клиента или сервера. Тогда следует провести исследование и перепроектировать проблемные части.

С фиктивными данными мы разобрались. Время перейти к уровню/слою контроллеров. Как упоминалось ранее, в нашем приложении контроллер - это конечная остановка обработки запроса. Но это только в нашем фиктивном сервере. В реальном сервере это не так. Если к примеру изучить архитектуру реального Java-сервера, использующего, скажем, Spring MVC, то контроллер - это только точка входа на сервер. В таком сервере, архитектура может выглядеть следующим образом:




На диаграмме представлена трёхуровневая архитектура сервера, которая включает: уровень контроллеров, уровень сервисов и уровень доступа к данным. Давайте рассмотрим основные задачи этих уровней:

  • Контроллер принимает HTTP запрос клиента и начинает обрабатывать его. Допустим запрос приходит на метод find(). Метод достаёт из запроса всю необходимую информацию, а затем вызывает одноимённый метод find() сервиса.

  • Сервисы представлены соответствующим уровнем и обычно выполняют разнообразную бизнес-логику. Они обращаются за данными к классам уровня доступа к данным и выполняют над ними разные преобразования.

  • Классы уровня доступа к данным производят запросы прямо в БД. Для этого они, как правило, используют какой-нибудь фреймворк, или библиотеку (для Java это JPA, Hibernate, JDBC и пр.)

Архитектура может содержать и больше уровней, например четыре или пять. Это зависит от предметной области. В Java серверах чаще всего используют трёх или четырёхуровневую архитектуру.

Если вы никогда раньше не сталкивались с написанием сервера, информация выше определённо даст вам общее понимание принципов его проектирования и работы. Знание этого - существенный плюс для UI разработчика, ведь это помогает правильно распределить задачи между клиентом и сервером!

В нашем фиктивном сервере нет необходимости в уровнях сервисов и доступа к данным. Какую-либо бизнес-логику мы можем написать прямо в файле MockData.js, а в контроллере лишь доставать конечные данные. Но если файл MockData.js становится довольно большим, возможно, уровень сервисов всё же следует добавить. В любом случае это не займёт много времени.

Давайте создадим базовый класс-контроллер, от которого мы будем наследовать все остальные контроллеры:


Код
    
  import UrlPattern from 'url-pattern'

  export default class BaseController {
     getPath () { return '' }

     getHandlers () { return [] }

     handle (request) {}
  }
  

Со всеми его методами мы уже знакомы. Методы getPath() и getHandlers() будут переопределены потомками. А вот метод handle() нужно реализовать именно здесь, затем он будет унаследован.

Как было сказано ранее, метод handle() вызывает метод getHandlers() и определяет какой обработчик должен быть вызван в соответствии с текущим путём. То есть он сопоставляет все имеющиеся пути с путём, указанным в запросе. Для такой задачи можно использовать специальную библиотеку.

Представляю вам мощный инструмент url-pattern. Он даёт богатый спектр возможностей для работы с путями: соответствие, получение переменных пути и параметров запроса и многое другое.

Используя эту библиотеку, реализация метода handle() получается очень простой:

В этом методе мы вызываем метод getHandlers(), получая список пар { path, handler }. Далее в цикле мы идем по этому списку и находим соответствующий путь с помощью pattern.match(url). Найдя нужный путь мы вызываем соответствующий обработчик handler(vars, params), передавая в него переменные пути и параметры запроса.

Базовый контроллер готов, настало время перейти к контроллеру AppointmentController:


Код
    
  import * as mock from '../MockData'
  import BaseController from './BaseController'

  class AppointmentController extends BaseController {
      getPath () {
          return '/appointments'
      }

      getHandlers () {
          return [
              {
                  path: '',
                  handler: (vars, params) => {
                      return mock.getAppointments({...vars, ...params})
                  }
              },
              /* {
                  path: '/:appointmentId',
                  handler: (vars, params) => {
                      return mock.getAppointmentDetails({...vars, ...params})
                  }
              },
              {
                  path: '/count',
                  handler: (vars, params) => {
                      return mock.getAppointmentCount({...vars, ...params})
                  }
              }, */
          ]
      }
  }

  export default new AppointmentController()
  

Как уже говорилось ранее, здесь реализованы методы getPath() и getHandlers(). Первый возвращает корневой путь /appointments, по которому будет осуществляться маршрутизация в классе MockServer. Второй возвращает список пар путь-обработчик: { path, handler }. В качестве примера я привёл еще две пары с путями /:appointmentId и /count, которые позже вы можете раскомментировать и реализовать самостоятельно.

Что ж, наш бэкэнд почти готов. Осталось реализовать самый главный класс: MockServer.


Код
    
  import * as mock from './MockData'
  import appointmentController from './controllers/AppointmentController'

  const RESPONSE_DELAY = 1000

  const ROUTING = {
     [appointmentController.getPath()]: appointmentController
  }

  class MockServer {
     service (request) {
         return new Promise((resolve) => {
             const { url, params } = request

             setTimeout(() => {
                 for (let path in ROUTING) {
                     if(url.includes(path)) {
                       const controller = ROUTING[path]

                       if (controller) {
                         resolve(
                           controller.handle(request)
                         )
                       }

                       else {
                         reject(
                           new Error('Resource is not found')
                         )
                       }
                     }
                 }
             }, RESPONSE_DELAY)
         })
     }
  }

  export default new MockServer()
  

Здесь всё также предельно просто. Сначала мы импортируем все реализованные контроллеры и регистрируем их в переменной ROUTING. Далее в основном методе service() мы возвращаем промис. Внутри мы вызываем setTimeout() c задержкой RESPONSE_DELAY для имитации времени обработки запроса. По истечении заданной задержки мы в цикле проходим все ключи ROUTING и сверяем их с текущим путём с целью найти подходящий контроллер. Так мы имитируем серверную маршрутизацию. Как только подходящий контроллер найден, обрабатываем запрос и посылаем ответ controller.handle(request) клиенту.



В этой реализации не учтена одна вещь, фиктивный сервер должен работать наравне с реальным. То есть для приложения они должны быть неотличимы! Для этого фиктивный и реальный сервер должны возвращать ответ одного формата. Сейчас наш фиктивный сервер возвращает данные напрямую и это может вызвать несовместимость. Для решения этой задачи нам необходимо реализовать имитацию ответа сервера.

Давайте модифицируем класс MockServer, добавив в него два новых метода getSuccessResponse() и getFailureResponse() имитирующих ответ сервера в случаях успеха или ошибки:


Код
    
  import * as mock from './MockData'
  import appointmentController from './controllers/AppointmentController'

  const RESPONSE_DELAY = 1000

  const ROUTING = {
     [appointmentController.getPath()]: appointmentController
  }

  function getSuccessResponse (data, extraBodyProps = {}, extraProps = {}) {
     let body = { success: true, ...extraBodyProps }

     if (data) {
         body.data = data
     }

     let resp = {
         body,
         ...extraProps,
         statusCode: 200
     }

     // текстовое представление ответа
     resp.text = JSON.stringify(resp)

     return resp
  }

  function getFailureResponse (code = 'error', message = 'Error', statusCode = 500) {
     let resp = {
         body: {
             success: false,
             error: {
                 code,
                 message
             }
         },
         statusCode: statusCode
     }

     // текстовое представление ответа
     resp.text = JSON.stringify(resp)

     return resp
  }

  class MockServer {
     service (request) {
         return new Promise(resolve => {
             const { url, params } = request

             setTimeout(() => {
                 for (let path in ROUTING) {
                     if(url.includes(path)) {
                       const controller = ROUTING[path]

                       if (controller) {
                         resolve(
                           getSuccessResponse(controller.handle(request))
                         )
                       }

                       else {
                         resolve(
                           getFailureResponse('resource.not.found', 'Resource is not found')
                         )
                       }
                     }
                 }
             }, RESPONSE_DELAY)
         })
     }
  }

  export default new MockServer()
  

Приведенная структура ответа сервера не обязана быть точь в точь такой. Хотя представленная довольно хороша, и я постоянно использую её в своих проектах с определёнными модификациями. Стоит также помнить, что такую же реализацию ответа должен возвращать и реальный сервер, чтобы избежать проблем с совместимостью.

Метод getSuccessResponse() по итогу возвращает объект-ответ:

Метод getFailureResponse() в свою очередь возвращает объект-ответ:


Код
    
  response = {
      body: {
          success: false,
          error: {
                 code: 'custom.error.code', // кастомный текстовый код ошибки
                 message: 'Custom error message' // кастомный текст ошибки
          }
      },
      statusCode: 500, // или любой другой код ошибки
      text: 'текстовое представление ответа'
  }
  

На этом реализация фиктивного сервера подошла к концу. Он может принимать, маршрутизировать, и обрабатывать запрос, после чего посылать ответ клиенту спустя установленное время задержки. Что ж, настало время написать код, который будет посылать запрос нашему серверу и принимать от него ответ. Этим занимаются сервисы.


5.7.4.2 Уровень сервисов


Вспомним нашу диаграмму классов из раздела Архитектура:




На ней видно, что слой сервисов взаимодействует с реальным сервером по протоколу HTTP или вызывая метод service() фиктивного. Результат для двух случаев должен быть одинаков. То есть у вас должна быть возможность переключаться между серверами в любой момент времени. Например вы узнали, что метод API для списка клиентов на реальном сервере готов. Тогда вы можете переключить приложение на реальные данные именно для этого метода. Нам необходимы классы для гибкого взаимодействия с серверами. Именно этим и будут заниматься наши сервисы.

Как это уже было с контроллерами, начнём с базового класса. Он имеет один публичный метод request(), с помощью которого и осуществляется запрос на сервер. Мы начнём с самой простой реализации, а затем будем расширять её, приводя в соответствие с нашими требованиями.

Итак, самая простая реализация базового сервиса будет иметь следующий вид:


Код
    
  import mockServer from '../lib/mock/MockServer'
  import ServerError from '../lib/errors/ServerError'

  export default class BaseService {
   request(opts) {
     return mockServer.service({
       method: 'GET',
       url: null,
       body: null,
       params: null,
       ...opts
     })
   }
  }
  

Как видно, код предельно простой. Мы просто обращаемся к нашему фиктивному сервер с помощью метода service(). Метод request() возвращает Promise коду, который его вызывает.

Здесь нет возможности переключиться на реальный сервер, а это жизненно необходимо, так как приложение, работающее только с фиктивными данными никому не нужно. Чтобы добавить такую возможность, следует начать с модификации файла конфигурации приложения config.js. В нём будет флаг переключения и URL удалённого сервера:


Код
    
  export default {
   responseTimeout: 10000,

   remote: {
     isEnabled: false, // флаг переключения между фиктивным и реальным сервером
     url: 'https://health-empire.com/app' // URL реального сервера
   }
  }
  

Отлично! Флаг и адрес удалённого сервера мы будем считывать в классе BaseService. Далее в зависимости от значения флага, будем обращаться к фиктивному или реальному серверу:


Код
    
  import mockServer from '../lib/mock/MockServer'
  import ServerError from '../lib/errors/ServerError'

  import config from '../config'

  export default class BaseService {
   request(opts) {

     opts = {
       method: 'GET',
       url: null,
       body: null,
       type: 'form',
       params: null,
       callback: null,
       ...opts
     }

     const { remote } = config

     if (!remote.isEnabled) {
         return mockServer.service({
           method: 'GET',
           url: null,
           body: null,
           params: null,
           ...opts
         })
     }

     else {
       // обращаемся к реальному серверу
     }
   }
  }
  

Итак, переключение готово. Но что если мы хотим, чтобы одна часть методов работала с реальными данными, а другая с фиктивными? Ведь такая ситуация будет постоянно, пока ведется разработка сервера. Давайте немного модифицируем наш код, добавив такую возможность:


Код
    
  import mockServer from '../lib/mock/MockServer'
  import ServerError from '../lib/errors/ServerError'

  import config from '../config'

  const notImplementedTemplates = [
   '/appointments',
   // '/clients' - реализован
  ]

  export default class BaseService {
   request(opts) {

     opts = {
       method: 'GET',
       url: null,
       body: null,
       type: 'form',
       params: null,
       callback: null,
       ...opts
     }

     const { remote } = config

     // проверяем, реализован ли на сервере данный API
     const isNotImplemented = some(notImplementedTemplates, t => {
         return opts.url.includes(t)
     })

     if (!remote.isEnabled || isNotImplemented) {
         return mockServer.service({
           method: 'GET',
           url: null,
           body: null,
           params: null,
           ...opts
         })
     }

     else {
       // обращаемся к реальному серверу
     }
   }
  }
  

Все пути приложения будем помещать в переменную notImplementedTemplates. Если какие-то из них уже готовы на сервере, их можно закомментировать, как это сделано для примера с '/clients'. Флаг isNotImplemented показывает, реализован ли на реальном сервере путь текущего запроса. Условие позволяет !remote.isEnabled || isNotImplemented вызывать фиктивный сервер, даже если активирован режим работы с реальным сервером, в случае если запрашиваемый путь не реализован.



Сейчас у нас есть полноценная возможность переключения между серверами вплоть до отдельного метода. Это конечно замечательно, но мы до сих пор не можем обращаться к реальному серверу. Зато у меня есть отличный повод познакомить вас с прекрасной библиотекой superagent. Здесь представлена её полная документация.

Эта мощная библиотека покрывает практически все случаи взаимодействия с сервером через AJAX. Вы можете делать запросы любых типов (GET, PUT и пр.), а также посылать и принимать файлы! Помимо прочего библиотека предоставляет простой и удобный интерфейс, а также проста в изучении.

Что ж, библиотека у нас есть (не забудьте ее подключить). Всё, что осталось - это определить, какие типы запросов наш базовый сервис будет посылать. Вообще для CRUD операций нужны лишь следующие типы: GET - получение данных, POST - создание нового объекта на сервере, PUT - модификация объекта на сервере и DELETE - удаление объекта на сервере. Основываясь на этом, финальная версия базового сервиса будет выглядеть так:


Код
    
  import mockServer from '../lib/mock/MockServer'
  import ServerError from '../lib/errors/ServerError'

  import config from '../config'

  const notImplementedTemplates = [
   '/appointments',
   // '/clients' - реализован
  ]

  export default class BaseService {
   request(opts) {

     opts = {
       method: 'GET',
       url: null,
       body: null,
       type: 'form',
       params: null,
       callback: null,
       ...opts
     }

     const { remote } = config

     // проверяем, реализован ли на сервере данный API
     const isNotImplemented = some(notImplementedTemplates, t => {
         return opts.url.includes(t)
     })

     if (!remote.isEnabled || isNotImplemented) {
         return mockServer.service({
           method: 'GET',
           url: null,
           body: null,
           params: null,
           ...opts
         })
     }

     else {
       // обращаемся к реальному серверу
     }
   }
  }
  

Итак, давайте по-порядку. В самом верхнем блоке else метода request() появился код, который в зависимости от типа запроса по-разному конфигурирует библиотеку superagent, а точнее её инструмент построения запроса request:

  • Блок с условием method === 'GET' инициализирует запрос, устанавливая целевой URL, время ожидания запроса config.responseTimeout, заголовки (c помощью rq.set()) и параметры (с помощью rq.query()). Затем он посылает этот запрос на сервер, вызывая .then().

  • Блок с условием method === 'POST' || method === 'PUT' инициализирует запрос, сперва устанавливая метод, целевой URL, тип кодирования данных и время ожидания запроса. Дальнейшая инициализация будет отличаться для разных типов типов кодирования данных формы type. Если тип равен 'multipart/form-data', тело запроса может содержать как файлы, так и строковые значения. Для файлов мы используем метод rq.attach(), а для обычных значений rq.field(), предварительно преобразовав в строки объекты. В всех остальных случаях используем просто rq.send().

  • Блок с условием method === 'DELETE' инициализирует запрос, устанавливая то же что и GET, только без параметров и заголовков. Хотя при желании последние можно добавить.

Представленная реализация, учитывающая типовые запросы, покрывает почти все стандартные потребности приложения. Если у вас есть какие-то специфические ситуации, код можно легко модифицировать.

Также я убрал добавление заголовков со всех типов запросов, кроме GET. Я сделал это умышленно, так как в нашем приложении мы не отправляем никаких заголовков. В реальных же приложениях, заголовки, как правило, должны передаваться для всех типов запросов. Типичные примеры: токен аутентификации текущего пользователя, версия фронтэнда, требуемая версия бэкэнда и пр. Если такая необходимость существует, можно просто скопировать код добавления заголовков из GET в остальные типы запросов, или вынести его в общую для всех типов инициализацию запроса.

Двигаемся дальше. Как вы могли заметить, я добавил два обработчика: onSuccess() и onFailure(). Они важны, так как срабатывают сразу после того, как пришёл ответ от сервера.

Метод onSuccess() срабатывает в том случае, если сервер успешно обработал запрос. В начале этого метода стоит блок try/catch, где происходит парсинг строки ответа, пришедшей с сервера.


Внимание!

Напомню, что для нашего приложения сервер должен возвращать строку json, имеющей структуру, совпадающую со структурой объектов, которые возвращают методы getSuccessResponse() и getFailureResponse(). Для вашего же приложения строка запроса может быть какой-угодно - вы определяете это сами. Я лишь дал один удобный вариант структуры ответа сервера из множества возможных.

Дело в том, что наша библиотека superagent при получении ответа создаёт объект класса Response. Этот JS-объект является удобным представлением ответа сервера и предлагает разные полезные свойства для чтения. Сейчас больше всего нас интересуют свойства body и text. Выдержка из документации:

Response text

The res.text property contains the unparsed response body string. This property is always present for the client API, and only when the mime type matches "text/*", "*/json", or "x-www-form-urlencoded" by default for node. The reasoning is to conserve memory, as buffering text of large bodies such as multipart files or images is extremely inefficient. To force buffering see the "Buffering responses" section.

Response body

Much like SuperAgent can auto-serialize request data, it can also automatically parse it. When a parser is defined for the Content-Type, it is parsed, which by default includes "application/json" and "application/x-www-form-urlencoded". The parsed object is then available via res.body.

В свойстве text хранится, переданная с сервера строка ответа. В нашем приложении это будет строка JSON. Именно её и парсит наш блок try/catch.

Однако парсить может и сама библиотека. На текущий момент поддерживаются следующие типы response-body для парсинга:

  • application/x-www-form-urlencoded

  • application/json

  • multipart/form-data

Результат парсинга и будет помещён в свойство body. Вы даже можете задать свой собственный парсер строки ответа сервера.

C учётом вышесказанного, метод onSuccess() может выглядеть вот так:

Нет никакой разницы, какой из этих способов использовать.


Внимание!

Важно отметить, что текущие реализации onSuccess и onError справедливы только для библиотеки superagent. Если использовать, например, JQuery, то код методов нужно изменить, так как JQuery не создаёт объект класса Response.

За блоком try/catch идет условие:


Код
    
  if ((status === 200 || status === 201) && body.success !== false)
  

Оно стоит не просто так. Дело в том, что если сервер обработал запрос, то не факт что пришли именно те данные, которые ожидались. Сервер мог обработать запрос без сбоев, но ошибка могла возникнуть где-то в бизнес-логике.

Например, вы хотели получить список клиентов для другого пользователя, но у вас недостаточно прав. Сервер может обработать запрос без сбоев, но вернёт вам ответ с ошибкой и кодом отличным от 200. Тогда на клиенте может сработать onSuccess, а не onError - это зависит только от реализации сервера! То есть мы заранее можем не знать, что сработает onSuccess или onError.

Конечно, теоретически можно реализовать сервер таким образом, что на любую серверную ошибку будет всегда срабатывать onError, однако это не всегда возможно на практике. Например разработка UI может вестись для уже готового сервера, а изменять его может быть нецелесообразно: дорого, риски регрессии и пр.

Именно поэтому в методе onSuccess стоит условие:


Код
    
  if ((status === 200 || status === 201) && body.success !== false)
  

Оно отсеивает лишь успешно выполненный запрос с ожидаемыми данными. Если этот так, мы возвращаем лишь необходимые данные body, обрезая все лишние. Если же код ответа сервера не проходит условие, мы сами генерируем ошибку собственного типа ServerError, помещая туда всю информацию о возникшей проблеме.

Далее идет обработчик onFailure(). Он обрабатывает сбои сервера. Это значит, что сервер мог "свалиться" во время обработки запроса. То есть могло возникнуть исключение, типа NullPointerException - ошибка нулевого указателя, или деление на ноль. Так же могло пропасть соединение с сервером или превышено время ожидания ответа - то есть ответ так и не пришёл! Во всех этих ситуациях сработает onFailure(), в котором мы генерируем свою ошибку типа ServerError (или другого типа), передав в неё всю необходимую информацию.

Созданные мной классы ошибок имеют следующий вид:




Их код:


Код
    
  export default class BaseError extends Error {
     constructor ({code, message, status}) {
         super()

         this.code = code
         this.message = message
         this.status = status
     }
  }

  export default class WebError extends BaseError {}

  export default class ServerError extends WebError {}
  

Я поместил их в папку /lib/errors/.

Код code - это кастомный читабельный строковый код ошибки, приходящий с сервера, имеющий вид 'session.timeout', 'invalid.token', 'incorrect.user.email' и пр. Он может понадобиться для того, чтобы отлавливать в приложении определенные ошибки, сверять их по этому коду и далее делать какие-либо специфические действия, например:


Код
    
  if(code === 'session.timeout') {
    //перевести пользователя на страницу логина
    history.push('/login')
  }
  

Это очень удобно, так как способствует лучшему пониманию кода. Читабельные строки гораздо понятнее цифр. Эти строковые коды должны быть реализованы на сервере, то есть бэкэнд разработчики должны вставлять его в ошибку, чтобы вы могли правильно его обрабатывать.

Поле message - это сам текст ошибки, например 'Your session has been expired'. Поле status представляет целочисленный статус, например 500, 501 и т.д.

Данные классы ошибок необязательны, но они существенно помогают унифицировать работу с серверными ошибками в приложении. Обработчики onSuccess и onFailure являются своего рода адаптерами. Они преобразуют ответ сервера в форму, понятную приложению.

Что ж, с базовым сервисом мы закончили. Осталось создать сервис-наследник AppointmentService:


Код
    
  import BaseService from './BaseService'

  export class AppointmentService extends BaseService {
     find ({ filter }) {
         return super.request({
             url: '/appointments',
             params: { ...filter }
         })
     }

  /*
     findById (appointmentId) {
         return super.request({
             url: `/appointments/${appointmentId}`
         })
     }

     count () {
         return super.request({
             url: '/appointments/count'
         })
     }
  */
  }

  export default new AppointmentService()
  

Как видно из кода, этот сервис в своих методах просто вызывает метод request() своего предка, передавая все необходимые параметры. Каждый метод возвращает промис коду-потребителю. На текущий момент у нас есть лишь один метод find ({ filter }), который принимает фильтр в качестве параметра и запрашивает список приёмов. Для примера я добавил еще два закомментированных метода: findById(appointmentId) и count(). Первый находит информацию о приёме по идентификатору, а второй возвращает общее количество всех приёмов для данного пользователя.



На этом всё! Слой сервисов готов. Осталось последнее: модифицировать компонент <Appontments>, добавив в него возможность использования нашего сервиса.


5.7.4.3 Модификация списка приёмов


Нам остался последний шаг. Необходимо модифицировать список приёмов, и самое главное, что нужно сделать - вызвать сервис AppointmentService в компоненте <Appontments>. Как мы установили ранее, загрузка данных будет происходить некоторый промежуток времени. В течение этого промежутка пользователю следует показывать процесс загрузки (например гифку), чтобы он видел что происходит какое-то действие. Учитывая всё вышесказанное, итоговая версия кода будет следующей:


Код
    
  import React, { Component } from 'react'

  import { Form } from 'reactstrap'
  import Moment from 'react-moment'
  import { map, filter } from 'underscore'
  import { Button } from 'reactstrap'

  import './Appointments.scss'

  import Table from '../Table/Table'
  import Header from '../Header/Header'
  import Loader from '../Loader/Loader'
  import TextField from '../Form/TextField/TextField'
  import DateField from '../Form/DateField/DateField'
  import CheckboxField from '../Form/CheckboxField/CheckboxField'

  import service from '../../services/AppointmentService'

  import { ReactComponent as Search } from '../../images/search.svg'
  import { ReactComponent as Appointment } from '../../images/appointment.svg'

  const TITLE = 'Приёмы'

  const USER = 'Иванов Иван Иванович'

  export default class Appointments extends Component {

    state = {
      data: null,
      isLoading: false,

      filter: {
        startDate: null,
        endDate: null,
        clientName: '',
        onlyMe: false
      }
    }

    componentDidMount() {
      this.load()
    }

    onChangeFilterField = (name, value) => {
      const { filter } = this.state

      this.setState({
        filter: { ...filter, ...{ [name]: value } }
      })
    }

    onChangeFilterDateField = (name, value) => {
      const { filter } = this.state

      this.setState({
        filter: { ...filter, ...{ [name]: value && value.getTime() } }
      })
    }

    onSearch = () => {
      this.load()
    }

    load() {
      this.setState({ isLoading: true })

      service
        .find({ filter: this.state.filter })
        .then(({ success, data }) => {
          if (success) {
            this.setState({
              data, isLoading: false
            })
          }
        })
    }

    render() {
      const {
        data,
        isLoading,
        filter: {
          startDate,
          endDate,
          clientName,
          onlyMe,
        }
      } = this.state

      return (
        <div className='Appointments'>
          <Header
                  title={TITLE}
                  userName={USER}
                  className='Appointments-Header'
                  bodyClassName='Appointments-HeaderBody'
                  renderIcon={() => (
              <Appointment className='Header-Icon' />
            )}
          />
          <div className='Appointments-Body'>
            <div className='Appointments-Filter'>
              <Form className='Appointments-FilterForm'>
                <DateField
                        hasTime
                        name='startDate'
                        value={startDate}
                        dateFormat='dd/MM/yyyy HH:mm'
                        timeFormat='HH:mm'
                        placeholder='С'
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterDateField}
                />
                <DateField
                        hasTime
                        name='endDate'
                        value={endDate}
                        dateFormat='dd/MM/yyyy HH:mm'
                        timeFormat='HH:mm'
                        placeholder='По'
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterDateField}
                />
                <TextField
                        name='clientName'
                        value={clientName}
                        placeholder='Клиент'
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterField}
                />
                <CheckboxField
                        name='onlyMe'
                        label='Только я'
                        value={onlyMe}
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterField}
                />
                <Button
                        className='Appointments-SearchBtn'
                        onClick={this.onSearch}>
                  <Search className='Appointments-SearchBtnIcon'/>
                </Button>
              </Form>
            </div>
            {isLoading ? (
              <Loader />
            ) : data ? (
              <Table
                      data={data}
                      className='AppointmentList'
                      columns={[
                      {
                      dataField: 'date',
                    text: 'Дата',
                    headerStyle: {
                      width: '150px'
                    },
                    formatter: (v, row) => {
                      return (
                        <Moment date={v} format='DD.MM.YYYY HH.mm' />
                      )
                    }
                  },
                  {
                    dataField: 'clientName',
                    text: 'Клиент',
                    headerStyle: {
                      width: '300px'
                    }
                  },
                  {
                    dataField: 'status',
                    text: 'Статус'
                  },
                  {
                    dataField: 'holderName',
                    text: 'Принимающий',
                    headerStyle: {
                      width: '300px'
                    }
                  },
                  {
                    dataField: 'compliences',
                    text: 'Жалобы',
                    headerStyle: {
                      width: '200px'
                    }
                  },
                  {
                    dataField: 'diagnosis',
                    text: 'Диагноз',
                    headerStyle: {
                      width: '200px'
                    }
                  }
                ]}
              />
            ) : 'Нет данных'}
          </div>
        </div>
      )
    }
  }
  

Код изменился незначительно. Добавился метод load(), который вызывает метод find() сервиса, а также устанавливает флаг isLoading, сигнализирующий о том, происходит загрузка или нет. Используя этот флаг в методе render(), пользователю будет показан компонент <Loader >, в случае если загрузка осуществляется, или компонент <Table >, если таковой не происходит.

Вот и всё! Наш код, использующий асинхронную загрузку данных, полностью готов:




Следующим и финальным нашим шагом станет добавление состояния приложения. О том, что это такое и зачем это нужно, вам подробно расскажет следующий раздел.