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

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

Типы программной архитектуры

Архитектурные стили можно разделить на два основных типа:

  • монолитные (с единым развертываемым блоком для всего кода) 

  • распределенные (с несколькими развертываемыми блоками, взаимодействующими друг с другом по различным протоколам).

На изображении ниже представлены типы программной архитектуры.

Типы программной архитектуры
Типы программной архитектуры

Монолитные архитектуры

Многоуровневая архитектура

Многоуровневая архитектура — популярный подход к разработке приложений, относящихся к монолитному типу.

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

Количество и типы уровней могут варьироваться, но чаще всего встречаются четыре стандартных уровня: слой представления, слой бизнес‑логики, слой доступа к данным и слой базы данных.

Многоуровневая архитектура
Многоуровневая архитектура

Архитектура каждого слоя состоит из множества элементов. Эти элементы представляют собой части программного кода. Такие элементы называют компонентами.

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

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

  • Компоненты слоя доступа к данным реализуют логику извлечения, записи, редактирования и удаления данных независимо от особенностей их хранения.

  • Компоненты слоя базы данных реализуют функцию хранения данных и представляют собой различные СУБД, платформы управления облачными хранилищами и другие источники данных.

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

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

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

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

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

➕ Плюсы: простота и скорость разработки и тестирования.

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

? Что почитать: Монолитные архитектуры

Распределенные архитектуры

Клиент-серверная архитектура

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

Клиент-серверная архитектура описывает как взаимодействует клиент и сервер.

Клиент-серверная архитектура
Клиент-серверная архитектура

Клиент это та часть системы, с которой работает пользователь, отвечает за отображение пользовательского интерфейса и отправку запросов на сервер.

Он может быть веб-приложением, открывающимся в браузере, или десктоп-приложением, установленным на компьютере. 

Сервер это та часть системы, которая обрабатывает запросы от клиента, выполняет определенную бизнес-логику и возвращает ответ клиенту. 

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

Преимуществами клиент-серверной архитектуры является:

  • экономия ресурсов и легкое масштабирование;

  • невысокая сложность разработки и поддержки;

  • безопасность данных, хранимых на сервере (например — в базе данных), т. к. доступ к ним напрямую с клиента невозможен.

Недостатками являются:

  • зависимость доступности системы (сервера) от состояния сети;

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

Сервис-ориентированная архитектура

Сервис-ориентированная архитектура (SOA) является архитектурой уровня предприятия, а не отдельной системы (приложения), и базируется на двух составляющих: сервисы и интеграционная шина (ESB — Enterprise Service Bus) для обмена сообщениями между ними.

Сервис-ориентированная архитектура
Сервис-ориентированная архитектура

Сервисы — это независимые элементы системы, которые реализуют некоторую бизнес‑функцию и/или соответствуют бизнес‑процессу предприятия.

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

Сервисы предприятия — высокоуровневые сервисы, соответствующие бизнес‑доменам или процессам, потребляюсь сервисы нижнего уровня.

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

Инфраструктурные сервисы — отличаются от прикладных лишь тем, то переиспользуются под разные задачи.

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

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

Микросервисная архитектура

Микросервисная архитектура (MSA) основана на концепции ограниченного контекста (bounded context) из предметно‑ориентированного проектирования (Domain‑driven design, DDD), при котором система разделяется на слабо связанные друг с другом части, разрабатываемые и развёртываемые независимо друг от друга.

Слабая связанность — важная характеристика MSA, заключающаяся в том, что все взаимодействие с сервисом проис­ходит через его API, содержащий подробности его реализации. Это позволяет изменять внутреннюю логику сервиса, не создавая необходимость доработки его клиентов (других сервисов, взаимодействующих с ним). Таким образом слабая связанность это ключ к ускорению разработки приложений, их тестирования и сопровождения.

Можно проследить определённое сходство у SOA и MSA, так как оба подхода рассматривают систему как набор сервисов, но в таблице я укажу из основные различия.

Параметр

SOA

MSA

Типовой сервис

Крупное монолитное приложение

Небольшой сервис, ограниченный контекстом

Межсервисное взаимодействие

«Умные» каналы, наподобие сервисной шины ESB

Примитивные каналы вроде интеграций по REST, gRPC или с помощью обмена сообщениями (Kafka, RabbitMQ)

Данные

Общая модель данных и БД

Отдельная модель данных и БД для каждого сервиса

В соответствии с DDD в каждом сервисе моделируется одна предметная область или рабочий процесс.

Из каких основных частей кодовой базы состоит абстрактный сервис в MSA? Терминология может быть расхожей, однако смысл одинаков.

  • Контроллер (он же слой API, принимающий запросы от сторонних сервисов).

  • Клиент (он же адаптер, отправляющий запросы в сторону внешних сервисов).

  • Репозиторий (адаптер, взаимодействующий с базой данных)

  • Сервис (реализация бизнес-логики, выполняемая внутри микросервиса)

Микросервисная архитектура
Микросервисная архитектура

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

Микросервисная архитектура не предъявляет требований к формату баз данных, но концептуально предполагает наличие отдельной базы данных для каждого сервиса (database per service). Этот подход называется изоляцией данных и является одним из паттернов проектирования MSA.

Еще одним из паттернов, реализующим единую точку входа в приложение, является API Gateway — серверный прокси, который перенаправляет запросы клиентов (приложений, устройств, пользователей) к нужному сервису и выполняет следующие функции: 

  • валидация параметров запроса;

  • фильтрация по спискам (allow/deny list); 

  • авторизация/аутентификация;

  • ограничение запросов (rate limit); 

  • балансировка трафика запросов (load balancing);

  • динамическая маршрутизация запросов (dynamic routing);

  • обнаружение сервисов (service discovery);

  • преобразование протоколов (protocol conversion);

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

Проектируя микросервисную архитектуру важно не забывать о следующих ограничениях:

  • сетевые задержки. Передача информации по сети не мгновенна (да ещё и стоит денег). Поэтому иногда целесообразно укрупнять микросервисы или использовать пакетные (batch) запросы для получения/отправки данных.

  • синхронное взаимодействие ухудшает доступность. Если вызываемый сервис недоступен (вышел из строя, перезагрузился и т. п.), то весь процесс остановится до тех пор, пока он не восстановится. Существует ряд методов, как с этим бороться (например, резервирование), но необходимо, в том числе, стараться использовать асинхронное взаимодействие.

  • обеспечение согласованности данных между сервисами.

➕ Плюсы MSA: производительность, независимая масштабируемость, простота тестирования, возможность непрерывной поставки и развёртывания (CI/CD - continuous integration, continuous delivery).

Минусы MSA: сложность сопровождения, сложность управления изолированными данными.

? Что почитать:

Событийно-ориентированная архитектура

Событийно-ориентированная архитектура (Event-driven architecture, EDA), является популярным стилем распределенной асинхронной архитектуры, которая используется для создания высокопроизводительных масштабируемых приложений. 

Асинхронный обмен данными может быть весьма эффективным приемом сокращения времени отклика системы.

Событийно-ориентированная архитектура
Событийно-ориентированная архитектура

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

Компоненты cобытийно‑ориентированной архитектуры:

  • Событие (event) — создание новой сущности или изменение состояния существующей.

  • Производитель события (producer) — сервис, который создаёт событие.

  • Потребитель события (consumer) — сервис, который получает событие и обрабатывает его, после чего порождается новое событие — результат обработки события.

  • Брокер сообщений (message broker) — система, обеспечивающая доставку события от производителя до потребителя.

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

Существуют две модели доставки событий:

  • Производитель/Подписчик (Publisher/Subscriber): производитель отправляет сообщения брокеру, брокер рассылает сообщения подписчикам, после отправки сообщения удаляются. Самый часто используемый пример такого брокера – RabbitMQ.

  • Потоковая передача: производитель отправляет сообщения брокеру, брокер сохраняет сообщения, потребители сами по готовности считывают сообщения из брокера. События не удаляются брокером, а хранятся в течение настраиваемого промежутка времени. Самый часто используемый пример такого брокера – Apache Kafka.

В событийно-ориентированной архитектуре, используются две основные топологии: медиатор (mediator) и брокер (broker).

Отличие топологии брокера от топологии медиатора в том, что обработчики событий в топологии медиатора не объявляют всей системе о своих действиях, а рассылают сообщения в режиме «точка‑точка», в то время, как в топологии брокера рассылку сообщений можно назвать «широковещательной» — все подписчики получат сообщение, но прочитает только тот, кому оно предназначалось.

➕ Преимуществами EDA являются:

  • слабая связанность сервисов, взаимодействующих исключительно через события;

  • высокая скорость и параллельная обработка событий;

  • высокая доступность и отказоустойчивость в виду возможности реплицирования/резервирования сервисов.

Недостатками EDA являются:

  • сложность разработки, развертывания и тестирования;

  • единая точка отказа в виде брокера сообщений;

  • высокие затраты на инфраструктуру в виду высокой производительности решения.

? Что почитать: Event-driven architecture

Как выбрать архитектуру системы?

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

С чем часто придется работать:

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

  • Оценка масштабируемости и производительности. Аналитику необходимо рассчитать нагрузку на систему, а затем оценить, насколько система должна быть масштабируемой и готовой к ее росту.

Как рассчитать нагрузку и оценить стоимость

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

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

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

  3. Считаем сетевой трафик.

  4. Считаем нагрузку на хранилище.

  5. Считаем нагрузку на сервер.

  6. Оцениваем стоимость.

Упрощенный пример. Проектируем сервис для обмена сторис (короткими видео).

  1. Предполагаемая дневная аудитория сервиса (DAU) в первый год после запуска ~ 10к человек.
    Объём сторис после сжатия: 1 Мб.

  2. Предположим, что пользователи в течение дня равномерно выкладывают 5 сторис и просматривают 50 сторис.

  3. Сетевой трафик:
    Входящей нагрузки (при сохранении сторис):
    10 000 пользователей * 5 сторис * 1 Мб * 365 дней = 18,25 ТБ
    Исходящей нагрузки (при просмотре сторис):
    10 000 пользователей * 50 сторис в день* 1 Мб * 365 дней = 182,5 ТБ нагрузки в год.

  4. Нагрузка на хранилище:
    5 сторис * 10 000 пользователей = 50 000 сторис/день * 365 дней = 18 250 000 сторис в год * 1 Мб = 18,25 ТБ требуемый объём хранилища.

  5. Нагрузка сервера на запись:
    10 000 пользователей * 5 сторис в день / 86400 сек/день = 0.57rps
    Нагрузка сервера на чтение:
    10 000 пользователей * 50 сторис в день / 86400 сек/день = 5.7rps

  6. Стоимость:
    Предположим, что стоимость хранения 1 Гб данных = 1,21 ₽
    Стоимость хранилища составит 262 800 ₽ в год.

    Предположим, что стоимость 1 Гб исходящей по сети информации (обычно платят за исходящий трафик) = 0,7 ₽
    Стоимость сетевого трафика составит 127 750 ₽ в год.

    Так как нагрузка сервера на запись и чтение невысокая, один VPS-сервер (2 CPU, 8Гб RAM) с ней вполне справится, добавим 60 000 ₽ в год.

  • Безопасность. Аналитик должен учитывать требования безопасности и определять, насколько критична информация, хранимая/обрабатываемая системой и какие меры защиты должны быть приняты.

  • Технологический стек. Аналитик должен учитывать, какие технологии доступны и какие из них лучше всего подходят для данной системы, и все это — с учетом трех пунктов выше.

С чем придется сталкиваться реже:

  • Бюджет. Аналитик должен учитывать бюджетные ограничения и выбирать архитектуру, которая соответствует этим ограничениям. Скорее всего вы не будете жестко ограничены в бюджете, и не будете обязаны рассчитывать косты проектов (от англ. cost — стоимость), это больше прерогатива сотрудников бизнес-звена.

  • Ограничения в организации и опыт команды. Думаю, тут и так все понятно.

В любом случае — не существует правильного или универсального решения. Любое архитектурное решение это компромисс, зависящий от большого количества обстоятельств.

Масштабирование сервисов

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

Масштабируют сервисы с помощью одного, двух или сразу трёх подходов:

  • Копирование приложения — создание копий приложения и равномерное распределение запросов между ними с помощью балансировщика.

Копирование приложения
Копирование приложения
  • Разделение по данным — создание копий приложения, каждая из которых хранит только определённое подмножество данных (партицию), и распределение запросов между ними на основании значений их атрибутов с помощью роутера.

Разделение по данным
Разделение по данным
  • Разделение по функциям — разделение приложения на сервисы, каждый из которых выполняет только часть функций. Сервис, который масштабируется по функциям, можно в дальнейшем копировать и/или масштабировать по данным.

Разделение по функциям
Разделение по функциям

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

Смешанный подход к масштабированию
Смешанный подход к масштабированию

Масштабировать можно и базу данных.

Выделяют три метода: 

  • Партиционирование — разделение БД на части в рамках одного сервера. Может быть вертикальным (по столбцам) или горизонтальным (по строкам);

Партиционирование БД
Партиционирование БД
  • Шардирование — разделение БД на части по разным серверам, может быть только горизонтальным (по строкам);

Шардирование БД
Шардирование БД
  • Репликация — копирование одних и тех же данных между разными серверами. Создается дваэкземпляра БД — ведущий (Master) и ведомый (Slave). В нормальном режиме функционирования данные пишутся в Master, и реплицируются в Slave. В случае выхода из строя Master-ноды, взаимодействие будет моментально переключено на Slave до восстановления.

Репликация БД
Репликация БД

Кэширование

Отдельно необходимо сказать про кэш, как подход к масштабированию и возможность снизить нагрузку на базу данных.

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

Один из популярных и самых распространённых подходов к кешированию - сквозное чтение. Получив запрос, сервер сначала проверяет наличие данных в кэше. Если данные есть, данные возвращаются клиенту. Если нет, то сервер обращается к базе данных, сохраняет ответ в кэше и пересылает его обратно клиенту.

При проектировании кэша помимо выбора подхода к кэшированию, необходимо помнить ещё о нескольких аспектах:

  • Предпочтительное использование кэша — для чтения редко изменяемых, но часто запрашиваемых данных.

  • Согласованность — периодическое обновление данных в кэше данными из БД.

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

  • Выбор политики вытеснения — когда кэш полностью заполнен, любой запрос на добавление новых элементов может привести к удалению существующих. Самой популярной политикой считается вытеснение давно неиспользуемых данных (least‑recently‑used, LRU). Для разных ситуаций могут также подойти вытеснение наименее часто используемых данных (least-frequently-used, LFU) или метод «первым пришел, первым ушел» (FIFO, first-in-first-out).

CAP-теорема, транзакции и ACID

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

Транзакция — это логическая операция, состоящая из одного или нескольких запросов, которые выполняются как единое целое.

Одним из краеугольных камней построения распределенных систем является CAP‑теорема, утверждающая то, что в любой распределенной системе с данными возможно обеспечить не более двух из трёх следующих свойств:

  • согласованность данных (англ. consistency) — во всех распределенных системах в один момент времени данные не противоречат друг другу — они актуальны и одинаковы в любой момент времени на каждом узле системы;

  • доступность (англ. availability) — любой запрос к распределённой системе завершается откликом, но без гарантии, что ответы всех узлов системы одинаковы;

  • устойчивость к разделению (англ. partition tolerance) — потеря связи между узлами распределённой системы не приводит к некорректности отклика от каждого из узлов.

Несмотря на то, что на практике возможно достичь компромисса в решениях, основными вариантами сочетания свойств являются:

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

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

  • СА — во всех узлах распределенной системы данные согласованы и обеспечена доступность, при этом система жертвует устойчивостью к разделению. Такие системы возможны на основе решений, поддерживающих ACID-транзакции.

Этот принцип проиллюстрирован на изображении ниже.

CAP-теорема, ACID, уровни изоляции транзакций.
CAP-теорема, ACID, уровни изоляции транзакций.

ACID (Atomicity, Consistency, Isolation, Durability) — набор характеристик, обеспечивающих надежность транзакций в базах данных.

  • Атомарность (Atomicity): Транзакция является единым объектом. Она либо выполняется полностью, либо не выполняет вообще.

  • Согласованность (Consistency): Транзакция должна переводить базу данных из одного согласованного состояния в другое (например, в каждом столбце БД значения имеют нужный тип данных, не нарушены ограничения, операции выполнены по порядку).

  • Изолированность (Isolation): Каждая транзакция должна быть изолирована от других и ее выполнение не должно влиять на другие транзакции.

  • Долговечность (Durability): После успешного завершения транзакции изменения в БД должны сохраняться даже в случае сбоев системы. Если пользователь получил подтверждение от системы, что транзакция выполнена, он может быть уверен, что сделанные им изменения не будут отменены.

Что такое ACID хорошо разобрано на примере в этой статье.

Расширением теоремы CAP является теорема PACELC.

Она гласит, что в случае сетевого разделения (P) в распределенной компьютерной системе необходимо выбирать между доступностью (A) и согласованностью (C) (согласно теореме CAP), но в противном случае (E), даже когда система работает нормально при отсутствии разделения, необходимо выбирать между задержкой (L) и потерей согласованности (C).

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

Говоря про транзакции нельзя не сказать о существовании понятия уровня изоляции транзакций в базе данных — условное значение, определяющее меру допустимости получения несогласованных данных в результате выполнения параллельных транзакций в БД.

Существует шкала из четырёх уровней изоляции: Read uncommitted, Read committed, Repeatable read, Serializable. Первый из них является самым слабым, последний — самым сильным, каждый последующий включает в себя все предыдущие.

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

  • Read committed (чтение фиксированных данных) — параллельно исполняемые транзакции видят только зафиксированные изменения других транзакций. Таким образом, данный уровень обеспечивает защиту от грязного чтения (каждая транзакция видит незафиксированные изменения другой транзакции), но не защищает от чтения фантомов (каждая транзакция видит вставленные другой транзакцией строки) и неповторяющегося чтения (каждая транзакция видит обновленные и удаленные другой транзакцией строки).

  • Repeatable read (повторяющееся чтение) — уровень , при котором читающая транзакция «не видит» изменения читаемых данных другой транзакцией, но видит добавленные строки. При этом никакая другая транзакция не может изменять данные, читаемые текущей транзакцией, пока та не окончена.

  • Serializable (упорядочиваемость) — транзакции полностью изолируются друг от друга. Достигается за счет того, что изменяющая транзакция блокирует всю таблицу или строки для изменяющих и читающих транзакций, а читающая транзакция блокирует всю таблицу или строки для изменяющих транзакций.

? Что почитать:

https://habr.com/ru/articles/469415/
https://habr.com/ru/articles/317884/


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

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

Эту и другие статьи по системному анализу и IT-архитектуре, вы сможете найти в моем небольшом уютном Telegram-канале: Записки системного аналитика

Комментарии (0)