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

ManyChat — это платформа для автоматизации маркетинга в Instagram, WhatsApp, Telegram и Facebook Messenger, которая помогает бизнесам строить осмысленную и эффективную коммуникацию с клиентами. С помощью ManyChat бизнесы масштабируют лидогенерацию, повышают вовлеченность, запускают маркетинговые кампании и обеспечивают непрерывную поддержку пользователей.

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

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

  1. Упростили интерфейс, чтобы новые пользователи не пугались функционала, который им не нужен на старте.

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

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

Мы сформировали следующие требования к сервису:

  • Ведет пользователя по сценарию. Следит, чтобы последовательность шагов не нарушалась;

  • Умеет подсвечивать и изолировать определенные области интерфейса:

    • Работает с 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 и парного программирования. Только благодаря этим двум подходам мы смогли сделать две вещи:

  1. Написали рабочее решение в короткий срок, да с изъянами, но рабочее;

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

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

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

Когда мы создали схему взаимодействия сущностей в первый раз, мы не учли Stable Dependencies Principle. Чтобы не попасть в ловушку, нужно сразу выделить стабильные и нестабильные сущности, а не просто указать направление зависимостей. Переодически возвращайтесь к вашей схеме, чтобы убедиться, что вы не нарушаете SDP.

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

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

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

Ох, чуть не забыл про то как обучающие кампании помогли нашему продукту. Мы увидели, что конверсия в активацию пользователя в группе с обучающими кампаниями выше на 10% ????. Если сказать проще, то количество пользователей, которые успешно запустили автоматизации и получили пользу от нашего продукта на 10% больше.

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


  1. kreddos
    25.11.2021 14:51

    Привет, есть вопрос по типизации, не очень понятно зачем расширять ручками интерфейсы, если тип к emitEvent можно высчитать автоматически

    что я имею ввиду

    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 шагов
        ]
    } as const;
    
    
    type StepIDS = typeof OUR_FIRST_ONBOARDING.steps[number]['id'];
    
    interface Event { 
        type: string,
        eventId: StepIDS
    }
    
    function emitEvent(id: Event) {
    
    }
    
    ну или вариант с несколькими конфигами
    
    interface Event<T extends string> { 
        type: string,
        eventId: T
    }
    
    
    function emitEvent<CompanyStepIds extends string>(id: Event<CompanyStepIds>) {}
    emitEvent<StepIDS>({
        type: '1',
        eventId: 'ID_ПЕРВОГО_ШАГА'
    })
    


    1. shuteev Автор
      25.11.2021 16:28
      +1

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

      А дальше используешь его так:

      // Конфиг
      const OUR_FIRST_ONBOARDING = {
      	id: "НАШ_ПЕРВЫЙ_ОНБОРДИНГ",
      	steps: [
      		new StepClick{
      			id: "ID_ПЕРВОГО_ШАГА",
      		}
      	]
      }
      
      
      // Вызов события в основном приложении
      OUR_FIRST_ONBOARDING.steps[0].emitEvent()


      1. kreddos
        25.11.2021 16:56

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

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

        class StepEvent {
        	constructor(public id: string) {}
        
        	emit() {}
        }
        
        class StepClick extends StepEvent {}
        
        ///
        
        interface ConfigEvent {
        	type: 'click',
        	id: string,
        }
        
        interface Config {
        	id: string,
        	steps: ConfigEvent[]
        }
        
        /// 
        
        const OUR_FIRST_ONBOARDING: Config = {
        	id: "НАШ_ПЕРВЫЙ_ОНБОРДИНГ",
        	steps: [
        		{
        			type: 'click',
        			id: 'ID_ПЕРВОГО_ШАГА',
        		}
        	]
        }
        
        class Service {
        	private events: StepEvent[]
        	
        	constructor (private config: Config) {
        		this.events = config.steps.map(this.mapConfigEventToStepEvent)
        	}
        
        	private mapConfigEventToStepEvent(configEvent: ConfigEvent) {
        		return new StepClick(configEvent.id);
        	}
        
        	
        
        	emitEvent(stepNum: number) {
        		const event = this.events[stepNum];
        		
        		if (!event) {
        			return;
        		}
        		
        		event.emit();
        	}
        }
        
        
        
        
        const service = new Service(OUR_FIRST_ONBOARDING);
        service.emitEvent(0);


  1. shuteev Автор
    25.11.2021 17:38

    В первой версии у нас так и было, но здесь будут проблемы:

    1. mapConfigEventToStepEvent будет бесконечно расширяемой мапой из-за того что у нас будут появляться новый шаги. Метод приватный и нужно будет писать какие-то интеграционные тесты, чтобы проверить правильность работы класса. Из-за раздуваемости этой мапы, тесты будут очень хрупкие и и них будет увесистая фаза Arrange

    2. Создавая сразу инстанс класса для шага в конфиге мы можем спрятать свойства необходимые для этого класса внутрь

    
    const OUR_FIRST_ONBOARDING = {
    	id: "НАШ_ПЕРВЫЙ_ОНБОРДИНГ",
    	steps: [
    		{
    			id: "ID_ПЕРВОГО_ШАГА",
          // Этот параметр спрячется в классе
    			type: StepTypes.CLICK,
    		}
    	]
    }
    
    
    const OUR_FIRST_ONBOARDING = {
    	id: "НАШ_ПЕРВЫЙ_ОНБОРДИНГ",
    	steps: [
    		new StepClick{
    			id: "ID_ПЕРВОГО_ШАГА",
    		}
    	]
    }


    1. kreddos
      25.11.2021 18:11
      +2

      const EventTypes = {
      	click: 'click',
      	dbClick: 'dbClick',
      } as const;
      
      type EventType = keyof typeof EventTypes;
      
      interface ConfigEvent {
      	type: EventType,
      	id: string,
      }
      
      interface Config {
      	id: string,
      	steps: ConfigEvent[]
      }
      
      /// 
      
      const OUR_FIRST_ONBOARDING: Config = {
      	id: "НАШ_ПЕРВЫЙ_ОНБОРДИНГ",
      	steps: [
      		{
      			type: EventTypes.click,
      			id: 'ID_ПЕРВОГО_ШАГА',
      		}
      	]
      }
      
      class Service {
      	private events: StepEvent[]
      
      	/*
         * можно вынести сюда Классы по типу
         * и тут будет проверка на этапе компиляции
         * а если бутет новый эвент, то эта строка стразу подсветится
         */
      	private eventClasses: Record<EventType, typeof StepEvent> = {
      		click: StepClick,
      		dbClick: StepClick,
      	}
      	
      	constructor (private config: Config) {
      		this.events = config.steps.map(this.mapConfigEventToStepEvent)
      	}
      
      	// если будет eventClasses, то этот метод вообще не придется трогать
      	private mapConfigEventToStepEvent(configEvent: ConfigEvent) {
      		const EventClass = this.eventClasses[configEvent.type];
      		return new EventClass(configEvent.id);
      	}
      
      	
      
      	emitEvent(stepNum: number) {
      		const event = this.events[stepNum];
      		
      		if (!event) {
      			return;
      		}
      		
      		event.emit();
      	}
      }

      В любом случае, я понимаю почему вы так сделали и спасибо за дискусию, просто не удержался вставить свои 5 копеек =(


      1. shuteev Автор
        25.11.2021 18:41
        +1

        хаха, лайк