Реинжиниринг программного обеспечения: от спагетти к чистому дизайну

Опубликовано: 2022-03-11

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

Любой, кто занимается разработкой программного обеспечения в течение разумного периода времени, знает, что этот, казалось бы, невинный запрос часто является началом проекта, в котором «все написано о катастрофе». Наследование чужого кода может стать кошмаром, особенно когда код плохо спроектирован и не имеет документации.

Поэтому, когда я недавно получил запрос от одного из наших клиентов на пересмотр его существующего приложения чат-сервера socket.io (написанного на Node.js) и его улучшения, я был крайне насторожен. Но прежде чем бежать в горы, я решил хотя бы согласиться взглянуть на код.

К сожалению, просмотр кода только подтвердил мои опасения. Этот чат-сервер был реализован в виде одного большого файла JavaScript. Реинжиниринг этого единственного монолитного файла в чисто спроектированную и легко поддерживаемую часть программного обеспечения действительно будет сложной задачей. Но мне нравятся вызовы, поэтому я согласился.

реинжиниринг программного обеспечения

Отправная точка — подготовка к реинжинирингу

Существующее программное обеспечение состояло из одного файла, содержащего 1200 строк недокументированного кода. Угу. Более того, было известно, что он содержит некоторые ошибки и имеет некоторые проблемы с производительностью.

Кроме того, изучение файлов журналов (всегда хорошее начало для наследования чужого кода) выявило потенциальные проблемы с утечкой памяти. В какой-то момент сообщалось, что процесс использует более 1 ГБ ОЗУ.

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

  • Структура кода. Код вообще не имел реальной структуры, из-за чего было трудно отличить конфигурацию от инфраструктуры и бизнес-логики. Практически не было модуляризации или разделения задач.
  • Избыточный код. Некоторые части кода (такие как код обработки ошибок для каждого обработчика событий, код для выполнения веб-запросов и т. д.) дублировались несколько раз. Дублированный код никогда не приносит пользы, поскольку значительно усложняет его поддержку и повышает вероятность ошибок (когда избыточный код исправляется или обновляется в одном месте, но не в другом).
  • Жестко запрограммированные значения. Код содержал ряд жестко запрограммированных значений (что редко бывает хорошо). Возможность изменять эти значения с помощью параметров конфигурации (вместо того, чтобы требовать изменения жестко заданных значений в коде) повысит гибкость, а также упростит тестирование и отладку.
  • Логирование. Система регистрации была очень простой. Это создаст один гигантский файл журнала, который будет трудно и неуклюже анализировать или анализировать.

Ключевые архитектурные задачи

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

  • Ремонтопригодность. Никогда не пишите программное обеспечение, ожидая, что его будет поддерживать только один человек. Всегда учитывайте, насколько ваш код будет понятен другим и насколько легко им будет модифицировать или отлаживать его.
  • Расширяемость. Никогда не думайте, что функциональность, которую вы реализуете сегодня, — это все, что когда-либо понадобится. Спроектируйте свое программное обеспечение таким образом, чтобы его было легко расширять.
  • Модульность. Разделите функциональность на логические и отдельные модули, каждый из которых имеет свою четкую цель и функцию.
  • Масштабируемость. Сегодняшние пользователи становятся все более нетерпеливыми, ожидая немедленного (или, по крайней мере, близкого к немедленному) времени отклика. Низкая производительность и высокая задержка могут привести к сбою даже самого полезного приложения на рынке. Как ваше программное обеспечение будет работать по мере увеличения количества одновременных пользователей и требований к пропускной способности? Такие методы, как распараллеливание, оптимизация базы данных и асинхронная обработка, могут помочь улучшить способность вашей системы оставаться отзывчивой, несмотря на увеличение нагрузки и потребности в ресурсах.

Реструктуризация кода

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

Для этого приложения я решил организовать код в виде следующих отдельных архитектурных компонентов:

  • app.js — это наша точка входа, отсюда будет запускаться наш код
  • config - здесь будут находиться наши настройки конфигурации
  • ioW — «обертка ввода-вывода», которая будет содержать всю логику ввода-вывода (и бизнес-логику).
  • logging — весь код, связанный с ведением журнала (обратите внимание, что структура каталогов также будет включать новую папку logs , которая будет содержать все файлы журналов)
  • package.json — список зависимостей пакетов для Node.js
  • node_modules — все модули, требуемые Node.js

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

Результирующая организация каталога и файлов показана ниже.

реструктурированный код

логирование

Пакеты ведения журналов были разработаны для большинства современных сред разработки и языков, поэтому в настоящее время редко возникает необходимость «разворачивать свои собственные» возможности ведения журналов.

Поскольку мы работаем с Node.js, я выбрал log4js-node, который по сути представляет собой версию библиотеки log4js для использования с Node.js. Эта библиотека имеет некоторые интересные функции, такие как возможность регистрации нескольких уровней сообщений (ПРЕДУПРЕЖДЕНИЕ, ОШИБКА и т. д.), и у нас может быть скользящий файл, который можно разделить, например, на ежедневной основе, поэтому нам не нужно иметь дело с огромными файлами, открытие которых займет много времени и которые будет сложно анализировать и анализировать.

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

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

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

Код для оболочки ведения журнала доступен здесь.

Конфигурация

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

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

Используя переменную среды Node.js, я могу указать, какой файл конфигурации я хочу использовать для конкретного выполнения. Поэтому я переместил все ранее жестко заданные параметры конфигурации в файлы конфигурации и создал простой модуль конфигурации, который загружает нужный файл конфигурации с нужными настройками. Я также классифицировал все настройки, чтобы обеспечить определенную степень организации файла конфигурации и упростить навигацию.

Вот пример результирующего файла конфигурации:

 { "app": { "port": 8889, "invRepeatInterval":1000, "invTimeOut":300000, "chatLogInterval":60000, "updateUsersInterval":600000, "dbgCurrentStatusInterval":3600000, "roomDelimiter":"_", "roomPrefix":"/" }, "webSite":{ "host": "mysite.com", "port": 80, "friendListHandler":"/MyMethods.aspx/FriendsList", "userCanChatHandler":"/MyMethods.aspx/UserCanChat", "chatLogsHandler":"/MyMethods.aspx/SaveLogs" }, "logging": { "appenders": [ { "type": "dateFile", "filename": "logs/chat-server", "pattern": "-yyyy-MM-dd", "alwaysIncludePattern": false } ], "level": "DEBUG" } }

Поток кода

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

Благодаря нашей новой модульной структуре кода наша точка входа app.js достаточно проста и содержит только код инициализации:

 var config = require('./config'); var logging = require('./logging'); var ioW = require('./ioW'); var obj = config.getCurrent(); logging.initialize(obj.logging); ioW.initialize(config);

Когда мы определяли нашу структуру кода, мы сказали, что папка ioW будет содержать код, связанный с бизнесом и socket.io. В частности, он будет содержать следующие файлы (обратите внимание, что вы можете щелкнуть любое из перечисленных имен файлов, чтобы просмотреть соответствующий исходный код):

  • index.js — обрабатывает инициализацию и соединения socket.io, а также подписку на события, а также централизованный обработчик ошибок для событий.
  • eventManager.js — содержит всю логику, связанную с бизнесом (обработчики событий).
  • webHelper.js — вспомогательные методы для выполнения веб-запросов.
  • linkedList.js — служебный класс связанного списка

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

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

Поскольку Node.js по определению является асинхронным, мы часто сталкиваемся с крысиным гнездом «ада обратных вызовов», что делает код особенно трудным для навигации и отладки. Чтобы избежать этой ловушки, в моей новой реализации я использовал шаблон обещаний и специально использовал bluebird, очень хорошую и быструю библиотеку обещаний. Обещания позволят нам следить за кодом, как если бы он был синхронным, а также обеспечивать управление ошибками и чистый способ стандартизировать ответы между вызовами. В нашем коде есть неявное соглашение о том, что каждый обработчик событий должен возвращать обещание, чтобы мы могли управлять централизованной обработкой ошибок и ведением журнала.

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

 function execEventHandler(socket, eventName, eventHandler, data){ var sLogger = logging.createLogger(socket.id + ' - ' + eventName); sLogger.info(''); eventHandler(socket, data, sLogger).then(null, function(err){ sLogger.error(err.stack); }); };

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

 var sLogger = logging.createLogger(socket.id + ' - ' + eventName);

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

 io.on('connection', function (socket) { ... Several event handlers .... setInterval(function() { try { var date = Date.now(); var tmp = []; while (0 < messageHub.count() && messageHub.head().date < date) { var item = messageHub.remove(); tmp.push(item); } ... Post Data to an external web service... } catch (e) { log('ERROR: ex: ' + e); } }, CHAT_LOGS_INTERVAL); });

Этот код создает таймер с заданным интервалом (в нашем случае это была 1 минута) для каждого отдельного запроса на подключение , который мы получаем. Так, например, если в любой момент времени у нас есть 300 онлайн-сокетов, то у нас будет 300 таймеров, выполняющихся каждую минуту. Проблема с этим, как вы можете видеть в приведенном выше коде, заключается в том, что не используется ни сокет, ни какая-либо переменная, которая была определена в области действия обработчика событий. Единственная используемая переменная — это переменная messageHub , объявленная на уровне модуля, что означает, что она одинакова для всех подключений. Таким образом, нет абсолютно никакой необходимости в отдельном таймере для каждого соединения. Поэтому мы удалили это из обработчика событий подключения и включили в наш общий код инициализации, которым в данном случае является функция initialize .

Наконец, при обработке ответов в webHelper.js мы добавили обработку для любого нераспознанного ответа, который будет регистрировать информацию, которая затем будет полезна для процесса отладки:

 if (!res || !res.d || !res.d.IsValid){ logger.debug(sendData); logger.debug(data); reject(new Error('Request failed. Path ' + params.path + ' . Invalid return data.')); return; }

Последний шаг — настроить файл журнала для стандартной ошибки Node.js. Этот файл будет содержать необработанные ошибки, которые мы могли пропустить. Для настройки процесса node в Windows (не идеального, но вы знаете…) в качестве службы мы используем инструмент под названием nssm, который имеет визуальный пользовательский интерфейс, который позволяет вам определить стандартный файл вывода, стандартный файл ошибок и переменные среды.

О производительности Node.js

Node.js — это однопоточный язык программирования. Чтобы улучшить масштабируемость, мы можем использовать несколько альтернатив. Существует модуль кластера узлов или просто добавление дополнительных узловых процессов и добавление nginx поверх них для пересылки и балансировки нагрузки.

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

Заключение

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

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

Надеюсь, вам понравилась статья, дайте мне знать, если у вас есть дополнительные комментарии или вопросы.