Работая над своим первым проектом, разработчики часто не знают, как построить правильную архитектуру: какие в ней должны быть функции, как этим функциям между собой взаимодействовать и какие модули стоит выделять? В статье отвечаем на эти вопросы, а также разбираем, как построить приложение на примере JavaScript-проекта.
Статья будет полезна студентам, которые приступают к выполнению первого проекта профессии в Хекслете.
Архитектура веб-приложений — это способ организации и структурирования программного кода, который обеспечивает работу веб-приложения. Это как фундамент, на котором строится приложение.
Веб-архитектура включает в себя различные компоненты, такие как клиентская и серверная части, база данных и интерфейсы для взаимодействия с пользователем.
Хорошая архитектура позволяет создавать надежные, масштабируемые и удобные в использовании веб-приложения. Чаще всего новички не уделяют архитектуре должного внимания при обучении, делая больший акцент на изучении синтаксиса языка. Однако если не изучить, как правильно строить архитектуру приложений, на выходе можно получить некачественный код.
Если вы — джун и работаете в команде, у вас есть шанс познакомиться с подходами построения архитектуры благодаря код-ревью и наставлениям более опытных разработчиков. Но в этом вас может ждать неудача, так как во многих компаниях стремятся поскорее закончить разработку, забывая о качестве кода и архитектуре.
Если вы не в команде, вам остается во всем разбираться самому и создавать приложение с нуля. Так можно легко запутаться в последовательности действий с будущей программой — знаем по собственному опыту — и код станет спагетти-кодом.
Давайте разберемся, как создать правильную архитектуру приложения на примере проекта — консольной утилиты, которая спрашивает у пользователя путь к файлу и выводит данные файла в виде объекта.
Утилита умеет работать с разными форматами файлов: YML и JSON. Для каждого формата есть отдельный исполняемый файл. Общую логику мы выделим в отдельный модуль, и она будет использоваться в каждом отдельном формате.
Примеры в статье будут на JavaScript. Но мы не будем описывать реализацию большинства функций, так как нас будут интересовать лишь их интерфейсы.
Получите профессию «Фронтенд-разработчик» с нуля за 10 месяцев! Погружение в практику с первого дня и обучение без дедлайнов. Вы получите готовое портфолио на GitHub к концу обучения, поддержку наставников на протяжении всего курса и помощь в трудоустройстве.
Для начала разберемся, что такое библиотеки и исполняемые файлы и как они устроены.
Многие программы устроены как библиотеки. Библиотека — это код, который мы можем подключить в любое другое приложение. Примеры таких библиотек: JQuery, lodash. Исполняемые файлы — это уже приложения, которые запускают код. Такие приложения не являются подключаемыми библиотеками.
Несмотря на то, что наша консольная утилита — это исполняемое приложение, мы создадим ее как библиотеку. Обычно библиотеки легче поддерживать и дорабатывать.
Когда вы разрабатываете приложение, чаще всего результатом работы приложения будут побочные эффекты, например, вывод в консоль. Этот результат сложно проверить: нужно написать еще код, чтобы прочитать такой вывод. Поэтому разумнее будет сначала выделить основной код в виде библиотеки, и уже после проверять результат вызова одной из функций этой библиотеки как обычную строку. Также если мы решим добавить в проект тесты, библиотеку будет легко импортировать в них — вот еще один плюс создавать приложение сразу как библиотеку.
Другая ситуация, если мы изначально разрабатываем приложение для командной строки, а затем решаем, что приложение можно еще использовать и в браузере. В таком случае библиотека будет работать, а исполняемый файл — нет. Получается, мы всегда должны стараться разрабатывать в первую очередь библиотеку, потом подключать ее в исполняемый файл и в нем уже запускать код.
Пример модуля библиотеки app.js:
// файл библиотеки
const app = () => {
// ...
};
И ее исполняемый файл:
#!/usr/bin/env node
import app from './app.js';
app();
Разделить исполняемый код и код библиотеки довольно легко. Нам нужно выделить основную функцию приложения, которая будет импортироваться в исполняемый файл и там вызываться. Такой подход дает преимущество. Например, в тестах можно легко проверять работу своего кода, просто импортировав в них эту функцию:
import app from './app.js';
it('test', () => {
expect(app()).toBeTruthy();
});
Читайте также: Стоит ли ставить библиотеки ради нескольких простых функций
Наше приложение будет спрашивать у пользователя путь к файлу и затем выводить содержимое. После вывода содержимого приложение повторит запрос файла. Если пользователь ничего не введет, приложение завершит работу.
Опишем алгоритм приложения:
Важно выделить логические шаги нашего приложения. На их основе будут строиться его модули.
Теперь мы можем написать код основной функции:
const app = () => {
// Цикл повторяется пока не будет выполнено условие выхода
while (true) {
const filePath = getFilePath();
// Условие выхода из цикла и всей функции
if (!filePath) {
return;
}
const content = getFileContent(filePath);
const result = parse(content, format);
console.log(result);
}
};
Код достаточно простой. Такой код удобно читать, и сразу видно общую логику. Его также легко отлаживать. Можно добавить логирование на любом этапе и проверить промежуточные результаты:
const app = () => {
while (true) {
const filePath = getFilePath();
console.log('filePath: ', filePath);
if (!filePath) {
return;
}
const content = getFileContent(filePath);
console.log('content: ', content);
const result = parse(content, format);
console.log(result);
}
};
Такой код еще называют пайплайном — цепочкой функций, которые вызываются друг за другом.
Читайте также: Как правильно создавать функции
Мы уже описали общую логику — она задала проекту модульность. Но такой подход работает не всегда. Чаще всего мы не видим общей картины приложения сразу, так как выстраиваем архитектуру постепенно, шаг за шагом. В таком случае нужно начинать с малого — создать функции, которые формируют абстракции. Например, мы точно знаем, что должны прочитать файл — это хорошая причина создать функцию чтения файла.
Прежде чем двинуться дальше, опишем, как должны работать функции:
getFilePath()
получает информацию от пользователяgetFileContent()
читает файл и возвращает данные из этого файла. Эта функция принимает путь к файлуparse()
парсит данные из файла в объекты, которые поддерживает язык программирования.Самый популярный формат данных в JavaScript — это JSON:
const content = '{"name":"Ivan","age":"18"}';
const user = JSON.parse(content);
console.log(user.name); // => Ivan
console.log(user.age); // => 18
Есть и другие форматы данных, для них используются разные инструменты парсинга. Но сейчас это нам не важно. Нам нужно лишь понимать, что наша задача — это получить объект или массив из строки, которая хранится в файле.
Также важно помнить, что чтение файла и парсинг данных — это разные операции, потому что данные для парсинга могут приходить из разных источников. Многие новички часто совершают эту ошибку еще при описании алгоритма приложения. Получается, что мы:
Представим, что вместо чтения файлов мы получаем данные по сети. Меняет ли это как-то парсинг? Нет. Но если внутри парсера будет идти работа с файлами, то это сильно затруднит работу.
У некоторых наших студентов в этот момент возникает вопрос: при чем тут работа с сетью? Почему мы должны учитывать, что данные приходят из других источников, когда по заданию этого не требуется?
Способ построения правильной архитектуры во многом закладывается следующим принципом: каждый модуль должен быть максимально изолированным. Это значит, что мы должны создавать модули и функции, которые могут работать автономно и максимально изолированно друг от друга. Когда мы находимся внутри функции, нужно думать только о том, что должна делать эта функция.
Правильный интерфейс функции — залог хорошей архитектуры. Если функция принимает множество параметров, то это повод задуматься над интерфейсом этой функции.
Поставим себя на место пользователя функции. Представим, что мы импортируем эту функцию из чужой библиотеки. Насколько нам удобно и понятно ей пользоваться?
Плохой пример:
const getFileContent = (firstFileName, firstFileLocation, user, fsType) => {
//
};
Функция принимает имя файла и его расположение отдельными параметрами. Хотя достаточно передать сразу целиком путь к файлу, в котором уже содержится имя. Также функция принимает текущего пользователя и тип файловой системы. Зачем это нужно — об этом пользователь функции может только догадываться.
Читайте также: Мой долгий путь во фронтенд-разработку: через строительный вуз, юриспруденцию и усердную учебу на Хекслете
После того как мы выделили общую логику, приступаем к формированию кода для каждого отдельного формата.
Исполняемый файл для каждого формата будет выглядеть похожим образом:
#!/usr/bin/env node
import runJson from './formats/jsonFormat.js';
runJson();
И для YML:
#!/usr/bin/env node
import runYml from './formats/ymlFormat.js';
runYml();
Важно, что в исполняемых файлах происходит просто вызов функции. Мы могли сделать общую функцию, которая бы принимала тип формата, и вызвать ее:
#!/usr/bin/node
import run from './index.js';
run('ymlFormat');
run('jsonFormat');
Но такой подход не очень хороший, так как вызов содержит логику — передачу параметра. Приходится думать, какой параметр передать. Вся логика должна находиться внутри функции, а исполняемый код просто вызывает эту функцию.
Теперь мы можем написать код для каждого формата. Но для этого нам нужно немного модифицировать общую логику. Посмотрим на нее еще раз:
const app = () => {
while (true) {
const filePath = getFilePath();
if (!filePath) {
return;
}
const content = getFileContent(filePath);
const result = parse(content);
console.log(result);
}
};
Функции getFilePath()
и getFileContent()
— общие для любых форматов, так как получение пути к файлу и чтение файла не зависят от формата. Эти функции мы можем определить в модуле общей логики, поэтому она будет выглядеть так. Но парсер уже в каждом формате разный, а мы не знаем, какой формат данных.
Функции форматов могут сами передавать нужный парсер в виде функции. Эту функцию мы будем вызывать внутри общей логики. Для каждого формата будет вызываться своя функция. Для этого в app()
сделаем передачу параметра.
В итоге модуль общей логики будет выглядеть так:
const getFilePath = () => {
// ...
};
const getFileContent = (filePath) => {
// ...
};
const app = (parse) => {
while (true) {
const filePath = getFilePath();
if (!filePath) {
return;
}
const content = getFileContent(filePath);
const result = parse(content);
console.log(result);
}
};
export default app;
А модули форматов будут использовать общую логику и функцию app()
и передавать в нее нужную функцию:
// JSON-формат
import app from './app.js';
const jsonParse = (content) => {
// json parse
};
app(jsonParse);
// YML-формат
import app from './app.js';
const ymlParse = (content) => {
// yml parse
};
app(ymlParse);
Такой подход позволяет легко дорабатывать приложение. Если нам понадобится добавить какой-то новый формат файлов, нужно будет добавить новый исполняемый файл и файл с кодом. В этом коде нам нужно создать свою функцию парсера нового формата данных и передать ее в общую логику приложения. Дорабатывать уже существующий код не придется.
В итоге, один раз написав модуль с общей логикой и логикой парсеров, нам не нужно больше вспоминать этот код, даже если нужно добавить новый формат.
Мы разработали небольшой проект, в котором есть несколько исполняемых файлов с разной логикой. Но при этом есть и общая логика — мы выделили ее в отдельный модуль, чтобы он мог переиспользоваться в других модулях.
Функция с общей логикой использует внутри себя некую другую функцию parse()
. Эта функция может быть разной для каждого формата данных. В этом сила абстракции: нам неважно, как функция работает внутри.
Целиком весь проект можно посмотреть по этой ссылке. В нем могут быть небольшие доработки, но суть сохранена.
Получите профессию «Фронтенд-разработчик» с нуля за 10 месяцев! Погружение в практику с первого дня и обучение без дедлайнов. Вы получите готовое портфолио на GitHub к концу обучения, поддержку наставников на протяжении всего курса и помощь в трудоустройстве.