Паттерн Стратегия: разрабатываем сложную логику
Паттерны проектирования в программировании — хороший способ улучшить архитектуру системы с точки зрения принципов SOLID. Многие паттерны реализованы в библиотеках различных языков программирования (например, итератор, прототип). В то же время зачастую многие паттерны неявно применяются при проектировании классов, исходя из общих соображений и здравого смысла. Мы бы хотели внести больше осознанности в применении паттернов и рассмотреть пример такого осознанного применения на примере паттерна Стратегия.
Стратегия — это поведенческий паттерн проектирования, который определяет семейство схожих алгоритмов и помещает каждый из них в собственный класс, после чего алгоритмы можно взаимозаменять прямо во время исполнения программы. Главной особенностью этого шаблона является то, что у клиента есть набор алгоритмов, из которых будет выбран конкретный алгоритм для использования во время выполнения.
Исходные данные
У нас в Xsolla есть приложение Internal Meeting App, которое продвигает и поддерживает нашу культуру внутренних встреч, а также помогает повышать их эффективность. При создании встречи в Google Calendar она автоматически появляется в приложении. Затем со встречами можно работать — искать сами встречи по фильтрам, смотреть записи со встреч (Meeting Notes), рассылать их всем участникам, легко и быстро добавлять или находить все относящиеся к этой встрече артефакты — задачи, документы и т.д.
В качестве корпоративной базы знаний мы используем 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);