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)


  1. HemulGM
    24.10.2024 20:38

    Здесь у вас нет DI, вы связывали жёстко логгер и базовый класс. Для DI нужен фабричный класс, который бы и занимался инжектированием. Одна из идей DI состоит именно в том, чтобы не пичкать конструктор кучей аргументов.


    1. comradeleet
      24.10.2024 20:38

      Покажите пример внедрения без конструктора


      1. HemulGM
        24.10.2024 20:38

        type
          [Path('scene'), RolesAllowed(UR_USER)]
          TCloudSceneRoute = class
          protected
            [Context, Connection('MAIN_DB')]
            MAIN_DB: TMARSFireDAC;
            [Context] 
            Token: TMARSToken;
          public
            [POST]
            function Create([BodyParam, Required] Params: TCloudSceneJson): TCloudScene;
            
            [GET, Path('{id}')]
            function Get([PathParam('id'), Required] const Id: string): TCloudScene;
            
            [GET, Path('{id}/status')]
            function Status([PathParam('id'), Required] const Id: string): TCloudSceneStatus;
          end;

        Например вот. Здесь вообще нет конструктора (дефолтный, без параметров). Поля Token и MAIN_DB инжектируются автоматически. К слову, параметры вызовов функций - тоже автоматически подставляются


        1. MihaOo
          24.10.2024 20:38

          Это что за зверь такой? В смысле язык, раньше не встречал как будто бы.

          И как в protected поля попадают данные? Через рефлексию?


          1. HemulGM
            24.10.2024 20:38

            Delphi. Через рефлексию.


        1. AgentFire
          24.10.2024 20:38


          1. HemulGM
            24.10.2024 20:38

            Значения не имеет. Смысл DI в том, чтобы не требовалось передавать ссылки вручную. Т.е. автоматическая инъекция требуемого.


        1. comradeleet
          24.10.2024 20:38

          Напечатал те же вопросы, что и у @MihaOo, благо прочитать успел и не отправил. Аналогичный пример можно на шарпе и без рефлексии, ещё чтобы конструктор был явный и без аргументов?


          1. HemulGM
            24.10.2024 20:38

            https://learn.microsoft.com/ru-ru/dotnet/core/extensions/dependency-injection


            1. comradeleet
              24.10.2024 20:38

              И что?

              public sealed class Worker(IMessageWriter messageWriter) 
                : BackgroundService

              Первичный конструктор принимает интерфейс, что вы этим показали?


              1. HemulGM
                24.10.2024 20:38

                Внимательнее посмотрите, как создаётся воркер. Никакие аргументы при этом не передаются.


                1. comradeleet
                  24.10.2024 20:38

                  HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
                  
                  builder.Services.AddHostedService<Worker>();
                  builder.Services.AddSingleton<IMessageWriter, MessageWriter>();
                  
                  using IHost host = builder.Build();

                  Вот создается узел с воркером, которому прокидывается райтер.

                  Или что вы имеете ввиду?


                  1. HemulGM
                    24.10.2024 20:38

                    host.Services.GetService<ExampleService>();

                    Логгер тут будет сам указан как зависимость автоматически. В статье примеры через выбор конструктора, но можно сделать и через рефлексию, без нужды в добавлении параметров в конструкторы


                    1. comradeleet
                      24.10.2024 20:38

                      Да, потому что логгеры прокидываются в конструктор

                       public ExampleService(
                              IMessageWriter messageWriter,
                              IEnumerable<IMessageWriter> messageWriters)


                      1. HemulGM
                        24.10.2024 20:38

                        Так смысл в том, что прокидываются они автоматически. Разработчику не нужно знать, где можно достать Logger, DI сам его укажет. В этом и смысл!


                      1. comradeleet
                        24.10.2024 20:38

                        Разработчик достанет логгер там, где он прокинул эту зависимость


                      1. HemulGM
                        24.10.2024 20:38

                        Т.е. в разных частях программы у тебя будет нужна в логгере и ты будешь его брать из глобальной переменной или синглтона, при этом добавляя каждый раз в using модуль с логгером и другими такими же зависимостями?


                      1. comradeleet
                        24.10.2024 20:38

                        Так это буквально ваше предложение. Это же ваш воркер "Никакие аргументы не принимает". Следовательно это ваше предложение дергать службу из локатора или синглтона, когда она понадобится


                        Я же говорю о явном пробросе зависимости.

                        И причем вообще здесь юзинг?


                      1. MihaOo
                        24.10.2024 20:38

                        В общем, я кажется понял что @HemulGMимеет в виду.

                        Мы берём, создаём класс с конструктором без параметров, приватным конструктором или чем-то подобным.

                        Дальше определяем свойства и аргументы для них. DI, используя рефлексию, видит что надо записать в эти свойства для создания экземпляра класса.

                        DI создаёт все зависимости и требуемый класс и отдаёт его нам в какой-то сервис.

                        К слову, аргументы можно заменить на какой-то глобальный конфиг в XML, JSON, YAML, да в чём душе угодно и аргументы не понадобятся.

                        Моё мнение по этому поводу: такие неявные финты ни к чему хорошему не приведут, но в целом никто нам такое делать не запрещает, хотя дополнительная работа с рефлексией не вызывает восторга.


                      1. comradeleet
                        24.10.2024 20:38

                        Я тоже об этом думал, но это уже не DI, а хрень какая-то. Потому что DI это про ЯВНЫЕ зависимости

                        Dependency injection makes implicit dependencies explicit

                        Именно поэтому я сделал предположение о некотором аналоге локатора служб


                    1. MihaOo
                      24.10.2024 20:38

                      Можно безусловно, но нужно ли? Вот тут вопрос.


        1. amironov
          24.10.2024 20:38

          Задача DI -- избавиться от ручного создания объектов. А через что это делается, через поля класса или через параметры конструктора, к самой концепции отношения не имеет.


          1. HemulGM
            24.10.2024 20:38

            Logger в статье создается и просто передается вручную как аргумент. Тут нет никакой автоматизации.

            В DI необходимо было логер зарегистрировать и через сервис DI создавать свой класс, в который DI и внедрит зависимость сам.


            1. amironov
              24.10.2024 20:38

              Комментарий был не к статье, а к высказыванию про поля vs конструторы.


              1. HemulGM
                24.10.2024 20:38

                Я и не говорю, что это должно быть именно через поля класса, я говорю, что тут вообще нет автоматизации. Ты сам создаешь объект Logger и сам его вручную передаешь в другой объект. Где тут DI? Да даже если это было бы свойством или прямым полем, которое назначается после создания. DI тут всё равно нет


                1. Mingun
                  24.10.2024 20:38

                  Потому что DI для демонстрации концепции здесь не нужен. Просто представьте, что логгер вставляете не вы сами, а DI и всё. Суть в том, что DI здесь легко подключить. А ваш пример на Delphi неудачный — класс теперь всегда требует DI для своего создания, что нехорошо. Тогда как в примере с конструктором при необходимости можно создать всё вручную.


                  1. HemulGM
                    24.10.2024 20:38

                    Равно как и требует в конструкторе в статье. Вручную я тоже могу создать этот класс (и уже создаю для тестов) и делается это достаточно легко, просто вызывается DI и передается ему надо объектов для инжектирования.


                    1. Mingun
                      24.10.2024 20:38

                      просто вызывается DI

                      Это не вручную. Вручную — это прямо руками:

                      Class1 c1 = new Class1();
                      Class2 c2 = new Class2(c1);
                      // и так далее
                      

                      А у вашего DI неизвестно, сколько магии под капотом закопано.


                      1. HemulGM
                        24.10.2024 20:38

                        Вручную это так же. Создать можно и без DI. Если надо, можно и конструктор объявить с нужными объектами или публичные свойства для установки вручную. В этом нет никакой сложности. Однако с DI нет необходимости создавать что-то вручную и заполнять зависимостями, если речь не о юниттестах.


                      1. AgentFire
                        24.10.2024 20:38

                        Холивар детектед.


                      1. Mingun
                        24.10.2024 20:38

                        Если надо, можно и конструктор объявить с нужными объектами или публичные свойства для установки вручную.

                        Я о том и говорю: чтобы (по-нормальному, а не выкрутасами вроде рефлексии) создать класс в обход DI, вам придётся править определение класса. Это и значит, что он жёстко к механизму DI приколочен.


  1. Odin41
    24.10.2024 20:38

    Возможно вечер на меня так влияет , но я не понимаю как создаётся мок объект нужный в тесте.


  1. mvv-rus
    24.10.2024 20:38

    Автор, поправьте исходный код: у вас там => в примерах превратилась в =&gt;. В результате комментатор выше не понял, что Moq вы конфигурируете ламбда-функциями.

    И, если вы пишете на Markdown, запомните - в примерах исходного кода (между обратными кавычками или их группами) использовать HTML entity вместо зарезервированных в HTML символов не нужно.