- PVSM.RU - https://www.pvsm.ru -
Последнее время в Email-маркетинге все чаще используются автоматические рассылки определенным группам потребителей. Типичные задачи:
В этой статье мы расскажем, как мы решали эту задачу — от написания каждой отдельной рассылки разработчиком с нуля 3 года назад, до заведения рассылок менеджером через веб-интерфейс в настоящее время. Рассказ может быть интересен не только тем, кто занимается Email-маркетингом но и вообще всем, кому приходится реализовывать периодическое выполнение сложных операций над определенными выборками потребителей (знаю, что звучит очень абстрактно, но в итоге именно такую абстрактную задачу нам и пришлось решить).
3 года назад подобные задачи возникали крайне редко и мы каждый раз реализовывали их с нуля. При этом возникали одни и те же вопросы:
На первый вопрос ответ для нас был очевиден: в нашей системе сохраняется информации о всех значимых действиях, выполняемых потребителем (вход на сайт, изменение персональных данных) или над ним (розыгрыш приза, отправка уведомления). Кроме того, мы используем действия для разнообразных технических пометок потребителей. Так что при отправке автоматической рассылки, мы также решили выдавать потребителю особое действие-маркер, в качестве пометки, что эта автоматическая рассылка ему уже была отправлена. Чтобы повторно не отправлять рассылку, к условию рассылки всегда добавляется условие “у потребителя нет действия-маркера”.
На втором вопросе мы набили множество шишек, связанных с блокировками в БД, и в итоге пришли к следующему шаблону:
Само собой после реализации нескольких рассылок по этому шаблону, мы решили вынести шаблонный код. Для этого был создан класс BatchMailing, и для каждой новой рассылки мы создавали и регистрировали в специальном реестре его наследника. В наследнике необходимо было перегрузить следующие свойства и методы:
Свойство и первые два метода никогда никаких проблем не вызывали, но вот составить Expression было довольно таки не просто. Этот Expression использовался два раза — сначала в Read Uncommitted запросе, чтобы вытащить Id потребителей, а затем в Serializable транзакции, чтобы повторно проверить подходит ли потребитель под условие. Его было нужно написать так, чтобы Linq to SQL смог его транслировать в T-SQL. Условия могли быть довольно сложными и в них всегда возникали проблемы. Ни одну рассылку нельзя было завести не написав на нее кучку тестов. Кроме того, для отправки СМС и email мы завели разных промежуточных наследников от BatchMailing. Когда же нам надо было отправить и email и СМС, приходилось копипастить. У меня были идеи как это исправить, но так как автоматические рассылки клиенты просили не так уж и часто, это была низкоприоритетная задача.
2 года назад при разработке очередной рекламной кампании клиент попросил сделать ему сразу 8 разных автоматических рассылок. При этом частично условия в рассылках повторялись. Тут уже не оставалось сомнений, что больше так жить нельзя, и я взялся за переписывание нашей архитектуры. Для того чтобы справится со всеми описанными выше проблемами достаточно было применить наш любимый прием: замену наследования композицией. Этот прием настолько много раз нам помогал, что я советую использовать композицию вместо наследования везде, где это возможно (ну или как минимум рассматривать такой вариант). Если вы создаете базовый абстрактный класс с мыслью “для каждой конкретной задачи у меня будет наследник перегружающий методы и свойства”, сразу спрашивайте себя “а почему бы мне вместо этого не регистрировать для каждой задачи экземпляр класса, передавая ему разные настройки”. И только если вы уверены, что композиция здесь не подходит, используйте наследование. Если подходит и то и то, всегда склоняйтесь к композиции — так получается гораздо более гибкая и понятная архитектура.
В нашей ситуации:
Так как кроме отправки рассылок эта сущность теперь может выполнять любые произвольные операции над потребителем, рассылкой ее называть некорректно. Фактически это класс который делает какую-то абстрактную работу над заданной выборкой потребителей. Не придумав ничего лучше, мы стали называть это триггерами (в маркетинге их примерно так и называют, так что название неплохое). Меня, честно говоря, немного пугало то, что я ввел в систему крайне абстрактную сущность, которую можно назвать 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<ModelContext, Expression<Func<Customer, bool>>> triggerExpressionBuilder;
public TriggerCondition(Func<ModelContext, Expression<Func<Customer, bool>>> triggerExpressionBuilder)
{
if (triggerExpressionBuilder == null)
throw new ArgumentNullException("triggerExpressionBuilder");
this.triggerExpressionBuilder = triggerExpressionBuilder;
}
public Expression<Func<Customer, bool>> GetExpression(ModelContext modelContext)
{
return triggerExpressionBuilder(modelContext, brand);
}
// Используется в Read Uncommitted транзакции для получения спиcка Id потребителей, подходящих под условие
public IQueryable<Customer> ChooseCustomers(ModelContext modelContext, IQueryable<Customer> customers)
{
if (modelContext == null)
throw new ArgumentNullException("modelContext");
if (customers == null)
throw new ArgumentNullException("customers");
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<CustomerRepository>().Items
.Where(aCustomer => aCustomer == customer)
.Where(aCustomer => expression.Evaluate(aCustomer))
.ExpandExpressions()
.Any();
}
}
Для часто используемых условий мы создавали наследников от TriggerCondition, в которых строился конкретный Expression в зависимости от переданных в конструктор параметров.
С использованием архитектуры, описанной выше, мы заводили триггер менее чем за пол часа, за счет комбинирования уже написанных условий и TriggerAction’ов. Однако и этого нам было мало. Следующим шагом мы захотели полностью исключить разработчиков из процесса заведения триггеров. Причем как это делать в общих чертах я понял уже через пару месяцев после реализации предыдущей версии архитектуры. Условия триггеров были один в один похожи на фильтры, которые мы используем в админке. Наша система фильтров позволяет описывать сложные условия, включая запросы к связанным сущностям, а также позволяет комбинировать их через И/ИЛИ. Фильтр формирует Expression, с помощью которого уже можно отфильтровывать сущности в БД. И для всего этого уже был написан UI и сериализация. Оставалось лишь добавить пару фильтров, которые часто нужны для триггеров, но не имели смысла при обычной работе со списком потребителей (например: “с действия прошло N дней”). Для TriggerAction’ов надо было написать UI и структуру для хранения их в БД, но тут тоже в общем все было понятно. Однако оставались еще небольшие вопросы, над которым пришлось поломать голову:
Все эти три проблемы связаны с тем, как мы определяем выполнился ли триггер над потребителем или нет. Если заводить для каждого триггера и операции на сайте свой маркер, задача сильно упрощается, но плодить лишние действия в системе очень не хотелось. Была даже идея заставлять менеджеров составлять фильтр таким образом, чтобы он полностью отвечал за то, можно ли сейчас выполнить действие над потребителем (и соответственно частота повторения триггера описывалась бы условием в фильтре), однако данный подход слишком уж располагает к ошибкам. После долгих мучительных размышлений мне все-таки пришла идея, как отслеживать выполнение триггеров без дополнительных сущностей и без усложнения работы менеджера.
Так как триггер выполняет абстрактный шаг операции (бывший TriggerAction) над потребителем, причем почти всегда этот шаг операции уникален (например, определенное письмо отсылается или определенный приз выдается только из этого триггера), то в этот шаг можно вынести логику проверяющую выполнился ли он. Так как в триггере может быть несколько шагов операции, то менеджеру надо будет выбрать какой из них является маркером (проверять выполнение каждого шага не имеет смысла). Однако просто, реализовать в шаге операции метод, возвращающий Expression<Func<Customer,bool>> нельзя, так как пришлось бы в каждом шаге операции формировать один Expression для одноразовых триггеров, другой для периодических. Тут нас спасает то, что практически любая операция над пользователем в нашей системе выдает ему действие. Соответственно шаг операции может отфильтровать те действия, которые были выдан им. Большинство шагов операции выдают конкретное действие и для них метод, формирующий Expression для фильтрации действий, выглядит вот так:
public sealed override Expression<Func<CustomerAction, bool>> GetIsMarkerExpression(ModelContext modelContext)
{
return action => action.ActionTemplateId == ActionTemplateId;
}
Но, например, у шага, выдающего приз, он выглядит следующим образом:
public override Expression<Func<CustomerAction, bool>> GetIsMarkerExpression(ModelContext modelContext)
{
IQueryable<CutomerPrize> customerPrizes = modelContext.Repositories.Get<CustomerPrizeRepository>().GetByPrizes(Prize);
// отфильтровываем действия, связанные с выдачей заданного приза
return action => customerPrizes.Any(prize => prize.CustomerActionId == action.Id);
}
Также, я опять применил свою любимую замену наследования композицией и вместо отдельных наследников для периодических и одноразовых триггеров сделал стратегию, которая проверяет нужно ли повторять выполнение триггера над текущим потребителем. Эта стратегия берет Expression<Func<CustomerAction, bool>> из маркерного шага триггера и с помощью него формирует Expression<Func<Customer, bool>>, для дополнительной проверки нужно ли выполнять триггер над потребителем. Вот реализация для одноразового триггера:
public override Expression<Func<Customer, bool>> BuildShouldRepeatExpression(ModelContext modelContext,
Expression<Func<CustomerAction, bool>> isMarkerExpression)
{
var markerActions = modelContext.Repositories.Get<CustomerActionRepository>().Items
.Where(isMarkerExpression.ExpandExpressions());
return customer => !markerActions.Any(action => action.Customer == customer);
}
А вот для периодического:
public override Expression<Func<Customer, bool>> BuildShouldRepeatExpression(
ModelContext modelContext, Expression<Func<CustomerAction, bool>> isMarkerExpression)
{
var isInPeriodExpression = PeriodType.BuildIsInPeriodExpression(modelContext, PeriodValue);
var markerActions = modelContext.Repositories.Get<CustomerActionRepository>().Items
.Where(isMarkerExpression.ExpandExpressions());
var markerActionsInPeriod = markerActions.Where(isInPeriodExpression.ExpandExpressions());
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, можно посмотреть здесь [1].
Автор: Youkai
Источник [2]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-2/84413
Ссылки в тексте:
[1] здесь: http://blog.directcrm.ru/?go=all/zavedenie-triggerov-dlya-kampaniy-v-sisteme-administrirovaniya/
[2] Источник: http://habrahabr.ru/post/251915/
Нажмите здесь для печати.