Паттерн Стратегия: разрабатываем сложную логику

Aleksandr Kostarev
Xsolla Tech Blog
Published in
6 min readAug 31, 2020
Photo by Clément H on Unsplash

Паттерны проектирования в программировании — хороший способ улучшить архитектуру системы с точки зрения принципов SOLID. Многие паттерны реализованы в библиотеках различных языков программирования (например, итератор, прототип). В то же время зачастую многие паттерны неявно применяются при проектировании классов, исходя из общих соображений и здравого смысла. Мы бы хотели внести больше осознанности в применении паттернов и рассмотреть пример такого осознанного применения на примере паттерна Стратегия.

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

Исходные данные

У нас в Xsolla есть приложение Internal Meeting App, которое продвигает и поддерживает нашу культуру внутренних встреч, а также помогает повышать их эффективность. При создании встречи в Google Calendar она автоматически появляется в приложении. Затем со встречами можно работать — искать сами встречи по фильтрам, смотреть записи со встреч (Meeting Notes), рассылать их всем участникам, легко и быстро добавлять или находить все относящиеся к этой встрече артефакты — задачи, документы и т.д.

Внутренние встречи в Xsolla
Внутренние встречи в Xsolla

В качестве корпоративной базы знаний мы используем Confluence. По итогам внутренних встреч в отдельном пространстве Confluence появляются страницы с Meeting Notes. Наиболее удобным способом их создания является специальная кнопка в приложении Internal Meetings App.

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

Но бывает так, что страница в Confluence создается самостоятельно и добавляется ко встрече в приложении позже. Или один Meeting Notes может быть результатом нескольких встреч. В этом случае нужно добавить несколько ссылок на разные встречи в приложении на одну и ту же страницу Confluence.

Проблема

С учетом вышесказанного могут возникнуть следующие варианты:

  • на странице может не быть раздела Internal Meetings App Link (no Header)
  • раздел есть, но нет ссылки на соответствующую страницу в приложении
  • раздел есть, но уже есть ссылка на другую встречу
  • есть и раздел и ссылка на нужную встречу

При этом варианты в некоторых случаях могут комбинироваться.

Решение

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

У такого подхода есть несколько преимуществ:

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

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

function getInternalMeetingsHeader() {
return ‘<h2>Internal Meetings App Link</h2>’;
}
function makeInternalLink(imId, pageTitle) {
return ‘<a href=”https://<link to Internal Meetings App>’ +
imId + ‘“>’ + pageTitle + ‘</a>’;
}
function makeLi(item) {
return ‘<li>’ + item + ‘</li>’;
}
function makeUl(li) {
return ‘<ul>’ + li + ‘</ul>’;
}

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

Стратегия по своей сути несложный паттерн. Это несколько схожих алгоритмов, объединенных общим интерфейсом, каждый из которых вынесен в отдельный класс. В нашем случае “схожие алгоритмы” — это различные способы форматирования страницы в Confluence с Meeting Notes.

Делаем интерфейс Strategy c двумя методами — check и run. Считаем, что стратегия знает, когда ей нужно запускаться, и умеет непосредственно изменять страницу Confluence, имея в своем распоряжении информацию о встрече. Также делаем хранилище стратегий StrategyCollection().

function Strategy(check, run) {
return {
check: check,
run: run,
};
}
function StrategyCollection() {
return {
strategy: [],
addStrategy: function (strg) {
this.strategy.push(strg);
},
run: function (page, meeting) {
return this.strategy
.filter(function (strategy) { return strategy.check(); })
.shift()
.run(page, meeting.id, meeting.title);
},
};
}

Теперь используем все это при обновлении страницы:

function updateConfluencePage(pageId, meeting, StrategyCollection strategies) {
let page = loadPage(pageId);
page = strategies.run(page, meeting);
updatePage(pageId, page);
}

Осталось главное — реализовать стратегии, т.е. те самые “схожие алгоритмы”.

Первое, если у страницы нет заголовка — добавляем его:

var noHeaderStrategy = Strategy(
function (page) {
return page.indexOf(getInternalMeetingsHeader()) === -1;
},
function (page, meeting) {
console.log(‘noHeaderStrategy!’);
return page + getInternalMeetingsHeader() +
makeUl(makeLi(makeInternalLink(meeting.id, meeting.title)));
}
);

Далее, если на странице отсутствует список — добавляем его. Список нужен, если страница относится к нескольким встречам. Проверяем, что после заголовка отсутствует список и, если это так, добавляем его через функцию makeUl():

var noUlStrategy = Strategy(
function (page) {
return !page.match(/<h2>Internal Meetings App Link<\/h2><ul>(.*?)<\/ul>/gm);
},
function (page, meeting) {
console.log(‘noUlStrategy!’);
return page.replace(‘<h2>Internal Meetings App Link</h2>’,
getInternalMeetingsHeader() +
makeUl(makeLi(makeInternalLink(meeting.Id, meeting.title))));
}
);

Стратегия в случае ссылки на другую встречу в Internal Meetings App будет выглядеть так:

var addLinkStrategy = Strategy(
function (page) {
var matchedLink = page.match(/<h2>Internal Meetings App Link<\/h2><ul><li><a href=”https:\/\/<Link to Internal Meetings>\/(\d+)/);
if (!matchedLink) {
return false; // TODO: Alert
}
return Number(matchedLink[1]) !== Number(imId) ? true : false;
},
function (page, meeting) {
console.log(‘addLinkStrategy!’);
return page + makeUl(makeLi(makeInternalLink(meeting.id,
meeting.title)));
}
);

Здесь мы проверяем, что id встречи соответствует id в ссылке на приложение. Отдельно проверяем случай, если ссылка на приложение не найдена (уведомление об этом еще не реализовано). При помощи функций makeUl() и makeLi() добавляем на страницу в список новую ссылку на текущую встречу.

Если по итогам тестирования или запуска в эксплуатацию потребуется обработать еще какие-нибудь варианты событий — делаем это аналогичным способом.

Теперь добавим все описанные стратегии в коллекцию, запустим их на выполнение и обновим страницу в Confluence:

var strCollection = StrategyCollection();
strCollection.addStrategy(noHeaderStrategy);
strCollection.addStrategy(noUlStrategy);
strCollection.addStrategy(addLinkStrategy);
updateConfluencePage(pageId, meeting, strCollection);

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

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

Полный пример реализации паттерна Стратегия:

// Header for Internal Meetings App links in Meeting Notes on Confluence serverfunction getInternalMeetingsHeader() {
return ‘<h2>Internal Meetings App Link</h2>’;
}
// Links to Internal Meetings App in Meeting Notes on Confluence serverfunction makeInternalLink(imId, pageTitle) {
return ‘<a href=”https://<Link To Meeting App>’ + imId + ‘“>’ +
pageTitle + ‘</a>’;
}
function makeLi(item) {
return ‘<li>’ + item + ‘</li>’;
}
function makeUl(li) {
return ‘<ul>’ + li + ‘</ul>’;
}
function Strategy(check, run) {
return {
check: check,
run: run,
};
}
function StrategyCollection() {
return {
strategy: [],
addStrategy: function (strg) {
this.strategy.push(strg);
},
run: function (page, meeting) {
return this.strategy
.filter(function (strategy) {
return strategy.check(page);
}).shift()
.run(page, meeting);
},
};
}
function updateConfluencePage(pageId, meeting, strategyCollection) {
let page = loadPage(pageId);
page = strategies.run(page, meeting);
updatePage(pageId, page);
}
var noHeaderStrategy = Strategy(
function (page) {
return page.indexOf(getInternalMeetingsHeader()) === -1;
},
function (page, meeting) {
console.log('noHeaderStrategy!');
return page + getInternalMeetingsHeader() +
makeUl(makeLi(makeInternalLink(meeting.id, meeting.title)));
}
);
var noUlStrategy = Strategy(
function (page) {
return !page.match(/<h2>Internal Meetings App Link<\/h2><ul>(.*?)<\/ul>/gm);
},
function (page, meeting) {
console.log('noUlStrategy!');
return page.replace('<h2>Internal Meetings App Link</h2>',
getInternalMeetingsHeader() +
makeUl(makeLi(makeInternalLink(meeting.Id, meeting.title))));
}
);
var addLinkStrategy = Strategy(
function (page) {
var matchedLink = page.match(/<h2>Internal Meetings App Link<\/h2><ul><li><a href="https:\/\/<Link to Internal Meetings>\/(\d+)/);
if (!matchedLink) {
return false; // TODO: Alert
}
return Number(matchedLink[1]) !== Number(imId) ? true : false;
},
function (page, meeting) {
console.log('addLinkStrategy!');
return page + makeUl(makeLi(makeInternalLink(meeting.id,
meeting.title)));
}
);
var strCollection = StrategyCollection();
strCollection.addStrategy(noHeaderStrategy);
strCollection.addStrategy(noUlStrategy);
strCollection.addStrategy(addLinkStrategy);
updateConfluencePage(pageId, meeting, strCollection);

--

--