Шаблон «Наблюдатель» на современном C++

Добавлено 13 февраля 2022 в 14:06

Мы начинаем серию постов, пытаясь представить популярные шаблоны проектирования на примерах современного C++. На этот раз мы поговорим о шаблоне наблюдателя, который поможет нам разделить различные модули в нашем коде.

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

Фото Маартен ван ден Хевел на Unsplash
Фото Маартен ван ден Хевел на Unsplash

Внимание, спойлер: это не подробный технический текст. Это просто пример, который пытается на непрофессиональном уровне объяснить сухую тему. Если вы очень серьезно относитесь к этому, то сначала попробуйте почитать соответствующую книгу.

Давайте представим следующий сценарий. Мы создаем систему управления пользователями. Пользователь может зарегистрироваться, сбросить свой пароль (если забыл его) и обновить свой тарифный план с бесплатного уровня до премиума. Для этого у нас есть база данных, slack для корпоративного общения, электронная почта для общения с клиентами и логгер для ведения системного журнала.

Начнем с представления неоптимизированной версии.

int main() {
  Database users_db{};
  UserManagement plain_user_system{users_db};
  PlanManagement plain_plan_system{users_db};

  plain_user_system.RegisterNewUser("angelos", "1234", "angelos@in.gr");
  plain_user_system.PasswordForgotten("angelos@in.gr");
  plain_plan_system.UpgradePlan("angelos@in.gr");

  return 0;
}

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

class UserManagement {
 public:
  explicit UserManagement(Database &user_database)
      : database_{user_database} {}

  void RegisterNewUser(std::string_view name, std::string_view password, std::string_view email) {
    User a_user = database_.Create_User(name, password, email);

    PostSlackMessage("sales", name.data() + std::string(" has registered with email ") + email.data());
    Log(std::string("User registered with email address ") + email.data());
    SendEmail(a_user.name_, a_user.email_, "Welcome", "Thanks for registering, " + a_user.name_);
  }

  void PasswordForgotten(std::string_view email) const {
    const auto found_the_user = database_.FindUser(email);
    if (found_the_user.has_value()) {
      User the_user = found_the_user.value();
      the_user.reset_code_ = "11111";

      SendEmail(the_user.name_, the_user.email_, 
                "Reset your password", 
                "To reset your password, use this very secure code:" + the_user.reset_code_);
      Log("User with email address " + the_user.email_ + " requested a password reset");
    }
  }

 private:
  Database &database_;
};

и

class PlanManagement {
 public:
  explicit PlanManagement(Database &user_database)
      : database_{user_database} {}

  void UpgradePlan(std::string_view email) const {
    const auto found_the_user = database_.FindUser(email);
    if (found_the_user.has_value()) {
      User the_user = found_the_user.value();
      the_user.plan_ = "paid";

      PostSlackMessage("sales", the_user.name_ + " has upgraded their plan.");
      SendEmail(the_user.name_, the_user.email_, "Thank you",
                "Thanks for upgrading, You're gonna love it.");
      Log("User with email address " + the_user.email_ + " has upgraded their plan");
    }
  }

 private:
  Database &database_;
};

В обоих API мы видим, что помимо основной задачи в каждом методе, например, в методе RegisterNewUser задача создания нового пользователя в базе данных, всё остальное с ней не сильно связано, т.е. отправка сообщения в slack, отправка письма по электронной почте и т. д. Этот код попахивает слабой связностью. Метод должен знать и зависит от множества вещей. Аналогичная ситуация с PasswordForgotten и UpgradePlan.

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

class Database {
 public:
  Database() = default;

  User Create_User(std::string_view name, std::string_view pasword, std::string_view email) {
    User new_user{name, pasword, email};
    users_.push_back(new_user);
    return new_user;
  }

  [[nodiscard]] std::optional<User> FindUser(std::string_view email) const {
    for (auto user : users_) {
      if (user.email_ == email)
        return user;
    }
    return std::nullopt;
  }

  std::vector<User> users_;
};
class User {
 public:
  User(std::string_view name, std::string_view password, std::string_view email)
      : name_{name}
      , password_{password}
      , email_{email}
      , plan_{"basic"} {}

  void ResetPassword(std::string_view code, std::string_view new_password) {
    if (code == reset_code_) {
      password_ = new_password;
    } else {
      std::cout << "Invalid password reset code." << std::endl;
    }
  }

  void PrintUser() const {
    std::cout << "[" << name_ << ", " << email_ << "]" << std::endl;
  }

  std::string name_;
  std::string password_;
  std::string email_;
  std::string plan_{"basic"};
  std::string reset_code_;
};
void PostSlackMessage(std::string_view channel, std::string_view message)  {
  spdlog::info("[SlackBot {}]: {}", channel, message);
}

#endif//ARJAN_OBSERVER_PATTERN_LIB_SLACK_H_
void SendEmail(std::string_view name, std::string_view address, std::string_view subject, std::string_view body)  {
  std::cout << "Sending email to " << name << " (" << address << ")" << std::endl;
  std::cout << "-----" << std::endl;
  std::cout << "Subject: " << subject << std::endl;
  std::cout << body << std::endl;
}
void Log(std::string_view message) {
  spdlog::info(std::string("[Log]: ") + message.data());
}

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

Начнем с создания нашего собственного менеджера системы событий, который выглядит так:

class EventSystem {
 public:
  void Subscribe(std::string_view event_type, const std::function<void(const User &)> &function) {
    subscribers_[event_type.data()].push_back(function);
  }

  void PostEvent(std::string_view event_type, const User &a_user) {
    for (const auto &fn : subscribers_[event_type.data()]) {
      fn(a_user);
    }
  }

  [[maybe_unused]] void ListSubscribers() {
    for (const auto &[event, fun] : subscribers_)
      spdlog::info("EventSystem type: " + event + " subscribers "
                   + std::to_string(subscribers_[event].size()));
  }

 private:
  std::map<std::string, std::vector<std::function<void(const User &)>>> subscribers_;
};

Сердцем системы событий является словарь, который связывает подписчиков с событиями. Когда происходит событие, подписчики получают уведомление. Таким образом, класс содержит метод для подписки подписчиков на определенное событие и метод для публикации события, которое является уведомлением подписчика о том, что конкретное событие произошло. События на самом деле представляют собой теги (строки), такие как «user_registered» (пользователь зарегистрирован), а подписчики – это функции.

После этого наши API управления пользователями и планами изменятся на:

class UserManagementEventDriven {
 public:
  explicit UserManagementEventDriven(Database &user_database, EventSystem &event_system)
      : database_{user_database}, event_system_{event_system} {}

  void RegisterNewUser(std::string_view name, std::string_view password, std::string_view email) {
    User a_user = database_.Create_User(name, password, email);

    event_system_.PostEvent("user_registered", a_user);
  }

  void PasswordForgotten(std::string_view email) const {
    const auto found_the_user = database_.FindUser(email);
    if (found_the_user.has_value()) {
      User the_user = found_the_user.value();
      the_user.reset_code_ = "11111";

      event_system_.PostEvent("user_password_forgotten", the_user);
    }
  }

 private:
  Database &database_;
  EventSystem &event_system_;
};
class PlanManagementEventDriven {
 public:
  explicit PlanManagementEventDriven(Database &user_database, EventSystem &event_system)
      : database_{user_database}
      , event_system_{event_system} {}

  void UpgradePlan(std::string_view email) const {
    const auto found_the_user = database_.FindUser(email);
    if (found_the_user.has_value()) {
      User the_user = found_the_user.value();
      the_user.plan_ = "paid";

      event_system_.PostEvent("user_upgrade_plan", the_user);
    }
  }

 private:
  Database &database_;
  EventSystem &event_system_;
};

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

void HandleUserRegisteredEvent(const User &user) {
  PostSlackMessage("sales", user.name_ + " has registered with email address " + user.email_);
}

void HandleUserPasswordForgottenEvent(const User &user) {
  PostSlackMessage("sales", user.name_ + " has upgraded their plan.");
}

void HandleUserUpgradePlanEvent(const User &user) {
  PostSlackMessage("sales", user.name_ + " has forgotten their password.");
}

void SetupSlackEventHandlers(EventSystem &events_system) {
  events_system.Subscribe("user_registered", HandleUserRegisteredEvent);
  events_system.Subscribe("user_password_forgotten", HandleUserPasswordForgottenEvent);
  events_system.Subscribe("user_upgrade_plan", HandleUserUpgradePlanEvent);
}

Важным моментом здесь является то, что с помощью метода SetupSlackEventHandlers мы сопоставляем подписчиков (методы) с конкретными событиями. Точно так же мы можем реализовать обработчики событий для электронной почты, всё аналогично:

void HandleUserRegisteredEventEmail(const User &user) {
  SendEmail(user.name_, user.email_, "Welcome",
            "Thanks for registering, " + user.name_ + "!");
}

void HandleUserPasswordForgottenEventEmail(const User &user) {
  SendEmail(user.name_, user.email_, "Reset your password",
            "To reset your password, use this very secure code: " + user.reset_code_ + ".");
}

void HandleUserUpgradePlanEventEmail(const User &user) {
  SendEmail(user.name_, user.email_, "Thank you",
            "Thanks for upgrading, " + user.name_ + "! You're gonna love it.");
}

void SetupEmailEventHandlers(EventSystem &events_system) {
  events_system.Subscribe("user_registered", HandleUserRegisteredEventEmail);
  events_system.Subscribe("user_password_forgotten", HandleUserPasswordForgottenEventEmail);
  events_system.Subscribe("user_upgrade_plan", HandleUserUpgradePlanEventEmail);
}

Если собрать всё вместе, итоговая картина будет выглядеть так:

int main() {
  EventSystem events_system{};
  SetupSlackEventHandlers(events_system);
  SetupEmailEventHandlers(events_system);
  SetupLogEventHandlers(events_system);

  Database users_db{};
  UserManagementEventDriven users_management_system{users_db, events_system};
  PlanManagementEventDriven plans_management_system{users_db, events_system};

  users_management_system.RegisterNewUser("angelos", "1234", "angelos@in.gr");
  users_management_system.PasswordForgotten("angelos@in.gr");

  plans_management_system.UpgradePlan("angelos@in.gr");

  return 0;
}

Создаем систему событий, настраиваем обработчиков событий, а затем наши системы управления пользователями и тарифными планами могут творить чудеса. Легко ведь?

Теги

C++ / CppПаттерн Наблюдатель (Observer)Паттерны проектирования / Design PatternsПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.