company_banner

Работа с данными на границе Vue.js-приложения

Автор оригинала: Vinicius Teixeira
  • Перевод
Подавляющее большинство веб-приложений как-то взаимодействует с окружающим миром. Например, с REST API серверных частей приложений или с некими внешними сервисами. Материал, перевод которого мы сегодня публикуем, посвящён обработке данных на границах приложений. В частности, речь пойдёт о том, как преобразовывать данные, поступающие в приложение из внешних источников, в правильно сформированные объекты тех типов, на работу с которыми рассчитано приложение.



Постановка задачи


Когда мы обращаемся к внешним API, мы не можем точно знать о том, в каком именно виде будут представлены данные, возвращаемые этими API. Нам, при разработке клиентских приложений, в таких ситуациях приходится полагаться на документацию к API, если она есть. При этом, так как HTTP JSON API не накладывают жёстких ограничений на схему возвращаемых ими данных, нам нужно поддерживать в себе некоторый уровень недоверия по отношению к поступающим в приложение внешним данным.

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

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

Использование сервисов в роли границ приложения



Переход от схемы прямого взаимодействия приложения и внешнего API к схеме, в которой между приложением и API имеется граница

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

Анализ объявлений типов


В качестве базы для экспериментов используем следующие типы:

// src/types/invoice.ts
namespace Types {
  export interface LineItem {
    product: Product
    rate: decimal.Decimal
    quantity: number
  }

  export interface Invoice {
    id: number | null
    createdBy: User
    lineItems: LineItem[]
    totalAmount: decimal.Decimal
  }
}

// src/types/product.ts
namespace Types {
  export interface Product {
    name: string
    description: string
  }
}

// src/types/user.ts
namespace Types {
  export interface User {
    name: string
    avatar: string
  }
}

Подробности о них, в частности, об интерфейсе Invoice, можно почитать здесь. В частности, в вышеупомянутой статье речь идёт добавлении к интерфейсу Invoice поля типа number или null (для тех случаев, когда счёт, представленный этим интерфейсом, ещё не создан на сервере).

Теперь мы собираемся создать сервисный слой, который позволит нам выполнять CRUD-операции с объектами Invoice. Начнём с описания каркаса сервиса.

Создание сервисного слоя


Для разработки сервисного слоя приложения мы воспользуемся паттерном Модуль (Module). Пример применения этого паттерна можно найти здесь. Вот каркас сервиса:

// src/services/invoice.ts
import axios from "axios"

async function createInvoice(invoice: Types.Invoice): Promise<Types.Invoice> {
  const { data } = await axios.post("/api/invoices", invoice)
  // парсинг данных и их возвращение в виде правильно сформированного объекта Invoice
}

async function readInvoice(invoiceId: number): Promise<Types.Invoice> {
  const { data } = await axios.get(`/api/invoices/${invoiceId}`)
  // парсинг данных и их возвращение в виде правильно сформированного объекта Invoice
}

async function updateInvoice(
  invoiceId: number,
  updatedInvoice: Types.Invoice
): Promise<Types.Invoice> {
  const { data } = await axios.put(`/api/invoices/${invoiceId}`, updatedInvoice)
  // парсинг данных и их возвращение в виде правильно сформированного объекта Invoice
}

async function deleteInvoice(invoiceId: number) {
  await axios.delete(`/api/invoices/${invoiceId}`)
}

async function listInvoices(): Promise<Types.Invoice[]> {
  const { data } = await axios.get("/api/invoices")
  // парсинг данных и их возвращение в виде массива объектов Invoice
}

export default {
  createInvoice,
  readInvoice,
  updateInvoice,
  deleteInvoice,
  listInvoices,
}

Здесь мы исходим из предположения о том, что REST API находится на том же сервере, на котором находится наше приложение, и о том, что доступны все конечные точки, обеспечивающие выполнение CRUD-операций.

Благодаря тому, что наше приложение работает с внешними данными исключительно через этот сервис, мы можем быть уверены в том, что оно будет получать правильно сформированные объекты, имеющие ожидаемый приложением тип. Обратите внимание на то, что в коде приведены комментарии, указывающие на места, в которых нужно выполнить парсинг ответов API. Теперь нам нужно написать соответствующие функции, которые будут выполнять парсинг этих ответов. Они будут частью основной логики приложения, будут представлены модулями. При этом каждый модуль будет ответственен за парсинг данных определённого типа.

Парсинг ответов API


В нашем проекте имеется тип Partial, подробности о котором можно найти здесь. Он предназначен для тех случаев, когда у объекта нет всех необходимых атрибутов типа. Здесь мы можем использовать этот тип повторно, сделав это для того чтобы рассматривать данные, приходящие от API, в виде объектов нужного нам типа, которые, возможно, не содержат всех необходимых атрибутов этого типа.

Данные, возвращаемые API, вероятно, будут иметь некоторые поля (в идеале — все поля), соответствующие определениям типов, имеющимся в клиентской части приложения. Но, чтобы себя обезопасить, мы будем исходить из предположения о том, что в данных, приходящих от API, любое поле может быть пропущено. Мы будем пользоваться типом Partial в роли обёртки для всех данных, приходящих от API.

Напишем функцию, выполняющую парсинг данных, из которых должны формироваться объекты Invoice. Мы поместим её в ранее созданный модуль invoice.ts:

// src/modules/invoice.ts
import Decimal from "decimal.js"
import LineItem from "@/modules/lineItem"
import User from "@/modules/user"

function parse(data?: Types.Partial<Types.Invoice>): Types.Invoice {
  return {
    id: data!.id || null,
    createdBy: User.parse(data!.createdBy || {}),
    lineItems: data!.lineItems ? data!.lineItems.map(LineItem.parse) : [],
    totalAmount: new Decimal(data!.totalAmount || 0),
  }
}

// другие функции модуля

export default {
  // ... другие экспортированные функции
  parse,
}

Функция parse принимает объект неизвестного типа data, при этом на её вход может поступить и значение undefined. Затем мы создаём корректный объект Invoice, назначая, по умолчанию, значение null его свойству id, и выполняя парсинг других полей. Например, при формировании поля totalAmount мы используем конструктор Decimal, позволяющий создать валидное десятичное число. При этом данные, поступающие от API, скорее всего, не будут объектами типа Decimal. Это, вероятно, будут строки или числа, что зависит от того, как именно сервер сериализует данные.

Обработку типов User и LineItem мы передаём функциям parse соответствующих модулей, которые мы пока не реализовали. Сделаем это сейчас:

// src/modules/user.ts

function parse(data?: Types.Partial<Types.User>): Types.User {
  return {
    name: data!.name || "",
    avatar: data!.avatar || "",
  }
}

// другие функции модуля

export default {
  // ... другие экспортированные функции
  parse,
}

Так как модель User очень проста, в функции parse из user.ts нет ничего сложного. Здесь просто нужно предоставить значения, применяемые по умолчанию, для имени и аватара пользователя. Делается это на тот случай, если в том, что поступило из API, этих данных нет.

Теперь взглянем на модуль для подготовки значений типа LineItem.

// src/modules/lineItem.ts
import Decimal from "decimal.js"
import Product from "@/modules/product"

function parse(data?: Types.Partial<Types.LineItem>): Types.LineItem {
  return {
    product: Product.parse(data!.product || {}),
    rate: new Decimal(data!.rate || 0),
    quantity: data!.quantity || 0,
  }
}

// другие функции модуля

export default {
  // ... другие экспортированные функции
  parse,
}

Здесь, занимаясь парсингом данных, мы преобразуем значение rate в валидный объект типа Decimal (так же, как делали при подготовке объекта типа Invoice). Мы назначаем полю quantity значение по умолчанию, равное 0, и делегируем парсинг данных, необходимых для поля product, соответствующему модулю. Вот код этого модуля:

// src/modules/product.ts
function parse(data?: Types.Partial<Types.Product>): Types.Product {
  return {
    name: data!.name || "",
    description: data!.description || "",
  }
}

// другие функции модуля

export default {
  // ... другие экспортированные функции
  parse,
}

Здесь, как и в случае с типом User, всё устроено очень просто.

Теперь у нас есть парсеры для всех используемых типов данных. А это значит, что мы можем подключить функцию парсинга Invoice-данных к сервису Invoice.

Вот итоговая реализация сервиса Invoice:

import axios from "axios"
import Invoice from "../modules/invoice"

async function createInvoice(invoice: Types.Invoice): Promise<Types.Invoice> {
  const { data } = await axios.post("/api/invoices", invoice)
  return Invoice.parse(data)
}

async function readInvoice(invoiceId: number): Promise<Types.Invoice> {
  const { data } = await axios.get(`/api/invoices/${invoiceId}`)
  return Invoice.parse(data)
}

async function updateInvoice(
  invoiceId: number,
  updatedInvoice: Types.Invoice
): Promise<Types.Invoice> {
  const { data } = await axios.put(`/api/invoices/${invoiceId}`, updatedInvoice)
  return Invoice.parse(data)
}

async function deleteInvoice(invoiceId: number) {
  await axios.delete(`/api/invoices/${invoiceId}`)
}

async function listInvoices(): Promise<Types.Invoice[]> {
  const { data } = await axios.get("/api/invoices")
  return Array.isArray(data) ? data.map(Invoice.parse) : []
}

export default {
  createInvoice,
  readInvoice,
  updateInvoice,
  deleteInvoice,
  listInvoices,
}

Обратите внимание на то, как просто устроен этот сервис. Это так благодаря тому, что мы делегировали логику парсинга данных модулю Invoice. Сервис ответственен за обмен данными с API и за отправку данных, полученных от API, механизмам парсинга. Всё это позволяет обеспечить то, что сервис будет возвращать именно такие данные, которые ожидает получить от него приложение.

Обработка данных, структура которых не соответствует ожидаемой


В предыдущих примерах мы исходили из предположения о том, что структура объектов, поступающих от API, соответствует структуре объектов, используемых в Vue.js-приложении. Если обе стороны обмена данными находятся под нашим контролем, то мы можем (и, вероятно, должны), приложить все усилия к тому, чтобы обеспечить это. Но иногда мы либо не можем оказать влияние на серверную сторону проекта, либо, по каким-то причинам, не можем её изменить.

Если всё обстоит именно так, мы можем объявить специфический тип для данных, приходящих из API, обернуть эти данные в тип Partial, а затем, внутри приложения, преобразовать эти данные к нужному нам типу.

Ради того, чтобы это продемонстрировать, предположим, что объект Product в данных, получаемых от API, имеет имена полей, отличающиеся от тех, которые применяются в приложении. Пусть вместо поля name у него будет поле product_name, а вместо поля description — поле product_desc. В подобной ситуации мы можем поступить так:

// src/modules/product.ts

interface ApiProduct {
  product_name: string
  product_desc: string
}

function parse(data?: Types.Partial<ApiProduct>): Types.Product {
  return {
    name: data!.product_name || "",
    description: data!.product_desc || "",
  }
}

Мы объявили интерфейс ApiProduct, который представляет форму объекта Product, получаемого от API.

Нам не нужно экспортировать этот интерфейс и делать его доступным для остальных частей приложения. Его не нужно и объявлять в пространстве имён Types. Он предназначен для использования исключительно в функции parse. В других местах приложения мы должны использовать только правильно сформированные значения типа Types.Product.

Если нам нужно преобразовать значение типа Product в значение типа ApiProduct, используемое API (например, при выполнении CRUD-операций с Product), мы должны добавить в модуль Product функцию, которая выполняет подобное преобразование. Эту функцию можно вызывать тогда, когда это нужно, учитывая лишь то, что мы должны последовательно подходить к именованию сущностей в наших модулях.

Получение уведомлений о неожиданных ответах API


В примере, которым мы занимались в этом материале, мы игнорировали то, что API может и не вернуть объект ожидаемого типа, содержащий все необходимые поля. Иногда, возможно, стоит поступать именно так. Но в большинстве ситуаций стоит предусмотреть систему уведомлений о подобный событиях. При получении подобного уведомления нужно исправить API (если мы его контролируем) или функции парсинга данных.

Функции parse, которые мы написали — это идеальное место для выдачи подобных уведомлений. Если вы используете что-то вроде Sentry для сбора сведений о клиентских исключениях, то, если окажется, что в объекте, полученном от API, не будет необходимого поля, можно вызвать исключение, которое будет перехвачено Sentry. Ещё можно воспользоваться интеграцией с Capture Console, записать в лог ошибку и вернуть подходящее значение, используемое по умолчанию (то есть, поступить, в части возврата значения, так, как сделано в вышерассмотренных примерах). В результате приложение окажется работоспособным, даже несмотря на то, что в ответе API нет каких-то данных.

function parse(data?: Types.Partial<Types.User>): Types.User {
  return {
    name: data!.name || (console.warn('Missing user.name in API response') || ""),
    avatar: data!.avatar || "",
  }
}

Итоги


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

Мы, в каждом из модулей, создали функции parse, которые предназначены для преобразования объектов, полученных из API (в таких объектах вполне могут отсутствовать некоторые поля), в объекты, тип которых известен приложению.

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

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

Вот репозиторий с кодом проекта, который мы здесь рассматривали. 

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



RUVDS.com
RUVDS – хостинг VDS/VPS серверов

Комментарии 5

    +1
    Мне кажется, что данный подход можно улучшить.

    Во-первых, неясно, зачем вам typescript, раз все равно валидация проходит в runtime. Во-вторых, валидация в runtime спорная. Если пришел объект без id, пытаться чинить (и молча проглатывать) это с помощью data!.id || null неправильно. Оно ничем не поможет в дальнейшем, и только усложнит отладку.

    Typescript согласно своей (спорной) идеалогии не вмешивается в runtime, но есть несколько проектов, которые пытаются решить эту ситуацию. Например, github.com/pelotom/runtypes протаскивает типы в js, и с их помощью позволяет валидировать входящие объекты. В случае ошибки он выбрасывает исключение, и это правильно. Интересен так же подход github.com/paroi-tech/typeonly, это следующий язык, суперсет над typescript. Кроме того, можно определять типы typescript динамически на основе js-объекта с помощью keyof и typeof, и опять-таки пользоваться исходным объектом для runtime-валидации.

    Конечно, интереснее всего было бы с помощью typescript писать fullstack-приложения, т.е. определять сущности в nest и typeorm один раз, и эти же определения использовать на фронтенде в vuex. А затем пробрасывать в runtime javascript для валидации (и даже формы валидировать без запроса на бек). Все к этому уверенно движется.
      0

      разве это еще не в бете? " data!.id" "data?.id"

        0
        В бете чего именно? Это ES2020, в nodejs 14 type module не поддерживается (https://node.green), а в babel >= 7.8.0 работает без плагинов (https://babeljs.io/blog/2020/01/11/7.8.0).
      0
      Валидация, транспорт и генерирование типов апи из схемы — это всё задачи фреймворка swagger, thrift, grpc, gql/apollo.js. Решать это вручную — жуткий колхоз, кодовая помойка и полное непонимание принципов распределённых систем. Отсутствие базовых знаний о базовых знаниях о мире
        0
        Кстати есть уже готовый, отличный инструмент Vuex ORM Axios

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое