Это статья об основах программирования на Go. На канале я рассказываю об опыте перехода в IT с нуля, структурирую информацию и делюсь мнением.
Хой, джедаи и амазонки!
Ранее познакомился с СУБД SQLite и PostgreSQL: о практике в SQLite и о разнице между БД и СУБД можно почитать в предыдущей публикации, а о практике с PostgreSQL в этой - в то время я проходил акселерацию в Яндекс Практикуме для трудоустройства. Сейчас приостановил поиск работы и участие в акселерации по личным причинам, но программировать стараюсь продолжать, и через пару месяцев намерен продолжить поиск работы.
Сейчас улучшаю навык работы с базами данных, и начал с основы основ - практиковаться с SQLite. В публикации мы разберём теорию: что такое миграции, какая нужна библиотека для этих целей, посмотрим примеры кода. А далее закрепим навык: напишем приложение, которое будет по IP или домену искать дополнительную информацию об IP и сохранять данные в БД через СУБД SQLite, созданную миграциями. Публикация получилась длинная.
1. Что такое миграции в СУБД
1.1. Введение
С термином миграции познакомился в тестовом задании, звучало оно примерно так:
Создать БД с помощью миграций.
Миграции баз данных бывают двух видов:
- Перенос данных: с одного диска на другой, с локального хранилища в облако, между СУБД;
- Изменение структуры хранения данных в текущей БД.
Речь в публикации пойдёт именно об изменениях структуры БД с помощью миграций.
Миграции - по сути, это папка с файлами формата .sql, которые можно использовать для обновления схемы БД или отката изменений. Пример содержимого файла миграций ниже - sql-запрос на создание таблицы и индекса на столбец в БД:
1.2. Миграции - это Git в мире СУБД?
По ходу изучения миграция возникла такая ассоциация, что миграции - это система контроля версий (Git) для СУБД. Для себя я выделил три пункта, обосновывающих такую связь, которые заодно объясняют, для чего нужны миграции:
- Как и в Git, миграции позволяют фиксировать каждое изменение в схеме БД: есть история изменений.
- Проще синхронизировать работу над одной БД для нескольких разработчиков.
- В случае ошибки, легко откатить внесённые изменения.
Я выделил для себя два способа работать с БД:
- Писать код sql напрямую в терминал - создание и изменение схемы БД и данных в БД, в т.ч. применение или откат миграций;
- Прописывать код создания и изменения БД в приложении, а откаты делать через терминал. Для меня этот способ приоритетный.
1.3. Миграции vs константный код
Занимаясь на курсах, и когда практиковал работу с БД сам, код по созданию или изменению схемы БД был константным, а это более примитивный способ работы с СУБД.
Что значит константный код? Да вот он, в переменной request:
Это простенький код по созданию таблицы в БД и индекса. Через терминал откроем таблицу в БД и посмотрим на схему:
Мы запустили команду .schema и получили структуру БД - в данном случае у нас одна таблица и выдаётся её код. Всё в порядке, программа отработала как мы и ожидали.
Однако константный код по созданию не позволяет воспользоваться преимуществами, описанными в параграфе 1.2, см. выше. Идём разбираться с миграциями детальнее
2. Практика кода с миграциями
2.1. Создаём БД миграциями
Первое, что нужно понять - для работы с БД через СУБД SQLite (или любую другую СУБД), нужен драйвер; популярный драйвер для SQLIte загружается с github.com/mattn/go-sqlite3 - это сторонняя библиотека для Go. А для работы с миграциями в SQLite нужна другая сторонняя библиотека. Например, для Go есть популярная библиотека миграций множества СУБД: github.com/golang-migrate/migrate
В ReadMe на SQLite3 указано, что в библиотеке миграций под капотом библиотека mattn в виде драйвера СУБД, о которой писал выше:
Далее в импорты пакета main, или где мы создаём БД, нужно прописать следующие строки:
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/golang-migrate/migrate/v4/source/file"
Выглядит это примерно так:
Далее открываем БД и создаём миграции. Я пока не пишу код для проверки подключения к БД в виде пинга, или, например, таймаута соединений для простоты и наглядности.
Что здесь нужно добавить: код создаёт в корне проекта БД с именем mydatabase.db - это вся база данных в одном файле. Также в корне проекта должны быть созданы вручную файлы миграций по примеру выше в отдельном каталоге, в данном случае в корне проекта есть папка migrations. Наименования файлов миграций, как я понял, выполняют двумя способами:
- Просто порядковый номер впереди до нижнего слеша, например: 001_create_users_table.up.sql, 001_create_users_table.down.sql, 002_add_email_to_users_table.up.sql и 002_add_email_to_users_table.down.sql;
- Текущей временной меткой файла, например: 20240929_122930_create_users_table.up.sql, 20240929_122930_create_users_table.down.sql, 20240930_125055_add_email_to_users_table.up.sql и 20240930_125055_add_email_to_users_table.down.sql.
Во втором примере 2024-09-30 и 12:50:55 - дата и время создания миграции. В обоих примерах после номера миграции через слеш идёт описание миграции в наименовании файла и суффикс up или down, обозначающий файл для создания или отката миграции.
Что ещё здесь хочу добавить. При работе с БД через миграции нужно создавать два файла: один для создания миграции, и второй для отката, примеры далее.
2.2. Создание и удаление таблицы миграциями
Файл создания таблицы может выглядеть так, как я указывал выше:
Это абстрактная таблица, в которой создаются три поля: id, name и price - например, для хранения товаров и их цен.
Файл миграции для отката этих изменений может выглядеть так:
2.3. Добавление столбца таблицы миграциями
Изменения схемы таблицы выполняется по алгоритму:
Иллюстрация взята с официального сайта sqlite, можно почитать там подробнее.
Файл миграции для создания нового столбца в таблице может выглядеть так:
Разберём код выше:
- ALTER TABLE: указывает, что мы хотим изменить таблицу. В данном случае, это таблица с именем items.
- ADD COLUMN: этот оператор указывает, что мы хотим добавить новый столбец в таблицу.
- note TEXT: здесь мы определили имя нового столбца note и его тип данных TEXT. Это означает, что в новом столбце можно будет хранить текстовые данные.
Теперь посмотрим на код отката этой миграции:
Вообще, в документации SQLite, указано, что для сложных изменений, потребуется создать новую таблицу, скопировать в неё требуемые для сохранения данные, а старую таблицу удалить (хотя алгоритм там посложнее, состоит из 12 шагов).
Ниже пример, как может выглядеть sql-запрос в файле миграции для удаление колонки этим способом. Говорят, в ранних версиях sqlite так и нужно было делать, т.к. не было прямой команды удалить столбец. А сейчас нам не нужен этот громоздкий код для удаления колонки, мы просто посмотрим, что бы мы примерно писали, если бы потребовалось, скажем, изменить тип хранимых в столбце данных, или изменить имя файла базы данных, или какую-то другую неподдерживаемую sqlte команду:
Команды, напрямую поддерживаемые SQLite для изменения схемы БД, следующие:
- Переименовать таблицу;
- Переименовать столбец;
- Добавить столбец;
- Удалить столбец.
2.4. Таблица миграций
При создании миграции создаётся таблица миграций в БД, т.е. в единственном файле из которого состоит вся БД. Запустим консольный интерфейс для работы с нашей БД в SQLite командой $ sqlite3 mydatabase.db (приложение sqlite3 должно быть установлено):
Командой $ .tables посмотрим все таблицы в БД:
Мы увидели, что в БД есть 2 таблицы, созданные СУБД SQLite: items и schema_migrations. Наименование второй таблицы было в ReadMe из библиотеки миграций SQLite:
Это были два способа узнать имя таблицы, в которой хранится информация о миграциях. Сейчас мы можем посмотреть, что в нашей таблице миграций, зная её имя через команду $ SELECT * FROM schema_migrations:
Запись 2|0, которую мы видим - это версия миграции и состояние. Расшифровка этой строки такова:
- 2: Это актуальная версия миграций, которая была применена к базе данных. Это указывает на то, что были применены две миграции. Собственно, в параграфах выше были показаны примеры с двумя миграциями: в одной мы создали таблицу items, а в другой миграции добавили колонку note.
- 0: Это "грязное" состояние базы данных. Если это число равно 0, это означает, что база данных находится в чистом состоянии (то есть не было ошибок в ходе миграций). Если был бы откат (или не завершенная миграция), это значение могло бы быть равно 1, указывая на то, что базу данных нужно "очистить" или возвратить к корректному состоянию. И с этим состоянием нужно работать особым образом, который не рассматривается в публикации.
Примеры содержимого таблицы миграций:
- 2|0: У меня применены две миграции, и не было ошибок или незавершенных миграций. То есть как на моей иллюстрации выше.
- 2|1: Были применены две миграции, но есть нефиксированное состояние или ошибка в одной из миграций.
2.5. Резервная копия БД
Перед применением новой миграции к БД, хорошей практикой будет создать резервную копию БД. Когда у нас вся БД - это один файл, сам собой напрашивается способ просто скопировать файл. Вот варианты действий:
1. Прямое копирование Ctrl+C, Ctrl+V.
2. Копирование через интерактивный режим SQLite3 в терминале:
3. Копирование файла БД через терминал:
4. Кодом в приложении.
2.6. Откат миграции
Я так и не разобрался, как откатить миграцию через терминал. Если знаете как - напишите в комментарий. Один из вариантов результатов такой:
С кодом в приложении как откатить миграции - разобрался. Нужно было только понять, как его логично встроить в структуру проекта.
Предположим, что после запуска приложения и применения миграций, в терминал будет выводиться запрос - нужен ли откат миграций. В упрощённой схеме это будет выглядеть так:
Сюда же можно добавить код бэкапа БД, если нам важны данные, которые могут быть удалены (например, при откате созданной таблицы или созданного столбца).
Собственно, это вся основная информация по созданию и использованию миграций в SQLite, с которой я разобрался на данный момент. Ещё хочу добавить пару слов о самой СУБД SQLite, раз я с ней начал разбираться лучше.
2.7. Ещё немного об SQLite
SQLite - это не просто учебная база данных, а профессиональный инструмент, используемый во множестве сфер деятельности: от пассажирских перевозок на авиалайнерах до приложений на телефонах. Взглянем на страницу официального сайта SQLite об известных пользователях СУБД.
SQLite встроен во все смартфоны и большинство компьютеров, эту СУБД применяют обычные люди каждый день, не задумываясь об этом. Разработчики обещают поддержку до 2050 года. А в активном использовании находится более 1 000 000 000 000 баз данных SQLite(!).
В общем, СУБД распространённая, можно осваивать и применять. Далее переходим к практике - пишем наше приложение.
3. Пишем приложение с миграциями БД
Хотел больше попрактиковаться с пакетом net/http, поэтому выбрал такое приложение, чтобы закрепить навык работы с SQLite и миграциями, где бизнес-логика связана с интернет-событиями.
В первой версии приложения мы по IP будем искать хосты и сохранять информацию в базу данных: ip, хост и время создания записи в БД.
Во второй версии приложения мы добавим функционал поиска как по ip, так и по домену, а также будем собирать дополнительную информацию об IP: страну, регион и город, с которым связан IP-адрес, провайдера, и другие данные. Также обновим схему базы данных через миграции: добавим новые столбцы для хранения соответствующей информации.
3.1. V1 приложения
Идею взял с сайта готовых рецептов Go и модифицировал код.
3.1.1. Код без БД
Первым делом создадим приложение без БД. Информация выводится в терминал. IP будем передавать через флаг.
Запустим программу с флагом -ip ip-адрес, например известных сайтов. Введём 129.222.0.0 или 8.8.8.8:
Пару слов о том, как работает код:
- В строках 11-14 мы прописали код ошибок, которые будет затем удобно подставлять в приложение.
- В строках 17-19 мы проверяем, введён ли аргумент командной строки при запуске программы. Команда go run main.go имеет 1 аргумент - main.go6 и он считается нулевым аргументом. Мы проверяем, имеется ли хотя бы два аргумента: нулевой и первый. И если аргументов меньше двух, то программа завершается.
- В строке 20 мы определяем имя флага командной строки и пишем подсказку, которая будет видна если скомпилировать приложение через go build и запросить подсказку --help:
В подсказке сказано, какой нужен флаг. Либо мы можем без компиляции программы, запустить её с некорректными аргументами - тоже получим ошибку:
4. В строке 21 мы разбираем флаги и их значения, переданные в командной строке и инициализируем связанные с флагами переменные.
4. В строках 23-25 проверяем, не пустой ли аргумент. Флаги парсятся в указатели, а значит мы проверяем значение переменной на которую ссылается указатель, а не сам указатель, т.к. сам указатель никогда не будет пустым.
5. В строках 27-30 вызываем функцию net.LookupAddr, которая выполняет обратный DNS-запрос, чтобы получить имя хоста на основе переданного IP-адреса. Результаты сохраняются в слайс строк. Обрабатываем ошибку - например, если передан несуществующий IP-адрес.
6. В строке 31 печатаем найденные хост/хосты и, для порядка, исходный IP-адрес.
3.1.2. Подключаем БД
Первым делом в функцию main добавим код:
Мы хотим, чтобы функция initDatabase возвращала указатель на объект, предоставляющий интерфейс для работы с БД типа *sql.DB, а также возвращаем ошибку.
В строке 26 мы закрываем объект db не привычной многим строкой defer db.Close(), а строкой более сложной, с анонимной функцией. Дело в том, что метод Close возвращает ошибку, и хотя мы её здесь не обрабатываем - будет хорошим тоном напомнить - что объект может быть не закрыт.
Далее напишем функцию initDatabase:
Я сразу решил добавить обработчик - нужна ли отмена последней миграции через функцию cancelMigrations, вот её код:
Затем создадим каталог migrations и создадим там два файла. Первый - для создания таблицы:
И второй файл - для отката изменений в БД по созданию таблицы, если это потребуется:
Таблицу я решил назвать hosts, т.к. это простое и понятное название места, где будут хранится данные о хостах.
Файл базы данных я назвал internet_resources, т.к. это широкое название для хранения различных данных, связанных с интернет-ресурсами, которое даёт пространство для размещения в БД в будущем других таблиц, связанных с общей идеей приложения.
Содержимое каталога миграций сейчас выглядит так:
Хорошо, код и миграции написали, пробуем запустить приложение: пока оно не будет сохранять информацию о хостах и ip в БД, а создаст схему БД и получит данные об ip:
Итак, БД создалась успешно и программа спрашивает, нужно ли откатить миграцию. Мы введём n и получим:
Теперь посмотрим что там внутри БД:
Здесь я сперва перешёл в утилиту sqlite3, затем сделал два запроса:
.schema - посмотреть структуру БД
select * from hosts; select * from schema_migrations; - посмотреть что лежит внутри таблиц. Мне показали содержимое таблицы миграций - видим, что применена одна миграция; а содержимое таблицы hosts пока пустое, поэтом данных и нет.
Вот я посмотрел содержание таблицы и - о ужас! для столбца host определён тип данных FLOAT, а мы ожидали хранить там строки, т.е. TEXT по типу данных SQLite. Вариантов тут два:
1. Удалить таблицу и создать всё снова.
2. Создать миграцию изменения таблицы.
Предположим, мы уже много-много информации добавили в таблицу и не хотим её удалять. Будем изменять схему таблицы новой миграцией.
3.1.3. Пишем миграцию изменения БД
В каталоге migrations добавляем файл 002...
Отмена миграции тоже будет объёмной - уже нельзя просто воспользоваться командой drop:
Содержимое каталога файлов миграций сейчас выглядит так:
Запускаем, проверяем:
Мы видим, что миграции применены успешно, затем я нажал n для того чтобы не откатывать последнюю миграцию. Но поскольку запустил программу без флага, программа завершилась с ошибкой. Но нас это пока не интересует, проверим структуру таблицы hosts:
Отлично, тип данных столба host изменился с FLOAT на TEXT.
Теперь можно ещё немного изменить БД, например добавить проверку на подключение к БД через пинг и таймаут соединения, а также добавить бекапы.
3.1.4. Пинг к БД
Начнём с простого улучшения - выполним пинг к БД. Для этого дополним функцию initDatabase:
Результат выполнения программы с пингом:
Теперь - зачем это нужно.
Пинг SQLite используется для проверки того, может ли программа взаимодействовать с базой данных. Если соединение успешно, это говорит о том, что файл базы данных доступен и правильно функционирует.
3.1.5. Резервное копирование БД перед миграцией
Особо выдумывать не буду, и добавлю в главную функцию запрос - хотим ли мы сделать резервную копию БД. Если мы знаем, что у нас будут новые миграции - будет нужно самому подтвердить необходимость выполнить резервную копию.
Сперва доработаем функцию initDatabase:
Затем напишем саму функцию reserveDatabase. Функция делает:
- Спрашивает, нужен ли бэкап БД.
- Если нет - завершает функцию.
- Если да - создаёт каталог для бэкапов, если его нет.
- Открывает текущую БД.
- Создаёт файл бэкапа с присвоением в имени текущей даты и времени до секунды.
- Копирует информацию из существующей БД в бэкап.
- Печатает лог в терминал
Результат выполнения кода в терминале будет таким:
А так будет выглядеть содержимое каталога с бэкапами - я сделал три бэкапа:
Формат начала наименования файла следующий: 2024-10-02 22:32:31 - год-месяц-день час:минута:секунда.
3.1.5. Создание таймаута соединений
Меняем начало функции с такого:
На такой:
По-хорошему, такие вещи нужно делать в отдельных функциях командой fmt.Sprintf, где прописывать из переменной наименование файла базы данных, число. Да, 50 здесь - это время соединения в миллисекундах, по истечении которого если не будет получен ответ от БД, операция обращения к базе данных завершится с ошибкой. Мол, долго запрос не выполняется.
Поработали над созданием БД, теперь можно переходить к её наполнению.
3.1.6. Заполняем БД
Сперва дополним функцию main:
Обратите внимание, что передаём мы первый элемент среза host. Просто для удобства будем считать, что нам достаточно одного хоста (их может быть по ip много). И вот ещё - по идее переменную host нужно назвать hosts, т.к. это срез.
Далее добавим в константы несколько сообщений об ошибках, которые будут повторяться в коде:
Далее напишем код добавления информации в базу данных
Что здесь интересного. В строках 154-157 использовал именованные параметры функции. Часто можно встретить в sql-запросе не что-то вроде :ip, а знак вопроса , т.е. ?. Это не наглядно, а так - наглядно.
Далее сделал несколько обработок ошибок: по тексту ошибок в константах понятно, что именно проверяется.
Результат запуска программы в терминал будет таким:
Проверим содержимое БД через консольную утилиту sqlite3:
Ну вот, всё на месте. Запустим программу ещё раз и добавим другой ip. Кстати, выход из sqlite3 можно выполнить сочетанием клавиш Ctrl+D.
Смотрим, что хранится в БД:
Кстати, для удобства отображения информации, рекомендую перейти в табличный режим командой .mode table:
Готово, теперь чтобы нам постоянно не обращаться за помощью к sqlite3, добавим в приложение вывод 10 (или меньше) последних добавленных позиций из БД.
И вот ещё что - глядя на отображение информации в табличном режиме, я увидел что нет информации в столбце timeAdd. Здесь можно пойти двумя путями: создать миграцию, где в тип данных для столбца указать поле автоматического заполнения даты. Либо доработать функцию вставки.
Первый вариант считаю полезнее, т.к. в дальнейшем может появиться одновременные попытки обратиться к БД для записи, и не факт что время, полученное в функции будет соответствовать времени, записанному в БД.
Но эту историю мы оставим для следующей стадии разработки, когда будем планово обновлять проект миграциями.
3.1.7. Выгрузка информации из БД
Сперва дорабатываем функцию main:
Далее пишем функцию печати. Первая часть кода:
Вторая часть кода:
Запускаем приложение:
Получили вывод содержимого БД в терминал. Переходим к следующей части.
3.2. V2 приложение
Поскольку мы изучаем миграции, нужно придумать что-то такое, что потребует изменить схему БД. Пусть это будет получение гео-данных по ip. Также я решил, что мало интересного искать данные просто по IP - интереснее ввести домен и получить информацию о нём, включая IP. Вот этим и будем заниматься в расширении функционала приложения.
3.2.1. Источник гео-информации
Чтобы получить гео-информацию по ip, мы воспользуемся сайтом (точнее, API), на котором хранится гео-информация по ip:
Вот как выглядит полученная информация из браузера, если напрямую ввести в поисковую строку запрос: http://ip-api.com/json/129.222.0.0
У меня установлено приложение для форматирование json-объектов в браузере. Без расширения будет скорее всего что-то такое, менее читаемое:
Итак, с источником информации разобрались, переходим к реализации бизнес-логики.
3.2.2. Обработка флагов ip и domain
Начнём с реализации поиска хоста по ip и ip по домену.
Выведем парсинг флагов в отдельную функцию:
Первая проверка на три аргумента подразумевает, что в командной строке мы должны передавать либо ip, либо домен, а не оба флага одновременно.
Затем я решил написать одну функцию, которая разбирает что мы получили ip или домен, печатает все найденные ip и один домен, если передан домен; либо все хосты и один ip, если передан ip во флаге при запуске программы. Затем возвращает один ip и один хост/домен:
Из интересного здесь - преобразование типа string в тип []net.IP в строке кода 77 и преобразование типа []net.IP обратно в тип string в строке кода 89.
Также по названию функции - назвал IPAndHost подразумевая, что получаю IP и хост в этой функции. Логичнее было бы написать getIPAndHost, однако слышал, что в среде разработчиков Go есть негласное правило не приписывать get к наименованию функции.
Доработаем функцию main, вызывая созданные функции:
Запустим, посмотрим что получили при передаче аргументом домена через флаг -domain vk.com:
Так, код работает. Ради интереса возьмём любой выданный ip сайта vk.com и запустим с флагом -ip 87.240.132.67:
Как видим, приложение также работает. Переходим к получению расширенных данных по ip.
3.2.3. Гео-информация
Создадим структуру данных, которые хотим собирать. Я решил собрать такие данные:
Я не прописал джейсон-тег для поля Host, т.к. источник гео-информации не предоставляет его, и мы заполним это поле ранее найденной информацией.
Далее соберём гео-информацию функцией geoInfo:
Здесь мы подключаемся к интернет-API. Создаём экземпляр структуры, описанной выше, и кладём в неё данные из запроса за счёт json-тегов в описании структуры.
В константы я добавил домен нашего источника данных для создания к нему Get-запроса:
Далее я написал функцию печати структуры в терминал:
А затем доработали функцию main:
Запускаем приложение:
Всё отлично работает. Пишем миграции для обновления схемы БД.
3.2.4. Пишем миграции к схеме БД
Задачи у нас две:
- Изменить тип данных в столбце БД, чтобы временная метка генерировалась внутри БД, а не передавалась извне.
- Добавить новые колонки.
Поскольку первую задачу можно сделать только путём создания новой таблицы, то мы будем создавать новую таблицу уже с новыми колонками.
Файл создания миграции выглядит так:
Файл отмены миграции выглядит так:
Проверим схему БД в sqlite3:
Схема БД та, что мы ожидали. Переходим к заполнению БД новыми данными.
3.2.5. Дорабатываем операции вставки
Доработаем функцию insertInfo. Прежде всего, сделаем её методом:
Остальной код функции без изменений. Функция main дорабатывается, думаю - понятно как - убрали передачу ip и хоста, добавили метку экземпляра структуры.
Проверяем содержимое БД:
Признаться, тут я уже запустил отдельный терминал, а не терминал из IDE, т.к. вся таблица не помещалась в окне и наезжала друг на друга.
Можно на этом заканчивать, т.к. публикация итак получилась длинной. Из домашней работы, что ещё здесь напрашивается сделать:
- Распределить код на слои с применением интерфейсов. Сейчас код идёт единым полотном на 321 строку кода.
- Добавить вывод всей информации в терминал из БД.
- Добавить тесты.
Ссылка на код в GitHub.
4. Выводы
Мы познакомились с миграцией схем базы данных и попрактиковали их разработку. Это большое подспорье в дальнейшей работе с базами данных не важно в какой СУБД - SQLite, PostrgeSQL или других. Также потренировались в работе с пакетом http/net и сопутствующим ему пакетах net/url, encoding/json и других.
Предстоит ещё немало работы, чтобы начать разбираться в БД хотя бы на начальном уровне; например, в ходе работы возникало "состояние грязной схемы БД" - я пока не разобрался, как восстанавливать схему; а также как применять и откатывать миграции через терминал, а не кодом в приложении.
Также будет полезно научиться применять миграции по правилам 12 шагов, обозначенных выше: с транзакциями.
Благодарю, что дочитали публикацию до конца. Продолжайте развиваться, не унывайте, верьте в свои силы и улучшайте свой характер: технологии приходят и уходят, а нравственность, мудрость и доброта остаются с нами всегда. Успехов, и будем на связи.
Бро, ты уже здесь? 👉 Подпишись на канал для начинающих IT-специалистов «Войти в IT» в Telegram, будем изучать IT вместе 👨💻👩💻👨💻