Русский
Русский
English
Статистика
Реклама

Nest.js

Из песочницы Typescript Compiler API возьми управление компилятором в свои руки

27.06.2020 02:21:47 | Автор: admin


В разработке приложений на Typescript всегда есть этап сборки проекта. Обычно для этого используются системы сборки и автоматизации workflow, такие как webpack или gulp, обвешанные достаточным количеством плагинов, либо процесс сборки размазывается в командах package.json и шелл-скриптах с использованием нативного tsc или команд CLI используемого в проекте фреймворка. Все эти решения имеют свои плюсы и минусы. Зачастую в процессе сборки нужно сделать что-то нестандартное, и оказывается, что используемая система сборки не предоставляет нужную функциональность из коробки, а имеющиеся плагины делают не совсем то, что надо. В такие моменты работа над проектом встает, и начинается судорожное ковыряние в конфигах и поиск подходящего плагина. В какой-то момент понимаешь, что за время, потраченное на поиск подходящего костыля, можно было написать свое решение.

Во многих случаях критичные процессы в проекте можно автоматизировать скриптами на javascript, выразительность и функциональность которого вполне позволяет описать нужный workflow и выбирать из всего разнообразия библиотек, не заморачиваясь наличием для них плагинов под конкретную систему сборки. Важное преимущество такого подхода полный контроль над процессами и максимальная гибкость. Для проектов, в которых используется Typescript в качестве основного языка разработки, возникает вопрос, как встроить процесс его компиляции в свой workflow. Здесь на помощь приходит Typescript Compiler API. В этой статье мы посмотрим, как его можно использовать для того, чтобы выполнить компиляцию проекта, реализованного на Typescript, взаимодействуя с компилятором на разных этапах его работы и напишем скрипт для hot-reloadingа REST-сервера, разработанного на Nest.js.

У меня есть проект на ноде, над которым я работаю в свободное время. Изначально он предназначался для небольшого бизнеса, который так и не взлетел, а теперь я использую его как полигон для своих экспериментов. Проект, который изначально строился на Nuxt.js в связке с Fastify, пережил множество трансформаций. Здесь и переход на typescript и преобразование в монорепозиторий, и замена Fastify на Nest.js. Поскольку проект небольшой и серьезной нагрузки не предполагалось, все это должно было крутится на одном сервере, поэтому я использовал единый инстанс Express как для REST сервера так для отдачи фронта через Nuxt.js. Для этого Express, который работает под капотом Nest.js, получает рендер-функцию Nuxt.js.

Это решение хорошо работает в продакшене, но мне, поскольку я работал над проектом один, было удобно параллельно писать функции API и делать интерфейс. Для это перезапуск Nest.js при изменении файлов не должен приводить к перезапуску работающего в режиме HMR Nuxt.js, иначе он начинал процесс сборки заново, что лишало смысла всю затею. Но тут возникла проблема, дело в том, что хотя в CLI Nest.js есть режим watch, который умеет делать hot-reload, загружает он его в отдельном процессе, который перезапускается при перекомпиляции проекта. Из-за этого каждый перезапуск сервера в таком режиме будет приводить к потере контекста для Nuxt и затем будет запускаться его полная сборка.

После того, как все это выяснилось, я решил сделать в проекте свой hot-reload. Первоначально я попробовал реализовать решение с использованием webpack, как предлагается в документации Nest.js , но оказалось, что start-server-webpack-plugin, который там используется для реализации перезапуска использовал ту же стратегию, что CLI, т.е. запускал его в отдельном процессе и перезапускал по необходимости. Этот плагин я переписал, чтобы он работал как нужно мне, но с использованием Webpack решение получилось довольно тяжеловесно и не без проблем, которые выплывали тут и там, мешая сосредоточится на разработке проекта.

Тогда я решил, что надо менять подход, мне хотелось напрямую использовать механизмы, заложенные в компилятор tsc, который всегда работал как часы быстро и стабильно. В итоге я нашел хорошую статью, в которой были примеры использования TypeScript Compiler API. Оказалось, что инструменты, встроенные в библиотеку Typescript позволяют реализовать нужные мне функции и достаточно удобны в применении. К сожалению, я столкнулся с тем, что по Typescript Compiler API очень мало информации, поэтому решил поделится тем, что мне удалось узнать. Отмечу также что данный API на момент написания статьи находится в процессе разработки и некоторые моменты со временем могут поменяться.

Простая компиляция


Чтобы разобраться с принципами работы TypeScript Compiler API для начала попробуем просто выполнить с его помощью компиляцию проекта. Проект, на котором я буду экспериментировать, имеет стандартную для nest.js структуру: исходники расположены в папке src, точка входа файл ./src/main.ts.

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

const {ModuleResolutionKind, ModuleKind, ScriptTarget} = require("typescript")module.exports =  {    moduleResolution: ModuleResolutionKind.NodeJs,    module: ModuleKind.CommonJS,    target: ScriptTarget.ES2019,    declaration: true,    removeComments: true,    emitDecoratorMetadata: true,    experimentalDecorators: true,    sourceMap: true,    outDir: "./dist",    baseUrl: "./",    allowJs: true,    skipLibCheck: true,  }

Обратите внимание, что некоторые параметры, такие как moduleResolution, module, target определяются через перечисления, поэтому напрямую забрать параметры из tsconfig.json, где они прописаны в виде строк не выйдет, так что для реализации простейшего примера я его использовать не буду. Как корректно забрать параметры из tsconfig.json мы разберемся немного позже.

Теперь у нас есть параметры компиляции и можно перейти к следующему шагу. Для этого создадим файл ts-compiler.js со следующим содержимым:

const ts = require('typescript');function compile() {  const compilerOptions = require('./tsconfig');  const program = ts.createProgram(['./src/main.ts'], compilerOptions);  const emitResult = program.emit();}compile();

Если теперь запустить этот скрипт на исполнение, в директории ./dist должны появится скомпилированные файлы программы. Компиляция происходит в два этапа: сначала команда createProgram анализирует исходные файлы и создает список файлов для компиляции, после чего выполняем компиляцию и сохранение скомпилированных файлов при помощи функции emit. Довольно просто.

Для повышения удобства использования теперь надо реализовать загрузку параметров компиляции из файла tsconfig.json. Добавим в скрипт следующую функцию:

const formatHost = {  getCanonicalFileName: path => path,  getCurrentDirectory: ts.sys.getCurrentDirectory,  getNewLine: () => ts.sys.newLine,};function getTSConfig() {  const configPath = ts.findConfigFile('./', ts.sys.fileExists, 'tsconfig.json');  const readConfigFileResult = ts.readConfigFile(configPath, ts.sys.readFile);  if (readConfigFileResult.error) {    throw new Error(ts.formatDiagnostic(readConfigFileResult.error, formatHost));  }  const jsonConfig = readConfigFileResult.config;  const convertResult = ts.convertCompilerOptionsFromJson(jsonConfig.compilerOptions, './');  if (convertResult.errors && convertResult.errors.length > 0) {    throw new Error(ts.formatDiagnostics(convertResult.errors, formatHost));  }  const compilerOptions = convertResult.options;  return compilerOptions;}

Для работы с файлами конфигурации API предоставляет ряд функций, которые позволяют найти, загрузить и преобразовать конфигурацию в тот вид, который понимает компилятор. Здесь используется функция findConfigFile чтобы определить путь к файлу конфигурации, потом он загружается как json при помощи readConfigFile, и далее, если нет ошибок, при помощи convertCompilerOptionsFromJson получаем параметры компиляции.

В случае, если при загрузке конфига что-то пошло не так, наша функция генерирует исключения, используя механизмы передачи сообщений об ошибках, заложенные в API. Все сообщения об ошибках и предупреждения библиотека возвращает в виде объектов класса Diagnostic. Чтобы на их основе сформировать сообщение для вывода в консоль, можно использовать стандартные функции форматирования библиотеки, в данном случае это formatDiagnostic для одного сообщения и formatDiagnostics для массива. Для корректной работы функций форматирования требуется объект formatHost, который содержит важные для форматирования сообщения параметры.

Теперь, чтобы получить параметры компилятора, мы можем вызвать в нашем скрипте функцию getTSConfig.

function compile() {  const compilerOptions = getTSConfig();}

На данном этапе, если компилятор найдет ошибки в коде, мы об этом не узнаем, давайте исправим это. Все ошибки в коде, которые были обнаружены на этапе компиляции функция emit возвращает в поле diagnostcs объекта EmitResult. Для красивого отображения ошибок в коде можно использовать функцию formatDiagnosticsWithColorAndContext. Также имеет смысл проверить ошибки, обнаруженные на этапе создания программы. Для этого используем функцию getPreEmitDiagnostics. Дополним наш скрипт следующим образом:

function compile() {  const compilerOptions = getTSConfig();  const program = ts.createProgram(['./src/main.ts'], compilerOptions);  console.log(    ts.formatDiagnosticsWithColorAndContext(      ts.getPreEmitDiagnostics(program),      formatHost,    ),  );  const emitResult = program.emit();  console.log(    ts.formatDiagnosticsWithColorAndContext(emitResult.diagnostics, formatHost),  );  return emitResult.diagnostics.length === 0;}

Если теперь внести в наши исходники ошибку, получим в консоли красиво отформатированное сообщение:



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

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

function displayFilename(originalFunc, operationName) {  let displayEnabled = false;  function displayFunction() {    const fileName = arguments[0];    if (displayEnabled) {      process.stdout.clearLine();      process.stdout.cursorTo(0);      process.stdout.write(`${operationName}: ${fileName}`);    }    return originalFunc(...arguments);  }  displayFunction.originalFunc = originalFunc;  displayFunction.enableDisplay = () => {    if (process.stdout.isTTY) {      displayEnabled = true;    }  };  displayFunction.disableDisplay = () => {    if (displayEnabled) {      displayEnabled = false;      process.stdout.clearLine();      process.stdout.cursorTo(0);    }  };  return displayFunction;}

Теперь дополним код функции compile().

function compile() {  const compilerOptions = getTSConfig();  const compilerHost = ts.createCompilerHost(compilerOptions);  compilerHost.readFile = displayFilename(compilerHost.readFile, 'Reading');  compilerHost.readFile.enableDisplay();  const program = ts.createProgram(    ['./src/main.ts'],    compilerOptions,    compilerHost,  );  compilerHost.readFile.disableDisplay();  console.log(    ts.formatDiagnosticsWithColorAndContext(      ts.getPreEmitDiagnostics(program),      formatHost,    ),  );  compilerHost.writeFile = displayFilename(compilerHost.writeFile, 'Emitting');  compilerHost.writeFile.enableDisplay()  const emitResult = program.emit();  compilerHost.writeFile.disableDisplay();  console.log(    ts.formatDiagnosticsWithColorAndContext(emitResult.diagnostics, formatHost),  );  return emitResult.diagnostics.length === 0;}

Итак, теперь мы создаем хост с помощью функции createCompilerHost и заменяем нашей реализацией функции readFile и writeFile. Также функция compile() возвращает истину, если после окончания работы не обнаружено ошибок. Теперь в случае, если компиляция прошла без ошибок можно сразу запустить скомпилированный сервер. Посмотрим, что получилось:



Вот полный код получившегося скрипта:

ts-compiler.js
const ts = require('typescript');const formatHost = {  getCanonicalFileName: path => path,  getCurrentDirectory: ts.sys.getCurrentDirectory,  getNewLine: () => ts.sys.newLine,};function getTSConfig() {  const configPath = ts.findConfigFile(    './',    ts.sys.fileExists,    'tsconfig.json',  );  const readConfigFileResult = ts.readConfigFile(configPath, ts.sys.readFile);  if (readConfigFileResult.error) {    throw new Error(      ts.formatDiagnostic(readConfigFileResult.error, formatHost),    );  }  const jsonConfig = readConfigFileResult.config;  const convertResult = ts.convertCompilerOptionsFromJson(    jsonConfig.compilerOptions,    './',  );  if (convertResult.errors && convertResult.errors.length > 0) {    throw new Error(ts.formatDiagnostics(convertResult.errors, formatHost));  }  const compilerOptions = convertResult.options;  return compilerOptions;}function displayFilename(originalFunc, operationName) {  let displayEnabled = false;  function displayFunction() {    const fileName = arguments[0];    if (displayEnabled) {      process.stdout.clearLine();      process.stdout.cursorTo(0);      process.stdout.write(`${operationName}: ${fileName}`);    }    return originalFunc(...arguments);  }  displayFunction.originalFunc = originalFunc;  displayFunction.enableDisplay = () => {    if (process.stdout.isTTY) {      displayEnabled = true;    }  };  displayFunction.disableDisplay = () => {    if (displayEnabled) {      displayEnabled = false;      process.stdout.clearLine();      process.stdout.cursorTo(0);    }  };  return displayFunction;}function compile() {  const compilerOptions = getTSConfig();  const compilerHost = ts.createCompilerHost(compilerOptions);  compilerHost.readFile = displayFilename(compilerHost.readFile, 'Reading');  compilerHost.readFile.enableDisplay();  const program = ts.createProgram(    ['./src/main.ts'],    compilerOptions,    compilerHost,  );  compilerHost.readFile.disableDisplay();  console.log(    ts.formatDiagnosticsWithColorAndContext(      ts.getPreEmitDiagnostics(program),      formatHost,    ),  );  compilerHost.writeFile = displayFilename(compilerHost.writeFile, 'Emitting');  compilerHost.writeFile.enableDisplay()  const emitResult = program.emit();  compilerHost.writeFile.disableDisplay();  console.log(    ts.formatDiagnosticsWithColorAndContext(emitResult.diagnostics, formatHost),  );  return emitResult.diagnostics.length === 0;}compile() && require('./dist/main');


Incremental program watcher своими руками


В предыдущем разделе мы посмотрели, как можно использовать TypeScript Compiler API для того, чтобы настроить процесс компиляции Typescript. Теперь пришла пора сделать нечто более насущное и полезное, что реально может повысить скорость и удобство разработки. В процессе компиляции программы компилятору необходимо разрешить все зависимости в программе.

Чтобы это сделать он последовательно считывает и анализирует файлы проекта и файлы библиотек. Даже для компиляции относительно небольшого проекта как у меня на этапе подготовки компилятору нужно считать, распарсить и проанализировать более 2000 файлов. Это приводит к значительному замедлению процесса компиляции, например у меня на ноутбуке компиляция занимает приблизительно 20 секунд. При этом в процессе разработки обычно между запусками компилятора меняются всего несколько файлов, поэтому логично, что начиная с версии 2.7 в Typescript появилась возможность инкрементальной компиляции, которая совместно с watch-режимом, позволяющим следить за изменениями файлов на диске, значительно повысила скорость повторной компиляции.

Эти функции использует и CLI Nest.js, а начать работу в этому режиме можно при помощи команды

nest start --watch

Другая возможность использовать преимущества такого режима разработки запускать tsc c ключом --watch. Но, как я уже писал во вступлении, если возможностей стандартных команд в какой-то момент перестает хватать, самое время заглянуть под капот Typescript и узнать, как там оно работает.

Для начала, как и в предыдущем случае, создадим ts-wather.js со следующим содержимым:

const ts = require('typescript');const formatHost = {  getCanonicalFileName: path => path,  getCurrentDirectory: ts.sys.getCurrentDirectory,  getNewLine: () => ts.sys.newLine,};async function watchMain() {  const configPath = ts.findConfigFile(    './',    ts.sys.fileExists,    'tsconfig.json',  );  const host = ts.createWatchCompilerHost(    configPath,    {},    ts.sys,    ts.createEmitAndSemanticDiagnosticsBuilderProgram,    diagnostic =>      console.log(        ts.formatDiagnosticsWithColorAndContext([diagnostic], formatHost),      ),    diagnostic =>      console.log(        'Watch status: ',        ts.formatDiagnosticsWithColorAndContext([diagnostic], formatHost),      ),  );  ts.createWatchProgram(host);}watchMain();

Запустим скрипт и посмотрим, как всё работает:



После запуска компилятор отслеживает и перекомпилирует измененные файлы, выдает красиво оформленные сообщения об ошибках, в общем работает очень похоже на tsc с опцией --watch. Обратите внимание, как быстро происходит повторная компиляция! Но какой же профит нам от этого, спросите вы, если наш скрипт делает все тоже самое что tsc или nest start --watch, не проще ли использовать готовый инструмент? А профит наш в том, что мы теперь можем с помощью хуков делать разные штуки, которые изрядно облегчат нам жизнь.

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

function on(host, functionName, before, after) {  const originalFunction = host[functionName];  host[functionName] = function() {    before && before(...arguments);    const result = originalFunction && originalFunction(...arguments);    after && after(result);    return result;  };}

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

    on(    host,    'createProgram',    () => {      console.log("** We're about to create the program! **");    },    () => {      console.log('** Program created **');    },  );

Теперь посмотрим, что поменялось:



Работает! А теперь давайте добавим строку с отображением читаемых файлов. Для этого воспользуемся функцией displayFilename из предыдущего примера:

  host.readFile = displayFilename(host.readFile, 'Reading');  on(    host,    'createProgram',    () => {      console.log("** We're about to create the program! **");      host.readFile.enableDisplay();    },    () => {      host.readFile.disableDisplay();      console.log('** Program created **');    },  );

Запустим и посмотрим, что получилось:



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

let currentProgram;  on(    host,    'afterProgramCreate',    (program) => {      console.log('** We finished making the program! Emitting... **');      currentProgram = program;    },    () => {      console.log('** Emit complete! **');      const onAppClosed = () => {        if (app) {          setTimeout(onAppClosed, 100);        } else {          clearCache();          require('./dist/bootstrap')            .bootstrap()            .then((res) => {              app = res;            });        }      };      if (currentProgram && currentProgram.getSemanticDiagnostics().length === 0) {        onAppClosed();      }    },  );

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

function clearCache() {  const cacheKeys = Object.keys(require.cache);  const paths = [    join(__dirname, 'dist'),    dirname(require.resolve('typeorm')),    dirname(require.resolve('@nestjs/typeorm')),  ];  cacheKeys    .filter((item) => paths.filter((p) => item.startsWith(p)).length > 0)    .forEach((item, index, arr) => {      delete require.cache[item];      );    });}

Здесь производится очистка кэша от зависимостей, которые могут нам помешать корректно перезапустить сервер. В нашем случае из кэша надо удалить все модули нашего проекта из каталога dist. Также я удаляю модули typeorm, поскольку особенности их реализации мешают серверу перезапуститься (почему так происходит, это уже другая история). При помощи таймаута дожидаемся момента, когда сервер будет закрыт и переменная app будет очищена. Закрытие сервера мы будем производить в момент, когда компилятор обнаруживает изменения файлов и приступает к созданию программы с измененными файлами. Для этого дополним хук на функции createProgram.

  let app;  on(    host,    'createProgram',    () => {      console.log("** We're about to create the program! **");      app && app.close().then(() => (app = undefined));      host.readFile.enableDisplay();    },    () => {      host.readFile.disableDisplay();      console.log('** Program created **');    },  );

Как видите функция закрытия сервера асинхронная, именно поэтому нам приходится городить огород с таймаутом.

Последний штрих это подмена process.exit().

process.exit = (code) => {  console.log('!!!!!!!!!!!!!!!!!!!!!!! WARNING!!!!!!!!!!!!!!!!!!!!!!!!!');  console.trace(`Process try to exit with code ${code}.`);};

Вообще так делать не стоит, но Nest.js имеет дурную привычку ее вызывать в случае, если в процессе запуска что-то пошло не так, поэтому мы просто заменим функцию выхода из программы на сообщение об ошибке. В этом случае наш скрипт продолжит работать после вызова process.exit(). Это надо сделать перед вызовом функции watchMain.

Итак, теперь запустим наш скрипт и посмотрим, что получилось:



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

ts-compiler.js
const ts = require('typescript');const { join, dirname } = require('path');const { exit } = require('process');const formatHost = {  getCanonicalFileName: (path) => path,  getCurrentDirectory: ts.sys.getCurrentDirectory,  getNewLine: () => ts.sys.newLine,};function on(host, functionName, before, after) {  const originalFunction = host[functionName];  host[functionName] = function () {    before && before(...arguments);    const result = originalFunction && originalFunction(...arguments);    after && after(result);    return result;  };}function clearCache() {  const cacheKeys = Object.keys(require.cache);  const paths = [    join(__dirname, 'dist'),    dirname(require.resolve('typeorm')),    dirname(require.resolve('@nestjs/typeorm')),  ];  cacheKeys    .filter((item) => paths.filter((p) => item.startsWith(p)).length > 0)    .forEach((item, index, arr) => {      delete require.cache[item];      process.stdout.clearLine(); // clear current text      process.stdout.cursorTo(0); // move cursor to beginning of line      process.stdout.write(        `Clearing cache ${Math.floor((index * 100) / arr.length + 1)}%`,      );    });  process.stdout.write(' finished.\n');}function displayFilename(originalFunc, operationName) {  let displayEnabled = false;  let counter = 0;  function displayFunction() {    const fileName = arguments[0];    if (displayEnabled) {      process.stdout.clearLine();      process.stdout.cursorTo(0);      process.stdout.write(`${operationName}: ${fileName}`);    }    counter++;    return originalFunc(...arguments);  }  displayFunction.originalFunc = originalFunc;  displayFunction.enableDisplay = () => {    counter = 0;    if (process.stdout.isTTY) {      displayEnabled = true;    }  };  displayFunction.disableDisplay = () => {    if (displayEnabled) {      displayEnabled = false;      process.stdout.clearLine();      process.stdout.cursorTo(0);    }    console.log(`${counter} times function was called`);  };  return displayFunction;}async function watchMain() {  const configPath = ts.findConfigFile(    './',    ts.sys.fileExists,    'tsconfig.json',  );  const host = ts.createWatchCompilerHost(    configPath,    {},    ts.sys,    ts.createEmitAndSemanticDiagnosticsBuilderProgram,    (diagnostic) =>      console.log(        ts.formatDiagnosticsWithColorAndContext([diagnostic], formatHost),      ),    (diagnostic) =>      console.log(        'Watch status: ',        ts.formatDiagnosticsWithColorAndContext([diagnostic], formatHost),      ),  );  host.readFile = displayFilename(host.readFile, 'Reading');  let app;  on(    host,    'createProgram',    () => {      console.log("** We're about to create the program! **");      app && app.close().then(() => (app = undefined));      host.readFile.enableDisplay();    },    () => {      host.readFile.disableDisplay();      console.log('** Program created **');    },  );  let currentProgram;  on(    host,    'afterProgramCreate',    (program) => {      console.log('** We finished making the program! Emitting... **');      currentProgram = program;    },    () => {      console.log('** Emit complete! **');      const onAppClosed = () => {        if (app) {          setTimeout(onAppClosed, 100);        } else {          clearCache();          require('./dist/bootstrap')            .bootstrap()            .then((res) => {              app = res;            });        }      };      if (currentProgram && currentProgram.getSemanticDiagnostics().length === 0) {        onAppClosed();      }    },  );  ts.createWatchProgram(host);}process.exit = (code) => {  console.log('!!!!!!!!!!!!!!!!!!!!!!! WARNING!!!!!!!!!!!!!!!!!!!!!!!!!');  console.trace(`Process try to exit with code ${code}.`);};watchMain();


Выводы


В этом туториале мы посмотрели, как можно использовать возможности Typescript Compiler API для того, чтобы гибко организовать процесс отладки и сборки кода. Как видите, разработчики Typescript дают нам достаточно мощный и удобный в использовании API, чтобы не было необходимости привлекать для этих целей сторонние библиотеки и при этом заметно повысить удобство и скорость разработки. Надеюсь, что приведенная здесь информация будет вам полезна, спасибо, что дочитали до конца!
Подробнее..
Категории: Javascript , Typescript , Node.js , Cli , Nest.js

NEST.JS. Работа с ошибками. Мысли и рецепты

14.03.2021 10:05:42 | Автор: admin

Холивар...

Одни умные люди говорят: "эксепшены - это зло, используйте монады!", другие кричат: "коды ошибок ещё никто не отменял!", а третьи включают механизм исключений в языки программирования.. Однако, у большинства статей, который попадались мне на глаза, есть (при всём уважении к авторам!) два проблемных момента:

  • Некоторая... академичность. Разобрано много и интересно, но заканчивается всё стандартным: "ваш выбор зависит от вашей ситуации".

  • Абсолютно отсутствуют упоминания о бюджете. Никто же не будет спорить, что теоретически мерседес лучше, чем восьмёрка по всем показателям кроме.. цены.

Задача этого поста - поделиться выработанным практическим рецептом. В конкретном фреймворке и с конкретными границами применимости. Без претензий на уникальность, универсальность и, тем более, академическую "правильность".


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

Стартовые условия.

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

ЯЗК

Платформа и ЯП, очень сильно влияют на выбор подходов по работе с ошибками.

К примеру, в go не стоит вопрос, использовать ли исключения - там их нет. В функциональных языках, в частности в F#, было бы очень странно не использовать монады или discriminated union'ы (возврат одного из нескольких возможных типов значений), т. к. это там это реализовано очень удобным и естественным образом. В C#, монады тоже можно сделать, но получается намного больше букв. А это не всем нравится, мне например - не очень. Правда, последнее время всё чаще упоминается библиотека https://www.nuget.org/packages/OneOf/, которая фактически добавляет в язык discriminated union'ы.

А к чему нас подталкивает javascript/typescript?... К анархии! Можно много за что ругать JS и вполне по делу, но точно не за отсутствие гибкости.

Скорее уж за сверхгибкость )). В общем, мы вольны делать так, как нам хочется. Но тут есть, небольшая проблема - когда у вас в команде 10 человек и каждый делает как ему хочется.. получается не очень. Даже если каждый подход в отдельности - неплох.

ФРЕЙМВОРК

С nestjs уже интереснее. Выброс исключений из прикладного кода предлагается нам в документации как основной механизм возврата неуспешных ответов. То есть, если взять обычное http приложение, то чтобы клиенту вернулся статус 404 нам надо бросить NotFoundException..

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

@Controller()class SomeController {  @Post()  do (): Either<SomeResult, SomeError> {    ...  }}

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

Важно также, что Фреймворк делает практически всё для того, чтобы нам не приходилось заботиться об устойчивости процесса приложения . Nest сам выстраивает для нас "конвейер" обработки запроса и оборачивает всё это в удобный глобальный "try/catch", который ловит всё.

Правда иногда случаются казусы

Например в одной из старых версий nest'а мы столкнулись с тем, что ошибка, вылетевшая из функции переданной в декоратор @Transform() (из пакета class-transformer) почему-то клала приложение насмерть. В версии 7.5.5 это не воспроизводится, но от подобных вещей, конечно никто не застрахован.

ТИП ПРИЛОЖЕНИЯ

Самое важное. Мы не пишем софт для спутников. Там вряд ли можно было бы себе позволить что-то в стиле "сервис временно недоступен, попробуйте позже". Для нас же - это вполне ожидаемая ситуация. Нежелательная, конечно, но и не фатальная.

Мы пишем веб-сервисы. Есть http-сервисы, есть rpc (на redis и RabbitMQ, смотрим в сторону gRPC), гибридные тоже есть. В любом случае, мы стараемся внутреннюю логику приложения абстрагировать от транспорта, чтобы в любой момент можно было добавить новый.

Мы фокусируемся на том, что у нас есть запрос, есть его обработчик и есть ответ (который иногда void). И мы допускаем, что обработка запроса может по каким-то причинам оказаться неудачной. В этом случае, либо запрос будет повторён (успешно), либо будет зафиксирован и затем исправлен баг.

При таком подходе, важно, чтобы ошибки не могли привести данные в неконсистентное состояние. Помогают нам в этом две вещи:

  • Транзакционность. То есть, либо получилось всё, либо не получилось ничего.

  • Идемпотентность. Повторное выполнение одной и той же команды не ломает и не меняет состояние системы.

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

Ближе к делу.

Наши принципы обработки ошибок базируются на следующих соглашениях:

КОНФИГУРАЦИЯ ПРИЛОЖЕНИЯ.

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

@Injectable()export class SomeModuleConfig {  public readonly someUrl: URL;public readonly someFile: string;public readonly someArrayOfNumbers: number[];  constructor (source: ConfigurationSource) {    // Бросит ConfigurationException если не удастся распарсить Url. Можно    // также проверять его доступность, например, при помощи пакета is-reachable    this.someUrl = source.getUrl('env.SOME_URL');// Бросит ConfigurationException если файл не существует или на него нет прав.this.someFile = source.getFile('env.SOME_FILE_PATH');// Бросит ConfigurationException если там не перечисленные через запятую числаthis.someArrayOfNumbers = source.getNumbers('env.NUMBERS')  }}

Значения из конфигурации считываются только в конструкторах подобных классов и валидируются максимально строго.

Подход к валидации

Мы написали свои валидаторы. Их преимущетсво в том, что мы не только валидируем данные, но в ряде случаев, можем сделать дополнительные проверки (доступность файла или удалённого ресурса, как в примере выше, например).

Однако, вполне можно использовать joi или json-схемы (может ещё есть варианты) - кому что больше нравится.

Неизменным должно быть одно - всё валидируется на старте.

УРОВНИ АБСТРАКЦИИ.

Мы максимально чётко разделяем бизнес-код и инфраструктурный код. И всё инфраструктурное выносим в библиотеки. Более менее очевидно, но всё же приведу пример:

// Задача: скачать файл по ссылке.const response = await axios.get(url, { responseType: 'stream' });const { contentType, filename } = this.parseHeaders(response);const file = createWriteStream(path);response.data.pipe(file);file.on('error', reject);file.on('finish', () => resolve({ contentType, filename, path }));

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

const file: NetworkFile = await NetworkFile.download('https://download.me/please', {  saveAs: 'path/to/directory'});

Фактически, мы заворачиваем в подобные переиспользуемые "смысловые" абстракции почти все нативные нодовские вызовы и вызовы сторонних библиотек. Стратегия обработки ошибок в этих обёртках: "поймать -> завернуть -> бросить". Пример простейшей реализации такого класса:

export class NetworkFile {private constructor (  public readonly filename: string,    public readonly path: string,    public readonly contentType: string,    public readonly url: string  ) {}    // В примере выше у нас метод download принимает вторым аргументов объект опций  // Таким образом мы можем кастомизировать наш класс: он может записывать файл на диск  // или не записывать, например.  // Но тут для примера - самая простая реализация.  public static async download (url: string, path: string): Promise<NetworkFile> {    return new Promise<NetworkFile>(async (resolve, reject) => {      try {      const response = await axios.get(url, { responseType: 'stream' });        const { contentType, filename } = this.parseHeaders(response);        const file = createWriteStream(path);        response.data.pipe(file);// Здесь мы отловим и завернём все ошибки связанную с записью данных в файл.        file.on('error', reject(new DownloadException(url, error));        file.on('finish', () => {        resolve(new NetworkFile(filename, path, contentType, url));        })    } catch (error) {        // А здесь, отловим и завернём ошибки связанные с открытием потока или скачиванием        // файла по сети.        reject(new DownloadException(url, error))      }    });  }private static parseHeaders (    response: AxiosResponse  ): { contentType: string, filename: string } {    const contentType = response.headers['content-type'];    const contentDisposition = response.headers['content-disposition'];    const filename = contentDisposition// parse - сторонний пакет content-disposition      ? parse(contentDisposition)?.parameters?.filename as string      : null;    if (typeof filename !== 'string') {      // Создавать здесь специальный тип ошибки нет смысла, т. к. на уровень выше      // она завернётся в DownloadException.      throw new Error(`Couldn't parse filename from header: ${contentDisposition}`);    }    return { contentType, filename };  }}
Promise constructor anti-pattern

Считается не круто использовать new Promise() вообще, и async-коллбэк внутри в частности. Вот и вот - релевантные посты на stackoverflow по этому поводу.

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

Уследить за потоком управления в таком маленьком классе (на самом деле, его боевая версия лишь немногим больше) - не проблема. А в итоге, вызывающий код работает только с одним типом исключений: DownloadException, внутрь которого завёрнута причина, по которой файл скачать не удалось. И причина носит исключительно информативный характер и не влияет на дальнейшую работу приложения, т. к.:

В БИЗНЕС-КОДЕ НИГДЕ НЕ НАДО ПИСАТЬ TRY / CATCH.

Серьёзно, о таких вещах, как закрытие дескрипторов и коннектов не должна заботиться бизнес-логика! Если вам прям очень надо написать try / catch в коде приложения, подумайте.. либо вы пишете то, что должно быть вынесено в библиотеку. Либо.. вам придётся объяснить товарищам по команде, почему именно здесь необходимо нарушить правило (хоть и редко, но такое всё же бывает).

Так почему не надо в сервисе ничего ловить? Для начала:

ЧТО М СЧИТАЕМ ИСКЛЮЧИТЕЛЬНОЙ СИТУАЦИЕЙ?

Откровенно говоря, в этом месте мы сломали немало копий. В конце концов, копья кончились, и мы пришли к концепции холивар-agnostic. Зачем нам отвечать на этот провокационный вопрос? В нём очень легко утонуть, причём мы будем не первыми утопленниками )

Наша концепция проста - при возникновении любой ошибки мы, без споров о её исключительности, завершаем работу обработчика. Никакого геройства - никто не пытается спасать положение!

Не смогли считать файл - до свиданья. Не смогли распарсить ответ от стороннего API - до свидания. В базе duplicate key - до свидания. Не можем найти указанную сущность - до свидания. Максимально просто. И механизм throw, даёт нам удобную возможность осуществить этот быстрый выход без написания дополнительного кода.

В основном исключения ругают за две вещи:

  • Плохой перформанс. Нас это не очень волнует, т. к. мы не highload. Если он нас всё же в какой-то момент настигнет, мы, пересмотрим подходы там, где это будет реально критично. Сделаем бенчмарки... Хотя, готов поспорить, оверхед на исключения будет не главной нашей проблемой.

  • Запутывание потока управления программы. Это как оператор goto который уже давно не применяется в высокоуровневых программах. Вот только в нашем случае, goto бывает только в одно место - к выходу. А ранний return из функции - отнють не считается анти-паттерном. Напротив - это очень широко используемый способ уменьшить вложенность кода.

ВИД ОШИБОК

Говорят, что надо обрабатывать исключения там, где мы знаем, что с ними делать. В нашем слое бизнес-логики ответ будет всегда один и тот же: откатить транзакцию, если она есть (автоматически), залогировать всё что можно залогировать и вернуть клиенту ошибку. Вопрос в том, какую?

Мы используем 5 типов рантайм-исключений (про конфигурационные уже говорил выше):

abstract class AuthenticationException extends Exception {  public readonly type = 'authentication';}abstract class NotAllowedException extends Exception {public readonly type = 'authorization';}abstract class NotFoundException extends Exception {  public readonly type = 'not_found';}abstract class ClientException extends Exception {  public readonly type = 'client';}abstract class ServerException extends Exception {  public readonly type = 'server';}

Эти классы семантически соответствуют HTTP-кодам 401, 403, 404, 400 и 500. Конечно, это не вся палитра из спецификации, но нам хватает. Благодаря соглашению, что всё, что вылетает из любого места приложения должно быть унаследовано от указанных типов, их легко автоматически замапить на HTTP ответы.

А если не HTTP? Тут надо смотреть конкретный транспорт. К примеру один из используемых у нас вариантов подразумевает получения сообщения из очереди RabbitMQ и отправку ответного сообщения в конце. Для сериализации ответа мы используем.. что-то типа either:

interface Result<T> {data?: T;  error?: Exception}

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

Базовый класс Exception выглядит примерно так:

export abstract class Exception {  abstract type: string;  constructor (    public readonly code: number,    public readonly message: string,    public readonly inner?: any  ) {}toString (): string {    // Здесь логика сериализации, работа со стек-трейсами, вложенными ошибками и проч...  }}
Коды ошибок

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

  • Бывает такое, что клиентское приложение должно предпринять различные действия в зависимости от пришедшей от сервера ошибки. С кодами мы можем решить это не добавляя новых http статусов и без, прости Господи, парсинга сообщений.

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

Насколько это всё нужно и полезно - жизнь покажет

Поле inner - это внутренняя ошибка, которая может быть "завёрнута" в исключение (см. пример с NetworkFile).

Реализации абстрактных дочерних классов содержат в себе только значение поля type. Это удобно для сериализации, но можно обойтись и без него. В буквальном смысле - тип ради типа.

ПРИМЕР ИСПОЛЬЗОВАНИЯ

Опустим AuthenticationException - он используется у нас только в модуле контроля доступа. Разберём более типовые примеры и начнём ошибок валидации:

import { ValidatorError } from 'class-validator';// ....export interface RequestValidationError {  // Массив - потому что ошибка может относиться к нескольким полям.  properties: string[];  errors: { [key: string]: string };nested?: RequestValidationError[]}// Небольшая трансформация стандартной ошибки class-validator'а в более удобный// "наш" формат.const mapError = (error: ValidationError): RequestValidationError => ({  properties: [error.property],  errors: error.constraints,  nested: error.children.map(mapError)});// Сами цифры не имеют значения.export const VALIDATION_ERROR_CODE = 4001;export class ValidationException extends ClientException {  constructor (errors: ValidationError[]) {    const projections: ValErrorProjection[] = ;    super(      VALIDATION_ERROR_CODE,      'Validation failed!',      errors.map(mapError)    );  }}

Иногда валидация производится в пайпе, иногда где-то в обработчике, но мы всегда хотим, чтобы на выходе был единообразный, удобный для нас формат. Заставить стандартный нестовый ValidationPipe выбрасывать то, что нам хочется можно просто передав функцию-фабрику в конструктор:

app.useGlobalPipes(new ValidationPipe({  exceptionFactory: errors => new ValidationException(errors);   });)

Соответственно, на выходе наш ValidationException замапится на BadRequestException с кодом 400 - потому что он ClientException.

Другой пример, с NotFoundException:

export const EMPLOYEE_NOT_FOUND_ERROR_CODE = 50712;export class EmployeeNotFoundException extends NotFoundException {  constructor (employeeId: number) {  super(      EMPLOYEE_NOT_FOUND_ERROR_CODE,      `Employee id = ${employeeId} not found!`    );  }}

Не очень приятно писать такие классы - всегда возникает лёгкое чувство... бойлерплейта ) Но зато, как приятно их потом использовать!

// Вместо, что не даст нам ни кодов, ни типа - ничего:throw new Error('...тут мы должны сформировать внятное сообщение...')// Простоthrow new EmployeeNotFoundException(id);

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

export const EMPLOYEE_NOT_ALLOWED_ERROR_CODE = 40565;export class EmployeeNotAllowedException extends NotAllowedException {  constructor (userId: number, employeeId: number) {  super(      EMPLOYEE_NOT_ALLOWED_ERROR_CODE,      `User id = ${userId} is not allowed to query employee id = ${employeeId}!`    );  }}

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

МАППИНГ

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

export interface IExceptionsFormatter {  // Verbose - флаг, который мы держим в конфигурации. Он используется для того,  // чтобы в девелоперской среде всегда на клиент отдавалась полная инфа о  // ошибке, а на проде - нет.  format (exception: unknown, verbose: boolean): unknown;    // При помощи этого метода можно понять, подходит ли данных форматтер  // для этого типа приложения или нет.  match (host: ArgumentsHost): boolean;}@Module({})export class ExceptionsModule {  public static forRoot (options: ExceptionsModuleOptions): DynamicModule {    return {      module: ExceptionsModule,      providers: [        ExceptionsModuleConfig,        {          provide: APP_FILTER,          useClass: GlobalExceptionsFilter        },        {          provide: 'FORMATTERS',          useValue: options.formatters        }      ]    };  }}const typesMap = new Map<string, number>().set('authentication', 401).set('authorization', 403).set('not_found', 404).set('client', 400).set('server', 500);@Catch()export class GlobalExceptionsFilter implements ExceptionFilter {  constructor (    @InjectLogger(GlobalExceptionsFilter) private readonly logger: ILogger,    @Inject('FORMATTERS') private readonly formatters: IExceptionsFormatter[],    private readonly config: ExceptionsModuleConfig  ) { }  catch (exception: Exception, argumentsHost: ArgumentsHost): Observable<any> {    this.logger.error(exception);    const formatter = this.formatters.find(x => x.match(argumentsHost));    const payload = formatter?.format(exception, this.config.verbose) || 'NO FORMATTER';    // В случае http мы ставим нужный статус-код и возвращаем ответ.if (argumentsHost.getType() === 'http') {      const request = argumentsHost.switchToHttp().getResponse();      const status = typesMap.get(exception.type) || 500;      request.status(status).send(payload);      return EMPTY;    }// В случае же RPC - бросаем дальше, транспорт разберётся.    return throwError(payload);  }}

Бывает конечно, что мы где-то напортачили и из сервиса вылетело что-то не унаследованное от Exception. На этот случай у нас есть ещё интерцептор, который все ошибки, не являющиеся экземплярами наследников Exception, заворачивает в new UnexpectedException(error) и прокидывает дальше. UnexpectedException естественно наследуется от ServerException. Для нас возникновение такой ошибки - иногда некритичный, но всё же баг, который фиксируется и исправляется.


В принципе, это всё. Для 95% наших задач этого вполне хватает. Способ может и не "канонический", но удобный и вполне рабочий - то, к чему мы и стремились.

И всё же бывают ситуации

КОГДА ВСЁ НЕ ТАК ЯСНО.

Приведу два примера:

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

В таких случаях всё-таки приходится проявлять "фантазию", например, обернуть в try/catch обработку каждой строки csv-файла. И в блоке catch писать ошибку в "отчёт". Тоже не бином Ньютона )

Второй. Я сознательно не написал выше реализацию DownloadException.

export class DOWNLOAD_ERROR_CODE = 5506;export class DownloadException extends ServerException {  constructor (url: string, inner: any) {    super(      DOWNLOAD_ERROR_CODE,      `Failed to download file from ${url}`,      inner    );  }}

Почему ServerException? Потому что, в общем случае, клиенту всё равно почему сервер не смог куда-то там достучаться. Для него это просто какая-то ошибка, в которой он не виноват.

Однако, теоретически может быть такая ситуация, что мы пытаемся скачать файл по ссылке, предоставленной клиентом. И тогда, в случае неудачи, клиент должен получить 400 или может быть 404, но не 500.

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

ЗАКЛЮЧЕНИЕ

Большое спасибо всем, кто дочитал до конца. Не думаю, что я в этой статье кому-то "открыл Америку". И я, конечно, далёк от навязывания кому-либо, чего-либо. И всё же, надеюсь, что эта попытка структурировать работу с ошибками для кого-то послужит отправной точкой в разработке собственного подхода под собственные задачи.

P. S. К сожалению, опенсорс в нашей компании в процессе согласования, поэтому привести реальный используемый код я не могу. Когда будет такая возможность, мы выложим на github библиотеку, при помощи которой работаем с исключениями. А за одно и некоторые другие пакеты, которые могут оказаться кому-то полезными.

P. P. S. Буду очень благодарен, за комментарии, особенно если в них будут практические примеры ситуаций, в которых у нас могут возникнуть трудности при использовании данного подхода. С удовольствием, поучаствую в обсуждении.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru