Как устроен наш код. Серверная архитектура одного проекта
Так сложилось, что к тридцати годам я менял работу лишь единожды и не имел возможности на собственном опыте изучить, как в различных компаниях устроены веб-проекты, расчитанные на высокую скорость отклика и большое количество пользователей.
MoneyFlow. Постановка задачиВиртуальный заказчик хочет создать облачную систему для учета расхода средств из семейного бюджета. Он уже придумал ей название — MoneyFlow и нарисовал макеты UI. Он хочет, чтобы у системы были веб, андроид и iOS версии, и приложение имело высокую (Разработка ПО — процесс итеративный, и количество итераций при разработке главным образом зависит от того, насколько точно задача была поставлена изначально. В этом смысле нашей команде повезло, у нас есть два замечательных аналитика и не менее замечательный дизайнер-верстальщик (да, и такая удача тоже бывает), так что к началу работы мы обычно имеем финальный вариант ТЗ и готовую верстку, что значительно упрощает жизнь и освобождает нас, разработчиков, от головной боли и развития экстрасенсорных навыков для чтения мыслей заказчика на расстоянии. Виртуальный заказчик в моем лице нас тоже не подвел и предоставил макеты UI в качестве описания своего видения сервиса. Я заранее прошу прощения перед специалистами в построении UI, пиксель-перфекционистами и просто людьми с развитым чувством прекрасного за жуткую графику макетов и не менее ужасное их юзабилити. Моим единственным (пусть и слабым) оправданием служит лишь то, что это больше прототип, нежели реальный проект, но я все равно спрячу эти макеты под кат.
Идея сервиса проста — пользователь заносит в систему расходы, на основе которых строятся отчеты в виде круговых диаграмм.
Макет 1. Добавление расхода Макет 2. Пример отчета Категории расходов и виды отчетов заранее определены заказчиком и одинаковы для всех пользователей. Веб-версия приложения будет представлять собой SPA, написанное на Angular JS, и серверное API на ASP.NET, от которого JS приложение получает данные в формате JSON.Контракт взаимодействия между клиентской и серверной частями приложения В нашей команде разработка нового функционала серверной и клиентской частей сервиса ведется параллельно. После ознакомления с ТЗ мы первым делом определяем интерфейс, по которому строится взаимодействие фронтенда с бэкендом. Так сложилось, что наше API построено как RPC, а не REST, и при его (API) определении мы в первую очередь руководствуемся принципом необходимости и достаточности передаваемых данных. Попробуем по макетам прикинуть, какая информация может потребоваться клиентскому приложению от серверной части.Макет 3. Список операций В макете списка у нас всего три столбца — сумма, описание и день, когда была совершена операция. Кроме этого клиентскому приложению для отображения списка потребуются категория, к которой относится расходная операция, и Id, чтобы можно было кликом из списка перейти к экрану редактирования.Пример результата вызова серверного метода для страницы со списком совершенных расходных операций.
[ { «id»: «fe9a2da8–96df-4171-a5c4-f4b00d0855d5», «sum»:540.0, «details»: «Покупка карточки для метро», «date»:»2015–01–25T00:00:00+05:00», «category»: «Transport» }, { «id»: «a8a1e175-b7be-4c34–9544–5a25ed750f85», «sum»:500.0, «details»: «Поход в кино», «date»:»2015–01–25T00:00:00+05:00», «category»: «Entertainment» } ] Теперь посмотрим на макет страницы редактирования.
Макет 4. Экран редактирования Для отображения страницы редактирования клиентскому приложению потребуется метод, возвращающий следующую JSON строку. { «id»:»23ed4bf4–375d-43b2-a0a7-cc06114c2a18», «sum»:500.0, «details»: «Поход в кино», «date»:»2015–01–25T00:00:00+05:00», «category»:2, «history»:[ { «text»: «отредактирована в веб версии приложения», «created»:»2015–01–25T16:06:27.318389Z» }, { «text»: «создана в веб версии приложения», «created»:»2015–01–25T16:06:27.318389Z» } ] } По сравнению с моделью операции, запрашиваемой в списке, на странице редактирования в модель добавилась информация об истории создания/изменения записи. Как я уже писал, мы руководствуемся принципом необходимости и достаточности и не создаем единую общую модель данных для каждой сущности, которая могла бы использоваться в API в каждом связанном с ней методе. Да, в приложении MoneyFlow разница в моделях списка и страницы редактирования всего в одно поле, но в реальности такие простые модели бывают только в книгах, наподобие «Как за 7 дней научиться программировать на C++ на уровне эксперта», с недобро улыбающимся упитанным мужиком в свитере на обложке. Настоящие проекты устроены намного сложнее, разница в модели для экрана редактирования по сравнению с запрашиваемой в списке моделью в реальном проекте у нас может достигать двузначного количества полей и, если мы будем пытаться везде пользоваться одной и тоже моделью, мы неоправданно увеличим время генерации списка и впустую будем нагружать наши сервера.JSON объекты, отдаваемые сервером, это конечно же сериализованные DTO объекты, поэтому в серверном коде у нас будут классы, определяющие модели для каждого метода из API. В соответствии с принципом DRY эти классы, если это возможно, построены в иерархию.
Классы, определяющие контракт API MoneyFlow для экранов списка, создания и редактирования операций.
//модель расхода для слоя создания public class ChargeOpForAdding { public double Sum { get; set; } public string Details { get; set; } public ECategory Category { get; set; } }
//модель расхода для списка public class ChargeOpForList: ChargeOpForAdding { public Guid Id { get; set; } public DateTime Date { get; set; } }
//модель расхода для слоя редактирования
public class ChargeOpForEditing: ChargeOpForList
{
public List
Синхронный и асинхронный стеки выполнения Кроме увеличения производительности за счет хранения данных в максимально удобном для чтения виде мы стараемся большую часть работы производить незаметно для пользователя в асинхронном стеке выполнения. Каждый раз, когда со стороны клиентского приложения к нам приходит запрос, мы смотрим, какую часть работы необходимо выполнить до того, как ответ будет возвращен клиенту. Очень часто мы возвращаем ответ почти сразу, выполнив лишь необходимую часть вроде проверки валидности пришедших данных, переложив большую часть работы на асинхронную обработку. Работает это следующим образом. Асинхронным стеком выполнения мы в команде называем фоновые процессы, занятые обработкой приходящих с сервера очередей сообщений. Устройство нашего фонового обработчика я опишу подробно чуть ниже, а пока посмотрим пример взаимодействия клиентского приложения и серверного API при клике на кнопку «Добавить» из макета 1. Для тех кому лень скроллить — при нажатии на эту кнопку происходит добавление нового расхода «Поход в кино»: 500р в систему.Какие действия необходимо выполнять каждый раз при внесении в систему нового расхода?
Проверить валидность входных данных Добавить информацию о совершенной операции в модуль учета расходов Обновить соответствующий отчет в модуле отчетности Какие из этих действий мы должны успеть выполнить до того, как отрапортуем клиенту об успешной обработке операции? Совершенно точно, что мы должны убедиться в том, что переданные клиентским приложением данные корректны, и вернуть ему ошибку в случае если это не так. Мы также вполне можем добавить новую запись в модуль учета расходов (хотя можем и это передать в фоновый процесс), но будет совершенно неоправданно синхронно обновлять отчеты, заставляя пользователя ждать пока каждый отчет, а их может быть и несколько, будет обновлен.Теперь сам код. Для каждого пришедшего с клиентской части запроса мы создаем объект, ответственный за его корректную обработку. Объект, обрабатывающий запрос на создание новой расходной операции, выглядел бы у нас так.
//Объект, обрабатывающий //запрос создания расходной операции public class ChargeOpsCreator { private readonly IChargeOpsStorage _chargeOpsStorage; private readonly ICategoryStorage _categoryStorage; private readonly IServiceBusPublisher _serviceBusPublisher;
public ChargeOpsCreator (IChargeOpsStorage chargeOpsStorage, ICategoryStorage categoryStorage, IServiceBusPublisher pub) { _chargeOpsStorage = chargeOpsStorage; _categoryStorage = categoryStorage; _serviceBusPublisher = pub; }
public Guid Create (ChargeOpForAdding op, Guid userId) { //Проверяем входные данные CheckingData (op); //Работу по внесению операции в //модуль расходов проведем синхронно var id = Guid.NewGuid (); _chargeOpsStorage.CreateChargeOp (op); //Передаем часть работы в фоновый процесс _serviceBusPublisher.Publish (new ChargeOpCreated () {Date = DateTime.UtcNow, ChargeOp = op, UserId = userId}); return id; }
//Проверяем входные данные
private void CheckingData (ChargeOpForAdding op)
{
//Сумма должна быть больше нуля
if (op.Sum <= 0)
throw new DateValidationException("Переданной категории не существует");
//Расходная операция должна относится к реально существующей категории
if (!_categoryStorage.CategoryExists(op.Category))
throw new DateValidationException("Переданной категории не существует");
}
}
Объект ChargeOpsCreator проверил корректность входных данных и добавил совершенную операцию в модуль учета расходов, после чего клиентскому приложению возвратился Id созданной записи. Процесс обновления отчетов у нас производится в фоновом процессе, для этого мы отправили на сервер очередей сообщение ChargeOpCreated, обработчик которого и обновит отчет для пользователя. Сообщения, отправляемые в сервисную шину, это простые DTO объекты. Вот так выглядит класс ChargeOpCreated, который мы только что отправили в сервисную шину.
public class ChargeOpCreated
{
//когда была совершена операция
public DateTime Date { get; set; }
//информация, пришедшая с клиентского приложения
public ChargeOpForAdding ChargeOp{ get; set; }
//пользователь, внесший расходную операцию
public Guid UserId { get; set; }
}
Разбиение приложения на слои
Оба стека выполнения (синхронный и асинхронный) на уровне сборок у нас разбиты на три слоя — слой приложения (контекст исполнения), слой бизнес-логики и сервисы хранения данных. У каждого слоя строго определена зона его ответственности.Синхронный стек. Слой приложения
В синхронном стеке контекстом исполнения является ASP.NET приложение. Его зона отвественности, кроме обычных для любого веб-сервера действий вроде приема запросов и сериализации/десериализации данных, у нас невелика. Это:аутентификация пользователей
инстанцирование объектов бизнес-логики с помощью IoC контейнера
обработка и логирование ошибок
Весь код в контроллерах у нас сводится к созданию с помощью IoC контейнера объектов бизнес-логики, ответственных за дальнейшую обработку запросов. Вот так будет выглядеть метод контроллера, вызываемый для добавления нового расхода в приложении MoneyFlow.
public Guid Add([FromBody]ChargeOpForAdding op)
{
return Container.Resolve
проверка корректности входящих данных авторизация пользователей (проверка наличия прав у пользователя для совершения того или иного действия) взаимодействие со слоем хранения данных формирование DTO сообщений для отправки клиентскому приложению отправка сообщений на сервер очередей для последующей асинхронной обработки Важно, что проверку входных данных на корректность мы проводим только в слое бизнес-логики синхронного стека. Все остальные модули (разборщики очередей, сервисы хранения) мы считаем «демилитаризованной» с точки зрения проверок зоной, за очень редкими исключениями.В реальном проекте объекты слоя бизнес-логики у нас отделены от контроллеров, где они инстанцируются, интерфейсами. Но особой практической пользы это отделение нам не принесло, а только усложнило секцию IoC контейнера в конфигурационном файле.
Асинхронный стек В асинхронном стеке приложением является служба, занимающаяся разбором поступающих на сервер очередей сообщений. Сама по себе служба не знает, к каким очередям она должна подключиться, сообщения каких типов и как должна обработать, и сколько потоков ей можно выделить на обработку сообщений той или иной очереди. Вся эта информация содержится в конфигурационном файле, загружаемом службой при запуске.Пример конфига службы (псевдокод).
<Очередь Имя = "ReportBuilder” Потоков = "10”> <Обработчики_сообщений> <Тип="ChargeOpCreatedHandler” Макс_Число_Попыток=”2” Таймаут_между_попытками=”200” Уровень_логирования=”CriticalError” … /> Обработчики_сообщений> Очередь> /* еще одна очередь на 5 потоков*/ <Очередь Имя = "MailSender” Потоков = "5”> … Служба при старте считывает конфигурационный файл, проверяет, что на сервере сообщений существует очередь с названием ReportBuilder (если нет — то создает ее), проверяет существование роутинга, отправляющего сообщения типа ChargeOpCreated в эту очередь (если нет — настроит роутинг сама), и начинает обрабатывать сообщения, попадающие в очередь ReportBuilder, запуская для них соответствующие обработчики. В нашем случае — это единственный обработчик типа ChargeOpCreatedHandler (об объектах обработчиках чуть ниже). Также служба поймет из конфигурационного файла, что на разбор сообщений очереди «ReportBuilder» она может выделить до 10 потоков, что в случае возникновения ошибки в работе объекта ChargeOpCreatedHandler сообщение должно вернуться в очередь с таймаутом в 200 мс, а при повторном падении обработчика сообщение должно попасть в лог с пометкой «CriticalError» и еще несколько подобных параметров. Это дает нам замечательную возможность «на лету», не внося изменений в код, масштабировать разборщики очередей, запуская на резервных серверах в случае накопления сообщений в какой-нибудь очереди дополнительную службу, указав ей в конфиге, какую именно очередь она должна разбирать, что очень, очень удобно.Сервис разбора очередей — это обертка над библиотекой MassTransit (сам проект, статья на хабре), реализующий паттерн DataBus над сервером очередей RabbitMQ. Но программист, пишущий в слое бизнес-логики, ничего об этом знать не должен, вся инфраструктура, которой он касается (сервера очередей, key/value хранилища, СУБД и т.д.), скрыта от него слоем абстракции. Наверное, многие видели пост «Как два программиста хлеб пекли» о Борисе и Маркусе, использующих диаметрально противоположные подходы к написанию кода. Нам бы подошли оба: Маркус разрабатывал бы бизнес-логику и слой, работающий с данными, а Борис бы занимался у нас работой над инфраструктурой, разработку которой мы стараемся вести на высоком уровне абстракции (иногда мне кажется, что даже Борис бы наш код одобрил). При разработке же бизнес-логики, мы не пытаемся выстроить объекты в длинную иерархию, создавая большое количество интерфейсов, мы скорее стараемся соответствовать принципу KISS, оставляя наш код максимально простым. Вот так, например, в MoneyFlow будет выглядеть обработчик сообщения ChargeOpCreated, который мы уже заботливо прописали в конфиг занимающейся разбором очереди ReportBuilder службы.
public class ChargeOpCreatedHandler: MessageHandler
public ChargeOpCreatedHandler (IReportsStorage reportsStorage) { _reportsStorage = reportsStorage; }
public override void HandleMessage (ChargeOpCreated message)
{
//Обновляем отчет
_reportsStorage.UpdateMonthReport (userId, message.ChargeOp.Category, message.Date.Year, message.Date.Month, message.ChargeOp.Sum);
}
}
Все объекты-обработчики являются наследниками абстрактного класса MessageHandler, где T — тип разбираемого сообщения, с единственным абстрактным методом HandleMessage, перегруженном в наследниках.
public abstract class MessageHandler
Обработка ошибок в асинхронном стеке выполнения Модуль отчетов у нас больше подходит под определение согласованности в конечном счете (eventual consistency), чем под определение сильной согласованности (strong consistency), но это все равно гарантирует конечную согласованность данных в модуле отчетов при любых возможных сбоях системы. Представим, что сервер с базой данных, хранящей отчеты, упал. Понятно, что в случае бинарной кластеризации, когда каждый инстанс базы данных продублирован на отдельном сервере, подобная ситуация практически исключена, но все-таки представим, что это произошло. Клиенты продолжают вносить свои расходы, сообщения о них появляются в сервере очередей, но разборщик, ответственный за обновление отчетов, не может получить доступ к серверу БД и падает с ошибкой. Согласно конфигу службы, приведенному выше, после падения на сообщении ChargeOpCreated это же сообщение вернется обратно на сервер очередей через 200 мс, после второй попытки (тоже неудачной) сообщение будет сериализовано и занесено в специальное хранилище упавших сообщений, которое в нашем проекте объединено с логами. После того, как сервер БД поднимется, мы можем взять все упавшие в процессе обработки сообщения из логов и отправить их на сервер очередей обратно (у нас это делается вручную), приведя тем самым данные модуля отчетов в согласованное состояние. Но все это накладывает на программистов обязательство писать код в объектах-обработчиках сообщений очереди по принципу атомарности. Обработчик должен либо полностью сработать, либо сразу упасть. Как вариант, он также может быть «идемпотентным», то есть выполнив часть работы и упав, он должен при повторной обработке сообщения понять, какую работу он уже выполнил, и не пытаться сделать ее повторно.Слой хранения данных Слой хранения данных у нас общий для асинронного и синхронного стеков выполнения. Для разработчика бизнес-логики сервис хранения — это просто интерфейс с методами для получения и изменения данных. Под интерфейсом скрывается сервис, полностью инкапсулирующий в себе доступ к данным определенного модуля. При проектировании интерфейсов сервисов мы пытаемся, если это возможно, следовать концепции CQRS — каждый метод у нас является либо командой, выполняющей какое-то действие, либо запросом, возвращающим данные в виде DTO объектов, но не одновременно. Делаем мы это не для разбиения системы хранения на две независимые структуры для чтения и для записи, а скорее порядка ради.Как бы мы не снижали время отклика, выполняя большую часть работы асинхронно, неудачно спроектированная система хранения может перечеркнуть всю проделанную работу. Описание того, как устроен слой хранения в нашем проекте, я не случайно оставил в самом конце. Мы взяли за правило разрабатывать сервисы-хранилища только после того, как завершена реализация объектов слоя бизнес-логики, чтобы при проектировании таблиц БД точно понимать, как данные этих таблиц будут использованы. Если при разработке бизнес-логики нам нужно получить какую-то информацию из слоя хранения, мы добавляем в интерфейс, скрывающий реализацию сервиса, новый метод, отдающий данные в удобном для бизнес-логики виде. У нас именно бизнес-логика определяет интерфейс хранилища, но никак не наоборот.
Вот пример интерфейса сервиса хранения отчетов, который был определен при разработке бизнес-логики приложения MoneyFlow.
public interface IReportsStorage { //метод для получения отчета. вернет json в готовом для передачи на клиент виде string GetMonthReport (Guid userId, int month, int year); //метод обновляет отчет за конкретный месяц void UpdateMonthReport (Guid userId, ECategory category, int year, int month, double sum); } Для хранения данных мы используем реляционную базу данных Postgresql. Но это конечно же не означает, что данные мы храним в реляционном виде. Мы закладываем возможность масштабирования шардингом и проектируем таблицы и запросы к ним по специфичным для шардинга канонам: не используем join-ы, строим запросы по первичным ключам и т.д. При построении хранилища отчетов MoneyFlow мы тоже оставим возможность перенести часть отчетов на другой сервер, если вдруг это потребуется впоследствии, не перестраивая при этом структуру таблиц. Как мы будем делать шардинг — с помощью встроенного механизма физического разделения таблиц (partitioning) или добавлением в сервис хранения отчетов менеджера шард — мы будем решать тогда, когда в шардинге появится необходимость. Пока же нам стоит сконцентрироваться на проектировании структуры таблицы, которая бы впоследствии не препятствовала шардингу.В Postgresql есть замечательные NoSQL типы данных, такие как json и менее известный, но не менее замечательный hstore. Отчет, который нужен клиентскому приложению, должен представлять собой json строку. Поэтому нам было бы логично использовать для хранения отчетов встроенный тип json и отдавать его на клиент как есть, не тратя ресуры на цепочку сериализаций DB Tables→DTO→json. Но, чтобы еще раз попиарить hstore, я буду делать то же самое с одной лишь разницей, что внутри БД отчет будет лежать в виде ассоциативного массива, для хранения которых и предназначен тип hstore.
Для хранения отчетов нам будет достаточно одной таблицы с четырьмя полями:
Поле Что означает id идентификатор пользователя year отчетный год month отчетный месяц report хеш-таблица с данными отчета Первичный ключ таблицы у нас будет составным по полям id year month. В качестве ключей ассоциативного массива report мы будем использовать категории расходов, а в качестве значений — сумму, потраченную на соответствующую категорию.Пример отчета в базе данных.
По этой строке ясно, что пользователь с id «d717b8e4–1f0f-4094-bceb-d8a8bbd6a673» потратил в январе 2015 года 500р на транспорт и 2500р на развлечения.
Если реализация метода GetMonthReport () не вызывает вопросов, сформировать json строку отчета из ассоциативного массива несложно встроенными средствами postgresql, то для корректной релизации обновляющего месячный отчет метода UpdateMonthReport () придется повозиться чуть побольше. Во-первых, нам надо убедиться, что отчет за этот месяц уже существует в БД и создать его, если это не так. Во-вторых, нам надо исключить состояние гонки (race condition) — попытки создания/обновления этого же отчета паралельным потоком. Пример получился довольно большим, но связано это не со сложностью типа hstore, а с необходимостью производить операцию UpSert, состоящую из двух запросов и следующую из этого необходимость исключения состояния гонки. 99% методов в сервисах хранения у нас устроены гораздо проще, я и сам не ожидал, что придется писать так много кода. Но нет худа без добра, этот пример отлично демонстрирует, почему именно слой бизнес-логики у нас определяет интерфейс сервиса хранения, а не наоборот. Если бы мы начали работу над проектом с создания хранилища отчетов, мы наверняка сделали бы классический репозиторий с методами AddReport (), GetReport (), UpdateReport () и невольно переложили бы тем самым необходимость обеспечения потокобезопасного доступа на клиентов этого репозитория. То есть на слой бизнес-логики. Именно поэтому, отношения объектов слоя бизнес-логики с сервисами хранения мы строим, руководствуясь принципами большого начальника, которые гласят следующее: ни один крупный руководитель не будет пытаться выполнять работу, с которой его подчиненный в состоянии справиться самостоятельно, и тем более он не будет подстраиваться под своего подчиненного.
Код сервиса-хранилища отчетов.
//Сервис хранения отчетов public class ReportStorage: IReportsStorage { private readonly IDbMapper _dbMapper; private readonly IDistributedLockFactory _lockFactory;
public ReportStorage (IDbMapper dbMapper, IDistributedLockFactory lockFactory) { _dbMapper = dbMapper; _lockFactory = lockFactory; }
//получить отчет за месяц в формате json
public string GetMonthReport (Guid userId, int month, int year)
{
var report = _dbMapper.ExecuteScalarOrDefault
//обновить отчет за месяц public void UpdateMonthReport (Guid userId, ECategory category, int year, int month, double sum) { //оставляем доступ к операции обновления отчетов только для одного потока using (_lockFactory.AcquireLock (BuildLockKey (userId, year, month) , TimeSpan.FromSeconds (10))) { //обновляем отчет RaceUnsafeMonthReportUpsert (userId, category.ToString ().ToLower (), year, month, sum); } }
//потоконебезопасный upsert в два запроса
private void RaceUnsafeMonthReportUpsert (Guid userId, string category, int year, int month, double sum)
{
//результат запроса: null — отчета не существует, число — сумма, потраченная на соответствующую категорию за месяц
double? sumForCategory = _dbMapper.ExecuteScalarOrDefault
//если отчета нет — его надо создать, сразу записав известные данные if (! sumForCategory.HasValue) { _dbMapper.ExecuteNonQuery («insert into reps values (: userId, : year, : month, : categorySum: hstore)», new QueryParameter («userId», userId), new QueryParameter («year», year), new QueryParameter («month», month), new QueryParameter («categorySum», BuildHstore (category, sum))); return; }
//отредактируем существующий отчет, увеличив сумму расходов по категории _dbMapper.ExecuteNonQuery («update reps set report = (report || : categorySum: hstore) where id = : userId and year = : year and month = : month», new QueryParameter («userId», userId), new QueryParameter («year», year), new QueryParameter («month», month), new QueryParameter («categorySum», BuildHstore (category, sumForCategory.Value + sum))); }
//построение элемента хеш-таблицы (пары ключ: значение) в формате hstore private string BuildHstore (string category, double sum) { var sb = new StringBuilder (); sb.Append (category); sb.Append (»=>\»); sb.Append (sum.ToString (»0.00», CultureInfo.InvariantCulture)); sb.Append (»\»); return sb.ToString (); }
//построение ключа блокировки обновления отчета
private string BuildLockKey (Guid userId, int year, int month)
{
var sb = new StringBuilder ();
sb.Append (userId);
sb.Append (»_»);
sb.Append (year);
sb.Append (»_»);
sb.Append (month);
return sb.ToString ();
}
}
В конструкторе сервиса ReportStorage у нас две зависимости — это IDbMapper и IDistributedLockFactory. IDbMapper — это фасад над легковесным ORM фреймворком BLToolkit.
public interface IDbMapper
{
List
«Смешанная» концепция разбиения на модули Есть определенная доля лукавства в словах, что мы выделили систему отчетности приложения MoneyFlow в отдельный, независимый модуль. Да, мы храним данные отчетов отдельно от данных системы учета, но в нашем приложении бизнес-логика и части инфраструктуры, такие как сервисная шина или например веб-сервер, являются общими ресурсами для всех модулей приложения. Для нашей небольшой компании, в которой для пересчета программистов вполне достаточно пальцев одной руки фрезеровщика, подобный подход более чем оправдан. В крупных же компаниях, где над разными модулями работа может вестись разными командами, принято заботиться о минимизации использования общих ресурсов и об отстутствии единых точек отказа. Так, если бы приложение MoneyFlow разрабатывалось бы в крупной компании, его архитектура бы представляла собой классическое SOA. Отказ от идеи сделать систему на основе полностью независимых модулей, общающихся друг с другом на основе одного простого протокола, дался нам непросто. Изначально при проектировании мы планировали делать настоящее SOA решение, но в последний момент, взвесив все за и против в рамках нашей компактной (не в смысле замкнутой и ограниченной, а просто очень небольшой) команды решили использовать «смешанную» концепцию разбиения на модули: общая инфраструктура и бизнес-логика — независимые сервисы хранения. Сейчас я понимаю, что это решение было верным. Время и силы, не потраченные на&nbs