Привет, Хабр! Меня зовут Дмитрий Виноградов, я руковожу направлением продуктовой разработки в Рунити, а если проще — то разработкой сайтов и витрин компании. Я и моя команда находимся в постоянном поиске удобных подходов к разработке технических решений.  В этой статье расскажу о продуктовых практиках в нашей группе компаний, а также подробнее разберемся, как создавать независимые компоненты, чтобы ускорить frontend-разработку.

Навигация по тексту:

Задачи при запуске новых продуктов

О продуктовом подходе

CMF vs CMS

Архитектура решения

Реализация компонентов

Организация бизнес-процессов

Подготовка к разработке бизнес требований (БТ)

Разработка БТ

Разработка архитектуры

Декомпозиция и разработка

Тестирование

Развертывание в продуктовой среде

Эксплуатация

Выводы

Задачи при запуске новых продуктов

Одним из приоритетных направлений моей работы является ускоренный запуск новых продуктов.

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

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

Другое дело, если у вас уже есть своя система, которой не один десяток лет, она состоит из разных продуктов и “зоопарка” технических решений, и вам надо в ваш “зоопарк” добавить еще одного (или не одного) зверька. Муки выбора обычно у всех более-менее одинаковые — берем текущий стек, ищем подходящие библиотеки, строим архитектуру, чтобы была похожа на ранее сделанное, и все внедряем в общий ИТ-ландшафт. Таким образом, мы поддерживаем состояние проекта на должном уровне, по ходу пьесы вносим правки в старый код, освежаем его новой логикой, интеграциями и всем должно быть хорошо. 

Однако на практике это далеко не так.

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

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

О продуктовом подходе

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

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

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

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

CMF vs CMS

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

  • Направление классических CMS — это WordPress, 1C-Битрикс и другие системы. Это направление нас не устраивало с точки зрения цикла разработки и тестирования. Вся кодовая база находится внутри монолитной технологии, которая сложно скалируется через средства контейниризации (Docker, K8s), шаблоны и бизнес-логика зачастую не отделены друг от друга. Но есть большое количество готовых виджетов, библиотек и расширений.

  • Направление Web Framework (CMF) — Next.JS, Nuxt.JS, Strapio и т.д. Данное направление нас заинтересовало больше всего, потому что оно позволяет внедрять изменения в процессы постепенно, добавляя все более новые элементы, проще переносить готовые продукты и вести процесс разработки.

Архитектура решения

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

  • «Бэкенд» — набор внутренних сервисов, которые предоставляют различный корпоративный функционал: сервис аутентификации, сервис оплат, сервисы биллинга.

  • «Конфигурация компонента» — настройки A/B-тестов, параметры приложений.

  • «Разметка компонента» — настройка отображения и списка блоков.

  • «Контент компонента» — управление содержимым приложения (тексты, описания, подсказки, иконки кнопок и их названия).

  • «Компонент» — конкретный компонент, который реализует дизайнер и разработчик (кнопка, страница или раздел, форма и т.п.).

  • «SPA Приложение» —приложение, в рамках которого собираются и настраиваются компоненты.

  • «UI-библиотека» — набор пакетов, в которых реализовано графическое представление элементов (кнопки, поля форм, блоки, карточки).

Рисунок 1. Слои системы и взаимодействие с ними участников процесса
Рисунок 1. Слои системы и взаимодействие с ними участников процесса

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

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

Реализация компонентов

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

Компонент Корзина (Cart)

В качестве среды выполнения будем использовать VueJS.

Структура проекта

% tree
.
├── cart.vue
├── config.ts
├── events
│   ├── OnItemAdded.ts
│   ├── OnItemAddedConfirmed.ts
│   └── index.ts
└── specs
    ├── events.json
    └── events.yaml

В корне располагаются файл компонента cart.vue, директория events с сгенерированными событиями, директория specs с документацией OpenAPI и AsyncAPI.

Рассмотрим пример компонента корзины. Чтобы его реализовать отдельной командой разработки нам потребуется:

1) События, которые триггерятся в родительских компонентах (за них может отвечать отдельная команда).

2) Функциональные требования и дизайн (с этим все понятно).

События

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

class OnItemAdded {
   private _userId?: string;
   private _itemId?: string;
   private _quantity?: number;
   private _additionalProperties?: Map<string, any>;
    constructor(input: {
     userId?: string,
     itemId?: string,
     quantity?: number,
     additionalProperties?: Map<string, any>,
   }) {
     this._userId = input.userId;
     this._itemId = input.itemId;
     this._quantity = input.quantity;
     this._additionalProperties = input.additionalProperties;
   }
    /**
    * Идентификатор пользователя, добавившего товар.
    */
   get userId(): string | undefined { return this._userId; }
   set userId(userId: string | undefined) { this._userId = userId; }
    /**
    * Идентификатор добавленного товара.
    */
   get itemId(): string | undefined { return this._itemId; }
   set itemId(itemId: string | undefined) { this._itemId = itemId; }
    /**
    * Количество добавленного товара.
    */
   get quantity(): number | undefined { return this._quantity; }
   set quantity(quantity: number | undefined) { this._quantity = quantity; }
    get additionalProperties(): Map<string, any> | undefined { return this._additionalProperties; }
   set additionalProperties(additionalProperties: Map<string, any> | undefined) { this._additionalProperties = additionalProperties; }
 }
 export default OnItemAdded;

По задумке отдельная команда реализовала кнопку добавления товара в корзину, и на эту тему создала спецификацию AsyncAPI.

asyncapi: 3.0.0
info:
 title: Корзина товаров
 version: 1.0.0
 description: Спецификация для событий, связанных с добавлением товара в корзину.
channels:
 cart/product-added:
   address: cart/product-added
   messages:
     onItemAdded.message:
       name: onItemAdded
       contentType: application/json
       payload:
         type: object
         $id: onItemAdded
         properties:
           userId:
             type: string
             description: Идентификатор пользователя, добавившего товар.
           itemId:
             type: string
             description: Идентификатор добавленного товара.
           quantity:
             type: integer
             description: Количество добавленного товара.
   description: Событие, когда товар добавляется в корзину.
cart/product-added-confirmed:
   address: cart/product-added-confirmed
   messages:
     onItemAddedConfirmed.message:
       name: onItemAddedConfirmed
       contentType: application/json
       payload:
         type: object
         $id: onItemAddedConfirmed
         properties:
           userId:
             type: string
             description: Идентификатор пользователя, добавившего товар.
           itemId:
             type: string
             description: Идентификатор добавленного товара.
           quantity:
             type: integer
             description: Количество добавленного товара.
           timestamp:
             type: string
             format: date-time
             description: Время, когда товар был добавлен в корзину.
   description: Событие, подтверждающее успешное добавление товара в корзину.
operations:
 onItemAdded:
   action: send
   channel:
     $ref: '#/channels/cart~1product-added'
   summary: Обработка события добавления товара в корзину.
   messages:
     - $ref: '#/channels/cart~1product-added/messages/onItemAdded.message'
 onItemAddedConfirmed:
   action: send
   channel:
     $ref: '#/channels/cart~1product-added-confirmed'
   summary: Обработка события подтверждения добавления товара в корзину.
   messages:
     - $ref: >-
         #/channels/cart~1product-added-confirmed/messages/onItemAddedConfirmed.message
components:
 messages:
   ItemAdded:
     contentType: application/json
     payload:
       type: object
       properties:
         userId:
           type: string
           description: Идентификатор пользователя, добавившего товар.
         itemId:
           type: string
           description: Идентификатор добавленного товара.
         quantity:
           type: integer
           description: Количество добавленного товара.
   ItemAddedConfirmed:
     contentType: application/json
     payload:
       type: object
       properties:
         userId:
           type: string
           description: Идентификатор пользователя, добавившего товар.
         itemId:
           type: string
           description: Идентификатор добавленного товара.
         quantity:
           type: integer
           description: Количество добавленного товара.
         timestamp:
           type: string
           format: date-time
           description: Время, когда товар был добавлен в корзину.

На уровне нашего компонента, чтобы поддержать эту спецификацию, мы можем использовать кодогенератор @asyncapi/modelina, который из документации нам сгенерит готовый код. В реальном проекте, скорее всего, эти DTO будут поставляться единым пакетом, для того чтобы было соответствие типов внутри SPA-проекта. В нашем примере все упрощено, и эти сгенерированные типы хранятся в отдельной директории events.

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

<template>
   <div>
       <div v-if="items">
           В корзине <i>{{ showCount }}<span v-if="isMaxReached">+</span></i> товар(ов)
       </div>
       <div v-else>
           Ваша корзина пуста
       </div>
   </div>
</template>
import { Parameters, OpenOnPos, useFeaturesConfig } from './config'
import { computed, ref } from 'vue'
import OnItemAdded from './events/OnItemAdded'

const items = ref([])

// Загрузка конфигуграции из админки компонентов
const configuration = useFeaturesConfig<Parameters>({
   MaxItemsInBasketThenPlusSign: 99,
   OpenOn: OpenOnPos.Left,
})

const isMaxReached = computed(() => {
   return configuration.MaxItemsInBasketThenPlusSign > items.value.length
})

const showCount = computed(() => {
   return isMaxReached.value ? configuration.MaxItemsInBasketThenPlusSign : items.value.length
})

function handleOnItemAdded(event: OnItemAdded) {
   // Если в дочернем компоненте добавили товар в корзину, реагируем на это
   items.value.push(event.itemId())
}

Давайте теперь рассмотрим логику работы самого компонента.

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

2. Дальше идет логика работы функций isMaxReached и showCount. С ней все понятно, если большее 99 товаров, то цифру показываем максимальную и дописываем знак плюс.

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

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

Организация бизнес-процессов

Изменения архитектуры в корне поменяли роли и порядок работ на проекте, теперь надо понять, как эти изменения отразились на бизнес-процессах.

Подготовка к разработке бизнес требований (БТ)

БТ начинаются с глоссария, или словаря терминов, в которых описывается решение. В этом словаре кроме стандартного описания что есть у нас добавляются несколько новых артефактов: «Компонент», «Конфигурация компонента», «Разметка компонента», «Контент компонента».

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

Задача состоит в том, чтобы на начальном этапе определить эти самые компоненты в визуальной части проекта, чтобы затем их постоянно переиспользовать и ссылаться на них в процессе разработки БТ. Это трудозатратно, но в последствии, доработки будут сильно упрощены, так как будет присутствовать стандартизация к описанию, функционалу и поведению компонента.

Разработка БТ

Ничего нового. Формализуем бизнес-задачу, оперируя новыми единицами, добавляем контекст и передаем в Архитектуру.

Разработка Архитектуры

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

Декомпозиция и разработка

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

Тестирование

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

Развертывание в продуктовой среде

Учитывая, что и приложение, и компоненты находятся в постоянном потоке CI/CD, то выкладка на продуктив ничем не отличается от выкатки на тестовый стенд. Повысили версии приложения или пакетов, которые в него входят, и готово.

Эксплуатация

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

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

Выводы

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

На этом всё, спасибо за внимание :)

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