Для многих в разработке программ самыми большими проблемами являются (а) их сложность и (б) изменчивость требований. Решение обеих проблем — в декомпозиции целого приложения на более мелкие части (пакеты, модули, классы и функции). Декомпозиция для уменьшения сложности в целом достаточно проста (закон Миллера). Но нужно не просто разбить приложение на части, а сделать эти части устойчивыми к изменениям требований.
В этой публикации я пытаюсь ответить на следующие вопросы: из каких элементов состоит JavaScript-код? каким образом эти элементы взаимодействуют друг с другом? можно ли как-то повысить устойчивость кода к изменениям?
Элементы кода в JS
В зависимости от уровня детализации в JavaScript можно выделить следующие группы кода:
- объекты (прописанные в коде, а не создаваемые программно)
- функции
- классы
es-модулиnpm-пакеты
Первые три (объекты, функции, классы) — это базовые элементы. Они могут
взаимодействовать друг с другом. Вторые два (es-модули и npm-пакеты)
— составные объекты. Они взаимодействуют друг с другом на уровне базовых
элементов, входящих в их состав.
Для каждого типа элементов кода существует свой интерфейс взаимодействия с ним — каким образом внешний код может использовать код элемента:
- объекты — через свойства объекта;
- функции — через входные и выходные аргументы;
- классы — через свойства и методы класса;
- es-модули — через экспорт-объект модуля;
- npm-пакеты — primary entry
point
(
main) и прямое обращение кes-модулям пакета;
При разработке приложения “снизу-вверх” мы создаём сначала базовые
элементы кода (объекты, функции, классы), а затем объединяем их в более
крупные образования. Базовые элементы кода могут состоять из других
базовых элементов (функция внутри функции, например), es-модули - из
базовых элементов, npm-пакеты — из es-модулей. При разработке
“сверху-вниз” мы движемся в обратном направлении — от пакетов к базовым
элементам.
Связность и зацепление
Для оценки качества декомпозиции используют такие понятия, как “связность” (cohesion) и “зацепление” (coupling):
Т.е., идеальным (и бесполезным!) вариантом является тот, когда все элементы кода внутри одной группы связаны только друг с другом (высокая связность) и не имеют связей с элементами кода других групп (отсутствует зацепление). На практике, разумеется, такой вариант не встречается вовсе, но зато он даёт ориентир: связность элементов в отдельном элементе кода должна быть как можно выше, а зацепление между различными элементами — как можно меньше.
Адресация элементов кода
Адресация на уровне всего приложения
Я рассматриваю JS в варианте ES2015+, где базовым “кирпичом” в
построении приложений является es-модуль, основанный на механизме
экспорта-импорта.
Если смотреть на ES2015+ приложение с точки зрения браузера, то оно
состоит из es-модулей, полученных с внешних серверов и размещённых в
иерархии, напоминающей файловую структуру:
С точки зрения nodejs все es-модули находятся в обычной файловой
структуре — файлы модулей находятся в npm-пакетах, а пакеты находятся
в каталоге ./node_modules/:
Адрес элемента кода на уровне приложения состоит из двух частей:
- путь к
es-модулю в иерархии всех файлов приложения (web или nodejs); - имя экспорта соответствующего
es-модуля;
Пример абсолютной адресации:
import { exportName } from 'https://domain.com/path/to/mod.mjs';
import { exportName as localExport } from '/var/prj/path/to/mod.mjs';
Адресация относительно местоположения текущего модуля:
import { exportName } from '../../path/to/mod.mjs';
В nodejs-приложениях возможна также адресация относительно каталога ./node_modules/:
import { exportName } from '@vnd/prj/src/path/to/mod.mjs';
Таким образом, основным элементом кода в JS-приложениях является es-модуль, который может входить в состав npm-пакета (в nodejs-приложениях) и может включать в себя базовые элементы кода (объекты, функции, классы) в качестве экспорта:
Получается, что на уровне приложения выстраиваются связи между базовыми
элементами кода, экспортируемыми es-модулями (экспортами):
Структуру адреса такого базового элемента, доступного в рамках целого приложения, можно отобразить так:
[path_to_the_module][exportName]
Понятно, что использование абсолютной адресации es-модулей вместо относительной резко снижает устойчивость кода к изменениям. В то же время такие механизмы, как import map, эту устойчивость повышают.
‘export default’
Использование export default в некотором роде отвязывает потребителя
кода от его поставщика. Сравните два варианта экспорта:
const fn = function (data) {};
export default fn;
export { fn };
И соответствующего ему импорта:
import func from './mod.mjs';
import { fn } from './mod.mjs';
В первом случае потребитель кода не привязан к имени экспорта внутри поставщика кода, но при этом поставщик ограничен всего одним экспортируемым объектом.
Внутримодульная адресация
Внутри отдельного es-модуля адресация базовых элементов кода требует
только, чтобы в пределах одной области видимости (scope) имена элементов
были уникальны. Во вложенных областях видимости есть риск перекрытия
имён используемых элементов (например, obj).
function outer() {
const obj = {};
function inner() {
const obj = {};
}
}
Глубоко вложенные области видимости ухудшают читаемость кода, а использование элементов кода с верхних уровней снижает устойчивость кода к изменениям (за исключением случая, когда элементы кода определяются на самом верхнем уровне — через импорты в es-модуле).
Адресация пакетов
Адресация пакетов актуальна только для nodejs-приложений и является
частью адресации es-модулей.
Интерфейсы элементов кода
Каждый элемент кода, помимо своего адреса (имени), обладает также некоторым интерфейсом, который определяет способы “зацепления” с ним других элементов кода.
Объекты
Для JS-объекта интерфейсом взаимодействия внешнего кода с объектом являются имена свойств:
const OBJ = { prop: 'value' };
function consumer() {
console.log(OBJ.prop);
}
Функции
“Классический” вариант предполагает, что функция определяется своим именем, входными аргументами (кол-во, порядок, тип) и типом возвращаемого результата:
function producer(x1, x2) {
return x2 * x1;
}
const y = producer(1, 2);
Я как-то уже размышлял на тему, что было бы, если бы у функции был только один входной параметр и один выходной:
const y = fn(x);
JS с его деструктурирующим присваиванием вплотную подошёл к этому варианту:
function producer({ x1, x2 }) {
return { y1: x1 + x2, y2: x2 * x1 };
}
const { y1, y2 } = producer({ x1: 1, x2: 2 });
Использование функций в таком виде добавляет коду устойчивости к изменениям относительно “классического” варианта. В этом случае мы можем смелее изменять входные и выходные аргументы (их количество и порядок следования), но платим за это читабельностью кода.
Использование переменной arguments для анализа входных аргументов и
остаточных
параметров
я отношу к промежуточным вариантам (между одним входным аргументом и
“классической” формой).
Классы
Классы совмещают в себе структуру обрабатываемых данных и методы, которыми эти данные обрабатываются. В ООП классы зачастую являются теми самыми элементами кода, отношение между которыми оцениваются на предмет связности и зацепления. Во многих языках программировании рядом с классами стоят интерфейсы, но JS в их список не входит (хотя на уровне JSDoc’ов такое понятие присутствует).
Интерфейс класса в JS, как и в других ЯП, определяется именем класса, именами доступных свойств и методов, входными/выходными аргументами методов.
es-модуль
Интерфейс взаимодействия с es-модулем определяется его
export-объектом
(упоминался выше, когда обсуждалась адресация элементов в es-модуле).
npm-пакет
Пакет является самым верхним уровнем группировки кода, самым крупным
“кирпичом” в приложении. Основа для взаимодействия пакетов — имя пакета
и его версия, которые прописываются в npm-дескрипторе приложения
(./package.json):
{
"name": "my_package",
"version": "1.0.0",
"dependencies": {
"my_dep": "^1.0.0",
"another_dep": "~2.2.0"
}
}
Имя npm-пакета участвует в адресации es-модулей в nodejs-приложениях. В пакете может быть определён входной объект, в его ./package.json:
{
"main": "lib/entry.js"
}
Таким образом, на уровне пакетов интерфейсом является имя пакета, его версия, входной объект и структура es-модулей внутри пакета.
Private & public области
Для всех типов элементов кода, за исключением разве что объектов, можно
выделить публичный и приватный код. Всё, что касается интерфейсной части
элемента кода (функции, класса, es-модуля, npm-пакета), является
публичной частью, всё остальное — приватной. Изменения в приватной части
объекта кода никак не отражаются за границами элемента кода (функции,
класса, …), в отличие от изменений в его публичной части.
Понятно, что чем больше кода будет находиться в приватной части, тем более устойчивым к изменениям будет код. Но мы вынуждены часть кода делать публичным для связи различных частей приложения, получая зацепление на разных уровнях.
Функции
Вот так выглядят приватные области кода на уровне функций:
function producer(opts) {
function nested() {}
}
Функция nested не видна за пределами producer и мы можем спокойно её
менять в рамках функции producer.
Классы
В классе могут быть определены приватные члены (свойства и методы) на уровне прототипа класса, а также переменные и функции ограниченного доступа на уровне экземпляра класса (в конструкторе):
class SomeClass {
#privProp;
constructor() {
const nestedObj = {};
this.instMethod = function () {
nestedObj.prop = this.#privProp;
};
}
setProp(data) {
this.#privProp = data;
}
}
#privProp и nestedObj являются внутренними элементами кода для класса SomeClass и недоступны извне напрямую.
es-модули
В es-модуле всё, что не является предметом экспорта, является
приватным кодом.
npm-пакеты
В пакетах предусмотрено “джентльменское соглашение”, что основная
точка входа в пакет объявляется в дескрипторе пакета (package.json), в
узле main (по-умолчанию
используется./index.js):
{
"main": "src/Shared/Container.mjs"
}
Тогда для импорта основной точки входа достаточно написать:
import Container from '@teqfw/di';
Остальные элементы кода пакета можно сделать доступными по цепочке через основную точку входа:
import subModule from './path/to/sub/module.mjs';
export { subModule };
И использовать:
import * as module from '@scope/prj';
const sub = module.subModule;
Но это именно “джентльменское соглашение” — ничто не мешает разработчику другого npm-пакета обратиться внутрь нашего npm-пакета к любому es-модулю напрямую.
Резюме
JS предоставляет разработчику возможность делать часть кода недоступной для внешнего использования (вне контекста функции, класса, пакета) и этим нужно пользоваться максимально широко, чтобы приложение было более устойчиво к изменениям.
Код в целом устойчив к добавлению (свойств, аргументов, методов), менее
устойчив к их удалению и совсем не устойчив к переименованию. При
добавлении к объекту новых свойств существующие “потребители” никак не
затрагиваются. При удалении какого-либо свойства и обращении к нему
извне возвращается undefined. Переименование свойства объекта приводит
к необходимости изменения всего кода, завязанного на данное свойство.
Поэтому наименование элементов кода (npm-пакеты, классы, функции,
объекты) и их размещение (es-модули) особенно важно с точки зрения
устойчивости, т.к. изменение имён впоследствие может привести к
массовому рефакторингу или сделает невозможным использование нашего кода
внешними “потребителями”.
Чем крупнее элемент кода (пакет, модуль, класс/функция), тем важнее стабильность его имени для стабильности всего кода.
Использование алиасов при использовании внешних элементов кода позволяют замкнуть текущий контекст (модуль или пакет) на алиас, что несколько уменьшает зацепление:
export default class TeqFw_Web_Back_Defaults {
constructor(spec) {
this.MOD_DI = spec['TeqFw_Di_Back_Defaults$'];
}
}
Функции с одним входным аргументом более устойчивы к изменениям, но функции со списком аргументов более дружелюбны к разработчику и IDE. Для кода, который будет изменяться в будущем лучше использовать деструктирующее присваивание для входных/выходных аргументов в функциях и методах. Функции со списком входных аргументов лучше употреблять в приватных частях элементов кода, т.к. у них ограниченная видимость.
“Джентльменское соглашение” по поводу “публичной” и “приватной”
частей пакета можно использовать не только в виде primary entry point,
но и на уровне naming/placing-соглашений (например, определить, что
все es-модули из каталога ./Api/ являются публичными модулями
пакета, а все остальные — приватные).
Такое же соглашение можно ввести на уровне группы es-модулей
(AZ-order — об этом отдельно).