Триггерные рассылки
Последнее время в Email-маркетинге все чаще используются автоматические рассылки определенным группам потребителей. Типичные задачи: поздравить с днем рожденья позвать на сайт, если потребитель на него долго не заходил сделать персонализированное предложение (делим потребителей на сегменты и рассылаем каждому сегменту свое письмо) В этой статье мы расскажем, как мы решали эту задачу — от написания каждой отдельной рассылки разработчиком с нуля 3 года назад, до заведения рассылок менеджером через веб-интерфейс в настоящее время. Рассказ может быть интересен не только тем, кто занимается Email-маркетингом, но и вообще всем, кому приходится реализовывать периодическое выполнение сложных операций над определенными выборками потребителей (знаю, что звучит очень абстрактно, но в итоге именно такую абстрактную задачу нам и пришлось решить).Первые реализации 3 года назад подобные задачи возникали крайне редко и мы каждый раз реализовывали их с нуля. При этом возникали одни и те же вопросы: Как помечать потребителей, которым мы уже отправили это письмо? Как максимально быстро обработать всех потребителей и при этом не тормозить работу сайтов (которые обращаются к тем же записям в БД)? На первый вопрос ответ для нас был очевиден: в нашей системе сохраняется информации о всех значимых действиях, выполняемых потребителем (вход на сайт, изменение персональных данных) или над ним (розыгрыш приза, отправка уведомления). Кроме того, мы используем действия для разнообразных технических пометок потребителей. Так что при отправке автоматической рассылки, мы также решили выдавать потребителю особое действие-маркер, в качестве пометки, что эта автоматическая рассылка ему уже была отправлена. Чтобы повторно не отправлять рассылку, к условию рассылки всегда добавляется условие «у потребителя нет действия-маркера».На втором вопросе мы набили множество шишек, связанных с блокировками в БД, и в итоге пришли к следующему шаблону:
Отправка рассылок идет из windows-сервиса, который периодически проверяет не появилось ли новых потребителей, подходящих под условия.
В сервисе первым шагом делается один запрос к БД с уровнем изоляции Read Uncommitted. Этот запрос вытаскивает Id всех потребителей, которым надо отправить письмо. Из-за низкого уровня изоляции такой запрос не накладывает блокировок на записи в БД и, как следствие, крайне слабо влияет на работу сайта. Однако он не гарантирует чистоту данных и их надо повторно проверить с более высоким уровнем изоляции.
После того, как мы вытащили Id потребителей, для каждого потребителя мы выполняем отдельную транзакцию с уровнем изоляции Serializable. В этой транзакции мы заново проверяем подходит ли потребитель под условия и если да, отправляем ему письмо и выдаем действие-маркер. Так как мы обрабатываем каждого потребителя в отдельной транзакции, блокировки накладываются только на данные одного потребителя и на работу остальных потребителей не влияют. Так как такая транзакция очень короткая, у потребителя, которому отправляют письмо, также не будет особых проблем, если он в это время ходит по сайту. Уровень изоляции транзакции должен быть именно Serializable, чтобы ненароком не отправить одно письмо дважды, или не отправить письмо тому потребителю, который внезапно перестал подходить под условия. Хотя, если мы гарантируем, что отправка одной и той же рассылки может идти только из одного потока и с одного сервера, а также забьем на небольшую вероятность того, что одному потребителю могут отправится две рассылки с взаимоисключающими условиями, то можно использовать и Read Committed транзакцию.
Само собой после реализации нескольких рассылок по этому шаблону, мы решили вынести шаблонный код. Для этого был создан класс BatchMailing, и для каждой новой рассылки мы создавали и регистрировали в специальном реестре его наследника. В наследнике необходимо было перегрузить следующие свойства и методы:
шаблон действия-маркера (раньше мы называли шаблон типом действия: думаю, для разработчиков это более понятный термин), которое выдается при отправке письма
метод, отправляющий письмо
метод, выполняющий дополнительные действия (например, вместе с отправкой поздравления с днем рожденья, мы можем выдавать потребителю баллы на счет)
метод, который формирует Expression
вместо перегрузки свойства, возвращающего шаблон действия-маркера, это свойство проставляется экземпляру класса вместо перегрузки методов отправляющих письма/смс и выполняющих дополнительную логику, у экземпляра класса проставляется произвольная операция, которую нужно совершить над потребителем. При этом операция может быть комбинацией из других операций вместо перегрузки метода формирующего Expression, экземпляру класса проставляется условие. При этом условия можно комбинировать через И/ИЛИ Так как кроме отправки рассылок эта сущность теперь может выполнять любые произвольные операции над потребителем, рассылкой ее называть некорректно. Фактически это класс который делает какую-то абстрактную работу над заданной выборкой потребителей. Не придумав ничего лучше, мы стали называть это триггерами (в маркетинге их примерно так и называют, так что название неплохое). Меня, честно говоря, немного пугало то, что я ввел в систему крайне абстрактную сущность, которую можно назвать DoSomeWorkOnSomeCustomers. Но никакого смысла в специализации триггеров не было, так что я решил над этим не заморачиваться, и в принципе больших проблем с пониманием, что такое триггер, у клиентов не возникает.Регистрация триггера выглядела примерно следующим образом:
Add (new Trigger («Приглашение на сайт для пришедших через канал one-to-one»)
{
MarkerActionTemplateSystemName = «InvitationMarker»,
TriggerAction = new TriggerActionCombination (
new GeneratePasswordForCustomerTriggerAction (),
new SendEmailTriggerAction («InvitationMailing»)),
TriggerCondition = new AndTriggerConditionSet (
new CustomerHasSubscripionCondition (),
new CustomerHasEmailTriggerCondition (),
new CustomerHadFirstActionOverChannelCondition («OneToOne»)),
});
Интерфейс TriggerAction«а крайне прост:
public interface ITriggerAction
{
void Execute (
ModelContext modelContext, // класс для работы с БД
Customer customer);
}
Базовый класс для условий триггера выглядит следующим образом:
public class TriggerCondition
{
private readonly Func
public TriggerCondition (Func
this.triggerExpressionBuilder = triggerExpressionBuilder; }
public Expression
// Используется в Read Uncommitted транзакции для получения спиcка Id потребителей, подходящих под условие
public IQueryable
var expression = GetExpression (modelContext); return customers.Where (expression).ExpandExpressions (); }
// Используется в Serializable транзакции, для проверки, что потребитель все еще подходит под условие public bool ShouldTrigger (ModelContext modelContext, Customer customer) { if (modelContext == null) throw new ArgumentNullException («modelContext»); if (customer == null) throw new ArgumentNullException («customer»);
var expression = GetExpression (modelContext);
// Можно бы было просто вызывать expression.Evaluate (customer),
//, но тогда для сложных условий выполнилось бы несколько запросов в БД вместо одного
return modelContext.Repositories.Get
// отфильтровываем действия, связанные с выдачей заданного приза
return action => customerPrizes.Any (prize => prize.CustomerActionId == action.Id);
}
Также, я опять применил свою любимую замену наследования композицией и вместо отдельных наследников для периодических и одноразовых триггеров сделал стратегию, которая проверяет нужно ли повторять выполнение триггера над текущим потребителем. Эта стратегия берет Expression
return customer =>! markerActions.Any (action => action.Customer == customer);
}
А вот для периодического:
public override Expression
if (MaxRepeatCount == null) { return customer =>! markerActionsInPeriod.Any (action => action.Customer == customer); } else { return customer => ! markerActionsInPeriod.Any (action => action.Customer == customer) && markerActions.Count () < MaxRepeatCount.Value; } } Тут поддерживается не только повторение раз в N дней, но и раз в календарный месяц/год, поэтому Expression, проверяющий находится ли действие в заданном периоде, вынесен в специальный класс PeriodType. Так же поддерживается ограничение количества повторений.Схема хранения всего этого добра в БД выглядит примерно так:
Сущность OperationStepGroup с одним полем выглядит довольно таки странно, но это позволяет разным сущностям (триггерам, операциям на сайте и др.) ссылаться на группу записей в реляционной БД. К тому же позже в этой сущности появились дополнительные поля, так что все не так уж и страшно.
Кроме того, что мы избавились от лишних маркерных шаблонов действий, мы можем использовать IsMarkerExpression, полученный из маркерного шага триггера, для того, чтобы отобразить статистику по количеству срабатываний триггера. Также мы можем добавить цепочки триггеров и операций (в операциях также используются шаги, один из которых помечается как маркерный).
В итоге менеджер может заводить триггер прямо в админке без участия разработчика, хотя подсказывать им частенько приходится: заведение нового триггера — эта задача не из легких, но такая уж цена за гибкость этого решения. Более простое решение было бы и менее гибким, хотя нам, конечно, придется еще много поработать, чтобы упростить UI при этом не потеряв текущей гибкости нашей архитектуры (можно, например, сделать Wizard«ы для заведения простых триггеров).
Как все это выглядит в UI, можно посмотреть здесь.