Как и зачем использовать Template Method в C#

1069713d2043e765e246a163bde417c3.png

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#». Подробнее

© Habrahabr.ru