Павел Симонов

Город IT · 2023 · Павел Симонов

Records API: переосмысление GraphQL для low-code платформы

Отредактированная расшифровка по русским автосубтитрам записи и слайдам; это не дословная расшифровка, сверенная с аудио. Повторы и слова-паразиты убраны, технические названия сверены со слайдами. Неразборчивые фрагменты отмечены. Содержание сохраняет контекст доклада 2023 года.

Изображения слайдов сохранены на русском. Текст выступления доступен на выбранном языке; код и API-идентификаторы не переводятся.

Содержание

1 / 36Records API

Оригинальный слайд 1: Records API
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Всем привет! Меня зовут Павел Симонов, я системный архитектор компании Citeck. Тема моего доклада - Records API, наша разработка и переосмысление GraphQL для low-code платформы.

2 / 36С чего всё начиналось

Оригинальный слайд 2: С чего всё начиналось
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

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

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

3 / 36Overfetching

Оригинальный слайд 3: Overfetching
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

По сути, мы столкнулись с overfetching: клиенту нужны отдельные данные, а сервер возвращает гораздо больше. На слайде это показано вопросом: где среди всего этого email пользователя?

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

4 / 36Low-code платформа

Оригинальный слайд 4: Low-code платформа
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Мы считаем, что вкалывать должны роботы, а не человек. Если что-то можно автоматизировать, это должно быть автоматизировано.

При обычной разработке создание нового типа и его сущностей означает: сделать сущность, репозиторий, сервис, контроллер и при необходимости ещё что-нибудь. К low-code это отношения не имеет. Нам нужен другой подход.

5 / 36Новый тип: идентификатор и имя

Оригинальный слайд 5: Новый тип: идентификатор и имя
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

В нашей платформе открываем раздел с типами и создаём новый тип. Заполняем идентификатор и имя. На слайде показан тип «Доверенность» с идентификатором attorney.

6 / 36Новый тип: атрибуты

Оригинальный слайд 6: Новый тип: атрибуты
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Затем заполняем атрибуты. В этом примере их два: имя и номер. Настраиваем их и создаём тип. На скриншоте name имеет тип Text, а number - Number.

7 / 36Добавляем таблицу в меню

Оригинальный слайд 7: Добавляем таблицу в меню
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

После создания типа переходим в редактор меню и добавляем таблицу для отображения сущностей нашего типа. На скриншоте это пункт «Журнал».

8 / 36Выбираем созданный тип

Оригинальный слайд 8: Выбираем созданный тип
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Поиском находим нужный тип, выбираем его и сохраняем настройку меню.

9 / 36Готовая таблица

Оригинальный слайд 9: Готовая таблица
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

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

10 / 36Автоматически созданная форма

Оригинальный слайд 10: Автоматически созданная форма
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Появляется автоматически сгенерированная форма. Заполняем её данными и сохраняем. В примере у доверенности есть имя и номер.

11 / 36Карточка документа

Оригинальный слайд 11: Карточка документа
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

После сохранения появляется карточка документа. Отдельно писать форму и карточку для этого нового типа не понадобилось.

12 / 36Запись в таблице

Оригинальный слайд 12: Запись в таблице
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Возвращаемся назад, обновляем таблицу и видим нашу запись. Всё выполняется достаточно просто и быстро.

13 / 36Что создаётся на бэкенде

Оригинальный слайд 13: Что создаётся на бэкенде
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

В результате на бэкенде создаётся довольно простая таблица. В ней есть метаданные: кто и когда создал запись, кто и когда изменил её. Есть и наши собственные поля - имя и номер.

Такая low-code платформа позволяет гибко решать подобные задачи. Теперь вернёмся к проблеме overfetching.

14 / 36Почему мы посмотрели на GraphQL

Оригинальный слайд 14: Почему мы посмотрели на GraphQL
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

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

15 / 36GraphQL не подошёл

Оригинальный слайд 15: GraphQL не подошёл
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Но когда начали внедрять GraphQL, оказалось, что подключение его к нашей low-code платформе похоже на натягивание совы на глобус. Он не подошёл. Давайте рассмотрим конкретные проблемы, с которыми мы столкнулись.

16 / 36Минусы GraphQL для нашей платформы

Оригинальный слайд 16: Минусы GraphQL для нашей платформы
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

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

Второе - работа универсальных клиентов затруднена. Из-за специфики платформы мы не можем завязывать фронтенд на конкретные бизнес-сущности, например на класс договора. Работаем с данными: мапами и массивами, максимально универсально и гибко.

Третье - бэкенд тоже может быть клиентом. Микросервисы должны общаться между собой через тот же API: зачем изобретать его дважды? Заказчики тоже спрашивают, как взаимодействовать с системой. Хотим дать им тот же API, которым пользуется браузер.

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

17 / 36Что понравилось в GraphQL

Оригинальный слайд 17: Что понравилось в GraphQL
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Есть и плюсы, которые нам очень понравились. Клиент точно знает тип результата, который получит из запроса: в GraphQL это обеспечивается схемой.

Нет проблем underfetching и overfetching. Есть единый endpoint для работы. Для нашей системы писать новые endpoints под типовые задачи просто излишне. Эти свойства мы хотели сохранить.

18 / 36Базовые понятия Records API

Оригинальный слайд 18: Базовые понятия Records API
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Мы разработали собственный API и назвали его Records API. Чтобы понять его, определим три базовых термина и связанные с ними правила.

Микросервис здесь - приложение с уникальным именем в пределах платформы. Запись, или Record, - любой ресурс системы. Источник данных - DAO для записей, выполняющий все или часть CRUD-операций: создание, чтение, изменение, удаление. У источника есть уникальный идентификатор в пределах микросервиса.

Каждая запись имеет глобальный идентификатор. В примере emodel/person@admin три части: имя микросервиса emodel, после слеша источник данных person, после @ локальный идентификатор записи admin в пределах этого источника.

Пример со слайда

emodel/person@admin

19 / 36Универсальный формат поиска

Оригинальный слайд 19: Универсальный формат поиска
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

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

Где ищем? sourceId: имя приложения или микросервиса, слеш, идентификатор источника данных. Как ищем? language: язык запроса. Для API это любая строка, но важно, чтобы источник данных знал этот язык.

Что ищем? query: сам запрос. Он зависит от языка, а его интерпретация полностью лежит на источнике данных. Как правило, это строка или объект.

Далее всё достаточно стандартно: groupBy задаёт атрибуты группировки; sortBy - атрибут и направление сортировки; page - сколько записей пропустить и сколько взять.

Пример со слайда

sourceId: String
language: String
query: Any
groupBy: String[]
sortBy: SortBy[] { attribute: String, ascending: Bool }
page: Page { skipCount: Int, maxItems: Int }

20 / 36Атрибуты и связи

Оригинальный слайд 20: Атрибуты и связи
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

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

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

21 / 36Вложенные атрибуты через точку

Оригинальный слайд 21: Вложенные атрибуты через точку
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

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

Первое правило атрибутов Records API: для получения вложенного значения используем точку. Имея ссылку на договор, полное имя контрагента можно запросить как counterparty.fullOrgName. Более длинная цепочка выглядит как association0.association1.someField.

Пример со слайда

association0.association1.someField
counterparty.fullOrgName

22 / 36Скаляры: тип результата

Оригинальный слайд 22: Скаляры: тип результата
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

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

Любое значение в графе можно представить по-разному. Число - как число или строку; для булевого значения тоже возможны разные представления. Если значение - ссылка на сущность, клиенту может быть нужна сама ссылка или отображаемое имя этой сущности. Исходное значение при этом одно и то же.

Скаляр пишется после атрибута и вопросительного знака: attribute?scalar. Клиент указывает, в каком виде хочет получить данные, а движок преобразует результат в нужный тип. [Часть дальнейшего пояснения в автосубтитрах неразборчива.]

Пример со слайда

association0.association1.someField?scalar

23 / 36Загрузка имени договора

Оригинальный слайд 23: Загрузка имени договора
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Рассмотрим простой пример чтения имени: пишем name. Если скаляр не указан, подразумевается disp, то есть display name.

Для строки это её содержимое. Для ссылок на сущности есть нюанс: отображаемое имя отличается от самой ссылки. Поэтому name и name?disp - эквивалентные записи в показанном примере.

Пример со слайда

name
name?disp

24 / 36Полное имя контрагента

Оригинальный слайд 24: Полное имя контрагента
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Чтобы по ссылке на договор получить полное имя контрагента, проходим ассоциацию counterparty, затем fullOrgName. Если хотим строку, указываем скаляр str: counterparty.fullOrgName?str.

Пример со слайда

counterparty.fullOrgName?str

25 / 36Постпроцессоры атрибутов

Оригинальный слайд 25: Постпроцессоры атрибутов
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

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

Самый распространённый - or. Если вычислилось null или пустая строка, он возвращает заданное значение. Для него есть синтаксический сахар: восклицательный знак, после которого пишется значение по умолчанию. counterparty.fullOrgName?str!"unknown" эквивалентно counterparty.fullOrgName?str|or("unknown").

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

Пример со слайда

counterparty.fullOrgName?str!"unknown"
==
counterparty.fullOrgName?str|or("unknown")

attribute|processor0(arg0,arg1,argN)|processor1(arg0,arg1,argN)

26 / 36HTTP API: поиск записей

Оригинальный слайд 26: HTTP API: поиск записей
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Перейдём от теории к практике. Один из вариантов использования Records API - HTTP. Мы всегда общаемся POST-запросами. Хотели сохранить независимость от транспорта, поэтому не раскладываем операции по разным HTTP-методам.

Для поиска передаём query в описанном формате и attributes, которые хотим получить. Слева в attributes - имя, под которым вернётся результат, справа - выражение атрибута. В этом примере ищем пользователя по email, берём одну запись и запрашиваем локальный идентификатор под именем userId.

Пример со слайда

POST /gateway/records/query

{
  "query": {
    "sourceId": "emodel/person",
    "language": "predicate",
    "query": {
      "t": "eq",
      "a": "email",
      "v": "admin@citeck.ru"
    },
    "page": {
      "maxItems": 1
    }
  },
  "attributes": {
    "userId": "?localId"
  }
}

27 / 36HTTP API: ответ на поиск

Оригинальный слайд 27: HTTP API: ответ на поиск
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

В результате получаем список записей. У каждой всегда есть глобальный идентификатор id и запрошенные attributes. Слева в attributes - выбранное имя результата, справа - значение. Для найденного пользователя userId равен admin. totalCount показывает количество найденных записей.

Пример со слайда

{
  "records": [
    {
      "id": "emodel/person@admin",
      "attributes": {
        "userId": "admin"
      }
    }
  ],
  "totalCount": 1
}

28 / 36HTTP API: получение атрибутов

Оригинальный слайд 28: HTTP API: получение атрибутов
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Если идентификаторы записей уже известны, передаём их массивом records и указываем атрибуты. Принцип тот же, что и при поиске.

Здесь запрашиваем email двух пользователей. Выражение email!'undefined' задаёт строковое значение по умолчанию, если email отсутствует.

Пример со слайда

POST /gateway/records/query

{
  "records": [
    "emodel/person@admin",
    "emodel/person@pushkin"
  ],
  "attributes": {
    "email": "email!'undefined'"
  }
}

29 / 36HTTP API: ответ с атрибутами

Оригинальный слайд 29: HTTP API: ответ с атрибутами
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Получаем две записи: у каждой есть id и вычисленные атрибуты. У admin email заполнен. Во втором примере вернулась строка "undefined", заданная в запросе как значение по умолчанию.

Пример со слайда

{
  "records": [
    {
      "id": "emodel/person@admin",
      "attributes": {
        "email": "admin@citeck.ru"
      }
    },
    {
      "id": "emodel/person@pushkin",
      "attributes": {
        "email": "undefined"
      }
    }
  ]
}

30 / 36HTTP API: изменение записей

Оригинальный слайд 30: HTTP API: изменение записей
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Для изменения используется другой URL - /gateway/records/mutate. Указываем запись и новые значения её атрибутов. В примере меняются email и address пользователя admin.

Пример со слайда

POST /gateway/records/mutate

{
  "records": [
    {
      "id": "emodel/person@admin",
      "attributes": {
        "email": "new-email@citeck.ru",
        "address": "Moscow, Pushkina st."
      }
    }
  ]
}

31 / 36HTTP API: ответ на изменение

Оригинальный слайд 31: HTTP API: ответ на изменение
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

При изменении обычно приходит тот же идентификатор. Здесь attributes пустые. Но при желании вместе с records в запросе мутации можно указать, какие атрибуты получить из результата. Такое поддерживается и кое-где используется; тогда attributes в ответе были бы заполнены.

Если бы создавали запись, в ответе был бы идентификатор новой сущности.

Пример со слайда

{
  "records": [
    {
      "id": "emodel/person@admin",
      "attributes": {}
    }
  ]
}

32 / 36HTTP API: создание записей

Оригинальный слайд 32: HTTP API: создание записей
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Создание идёт через тот же endpoint мутации. После @ в идентификаторе ничего нет: emodel/person@. Это обозначает создание новой сущности.

Если передать локальный id в атрибутах и запись с ним уже существует, возможен сценарий обновления; иначе создаётся запись с указанным идентификатором. В примере передаём id pk.simonov, email и address.

Пример со слайда

POST /gateway/records/mutate

{
  "records": [
    {
      "id": "emodel/person@",
      "attributes": {
        "id": "pk.simonov",
        "email": "pavel.simonov@citeck.ru",
        "address": "Tomsk, Pushkina st."
      }
    }
  ]
}

33 / 36HTTP API: ответ на создание

Оригинальный слайд 33: HTTP API: ответ на создание
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Ответ устроен так же, как при изменении, но содержит идентификатор созданной записи. Здесь это emodel/person@pk.simonov.

Пример со слайда

{
  "records": [
    {
      "id": "emodel/person@pk.simonov",
      "attributes": {}
    }
  ]
}

34 / 36HTTP API: удаление записей

Оригинальный слайд 34: HTTP API: удаление записей
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

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

Пример со слайда

POST /gateway/records/delete

{
  "records": [
    "emodel/person@admin",
    "emodel/person@pushkin"
  ]
}

{
  "statuses": [
    "OK",
    "OK"
  ]
}

35 / 36Демо-проект

Оригинальный слайд 35: Демо-проект
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

На этом основная часть заканчивается. Есть демо-проект: его можно скачать и запустить самостоятельно. Он не привязан к нашей платформе. В проект добавлена библиотека Records API и тестовый источник данных, который хранит данные в памяти.

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

https://github.com/Citeck/ecos-records-demo

36 / 36Документация и завершение

Оригинальный слайд 36: Документация и завершение
Оригинальный слайд · RU Этот фрагмент в видео

Текст выступления

Спасибо за внимание! На слайде ссылка на документацию. Она больше описывает работу в рамках нашей платформы. Если не успели открыть ссылку на демо-проект, в начале документации она тоже есть.

https://citeck-ecos.readthedocs.io/ru/latest/general/ECOS_Records.html

Вопросы после доклада

Есть ли клиент для фронтенда?

Вопрос: есть готовая реализация клиента или запросы нужно отправлять самостоятельно, например через Axios?

Ответ: клиент в нашей платформе есть. Он прежде всего оптимизирует запросы. Если данные пользователя или атрибуты разных сущностей нужны нескольким компонентам, клиент объединяет запросы и отправляет их вместе. Но в этом и прелесть подхода: можно формировать обычный JSON и отправлять его любым HTTP-клиентом. Дополнительная обёртка есть в рамках платформы.

Не позволяет ли универсальный API прочитать все данные?

Вопрос: если клиент не привязан к бизнес-сущностям, нельзя ли просто собрать всю информацию?

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

Проверки доступа относятся к реализации Record Source или Records DAO. У нас есть общие реализации для разных типов, но это уже не часть самого протокола API.

Как работают фильтрация и поиск по связанным данным?

Вопрос: есть ли условия выборки и поиск, в том числе во вложенных справочниках?

Ответ: у скаляров бывают аргументы, которые обычно используют для поиска, но их обработка зависит от конкретной реализации. Для общего поиска есть Records Query: он описывает источник, язык, условия и пагинацию. Универсальная таблица отправляет один формат запроса нужному источнику и получает ответ, независимо от типа данных.

Поиск по ассоциациям тоже добавили. Если данные хранятся в одной схеме базы, можно указать путь вроде user.email. Бэкенд определяет, что user - ассоциация, узнаёт тип и sourceId связанной сущности, находит её таблицу и добавляет JOIN в SQL-запрос.

Насколько сложными могут быть запросы?

Вопрос: есть ли ограничение на сложность запросов?

Ответ: общий движок не покрывает всё множество возможных запросов. Реализованы базовые операции: равно, не равно, начинается, заканчивается, а также упомянутый поиск по связанным таблицам. Для более сложной задачи может понадобиться отдельный источник данных.