Biryukov Victor

https://sber-tech.com/ Platform V1 developer

email: vvbiryukov.sbt@sberbank.ru2

Telegram: @birvictor

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

Меня зовут Виктор Бирюков, я главный руководитель IT-направления в СберТехе — компании, которая создаёт основные технологические решения для Сбера. В статье я расскажу, как с помощью PaaS-инструментов упростить и ускорить разработку микросервисов так, чтобы в конечном счёте на создание полноценного продукта у вас уходило не больше 15 минут.

У этой статьи будет продолжение. В этой части мы познакомимся с сервисом Platform V DataSpace и напишем frontend-приложение, используя DataSpace как сервис (Backend-as-a-Service). Во второй статье разберём Platform V Functions, напишем backend-приложение как облачную функцию и разместим наше frontend-приложение также как функцию (Function-as-a-Services).

Теперь обо всём по порядку.

О сервисах

Для разработки сервиса будем использовать продукты Platform V — технологической платформы Сбера.

Platform V создавалась как фундамент цифровой трансформации банка, а с 2021 года поставляется бизнесу и государству по модели PaaS. Всего на базе платформы более 60 инструментов для разработки. Но здесь речь пойдёт о двух, которые уже доступны разработчикам в SmartMarket, витрине IT-технологий Сбера. Это Platform V DataSpace и Platform V Functions.

Что будем разрабатывать

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

Схема сервиса:

Архитектура приложения

В системе есть два вида пользователей: Администратор и Клиент. У них разные каналы для работы:

  • «Администратор» (Admin) работает с DataSpace через авторизованные запросы на API gateway, зная адрес сервиса, appKey (логин) и appSecret (пароль), далее ak/sk. Необходимая для работы статика приезжает посредством функции Function 1 Admin Frontend.

  • «Клиент» (Client) через общедоступный сервис вводит промокод и выбирает подарок. Воспользоваться промокодом можно только один раз. Необходимая для работы статика приезжает посредством функции Function 2 Client Frontend. В функции Function 3 Client Backend реализуем серверную логику обработки запросов от «Клиентов». 

Frontend-приложение с помощью Platform V DataSpace

Platform V DataSpace — это облачный сервис для хранения и управления данными приложения. Позволяет создать на основе модели данных клиента слой доступа к data и за счёт этого ускорить процесс разработки приложений.

Как начать работу:

  1. заходим на сайт Smartmarket;

  2. регистрируемся или входим по Сбер ID, можно через QR-код в приложении СберБанк Онлайн;

  3. В созданном пространстве жмём «Создать проект», где выбираем Platform V DataSpace и переходим в визуальный редактор конструирования модели данных.

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

Но для наглядности можно уже сейчас загрузить в редактор готовую модель данных для приложения «Промоакция» model.xml и нажать кнопку «Выпустить». После выпуска сервиса в приложении можно начать работу в качестве администратора.

Сервис будет доступен по адресу, где в качестве хранилища выступит DataSpace. Чтобы начать работу, нужно авторизоваться: нажать кнопку «Login page» и указать данные авторизации. Адрес/логин/пароль будут доступны в настройках вашего проекта DataSpace: адрес проекта, app_key, app_secret соответственно.

Фактически, пройдя по ранее указанному адресу, вы уже воспользовались сервисом Platform V Functions — получили статику web-приложения, работающего напрямую с вашим DataSpace.

Более детально поговорим об этом во второй статье, посвящённой Functions. А пока продолжаем разбираться с тем, как написать frontend-приложение c помощью DataSpace.

Знакомство с Platform V DataSpace

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

Получившаяся модель в формате xml: https://github.com/VictorBiryukov/promo-action/blob/release/model.xml

После выпуска сервиса DataSpace можно работать с данными через GraphQL API, в том числе через конструктор в визуальном редакторе. Вот видеоинструкция, как это делать, а вот документация.

Подписание запросов (авторизация)

Итак, модель предметной области есть, сервис, предоставляющий GraphQL API для работы с данными, выпущен. Но для вызова извне необходимо отправлять на него http-запросы, предварительно подписывая их при помощи ключа.

Делать такие запросы можно с помощью соответствующего Java Script SDK с возможностью формировать запросы вручную через визуальную форму.

Данные для подписи запроса можно найти в настройках проекта в SmartMarket: там доступны  ak/sk + адрес сервиса для вызова.

Function 1 Admin Frontend

Давайте теперь перейдём к написанию frontend-приложения, где в качестве backend'а используется DataSpace.

Вооружимся следующим технологическим стеком:

  • TypeScript  — язык программирования;

  • GraphQL — язык запросов к серверной части (DataSpace);

  • GraphQL Code Generator (TypeScript) — очень полезная утилита для преобразования GraphQL-запросов в конструкции на TypeScript;

  • ReactJS — ReactJS;

  • Apollo Client v3 — JS-библиотека для работы через GraphQL: кэширование, react-хуки и другие «печеньки»;

  • Ant Design — готовые экранные компоненты.

Собирать всё это будем при помощи Webpack.

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

В этом заключается главная идея статьи: чтобы работать с моделью, необязательно быть матёрым frontend-разработчиком (я и сам таковым не являюсь). По той же причине в материале много ссылок на конкретные документации: они будут полезны тем, кто захочет детальнее разобраться в сути технологий. Например, по этой ссылке доступны все исходники приложения.

Разработка приложения

Начнём с режима разработки: webpack.dev.config.js

Из интересного в конфигурации сборки только настройки прокси у сервера разработки:

...   
    devServer: {
        hot: true,
        port: 3000,
        before: (app) => {
            app.use(createProxyMiddleware("/graphql",
                {
                    target: process.env.DS_ENDPOINT,
                    changeOrigin: true,
                    secure: false,
                    pathRewrite: { '/graphql': '' }
                }));
        },
        watchOptions: {
            poll: true,
            ignored: "/node_modules/"
        }
    },
...

Таким образом мы обходим проверку запрета на CORS со стороны сервиса при работе через браузер в режиме локальной разработки.

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

DS_ENDPOINT=[Enter your dataspace graphql endpoint here]

Теперь переходим к написанию приложения.

В файле src/index.tsx ничего необычного: подключаем React, отрисовываем корневой компонент App.

Прежде чем начать работать с DataSpace, надо научить наш провайдер GraphQL-запросов правильно их подписывать. Для этого даём возможность пользователю ввести адрес DataSpace + ak/sk, сохраняем данные в localStorage:

<Form>
    <Form.Item>
        <Input placeholder="Service address"
            value = {appAddress}
            onChange={e => setAppAddress(e.target.value)}
        />
    </Form.Item>
    <Form.Item>
        <Input placeholder="Service key"
            value = {appKey}
            onChange={e => setAppKey(e.target.value)}
        />
    </Form.Item>
    <Form.Item>
        <Input.Password placeholder="Service secret"
            value = {appSecret}
            onChange={e => setAppSecret(e.target.value)}
        />
    </Form.Item>
</Form>

Заполнив параметры, передаём их далее в AppProvider, где уже при помощи ранее представленного JavaScript SDK определяем правило подписания, после чего инициализируем ApolloClient:

Apollo Client нужен, чтобы упростить работу с сервисом DataSpace через GraphQL API. С одной стороны, он замечательно интегрируется с React через хуки (hooks), с другой — обеспечивает бесшовную интеграцию с GraphQL-сервером DataSpace, элегантно решая вопросы кэширования и нормализации данных на клиенте.

Вернёмся к нашему приложению «Промоакция».  

В UI-консоли администратора необходимо обеспечить следующие возможности:

  • запрос списка компаний-спонсоров;

  • создание/удаление компании-спонсора;

  • запрос списка подарков компании-спонсора;

  • создание/удаление подарка.

Для написания соответствующих запросов воспользуемся GraphQL-конструктором внутри визуального редактора.

В примере ниже мы одним запросом создаём компанию-спонсора (GiftVendor) и два её первых подарка (Gift):

В верхней левой части представлен сам запрос, в левом нижнем углу — передаваемые в запрос параметры, справа — результат его выполнения
В верхней левой части представлен сам запрос, в левом нижнем углу — передаваемые в запрос параметры, справа — результат его выполнения

Давайте уделим внимание двум моментам:

  1. Указывая ключ «SberBankAndTwoFirstGifts» в параметре idempotencePacketId в мутации packet, мы делаем этот запрос идемпотентным.

    При повторном выполнении данного запроса (если первый был успешным) DataSpace вернёт результат выполнения, но не будет повторять саму операцию создания (изменения состояния БД).

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

  2. Также обратите внимание на лексему «ref:createGiftVendor», передаваемую в поле vendor создаваемых подарков: таким образом обеспечена связь между подарками и компанией-спонсором, создаваемой на первом шаге выполнения пакета.

Детальное описание формата GraphQL-запросов DataSpace доступно в документации.

Для нашего же приложения понадобится набор совсем простых GraphQL-запросов:

searchGiftVendor createGiftVendor deleteGiftVendor searchGift createGift deleteGift

query searchGiftVendor{
  searchGiftVendor{
    elems{
      id
      __typename
      name
    }
  }
}

searchGiftVendor createGiftVendor deleteGiftVendor searchGift createGift deleteGift

mutation createGiftVendor($name:String!){
  packet{
    createGiftVendor(input:{
      name: $name
    }){
      id
      __typename
      name
    }
  }
}

searchGiftVendor createGiftVendor deleteGiftVendor searchGift createGift deleteGift

mutation deleteGiftVendor($id: ID!){
  packet{
    deleteGiftVendor(id: $id)
  }
}

searchGiftVendor createGiftVendor deleteGiftVendor searchGift  createGift deleteGift

query searchGift($cond: String){
  searchGift(cond: $cond){
    elems{
      id
      __typename
      serialNumber
      kind
    }
  }
}

searchGiftVendor createGiftVendor deleteGiftVendor searchGift createGift deleteGift

mutation createGift($vendorId: ID!, $serialNumber:String!, $kind: _EN_GiftKind){
  packet{
    createGift(input:{
      vendor: $vendorId
      serialNumber: $serialNumber
      kind: $kind
    }){
      id
      __typename
      serialNumber
      kind
    }
  }
}

searchGiftVendor createGiftVendor deleteGiftVendor searchGift createGift deleteGift

mutation deleteGift($id: ID!){
  packet{
    deleteGift(id: $id)
  }
}

Зафиксируем данные запросы в файле src/graphql/requests.graphql.

Нам также понадобится GraphQL-схема API DataSpace, которая доступна в конструкторе визуального редактора.

Справа в закладке SCHEMA выбираем DOWNLOAD → SDL:

Выгруженный файл поместим в корневую папку проекта: schema.graphql.

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

В файле package.json у нас подключены необходимые JS-библиотеки для этапа разработки и прописана команда генерации в разделе scripts:

... 
  "devDependencies": {
    ...
    "@graphql-codegen/cli": "^1.9.0",
    "@graphql-codegen/typescript": "1.22.3",
    "@graphql-codegen/typescript-operations": "1.18.2",
    "@graphql-codegen/typescript-react-apollo": "2.2.7",
    ...
  },
...
 "scripts": {
    ...
    "codegen": "graphql-codegen --config codegen.yml",
    ...
  },
...

Конфигурируем правила генерации в codegen.yml:

overwrite: true # флаг перезаписи файла генерируемого кода
schema: 'schema.graphql' # файл graphql-схемы
documents: 'src/graphql/**/*.graphql' # маска файлов c graphql-запросами
generates:
  ./src/__generate/graphql-frontend.ts: # результирующий файл генерации
    plugins:
      - typescript # генерация типов
      - typescript-operations # генерация операций
      - typescript-react-apollo # генерация React Apollo компонентов

Всё готово для генерации. Запускаем из консоли соответствующую команду «npm run codegen»:

Генерация прошла успешно, без ошибок. Давайте посмотрим на результаты: src/    generate/graphql- frontend.ts.

Остановимся подробнее на некоторых конструкциях в этом файле. В первую очередь теперь у нас есть Typescript-типы, определяющие ранее заведённые в DataSpace сущности. В их числе ряд служебных полей:

  • aggVersion: версия агрегата, которую можно использовать для формирования транзакции между получением и сохранением данных в БД (оптимистическая блокировка);

  • lastChangeDate: дата/время последнего изменения экземпляра сущности;

  • type: тип сущности (может быть полезен в случае использования наследования в модели данных);

  • aggregateRoot: ID корня агрегата.

Например, тип для сущности Gift:

export type Gift = {
  id: Scalars['ID'];
  aggVersion: Scalars['Long'];
  lastChangeDate?: Maybe<Scalars['_DateTime']>;
  chgCnt?: Maybe<Scalars['Long']>;
  kind?: Maybe<_En_GiftKind>;
  serialNumber: Scalars['String'];
  type: Scalars['String'];
  vendor: GiftVendor;
  aggregateRoot?: Maybe<GiftVendor>;
  ...
};

Также отражено определённое нами ранее перечисление GiftKind:

export enum _En_GiftKind {
  Cap = 'CAP',
  Tshirt = 'TSHIRT',
  Mug = 'MUG'
}

В дальнейшем мы воспользуемся данным перечислением при написании формы создания подарков.

В конце файла видим сгенерированный ряд хуков, соответствующих нашим GraphQL-запросам (src/ graphql/requests.graphql).

Например, запросы searchGift, createGift, deleteGift представляют следующие функции

...
export function useSearchGiftQuery(baseOptions?: Apollo.QueryHookOptions<SearchGiftQuery, SearchGiftQueryVariables>) {
        const options = {...defaultOptions, ...baseOptions}
        return Apollo.useQuery<SearchGiftQuery, SearchGiftQueryVariables>(SearchGiftDocument, options);
      }
...
export function useCreateGiftMutation(baseOptions?: Apollo.MutationHookOptions<CreateGiftMutation, CreateGiftMutationVariables>) {
        const options = {...defaultOptions, ...baseOptions}
        return Apollo.useMutation<CreateGiftMutation, CreateGiftMutationVariables>(CreateGiftDocument, options);
      }
...
export function useDeleteGiftMutation(baseOptions?: Apollo.MutationHookOptions<DeleteGiftMutation, DeleteGiftMutationVariables>) {
        const options = {...defaultOptions, ...baseOptions}
        return Apollo.useMutation<DeleteGiftMutation, DeleteGiftMutationVariables>(DeleteGiftDocument, options);
      }
...

Это функции-обёртки над React-хуками Apollo.useQuery и Apollo.useMutation. Данные конструкции призваны типизировать нашу бесшовную интеграцию между серверной и клиентской частью приложения.

Давайте более детально посмотрим на useCreateGiftMutation:

  • CreateGiftMutationVariables определяют сигнатуру входящих параметров:

...
export type CreateGiftMutationVariables = Exact<{
  vendorId: Scalars['ID'];
  serialNumber: Scalars['String'];
  kind?: Maybe<_En_GiftKind>;
}>;
...
  • CreateGiftMutation определяет сигнатуру возвращаемого результата:

...
export type CreateGiftMutation = (
  { __typename?: '_Mutation' }
  & { packet?: Maybe<(
    { __typename?: '_Packet' }
    & { createGift?: Maybe<(
      { __typename: '_E_Gift' }
      & Pick<_E_Gift, 'id' | 'serialNumber' | 'kind'>
    )> }
  )> }
);
...

Итак, на данном этапе мы:

  • научились подписывать наши http-запросы к серверной части;

  • определили набор GraphQL-запросов, которые нам понадобятся для работы;

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

Разработка прикладных форм

Форма отображения/добавления/удаления компаний-спонсоров реализована в соответствующем компоненте: src/components/GiftVendorList.tsx.

Она отрисовывает список доступных компаний в виде вкладок (Tabs):

Кнопка «Add new gift vendor» позволяет заводить новые компании-спонсоры, кнопка Delete gift vendor» удаляет компанию.

На что важно обратить внимание:

  • Для получения списка компаний-спонсоров используется хук useSearchGiftVendorQuery. Он был сгенерирован на основе запроса SearchGiftVendor, зафиксированного в файле src/graphql/ requests.graphql.

Из результата выполнения хука деструктуризируем параметры data, loading, error. Чуть ниже по коду обрабатываем соответствующим образом значения loading, error

...
    const { data, loading, error } = useSearchGiftVendorQuery()
    const giftVendorList = data?.searchGiftVendor.elems
...
    if (loading) return (<Spin tip="Loading..." />);
    if (error) return <p>`Error! ${error.message}`</p>;
  • Сам GraphQL-запрос SearchGiftVendor нам вернёт JSON-структуру следующего вида:

Так как в дальнейшем для отрисовки вкладок с компаниями нам нужен только сам массив компаний, определим для этого отдельную константу giftVendorList, ссылающуюся на массив elems возвращаемой запросом конструкции.

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

Более подробно про работу с мутациями в Apollo можно почитать здесь.

Далее при помощи функции getTabs, принимающей на вход параметром список полученных компаний, отрисовываем вкладки.

...
            <Form style={{ margin: "10px" }}>
                <Form.Item>
                    <Tabs>
                        {getTabs(giftVendorList)}
                    </Tabs>
                </Form.Item>
            </Form>
...

Давайте теперь разберёмся с добавлением/удалением компаний:

...
    const [createGiftVendorMutation] = useCreateGiftVendorMutation()
    const [deleteGiftVendorMutation] = useDeleteGiftVendorMutation()
...

Вытаскиваем из соответствующих хуков функции — мутации добавления/удаления. В модальной форме на кнопку «Ок» вешаем соответствующий обработчик, где делаем вызов мутации, передавая два параметра:

...
            <Modal visible={showCreateForm}
                onCancel={() => setShowCreateForm(false)}
                onOk={() => {
                    setShowCreateForm(false)
                    createGiftVendorMutation({
                        variables: {
                            name: vendorName!
                        },
                        update: (store, result) => {
                            store.writeQuery({
                                query: SearchGiftVendorDocument,
                                data: {
                                    searchGiftVendor: {
                                        elems: [, ...giftVendorList!, result.data?.packet?.createGiftVendor]
                                    }
                                }
                            })
                        }
                    })
                }}
            >
...

Первый — variables — набор параметров, заполняемых пользователем на форме. В нашем случае имя компании-спонсора.

Второй, update, позволяет передать функцию, которая в нашем случае обновит кэш Apollo-клиента (store) для запроса SearchGiftVendor, добавив туда результат (result) GraphQL-запроса createGiftVendor.

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

С удалением ситуация аналогичная: только здесь обработчик не добавляет элемент, а фильтрует массив, исключая ранее удалённый элемент.

...
                        <Button style={{ margin: "20px" }}
                            key={elem.id ?? ""}
                            onClick={(e) => {
                                deleteGiftVendorMutation({
                                    variables: {
                                        id: elem.id
                                    },
                                    update: (store) => {
                                        store.writeQuery({
                                            query: SearchGiftVendorDocument,
                                            data: {
                                                searchGiftVendor: {
                                                    elems: giftVendorList!.filter(x => x.id !== elem.id)
                                                }
                                            }
                                        })
                                    }
                                })
                            }}>Delete gift vendor</Button>cond: "it.vendor.$id == '" + vendorId + "'"
...

Функционал для заведения подарков в рамках конкретной компании-споносора аналогичен заведению самих компаний: src/components/GiftList.tsx.

Вместо компонента Tabs используется компонент Table38 в самом простом его варианте. Что важно:

  1. В хук useSearchGiftQuery передаётся через переменную cond соответствующего GraphQL-запроса условие фильтрации: "it.vendor.$id == '" + vendorId + "'". То есть запрашиваются подарки только конкретной компании-спонсора, на вкладке которой мы сейчас находимся.

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

query searchGiftVendor{
  searchGiftVendor(cond: "it.name $like 'Sber%' && it.gifts.$exists"){
    elems{
      name
      lastChangeDate
      gifts(cond:"it.lastChangeDate < root.lastChangeDate.$addDays(1) || it.serialNumber.$substr(1,1) == '1'"){
        elems{
 
          serialNumber
        }
      }
    }
  }
}

Это пример запроса всех спонсоров, начинающихся с лексемы 'Sber' и имеющих хотя бы один подарок. У таких компаний нам будут интересны только подарки, которые создавались/менялись в течение суток после создания компании-родителя.

Детальное описание всех возможностей данного синтаксиса фильтрации: documentation/39expressions.md.

  1. Обратите внимание на возможности сортировки и постраничной вычитки запросов. Подробная информация в документации: documentation/graphql.md

Мы написали приложение для фиксации компаний-спонсоров и выпускаемых ими подарков. Его можно развивать и дальше: добавлять формы для фиксаций серий и выдаваемых в рамках акций ваучеров. С готовыми сущностями в модели и сервисом для работы с ними это не займёт много времени. Достаточно будет зафиксировать новые запросы по аналогии с запросами к спонсорам и их подаркам src/ graphql/requests.graphql и отразить новые формы по аналогии с ранее рассмотренными компонентами src/components/GiftVendorList.tsx и src/components/GiftList.tsx.

Во второй статье будем разбирать Functions — платформенный сервис, который позволяет создавать приложения в парадигме serverless, реализуя архитектурный шаблон Function-as-a-Services. А пока — спасибо за внимание и до встречи!

Комментарии (7)


  1. EvilShadow
    15.03.2022 18:36
    +6

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

    Никак. Потому что микросервисы - это про интерфейсы. То, о чём можно не задумываться в POC/MVP монолита, а именно проектирование модулей и интерфейсов, в случае микросервисов нужно делать заранее, до начала разработки. А проектирование (или архитектура, если угодно) требует времени на то, чтобы думать. Собственно код (и уже тем более инфра) вторичны.


    1. VictorBiryukov
      16.03.2022 21:40

      С этим сложно спорить. Статья не о проектировании. А о практической реализации решения. Там вначале самом архитектура сразу зафиксирована


  1. a-postx
    15.03.2022 19:55
    +3

    Ваше требование входа только по СберИД или через приложку приводит к тому, что лично у меня есть инфраструктура даже в убогом облаке ВК, но сберовская отсутствует. Было бы хорошо, если бы сервисами могли пользоваться не только перетреканые и отскриненные клиенты, но и никак с вами не связанные физики.


    1. Alphacanalya
      16.03.2022 09:55

      Плюсую (настоящий плюс пока поставить не могу)


    1. VictorBiryukov
      16.03.2022 21:44
      -1

      Сам же выпущенный сервис не через SberId авторизуется тут. Не связанные физики как-то должны авторизовываться в Вашем случае?


  1. coldwind
    16.03.2022 11:32
    +2

    Я несколько раз перечитал описанные шаги, но никак не могу понять, как всё это можно выполнить за обозначенные в названии статьи 15 минут? Что из описанного было сгенерировано, а что было изменено? Да и «полноценным компонентом» полученный результат не назвать.

    На мой взгляд, не хватает видео с демонстрацией того, как за 15 минут создавался этот компонент.


    1. VictorBiryukov
      16.03.2022 20:12
      -1

      Закономерное замечание.
      Здесь видео со сквозной демонстрацией: https://www.youtube.com/watch?v=BRFDn6Jw-YI
      Здесь шаблонный проект: https://github.com/VictorBiryukov/tabs-list-template