Template Method (он же «Шаблонный метод») — это паттерн проектирования, который определяет скелет алгоритма в методе, оставляя определенные шаги подклассам. Проще говоря, есть базовый алгоритм, но мы можно менять детали, переопределяя части этого алгоритма в наследниках.
Классический пример — процесс заказа товара в интернет-магазине. Независимо от того, какой у вас магазин, шаги примерно одинаковые: проверка наличия товара, оплата, упаковка и доставка. Но в зависимости от специфики магазина, эти шаги могут отличаться в деталях.
Template Method позволяет создать базовую структуру этих шагов и менять конкретные реализации без изменения самой структуры. В этой статье мы рассмотрим как реализовать этот паттерн на C#.
Основная структура паттерна
Для начала — нарисуем на пальцах. Вот есть базовый класс OrderProcess
, и он содержит метод ProcessOrder()
. В этом методе прописаны основные шаги — шаги шаблона. Эти шаги могут быть представлены как методы, которые подклассы могут переопределять, изменяя поведение.
public abstract class OrderProcess
{
// Шаблонный метод, определяющий основной алгоритм.
public void ProcessOrder()
{
SelectProduct();
MakePayment();
if (CustomerWantsReceipt()) // Перехватчик хука — необязательный шаг
{
GenerateReceipt();
}
Package();
Deliver();
}
// Шаги, которые могут быть переопределены в подклассах.
protected abstract void SelectProduct();
protected abstract void MakePayment();
protected abstract void Package();
protected abstract void Deliver();
// "Хук" — метод с базовой реализацией, который можно переопределить.
protected virtual bool CustomerWantsReceipt()
{
return true; // По умолчанию считаем, что клиент хочет чек
}
// Этот метод остается фиксированным — он не изменяется.
private void GenerateReceipt()
{
Console.WriteLine("Чек сгенерирован.");
}
}
Теперь создадим две реализации процесса заказа — OnlineOrder
и StoreOrder
. OnlineOrder
будет представлять покупку в онлайн-магазине, а StoreOrder
— обычный заказ в розничном магазине.
Пример кода для OnlineOrder:
public class OnlineOrder : OrderProcess
{
protected override void SelectProduct()
{
Console.WriteLine("Выбран товар в интернет-магазине.");
}
protected override void MakePayment()
{
Console.WriteLine("Оплата произведена онлайн.");
}
protected override void Package()
{
Console.WriteLine("Товар упакован для доставки.");
}
protected override void Deliver()
{
Console.WriteLine("Товар отправлен почтой.");
}
protected override bool CustomerWantsReceipt()
{
return false; // Онлайн-заказчик, предположим, не хочет чека
}
}
Пример кода для StoreOrder:
public class StoreOrder : OrderProcess
{
protected override void SelectProduct()
{
Console.WriteLine("Выбран товар в магазине.");
}
protected override void MakePayment()
{
Console.WriteLine("Оплата произведена на кассе.");
}
protected override void Package()
{
Console.WriteLine("Товар упакован в пакет.");
}
protected override void Deliver()
{
Console.WriteLine("Товар выдан покупателю.");
}
}
Здесь мы сделали вот что:
Шаблонный метод
ProcessOrder
— фиксирует общую структуру алгоритма.Абстрактные методы
SelectProduct
,MakePayment
,Package
,Deliver
— определяют шаги, которые должны быть реализованы в подклассах.Метод
CustomerWantsReceipt
— "хук", который позволяет подклассам модифицировать алгоритм, не переопределяя его целиком.
Этот подход позволяет избежать дублирования и повысить гибкость, если вдруг потребуется изменить шаги, добавив новые особенности в подклассах. Например, можно добавить новый подкласс GiftOrder
с нестандартной упаковкой подарков.
Пример с подарочным заказом:
public class GiftOrder : OrderProcess
{
protected override void SelectProduct()
{
Console.WriteLine("Выбран товар для подарка.");
}
protected override void MakePayment()
{
Console.WriteLine("Оплата подарка произведена.");
}
protected override void Package()
{
Console.WriteLine("Товар упакован как подарок.");
}
protected override void Deliver()
{
Console.WriteLine("Подарок доставлен курьером.");
}
// Переопределяем хук — клиент может выбрать подарочную упаковку.
protected override bool CustomerWantsReceipt()
{
return true; // Допустим, клиент всё-таки хочет чек
}
}
Теперь запустим все три реализации. Просто создадим объекты и вызовем ProcessOrder()
.
class Program
{
static void Main()
{
OrderProcess onlineOrder = new OnlineOrder();
onlineOrder.ProcessOrder();
Console.WriteLine();
OrderProcess storeOrder = new StoreOrder();
storeOrder.ProcessOrder();
Console.WriteLine();
OrderProcess giftOrder = new GiftOrder();
giftOrder.ProcessOrder();
}
}
Результат:
Выбран товар в интернет-магазине.
Оплата произведена онлайн.
Товар упакован для доставки.
Товар отправлен почтой.
Выбран товар в магазине.
Оплата произведена на кассе.
Товар упакован в пакет.
Чек сгенерирован.
Товар выдан покупателю.
Выбран товар для подарка.
Оплата подарка произведена.
Товар упакован как подарок.
Чек сгенерирован.
Подарок доставлен курьером.
Интеграция Template Method с другими паттернами
Полезно знать как Template Method может гармонично сосуществовать с другими паттернами
Template Method и Dependency Injection
Когда мы комбинируем Template Method с DI, мы получаем гибкую и тестируемую архитектуру, где зависимости могут легко заменяться без изменения базового алгоритма.
Допустим, есть система, которая обрабатывает заказы, и нам нужно логировать каждый шаг процесса. Вместо того чтобы жестко связывать логгер с базовым классом, мы внедрим его через конструктор:
public interface ILogger
{
void Log(string message);
}
public class ConsoleLogger : ILogger
{
public void Log(string message)
{
Console.WriteLine($"[ConsoleLogger] {message}");
}
}
public abstract class OrderProcess
{
private readonly ILogger _logger;
protected OrderProcess(ILogger logger)
{
_logger = logger;
}
public void ProcessOrder()
{
_logger.Log("Начало обработки заказа.");
SelectProduct();
MakePayment();
if (CustomerWantsReceipt())
{
GenerateReceipt();
}
Package();
Deliver();
_logger.Log("Заказ обработан.");
}
protected abstract void SelectProduct();
protected abstract void MakePayment();
protected abstract void Package();
protected abstract void Deliver();
protected virtual bool CustomerWantsReceipt()
{
return true;
}
private void GenerateReceipt()
{
Console.WriteLine("Чек сгенерирован.");
}
}
Теперь создадим конкретную реализацию заказа с использованием логгера:
public class OnlineOrder : OrderProcess
{
public OnlineOrder(ILogger logger) : base(logger) { }
protected override void SelectProduct()
{
Console.WriteLine("Выбран товар в интернет-магазине.");
}
protected override void MakePayment()
{
Console.WriteLine("Оплата произведена онлайн.");
}
protected override void Package()
{
Console.WriteLine("Товар упакован для доставки.");
}
protected override void Deliver()
{
Console.WriteLine("Товар отправлен почтой.");
}
protected override bool CustomerWantsReceipt()
{
return false;
}
}
Использование:
class Program
{
static void Main()
{
ILogger logger = new ConsoleLogger();
OrderProcess onlineOrder = new OnlineOrder(logger);
onlineOrder.ProcessOrder();
}
}
Результат:
[ConsoleLogger] Начало обработки заказа.
Выбран товар в интернет-магазине.
Оплата произведена онлайн.
Товар упакован для доставки.
Товар отправлен почтой.
[ConsoleLogger] Заказ обработан.
Тестирование Template Method
Тестирование паттерна Template Method может показаться сложным из-за зависимости от наследования, но с правильным подходом это вполне выполнимая задача. Рассмотрим, как можно протестировать наш OrderProcess
и его подклассы.
С помощью фреймворков для создания мок-объектов, например как как Moq, можно проверять вызовы методов и поведение подклассов.
Пример теста с использованием Moq и xUnit:
using Moq;
using Xunit;
public class OnlineOrderTests
{
[Fact]
public void ProcessOrder_ShouldExecuteStepsCorrectly()
{
// Arrange
var loggerMock = new Mock();
var onlineOrderMock = new Mock(loggerMock.Object)
{
CallBase = true
};
// Act
onlineOrderMock.Object.ProcessOrder();
// Assert
onlineOrderMock.Verify(o => o.SelectProduct(), Times.Once);
onlineOrderMock.Verify(o => o.MakePayment(), Times.Once);
onlineOrderMock.Verify(o => o.GenerateReceipt(), Times.Never); // Поскольку CustomerWantsReceipt() возвращает false
onlineOrderMock.Verify(o => o.Package(), Times.Once);
onlineOrderMock.Verify(o => o.Deliver(), Times.Once);
}
}
В этом примере создаем мок-объект OnlineOrder
, который позволяет отслеживать вызовы методов. Мы проверяем, что все необходимые методы вызываются один раз, а метод GenerateReceipt
не вызывается, поскольку CustomerWantsReceipt()
возвращает false
.
Также полезно тестировать конкретные шаги алгоритма в подклассах, чтобы убедиться, что они выполняют ожидаемые действия. Примерчик:
public class GiftOrderTests
{
[Fact]
public void ProcessOrder_ShouldGenerateReceipt()
{
// Arrange
var loggerMock = new Mock();
var giftOrder = new GiftOrder(loggerMock.Object);
// Act
giftOrder.ProcessOrder();
// Assert
Assert.True(giftOrder.CustomerWantsReceipt());
// Дополнительные проверки могут включать использование моков для отслеживания вызовов
}
}
В продакшене можно разделить логику для упрощения тестирования, внедряя зависимости или используя события вместо прямых вызовов методов.
Потенциальные подводные камни
Как и любой паттерн, Template Method имеет свои ограничения и может привести к проблемам, если использовать его неправильно.
Глубокая иерархия наследования: чрезмерное использование Template Method может привести к созданию сложной иерархии классов.
Сильная связанность: подклассы сильно зависят от базового класса, что может затруднить их изменение или переиспользование в других контекстах.
Краткие выводы
Template Method помогает определить общий алгоритм, оставляя детали подклассам.
Он отлично подходит для сценариев с повторяющейся общей логикой и изменяющимися шагами.
Важно избегать чрезмерного использования наследования и помнить о возможных подводных камнях, таких как глубокая иерархия классов.
Комбинирование с другими паттернами, такими как Dependency Injection или Decorator, в каких то случаях повышает гибкость системы.
До новых встреч!
В заключение порекомендую обратить внимание на открытые уроки курса "C# Developer. Professional":
28 октября: «Сериализатор данных с использованием Reflection и Generics». Подробнее
12 ноября: «Поведенческие шаблоны проектирования в C#». Подробнее
Комментарии (33)
Odin41
24.10.2024 20:38Возможно вечер на меня так влияет , но я не понимаю как создаётся мок объект нужный в тесте.
mvv-rus
24.10.2024 20:38Автор, поправьте исходный код: у вас там
=>
в примерах превратилась в=>
. В результате комментатор выше не понял, что Moq вы конфигурируете ламбда-функциями.И, если вы пишете на Markdown, запомните - в примерах исходного кода (между обратными кавычками или их группами) использовать HTML entity вместо зарезервированных в HTML символов не нужно.
HemulGM
Здесь у вас нет DI, вы связывали жёстко логгер и базовый класс. Для DI нужен фабричный класс, который бы и занимался инжектированием. Одна из идей DI состоит именно в том, чтобы не пичкать конструктор кучей аргументов.
comradeleet
Покажите пример внедрения без конструктора
HemulGM
Например вот. Здесь вообще нет конструктора (дефолтный, без параметров). Поля Token и MAIN_DB инжектируются автоматически. К слову, параметры вызовов функций - тоже автоматически подставляются
MihaOo
Это что за зверь такой? В смысле язык, раньше не встречал как будто бы.
И как в protected поля попадают данные? Через рефлексию?
HemulGM
Delphi. Через рефлексию.
AgentFire
HemulGM
Значения не имеет. Смысл DI в том, чтобы не требовалось передавать ссылки вручную. Т.е. автоматическая инъекция требуемого.
comradeleet
Напечатал те же вопросы, что и у @MihaOo, благо прочитать успел и не отправил. Аналогичный пример можно на шарпе и без рефлексии, ещё чтобы конструктор был явный и без аргументов?
HemulGM
https://learn.microsoft.com/ru-ru/dotnet/core/extensions/dependency-injection
comradeleet
И что?
Первичный конструктор принимает интерфейс, что вы этим показали?
HemulGM
Внимательнее посмотрите, как создаётся воркер. Никакие аргументы при этом не передаются.
comradeleet
Вот создается узел с воркером, которому прокидывается райтер.
Или что вы имеете ввиду?
HemulGM
Логгер тут будет сам указан как зависимость автоматически. В статье примеры через выбор конструктора, но можно сделать и через рефлексию, без нужды в добавлении параметров в конструкторы
comradeleet
Да, потому что логгеры прокидываются в конструктор
HemulGM
Так смысл в том, что прокидываются они автоматически. Разработчику не нужно знать, где можно достать Logger, DI сам его укажет. В этом и смысл!
comradeleet
Разработчик достанет логгер там, где он прокинул эту зависимость
HemulGM
Т.е. в разных частях программы у тебя будет нужна в логгере и ты будешь его брать из глобальной переменной или синглтона, при этом добавляя каждый раз в using модуль с логгером и другими такими же зависимостями?
comradeleet
Так это буквально ваше предложение. Это же ваш воркер "Никакие аргументы не принимает". Следовательно это ваше предложение дергать службу из локатора или синглтона, когда она понадобится
Я же говорю о явном пробросе зависимости.
И причем вообще здесь юзинг?
MihaOo
В общем, я кажется понял что @HemulGMимеет в виду.
Мы берём, создаём класс с конструктором без параметров, приватным конструктором или чем-то подобным.
Дальше определяем свойства и аргументы для них. DI, используя рефлексию, видит что надо записать в эти свойства для создания экземпляра класса.
DI создаёт все зависимости и требуемый класс и отдаёт его нам в какой-то сервис.
К слову, аргументы можно заменить на какой-то глобальный конфиг в XML, JSON, YAML, да в чём душе угодно и аргументы не понадобятся.
Моё мнение по этому поводу: такие неявные финты ни к чему хорошему не приведут, но в целом никто нам такое делать не запрещает, хотя дополнительная работа с рефлексией не вызывает восторга.
comradeleet
Я тоже об этом думал, но это уже не DI, а хрень какая-то. Потому что DI это про ЯВНЫЕ зависимости
Именно поэтому я сделал предположение о некотором аналоге локатора служб
MihaOo
Можно безусловно, но нужно ли? Вот тут вопрос.
amironov
Задача DI -- избавиться от ручного создания объектов. А через что это делается, через поля класса или через параметры конструктора, к самой концепции отношения не имеет.
HemulGM
Logger в статье создается и просто передается вручную как аргумент. Тут нет никакой автоматизации.
В DI необходимо было логер зарегистрировать и через сервис DI создавать свой класс, в который DI и внедрит зависимость сам.
amironov
Комментарий был не к статье, а к высказыванию про поля vs конструторы.
HemulGM
Я и не говорю, что это должно быть именно через поля класса, я говорю, что тут вообще нет автоматизации. Ты сам создаешь объект Logger и сам его вручную передаешь в другой объект. Где тут DI? Да даже если это было бы свойством или прямым полем, которое назначается после создания. DI тут всё равно нет
Mingun
Потому что DI для демонстрации концепции здесь не нужен. Просто представьте, что логгер вставляете не вы сами, а DI и всё. Суть в том, что DI здесь легко подключить. А ваш пример на Delphi неудачный — класс теперь всегда требует DI для своего создания, что нехорошо. Тогда как в примере с конструктором при необходимости можно создать всё вручную.
HemulGM
Равно как и требует в конструкторе в статье. Вручную я тоже могу создать этот класс (и уже создаю для тестов) и делается это достаточно легко, просто вызывается DI и передается ему надо объектов для инжектирования.
Mingun
Это не вручную. Вручную — это прямо руками:
А у вашего DI неизвестно, сколько магии под капотом закопано.
HemulGM
Вручную это так же. Создать можно и без DI. Если надо, можно и конструктор объявить с нужными объектами или публичные свойства для установки вручную. В этом нет никакой сложности. Однако с DI нет необходимости создавать что-то вручную и заполнять зависимостями, если речь не о юниттестах.
AgentFire
Холивар детектед.
Mingun
Я о том и говорю: чтобы (по-нормальному, а не выкрутасами вроде рефлексии) создать класс в обход DI, вам придётся править определение класса. Это и значит, что он жёстко к механизму DI приколочен.