Привет, меня зовут Влад, я фронтенд-разработчик в ManyChat. В этой статье я расскажу, как мы писали сервис для проведения обучающих кампаний в продукте, и почему важна хорошая проработка задачи на раннем этапе.
ManyChat — это платформа для автоматизации маркетинга в Instagram, WhatsApp, Telegram и Facebook Messenger, которая помогает бизнесам строить осмысленную и эффективную коммуникацию с клиентами. С помощью ManyChat бизнесы масштабируют лидогенерацию, повышают вовлеченность, запускают маркетинговые кампании и обеспечивают непрерывную поддержку пользователей.
Основной функционал ManyChat – это конструктор чат-ботов, который позволяет пользователю создавать различные сценарии взаимодействия с помощью визуального программирования. Вот как это выглядит:
Не так давно мы добавили возможность автоматизации в Instagram и ожидали большого наплыва пользователей. Мы знали, что у нас не самый простой продукт, а тут еще добавляется новый канал коммуникации. Чтобы помочь пользователю не только познакомиться с продуктом, но и ощутить его ценность, мы сделали следующее:
Упростили интерфейс, чтобы новые пользователи не пугались функционала, который им не нужен на старте.
Подготовили обучающие кампании, которые проведут их за руку и познакомят с платформой.
Обучающая кампания — сценарий, который воспроизводится в несколько шагов, на каждом шаге доступна полезная информация и призыв к действию. Такими действиями могут быть клик или ввод текста. Информация доносится через тултипы или модальные окна. Фокус пользователя удерживается с помощью изолированной и подсвеченной области.
Мы сформировали следующие требования к сервису:
Ведет пользователя по сценарию. Следит, чтобы последовательность шагов не нарушалась;
Умеет подсвечивать и изолировать определенные области интерфейса:
Работает с DOM;
Работает с Canvas. Наш основной инструмент использует его.
Обрабатывает ввод текста, клики по области, двойные клики, ввод в определенной последовательности. Позволяет легко добавлять новые механики;
Приводит приложение в определенное состояние перед тем, как пользователь совершит действие. Например, создает необходимые сущности, открывает модальные окна или скрывает отвлекающую информацию;
Его легко поддерживать и расширять.
После того как мы поняли требования, сразу возник вопрос: нет ли готовых решений? Мы не нашли решение, которое бы удовлетворяло всем нашим условиям.
Вот пример ограничений у большинства решений:
Ограничения по механикам:
Не расширяемые. То есть нельзя добавить необходимые действия - двойной клик, ввод с ограничением на количество символов, свободный ввод и все что мы придумаем в будущем;
Не поддерживают сложные механики, когда один шаг влияет на другой. Например, ввод в первом инпуте определяет, ввод во втором инпуте.
Ограничение по жизненному циклу шагов:
Нельзя добавить кастомные события, например, если пользователь не взаимодействует с шагом или запутался в механике.
Ограничение для изолированной области:
Нельзя «подружить» с нашим инструментом, который использует Canvas. Нельзя изменить механизм поиска элемента и определения координат, необходимых для изолирующей области.
Элементы донесения информации:
Нельзя добавить собственные компоненты или темизировать существующие.
Мы не хотели, чтобы решение накладывало ограничения на наши кампании и быстро поняли, что будем дольше адаптировать, чем писать свою реализацию.
Первое с чего мы начали — нарисовали схему взаимодействия сущностей:
После того, как взаимодействие сущностей было продумано, мы написали код сервиса. Давайте посмотрим, как это работает на практике. Например, команде нужно обучить пользователя настраивать триггер для автоматизации. В таком случае кампания будет состоять из двух шагов:
Шаг 1. Клик по элементу (Add Trigger)
Шаг 2. Клик по самому триггеру (Instagram Keyword)
Результат: у пользователя есть триггер (ключевое слово), который запускает автоматизацию.
Теперь перейдем к тому, что нужно добавить в код, чтобы запустить эту обучающую кампанию.
1. Создаем Onboarding config:
const OUR_FIRST_ONBOARDING = {
id: "НАШ_ПЕРВЫЙ_ОНБОРДИНГ",
steps: [
{
id: "ID_ПЕРВОГО_ШАГА",
type: StepTypes.CLICK,
views: {
pointer: {
type: PointerType.CANVAS,
// Элемент на который хотим получить клик
elementId: 'ADD_TRIGGER_ELEMENT_ID',
},
progress: {
current: 1,
total: 2,
},
},
},
{
id: "ID_ВТОРОГО_ШАГА",
type: StepTypes.CLICK,
views: {
pointer: {
type: PointerType.DOM,
// Элемент на который хотим получить клик
elementId: 'INSTAGRAM_KEYWORD_ELEMENT_ID',
},
progress: {
current: 2,
total: 2,
},
},
}
]
}
2. Регистрируем его:
onboardingService.create(OUR_FIRST_ONBOARDING)
3. Запускаем Onboarding:
onboardingService.run("НАШ_ПЕРВЫЙ_ОНБОРДИНГ")
4. Отправляем событие:
onboardingService.emitEvent({
type: EventTypes.CLICK,
eventId: "ID_ПЕРВОГО_ШАГА"
})
5. ??? Первая обучающая кампания готова! ???
Получилось неплохо и мы побежали создавать обучающие кампании. Одну сюда, другую туда, нас было не остановить, но каждая новая была сложнее предыдущей, как по сценариям, так и по реализации. Мы поняли что что-то не так.
Разбираясь мы обнаружили 3 проблемы:
1. Раздувающиеся конфиги.
Посмотрим на конфиг кампании из 12 шагов:
const OUR_FIRST_ONBOARDING = {
id: "НАШ_ПЕРВЫЙ_ОНБОРДИНГ",
steps: [
{
id: "ID_ПЕРВОГО_ШАГА",
type: StepTypes.CLICK,
views: {
pointer: {
type: PointerType.CANVAS,
elementId: 'ЭЛЕМЕНТ_НА_КОТОРЫЙ_ХОТИМ_ПОЛУЧИТЬ_КЛИК',
},
progress: {
current: 1,
total: 12,
},
},
},
{
id: "ID_ВТОРОГО_ШАГА",
type: StepTypes.INPUT,
text: "текст, который нужно ввести",
views: {
pointer: {
type: PointerType.DOM,
elementId: 'ЭЛЕМЕНТ_НА_КОТОРЫЙ_ХОТИМ_ПОЛУЧИТЬ_ВВОД',
},
progress: {
current: 2,
total: 12,
},
},
}
// Еще 10 шагов
]
}
Представьте, что там еще 10 шагов и у каждого шага есть много уникальных параметров. Мы не подумали, как мы будем оформлять шаги, чтобы это не превращалось в полотно и кучу непонятных свойств.
2. Отправка событий.
Отправляем разные события с помощью метода .emitEvent().
onboardingService.emitEvent({
type: EventTypes.CLICK,
eventId: "ID_НУЖНОГО_ШАГА"
})
onboardingService.emitEvent({
type: EventTypes.DOUBLE_CLICK,
eventId: "ID_НУЖНОГО_ШАГА"
})
onboardingService.emitEvent({
type: EventTypes.INPUT,
eventId: "ID_НУЖНОГО_ШАГА"
text: "текст для сравниения"
})
onboardingService.emitEvent({
type: EventTypes.LENGTH_INPUT,
eventId: "ID_НУЖНОГО_ШАГА"
text: "текст для сравниения"
})
// И еще множество разных ивентов.
Мы не подумали, как наиболее просто и понятно вызывать событие для конкретного шага. Посмотрите выше и попробуйте понять: к какой кампании относится каждое событие и для какого шага оно нужно. Вы не сможете. Также вам придется потратить силы на то, чтобы разобраться, какого типа должно быть событие для конкретного шага и какая структура передаваемых данных. С последним вам поможет TypeScript, но и тут кроется проблема.
3. Типизация
При добавлении нового типа шага, приходится синхронизировать большое количество мест одинаковым интерфейсом. Это приводит к раздуванию интерфейсов и тяжелой поддержке. Например, чтобы поддержать добавление нового шага, вам придется расширить интерфейс метода .emitEvent()
, доработать конфиг онбординга, создать интерфейс шага и еще множество мест расширить или модифицировать. Это сложно и когда-нибудь поломается.
С проблемами на поверхности мы разобрались, давайте перейдём к не самым очевидным. Мы придерживались Single Responsibility Principle для разных типов шагов, поэтому поддержка и добавление новых сущностей не должно вызывать у нас сложности, но это не так. Добавление новых типов шагов с каждым разом становилось все сложнее, появлялась логика, которую приходилось поддерживать на разных уровнях. Где-то мы ошиблись концептуально, и как оказалось мы нарушили Stable Dependencies Principle. Давайте я добавлю к нашей схеме, как часто изменяются те или иные сущности:
Отсюда видно, что редко изменяемые сущности зависят от часто изменяемых и это приводит к двум последствиям:
Добавление новых типов шагов или изменение существующих (
StepClick
,StepInput
и т.д.) расширяетOnboarding
иOnboardingProgress
. Шаги могут конфликтовать друг с другом, и чем больше шагов, тем сложнее нам их разрабатывать, синхронизировать и поддерживать.Мы вынуждены нарушать Interface Segregation Principle из-за постоянно изменяющихся шагов.
Изменения Onboarding
и OnboardingProgress
приводили к изменениям шагов, что не должно происходить, кажется где-то тут спряталась циклическая зависимость. Добавим ее в нашу схему:
Что же дает нам эта милая стрелочка:
Сложно писать тесты, приходится мокать всю систему. Так как мы решали задачу через TDD, это сильно бьет по скорости разработки. Тесты должны писаться легко и непринужденно :)
Сильную связность кода, а как следствие — хрупкое взаимодействие между сущностями. Изменение одной сущности запускает изменение другой в обоих направлениях.
Получилось много недочетов. Исправить это можно было только в несколько итераций, так как мы написали уже много обучающих компаний, ресурсов на то, чтобы проводить рефакторинг у нас не так много, а разработку новых и изменение существующих кампаний мы не можем остановить.
Первое с чего начнем: поработаем над конфигом и ивентами.
Основное изменение заключается в том, что шаги стали напрямую использоваться в конфиге. Благодаря этому, умер ненужный класс StepProgress
, который постоянно приходилось мокать и расширять.
После того, как мы выполнили изменения на схеме, изменилась и работа с сервисом.
Было:
// Конфиг
const OUR_FIRST_ONBOARDING = {
id: "НАШ_ПЕРВЫЙ_ОНБОРДИНГ",
steps: [
{
id: "ID_ПЕРВОГО_ШАГА",
type: StepTypes.CLICK,
}
]
}
// Вызов события в основном приложении
onboardingService.emitEvent({
type: EventTypes.CLICK,
eventId: "ID_ПЕРВОГО_ШАГА"
})
Стало:
// Конфиг
const OUR_FIRST_ONBOARDING = {
id: "НАШ_ПЕРВЫЙ_ОНБОРДИНГ",
steps: [
new StepClick{
id: "ID_ПЕРВОГО_ШАГА",
}
]
}
// Вызов события в основном приложении
OUR_FIRST_ONBOARDING.steps[0].emitEvent()
Базовые свойства, которые нужно описывать, чтобы соответствовать интерфейсу шага (в примере это type
), ушли внутрь класса. Поддержка типов стала в разы легче, теперь нужно описывать только интерфейс шага. Вишенкой на торте стало то, что теперь можно ответить на вопрос: В какой кампании используется этот событие, какому шагу принадлежит и какой он по счету? Здесь разобрались, идем дальше.
Теперь уберем циклические зависимости и нарушения Stable Dependencies Principle.
Мы разработали общую шину обмена данными на основе EventEmitter
, она неизменяема, что гарантирует Stable Dependencies Principle и избавляет от циклической зависимости.
В итоге, спринт на разработку, еще два спринта в фоновом режиме на исправления, и как результат – решение, которое легко поддерживать и расширять.
Мы писали код применяя практики TDD и парного программирования. Только благодаря этим двум подходам мы смогли сделать две вещи:
Написали рабочее решение в короткий срок, да с изъянами, но рабочее;
Смогли безболезненно провести рефакторинг, не опасаясь за работоспособность уже работающих кампаний и системы целиком.
Рефакторинг это полезно, но что мы могли сделать, чтобы сразу написать все нормально и не тратить время потом?
В формировании конфига кампаний и событий, наша ошибка была в том, что мы придумали лишь базовые сценарии. Если бы мы создали несколько разных сценариев со сложной механикой, мы бы сразу заметили незрелость нашего решения.
Когда мы создали схему взаимодействия сущностей в первый раз, мы не учли Stable Dependencies Principle. Чтобы не попасть в ловушку, нужно сразу выделить стабильные и нестабильные сущности, а не просто указать направление зависимостей. Переодически возвращайтесь к вашей схеме, чтобы убедиться, что вы не нарушаете SDP.
Чтобы отыскать циклическую зависимость нужно было очень скрупулезно делать схему взаимодействия сущностей или использовать специальный npm пакет. Понять, что что-то идёт не так можно на этапе тестирования: если тесты пишутся сложно, возможно, где-то закралась циклическая зависимость.
Наши ошибки не были связаны с тем, что мы чего-то не знали, нам не хватало опыта одернуть себя в нужный момент и понять, что мы проектируем с ошибками.
Вывод может показаться очевидным, но работа над этим решением еще раз подсветила очевидную мысль — мало знать о принципах разработки, нужно еще научиться вовремя включать эти знания в работу.
Ох, чуть не забыл про то как обучающие кампании помогли нашему продукту. Мы увидели, что конверсия в активацию пользователя в группе с обучающими кампаниями выше на 10% ?. Если сказать проще, то количество пользователей, которые успешно запустили автоматизации и получили пользу от нашего продукта на 10% больше.