Cross-cutting concerns

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

Функциональность программы происходит от предъявляемых к ней требований (requirements). Этот термин важен для разделения видов ответственностей в ПО.

«Требование – спецификация того, что должно быть реализовано» – (https://datafinder.ru/products/razrabotka-trebovaniy-k-po-obshchie-ponyatiya)

Требования разделяют на две группы:

  • Функциональные (ФТ)

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

  • Нефункциональные (НФТ)

    Содержат описания того как продукт должен работать. Налагаемые на него ограничения и его качества.

Например, от клиента поступило новое требование: добавить на страницу компонент с калькулятором. ФТ будет – работающий калькулятор. Калькулятор должен быстро отображать результат: не дольше 500 мс. Это требование накладывает ограничение и является НФТ. Оно тоже важно, т.к. медленно работающий калькулятор будет вызывать плохой опыт использования продукта (User Experience), что может привести к уходу клиента и в конченом итоге скажется на бизнес.

Рассмотрев, что такое требования и какими они бывают можно перейти к видам ответственностей.

Виды ответственностей

Выделяют два вида:

  • Core concerns (ответственности 1-го класса) – бизнес-логика приложения, следует из функциональных требований.

    Данная ответственность является ядром приложения. Она несет ценность бизнесу и клиентам.

  • Cross-cutting concerns (CCC, ответственности 2-го класса, сквозная функциональность) – остальная функциональность, не относящаяся к бизнес-логике.

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

    • логирование;
    • обработка ошибок;
    • кэширование;
    • авторизация и проверка прав;
    • обработка транзакций и т.д.

    Это «вспомогательные» ответственности. Они не относятся к бизнес-логике напрямую. Под сквозной функциональностью понимается то, что данный код может встречаться во всех модулях разрабатываемого ПО. Он обладает свойством тесно «переплетаться» с основной функциональностью. Его сложно вынести в один определенный модуль.

Рассмотрим пример из статьи «Принцип разделения ответственностей», добавив в него ССС: логирование и обработку ошибок. Затем попробуем разделить ответственности 1-го и 2-го класса.

<template id="list">
  <ul class="list"></ul>
</template>

<template id="item">
  <li class="item"></li>
</template>

<script>
  // ДОСТУП К ДАННЫМ
  async function fetchHotels() {
    // Обрабатываем ошибки получения данных по сети
    try {
      const hotelsReq = await fetch('/hotels')

      return await hotelsReq.json()
    } catch (error) {
      // Логируем ошибку
      console.error(error)
    }
  }

  // БИЗНЕС-ЛОГИКА
  async function main() {
    const hotels = await fetchHotels()
      
    // Логируем все данные 
    console.log(`All hotels: ${JSON.stringify(hotels)}`)
      
    if (!hotels) {
      return
    }

    const fiveStarHotels = getFiveStarHotels(hotels)
    // Логируем данные после преобразований  
    console.log(`All hotels: ${JSON.stringify(hotels)}`)

    showHotels(fiveStarHotels)
  }

  function getFiveStarHotels(hotels) {
    return hotels.filter(({ stars }) => stars === 5)
  }
   
  // ПРЕДСТАВЛЕНИЕ
  function showHotels(fiveStarHotels) {
    const elems = showList(fiveStarHotels)
      // Логируем получившееся представление  
      console.log(`Elems: ${elems}`)

      document.body.append(elems)
  }
   
  function showList(items) {
    const list = document.body.querySelector('#list')
    const listClone = list.content.cloneNode(true)
   
    const item = document.body.querySelector('#item')
   
    const elems = items.reduce((acc, { title }) => {
      const itemClone = item.content.cloneNode(true)
      itemClone.querySelector('.item').textContent = title
   
      acc.append(itemClone)
   
      return acc
    }, listClone.querySelector('.list'))
   
    return elems
  }
   
  main()
</script>

Можно заметить, что код бизнес-логики стал «обрастать» дополнительной функциональностью. Cross-cutting concerns стали «переплетаться» с core concerns.

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

Допустим, пришло требование оптимизировать время на выполнения сетевых запросов, из-за чего было добавлено кэширование или бизнес-аналитик попросил добавить Google-метрику для анализа действия пользователей. Новые cross-cutting concerns все больше «переплетаться» с основным кодом, увеличивают его объем. Код бизнес-логики начинает «теряться» за «вспомогательным» кодом. Это усложняет поддержку проекта и увеличивает время на чтение кода. О важности простоты чтения кода говорилось в статье «Правило 7±2».

Попробуем решить эту проблему, сделав более явную границу между cross-cutting concerns и core concerns.

Вынесем CCC в отдельный файл.

utils.js
  export function catchErrors(target) {
    return async (...args) => {
      try {
        return await target(...args)
      } catch (error) {
        return error
      }
    }
  }
  
  export function log(target) {
    return (...args) => {
      console.log(`Call ${target.name} with`, ...args)
      const result = target(...args)
      console.log(`Result of ${target.name}`, result)
      return result
    }
  }

Функция catchErrors содержит ответственность – обработку ошибок, log – логирование. Если в каком-либо коде потребуется эти CCC, необходимо вызывать соответствующие функции.

Один из способов добиться разделения ответственностей – применить структурный шаблон проектирования «Декоратор». В функциональной парадигме программирования достаточно передать декорируемую функцию (параметр target) в декоратор и вернуть новую функцию, которая выполняет определенную функциональность и вызывает декорируемую ф-ю (target), передав в неё аргументы.

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

Добавим получившиеся декораторы в core concerns.

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

business – бизнес-логика

business/getHotels.js
  import { fetchHotelsDecorated as fetchHotels} from '../data/hotels.js'
  import { getFiveStarHotelsDecorated as getFiveStarHotels } from './hotels.js'
  import { showHotels } from '../presentation/hotels.js'
  
  export async function getHotels() {
    const hotels = await fetchHotels()
    if (!hotels) {
      return
    }
  
    const fiveStarHotels = getFiveStarHotels(hotels)
  
    showHotels(fiveStarHotels)
  }

Ф-я getHotels теперь содержит только core concerns. В ней нет вспомогательного кода.

business/hotels.js
  import { log } from '../utils.js'
  
  export function getFiveStarHotels(hotels) {
    return hotels.filter(({ stars }) => stars === 5)
  }
  
  export const getFiveStarHotelsDecorated = log(getFiveStarHotels)

В getFiveStarHotels содержит только core concerns. Добавлена ф-я getFiveStarHotelsDecorated, которая декорирует основную функциональность.

data – доступ к данным

data/hotels.js
  import { log, catchErrors } from '../utils.js'

  export async function fetchHotels() {
    const hotelsReq = await fetch('/hotels')
  
    return await hotelsReq.json()
  }
  
  export const fetchHotelsDecorated = log(catchErrors(fetchHotels))

Добавлена ф-я fetchHotelsDecorated, которая декорирует основную функциональность. Было применено два декоратора. Аналогичным образом, возможно добавлять новые CCC, не меняя основной код.

presentation – представление

presentation/hotels.js
  import { showListDecorated as showList } from './list.js'
  
  export function showHotels(fiveStarHotels) {
    const elems = showList(fiveStarHotels)
  
    document.body.append(elems)
  }
presentation/list.js
  import { log } from '../utils.js'
  
  export function showList(items) {
    const list = document.body.querySelector('#list')
    const listClone = list.content.cloneNode(true)
  
    const item = document.body.querySelector('#item')
  
    const elems = items.reduce((acc, { title }) => {
      const itemClone = item.content.cloneNode(true)
      itemClone.querySelector('.item').textContent = title
  
      acc.append(itemClone)
  
      return acc
    }, listClone.querySelector('.list'))
  
    return elems
  }
  
  export const showListDecorated = log(showList)

Используется декоратор логирования.

В данном примере, для разделения бизнес-логики и вспомогательного кода применен паттерн «Декоратор». Предполагаю, это не единственный вариант. Например, можно добавить в приложение поведенческий шаблон проектирования – «Наблюдатель», который позволит создавать различные события в core concerns. Вспомогательный код будет «слушать» их и выполнять некоторые cross-cutting concerns.

Разделение кода на ответственности 1-го и 2-го класса помогает в поддержке проекта. Таким образом, мы делаем явную границу между ними. Бизнес-логика на «разрастается» вспомогательным кодом, который увеличивает сложность кода и время на его чтение.

Вспомогательный код, который требуется во многих модулях, «выносится» в определенный модуль, отвечающий за эту ответственность и не дублируется во всех модулях приложения. Это может помочь в случае, когда потребуется заменить реализацию некоторой cross-cutting concerns: нужно заменить сам модуль, не меняя весь код проекта, где он используется.