Предыстория

Случай #1

Познакомьтесь, это Петя.

Петя работает в большой-большой компании. Продукты этой компании используются ежедневно по всему миру. Миллионы строк кода были написаны и ещё миллионы будут придуманы.

Петя антиутопично пишет компоненты на React. Много компонентов. Очень много компонентов и даже больше. Только за сегодняшний день Петя реализовал не меньше пятидесяти виджетов.

Выгоревший прогер Петя. Выпил 3 литра кофеВыгоревший прогер Петя. Выпил 3 литра кофе

А это Вася.

Вася тоже работает в большой-большой компании. Однако, в отличие от Пети, Вася работает в отделе аналитиков.

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

А ещё Вася очень хороший друг Пети. По крайней мере, был им до сегодняшнего необычайного случая…

Креативный аналитик Вася. В галстукеКреативный аналитик Вася. В галстуке

Одним летним знойным днём Вася подошёл к Петиному столу и попросил его внедрить в свои компоненты некоторую метрику — время, которое пользователь провёл на компоненте.

Петя удручающе посмотрел на Васю, затем в свою IDE.

«193 компонента» — подумал Петя и ощутил чувство глубокого предательства со стороны Васи. Несчастный и измученный, он не выдержал и написал заявление об увольнении.

О событиях дальше история умалчивает, но ходят слухи, что Петя бросил программирование, уехал в лес и счастливо живёт в хижине на опушке, питаясь лисичками и дикой картошкой на костре.

Случай #2

Перенесёмся в отдел бекенд-разработки. Это Гриша. Гриша хороший программист. Он пишет API для фронта на TS.

Гриша ещё джун и не очень понимает, какие практики являются хорошим, а какие — не очень.

Тяжело представить себе код без логирования. Но только не для Гриши — лишь спустя 7 спринтов и 15000 строчек кода он понял, что отладка проекта без логов — это будущая причина его увольнения.

Бекендер Гриша. Недавно гуглил картинки травыБекендер Гриша. Недавно гуглил картинки травы

А это Артемий.

Артемий тимлид и ментор Гриши. Он ценит чистоту кода и принципы SOLID.

Когда Артемий увидел код Гриши, он понял, что нужно преподать юнцу урок. Он заставил его добавить логирование в каждый компонент.

Креативный аналитик Вася. В галстукеКреативный аналитик Вася. В галстуке

Итак, несчастный Гриша начал добавлять новый код. Модули раздувались, пальцы Гриши тоже. В один момент он решил полностью переосмыслить свою жизнь. И уволился. Теперь он живёт в лесу, вместе с Петей.

Мораль

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

Введение

Итак, представьте, что Петя — это вы. Вы работаете над крупным проектом, где логирование, авторизация и обработка ошибок разбросаны по сотням методов и классов. Код становится громоздким, дублирование растет, а поддержка превращается в настоящий кошмар. Знакомая ситуация? Тогда на помощь придёт АОП!

Аспектно-ориентированное программирование — это паттерн, который часто используется для удобного внедрения сквозных функциональностей в проект. Это помогает делать код чище и модульнее.

В рамках данного доклада мы более подробно разберём что такое АОП, его плюсы и минусы, а также реализуем его в Typescript.

Основные концепции АОП

Давайте начнём с самых базовых понятий. Как было сказано ранее, АОП фокусируется на отделении сквозной функциональности от бизнес-логики. Но что же такое сквозная функциональность?

Сквозная функциональность (англ. “cross-cutting concerns”) — это такая функциональность, реализация которой затрагивает множество различных частей программы и не укладывается в единственный класс или модуль при обычных парадигмах программирования. Вместо этого она «рассыпана» по всему коду, что делает его запутанным и сложным для поддержки без специальных средств.

Пример сквозной функциональности Пример сквозной функциональности

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

Заметив это, группа инженеров исследовательского центра Xerox PARC под руководством Грегора Кичалеса предложила паттерн АОП. Ими же было разработано первое аспектно-ориентированное расширение AspectJ для языка Java.

Важно понимать, что эта парадигма не используется самостоятельно. Её можно удобно интегрировать как с функциональными, так и с объектными программами.

Далее мы поговорим про три основные концепции АОП.

Аспекты (Aspect)

Аспекты являются основной единицей методологии АОП. Это некоторое поведение, которое разработчик намерен внедрить в целевой код. В контексте Typescript мы говорим про функции, инкапсулирующие дополнительное поведение.

Аспекты отвечают на вопрос «что?»

Схематичное изображение аспектов Схематичное изображение аспектов

Помимо логики, в аспекте также содержится описание того, когда и где эта самая функциональность должна исполняться. Далее мы рассмотрим конкретные примеры реализации задачи Пети.

Точки среза (Pointcut) и точки соединения (Join points)

Точки среза и точки соединения указывают на место в коде, куда мы хотим внедрить аспект.

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

Примеры Pointcut и Join points Примеры Pointcut и Join points

И, хотя, с первого взгляда, различия могут показаться минимальными, на деле pointcut и join points выполняют совершенно разные задачи. Точки среза делают выборку по методам — в них мы объявляем ЗА какими методами будем следить. Точки соединения — это конкретный метод, ИЗ которого было вызвано событие.

Точки среза и соединения отвечают на вопрос «где?»

Советы (Advice)

Советы — это методы, которые исполняют аспект при некоторых обстоятельствах (вызов события в определённом контексте). Во фронтенде, например, очень часто советы интегрируются в методы жизненного цикла компонента.

Основные виды Advice:

  • Before — перед вызовом метода
  • After — после вызова метода
  • After returning — после возврата значения из функции
  • After throwing — после исключения внутри функции
  • Around — пред- и постобработка метода.

Обычно советы принимают в качестве аргументов какой-то контекст или join point (говоря про фронтенд, это может быть реализовано через компоненты высшего порядка), вспомогательные аргументы, а также в отдельных случаях (например, для after-советов) результат выполнения метода.

Реализация аспектов на языке Java

Так как АОП впервые был применён в языке Java, разумно рассмотреть в общих чертах пример кода с использованием AspectJ.

@Component
@Aspect
public class LoggingAspect {
  private Logger logger = Logger.getLogger(
    LoggingAspect.class.getName()
  );

  @Pointcut("@target(org.springframework.stereotype.Repository)")
  public void repositoryMethods() {}

  @Before("repositoryMethods()")
  public void logMethodCall(JoinPoint jp) {
    String methodName = jp.getSignature().getName();
    logger.info("Before " + methodName);
  }
}

Итак, у нас есть класс логирующего аспекта. В 8 строке с помощью декоратора выбирается срез, по которому будет производиться логирование (в примере это org.springframework.stereotype.Repository).

Далее, в 10 строке, мы используем совет Before и указываем метод, перед которым мы будем исполнять logMethodCall. В самом же методе мы просто выведем название метода, откуда мы отловили событие.

Теперь должна быть достаточно ясна разница между Pointcut и Join point: в 7 строке мы по pointcut начали слежку за событиями методов, а в 10 строке отлавливали события из этих самых методов и работали с конкретными join point-ами.

Заметим, что в Java для работы с аспектами используются декораторы. Декораторы также поддерживаются и в TS, но лишь с одним ограничением — они работают только с классами и методами классов. А так как в React лучшей практикой считается использование именно функциональных компонентов с React hooks, мы не будем применять декораторы.

Ну а детали реализации рассмотрим далее.

Решение задачи Пети

В большую-большую компанию вместо Пети наняли смышлённого и замотивированного джуна Роберта, который был хорошо осведомлён о причинах увольнения предыдущего сотрудника.

К счастью, Роберт читал доклад на тему «Аспектно-ориентированное программирование на TS» и точно знал как решить поставленную задачу.

А всё потому что он вступил в Клубы!

Замотивированный джун РобертЗамотивированный джун Роберт

Пусть нам дан функциональный компонент на React и TS:

import React, { type FC } from "react";

const MyComponent: FC = () => {
  return <div>Hello from Aboba!</div>
}

Давайте реализуем функциональность подсчёта времени, которое было проведено пользователем в компоненте MyComponent. Предположим, что для этого реализован какой-то утилитарный класс AnnoyingMetrics.

Тогда мы можем переписать компонент так:

import React, { useEffect, type FC } from "react";
import AnnoyingMetrics from "./metrics"

const MyComponent: FC = () => {
  useEffect(() => {
    // Component mounted
    AnnoyingMetrics.start();

    return () => {
      // Component unmounted
      AnnoyingMetrics.logResultAndStop();
    }
  }, []);

  return <div>Hello from Aboba!</div>
}

В целом, кода прибавилось немного. Однако осторожный программист всегда смотрит в будущее: это сейчас мы работаем с одним маленьким компонентом и несколькими запусками функции. А теперь вспомните проблему Пети — 193 огромных компонента со сложной логикой. А в реальности их может быть и больше, да и сквозные функциональности могут быть сложнее, и их тоже может быть больше.

Поэтому для имплементации АОП в данном случае мы воспользуемся паттерном Higher-Order Component (HOC) — разработаем так называемый «компонент высшего порядка». Согласно этому паттерну, мы опишем функцию, которая примет на вход компонент, который будет обёрнут новым, промежуточным компонентом:

function MetricsAspect<C extends ComponentType<any>>(Component: C) {
  const ComponentWithAnnoyingMetrics: FC<any> = (props) => {
    useEffect(() => {
      // Component mounted
      AnnoyingMetrics.start();

      return () => {
        // Component unmounted
        AnnoyingMetrics.logResultAndStop();
      };
    }, []);

    return <Component {...props} />;
  };
}

А дальше просто вместо MyComponent использовать MetricsAspect(MyComponent). Задача Пети решена спринт спустя!

Решение задачи Гриши

В эту же компанию, в отдел бекенд-разработки, вместо Гриши наняли не менее смышлённого и замотивированного джуна Тёму, который был хорошо осведомлён о причинах увольнения предыдущего сотрудника.

К счастью, Тёма слушал доклад Роберта на тему «Аспектно-ориентированное программирование на TS» и точно знал как решить поставленную задачу.

А всё потому что он вступил в Клубы!

Замотивированный джун ТёмаЗамотивированный джун Тёма

Пусть нам дан какой-то классовый модуль на TS:

class MyModule {
  doSomething() {
    // some implementation
  }
}

Давайте реализуем логирование этого модуля!

Во-первых, создадим класс Logger с использованием паттерна Singleton [GOF]. В нём будут содержаться основные методы для логирования:

class Logger {
  private static instance: Logger | null = null;

  public static getInstance(): Logger {
    if (!Logger.instance) {
      Logger.instance = new Logger();
    }
    return Logger.instance;
  }

  public log(message: string): void {
    // some implementation
  }
}

Здесь нет ничего сложного, у нас всего два метода: getInstance для получения текущего одиночки и log для создания сообщения.

Осталось самое интересное — как мы будем интегрировать логирование? Ведь самое наивное решение, вроде:

class MyModule {
  doSomething() {
    Logger.getInstance().log("Started doing something")
    // some implementation
    Logger.getInstance().log("Ended doing something")
  }
}

буквально заставляет нас переписывать код каждого метода. И тут на помощь нам снова придёт АОП. Но для начала поговорим про особенности имплементации, а именно про декораторы.

Декораторы — это специальные функции-обёртки, которые в заданном контексте перехватывают исполнение методов и дополнительно исполняют какой-то свой код. И я не просто так сказал «методы» — декораторы в TS не могут существовать вне классов.

Декораторы бывают разные: декораторы для классов, для полей, для геттеров и сеттеров. Мы же будем рассматривать декораторы для методов.

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

Принцип работы декораторов. Внутри декоратора исходный метод не обязательно должен вызываться: получается просто подмена методов! Принцип работы декораторов. Внутри декоратора исходный метод не обязательно должен вызываться: получается просто подмена методов!

Итак, декоратор для метода будет являться функцией, возвращающей функцию (которая будет исполняться вместо метода). Однако, чтобы сохранить информацию об исходном методе, тем самым создав его проксирование, мы будем обрабатывать текущий контекст метода:

function LogDeco(
  target: object,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const originalMethod = descriptor.value;
  descriptor.value = function (...args: any[]) {
    Logger.getInstance().log("Started doing something")
    const result = originalMethod.apply(this, args);
    Logger.getInstance().log("Ended doing something")
    return result;
  };
  return descriptor;
}

Давайте разберёмся построчно.

Мы инициализировали функцию-декоратор LogDeco. На вход эта функция принимает три аргумента:

  • target: прототип класса, с которым мы работали (который содержит декорируемый метод);
  • propertyKey: имя метода, который мы декорируем;
  • descriptor: интерфейс взаимодействия с методом. Это объект специального типа, в котором есть различные поля и методы, такие как геттеры и сеттеры, а также ряд свойств, описывающих метод.

В частности, в 6 и 7 строках мы обращаемся к полю descriptor.value, которое содержит в себе исходный декорируемый метод. Это поле мы будем сохранять и перезаписывать на нашу новую функцию, которая в процессе исполнения будет вызывать исходный метод. Этот принцип действительно очень похож на Proxy.

Самое главное: в нашей новой функции необходимо вернуть результат исполнения исходного метода (строка 11), чтобы мы могли использовать его дальше в коде не теряя.

В конце функции-декоратора мы вернём модифицированный descriptor, который содержит новый метод. Таким образом мы произвели подмену методов.

Использование декоратора в модуле:

class MyModule {
  @LogDeco
  doSomething() {
    // some implementation
  }
}

Мы можем написать сколь угодно много декораторов, реализующих разное поведение. При этом мы не засоряем методы, оставляя их чистыми.

Бонус: про фабрики декораторов

Иногда нам может быть необходимо сделать условный декоратор. Например, декоратор доступа для разных ролей:

class MyModule {
  @RoleDeco("admin")
  onlyAdminMethod() {}

  @RoleDeco("special-user")
  onlySpecialUserMethod() {}
}

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

function RoleDeco(role: string) {
  return function(
    target: object,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    // decorator implementation
    // it can use `role` as parameter
  }
}

Такие функции называются фабриками декораторов.

Выводы

Минусы и плюсы по АОП

Конечно, методология АОП не идеальна. И основным минусом является то, что методология не до конца сформирована. И из-за того, что изначально АОП применялось в Java со своими особенностями, то в других языках реализации могут различаться.

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

Эпилог

И вновь вернулись Петя и Гриша в большую-большую компанию. И начали они применять АОП и писать хороший и чистый код. Ошибки стали более явные, а программы — более читаемыми.