Как устроен наш код. Серверная архитектура одного проекта

в 7:03, , рубрики: .net, ASP.NET, hstore, postgresql, никто не читает теги, Программирование, проектирование, Проектирование и рефакторинг

Картинка для привлечения внимания Так сложилось, что к тридцати годам я менял работу лишь единожды и не имел возможности на собственном опыте изучить, как в различных компаниях устроены веб-проекты, расчитанные на высокую скорость отклика и большое количество пользователей. <irony> Так что, дорогой читатель, попавший в поле моего зрения в оффлайне, увидев меня, лучше беги, пока я не начал докучать тебе вопросами на тему обработки ошибок, логирования и процесса обновления на рабочих серверах&lt/irony&gt. Мне интересен не столько набор используемых технологий, сколько принципы, на которых построена кодовая база. Как код разбит на классы, как классы распределены по слоям, как бизнес-логика взаимодействует с инфраструктурой, каковы критерии по которым оценивается качество кода и как организован процесс разработки нового функционала. К сожалению, подобную информацию найти непросто, в лучшем случае всё ограничивается перечислением технологий и кратким описанием разработанных велосипедов, а хочется, конечно, более детализированной картинки. В этом топике я попытаюсь как можно более подробно описать, как устроен код в компании, где работаю я.

Заранее извиняюсь, если мой тон кому-то покажется наставническим — я не имею амбиций обучать кого-либо, максимум на что претендует этот пост — это рассказ об архитектуре серверной части одного реального проекта. В моем не самом развитом с точки зрения разработки ПО городе я не один и не два раза встречал разработчиков, которым был очень интересен наш опыт построения серверной части веб-приложений, так что, ребята, я пишу этот пост во многом из-за вас, и я искренне надеюсь, что у меня получится удовлетворить ваш интерес.
Как и любой другой наемный разработчик, я не являюсь собственником кода и не могу демонстрировать листинги рабочего проекта, но и рассказывать о том, как устроена кодовая база без листингов, будет все-таки тоже неправильно. Поэтому мне не остается ничего другого, как “придумать” абстрактный продукт и на его примере пройти весь процесс разработки серверной части: от получения программистами ТЗ до реализации сервисов хранения, используя при этом принятые в нашей команде практики и проводя параллели с тем, как устроен наш реальный проект.

MoneyFlow. Постановка задачи

Виртуальный заказчик хочет создать облачную систему для учета расхода средств из семейного бюджета. Он уже придумал ей название — MoneyFlow и нарисовал макеты UI. Он хочет, чтобы у системы были веб, андроид и iOS версии, и приложение имело высокую (<200 мс) скорость отклика для любых действий пользователя. Заказчик собирается вложить в раскрутку сервиса серьезные средства и обещает сразу после запуска лавинообразный рост пользователей.
Разработка ПО — процесс итеративный, и количество итераций при разработке главным образом зависит от того, насколько точно задача была поставлена изначально. В этом смысле нашей команде повезло, у нас есть два замечательных аналитика и не менее замечательный дизайнер-верстальщик (да, и такая удача тоже бывает), так что к началу работы мы обычно имеем финальный вариант ТЗ и готовую верстку, что значительно упрощает жизнь и освобождает нас, разработчиков, от головной боли и развития экстрасенсорных навыков для чтения мыслей заказчика на расстоянии. Виртуальный заказчик в моем лице нас тоже не подвел и предоставил макеты UI в качестве описания своего видения сервиса. Я заранее прошу прощения перед специалистами в построении UI, пиксель-перфекционистами и просто людьми с развитым чувством прекрасного за жуткую графику макетов и не менее ужасное их юзабилити. Моим единственным (пусть и слабым) оправданием служит лишь то, что это больше прототип, нежели реальный проект, но я все равно спрячу эти макеты под кат.

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

Макет 1. Добавление расхода

Как устроен наш код. Серверная архитектура одного проекта - 2
Макет 2. Пример отчета

Как устроен наш код. Серверная архитектура одного проекта - 3

Категории расходов и виды отчетов заранее определены заказчиком и одинаковы для всех пользователей. Веб-версия приложения будет представлять собой SPA, написанное на Angular JS, и серверное API на ASP.NET, от которого JS приложение получает данные в формате JSON.

Контракт взаимодействия между клиентской и серверной частями приложения

В нашей команде разработка нового функционала серверной и клиентской частей сервиса ведется параллельно. После ознакомления с ТЗ мы первым делом определяем интерфейс, по которому строится взаимодействие фронтенда с бэкендом. Так сложилось, что наше API построено как RPC, а не REST, и при его (API) определении мы в первую очередь руководствуемся принципом необходимости и достаточности передаваемых данных. Попробуем по макетам прикинуть, какая информация может потребоваться клиентскому приложению от серверной части.

Макет 3. Список операций

Как устроен наш код. Серверная архитектура одного проекта - 4

В макете списка у нас всего три столбца — сумма, описание и день, когда была совершена операция. Кроме этого клиентскому приложению для отображения списка потребуются категория, к которой относится расходная операция, и 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. Экран редактирования

Как устроен наш код. Серверная архитектура одного проекта - 5

Для отображения страницы редактирования клиентскому приложению потребуется метод, возвращающий следующую 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<HistoryMessage> History { get; set; }
    }

После того как контракт определен, занимающиеся фронтендом разработчики создают моки (mocks) на еще несуществующие серверные методы и идут творить Angular JS магию. Да, у нас очень толстый и очень замечательный клиент, руку к которому приложили уважаемые читатели iKbaht, Houston и еще несколько не менее замечательных хабра-анонимов. И было бы совсем некрасиво со стороны серверных разработчиков, если бы наше красивое, мощное JS приложение работало бы на медленном API. Поэтому на бэкенде мы стараемся разрабатывать наше API быстрым настолько, насколько это вообще возможно, выполняя большую часть работы асинхронно и строя модель хранения данных максимально удобной для чтения.

Разбиваем систему на модули

Если рассматривать приложение MoneyFlow с точки зрения функционала, можно выделить в нем два различных модуля — это модуль по работе с расходами и модуль отчетности. Если бы мы были уверены, что число пользователей у нашего приложения будет невелико, мы бы строили модуль отчетности прямо поверх модуля внесения расходов. В этом случае у нас была бы, к примеру, хранимая процедура, которая бы строила для пользователя отчет за год непосредственно по базе внесенных расходов. Такая схема очень удобна с точки зрения консистентности данных, но, к сожалению, у нее есть и недостаток. При достаточно большом количестве пользователей таблица с данными расходов станет слишком большой, чтобы наша хранимая процедура отрабатывала быстро, рассчитывая “на лету”, к примеру, сколько средств было потрачено пользователем на транспорт за год. Поэтому, чтобы пользователю не приходилось ждать отчета долго, нам придется генерировать отчеты заранее, обновляя их содержимое по мере появления в системе учета расходов новых данных.
Разработка ПО — процесс итеративный. Если считать схему с единой моделью данных для расходов и отчетов первой итерацией, то вынесение отчетов в отдельную денормализованную БД уже итерация номер два. Если теоретизировать дальше, то можно вполне нащупать направление и для следующих улучшений. К примеру, как часто нам придется обновлять отчеты за прошлый месяц или год? Наверное, в первых числах января пользователи будут вносить в систему информацию о покупках, совершенных в конце декабря, но большая часть приходящих в систему данных будет относиться к текущему календарному месяцу. То же справедливо и для отчетности, пользователей гораздо чаще будут интересовать отчеты за текущий месяц. Поэтому в рамках третьей итерации, если эффект от использования отдельного хранилища для отчетов будет нивелирован увеличением количества пользователей, можно оптимизировать систему хранения перенесом данных по текущему периоду в более быстрое хранилище, расположенное, к примеру, на отдельном сервере. Или использованием хранилища вроде Redis, хранящего свои данные в оперативной памяти. Если проводить аналогию с нашим реальным проектом, то мы находимся на итерации 2.5. Cтолкнувшись с падением производительности, мы оптимизировали нашу систему хранения, перенеся данные каждого модуля в независимые базы данных, а также мы перенесли часть часто используемых данных в Redis.
По легенде проект MoneyFlow только готовится к запуску, поэтому мы оставим его на итерации номер два, оставив себе простор для последующих улучшений.

Синхронный и асинхронный стеки выполнения

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

Как устроен наш код. Серверная архитектура одного проекта - 6

Асинхронным стеком выполнения мы в команде называем фоновые процессы, занятые обработкой приходящих с сервера очередей сообщений. Устройство нашего фонового обработчика я опишу подробно чуть ниже, а пока посмотрим пример взаимодействия клиентского приложения и серверного 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<ChargeOpsCreator>().Create(op, CurrentUserId);
    }

Слой приложения в нашей системе очень прост и легковесен, мы за день можем поменять контекст выполнения нашей системы с ASP.NET MVC (так у нас исторически сложилось) на ASP.NET WebAPI или Katana.

Синхронный стек. Слой бизнес-логики

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

  • проверка корректности входящих данных
  • авторизация пользователей (проверка наличия прав у пользователя для совершения того или иного действия)
  • взаимодействие со слоем хранения данных
  • формирование 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<ChargeOpCreated>
{
  private readonly IReportsStorage _reportsStorage;

  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<T> : where T : class
  {
    public abstract void HandleMessage(T message);
  }

После получения сообщения с сервера очередей, служба создает нужный объект-обработчик с помощью IoC контейнера и вызывает метод HandleMessage, передавая в качестве параметра полученное сообщение. Чтобы оставить возможность тестировать поведение обработчика в отрыве от его зависимостей, все внешние зависимости, а у ChargeOpCreatedHandler это только сервис хранения отчетов, инжектируются в конструктор.
Как я уже писал, мы не занимаемся проверкой корректности входных данных при обработке сообщений — этим должна заниматься бизнес-логика синхронного стека и не обрабатываем ошибки — это ответственность службы, в которой был запущен обработчик.

Обработка ошибок в асинхронном стеке выполнения

Модуль отчетов у нас больше подходит под определение согласованности в конечном счете (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 мы будем использовать категории расходов, а в качестве значений — сумму, потраченную на соответствующую категорию.
Пример отчета в базе данных.
Как устроен наш код. Серверная архитектура одного проекта - 7
По этой строке ясно, что пользователь с 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<string>("select hstore_to_json(report) from reps where id = :userId and year = :year and month = :month",
                 new QueryParameter("userId", userId),
                 new QueryParameter("year", year),
                 new QueryParameter("month", month));
            //если отчета в базе нет - вернем пустой json объект
            if (string.IsNullOrEmpty(report))
                return "{}";
            return report;
        }

        //обновить отчет за месяц
        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<double?>("select Coalesce((report->:category)::real, 0) from reps where id = :userId and year = :year and month = :month",
                 new QueryParameter("category", category),
                 new QueryParameter("userId", userId),
                 new QueryParameter("year", year),
                 new QueryParameter("month", month));

            //если отчета нет - его надо создать, сразу записав известные данные
            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<T> ExecuteList<T>(string query, params QueryParameter[] list) where T : class;
        List<T> ExecuteScalarList<T>(string query, params QueryParameter[] list);
        T ExecuteObject<T>(string query, params QueryParameter[] list) where T : class;
        T ExecuteObjectOrNull<T>(string query, params QueryParameter[] list) where T : class;
        T ExecuteScalar<T>(string query, params QueryParameter[] list) ;
        T ExecuteScalarOrDefault<T>(string query, params QueryParameter[] list);
        int ExecuteNonQuery(string query, params QueryParameter[] list);
        Dictionary<TKey, TValue> ExecuteScalarDictionary<TKey, TValue>(string query, params QueryParameter[] list);
    }

Для генерации запросов было бы вполне допустимо использовать NHibernate или еще какой-либо ORM, но мы решили писать запросы руками и только маппинг результатов выполнения в DTO объекты переложить на фреймворк, с чем BLToolkitсправляется просто прекрасно.
IDistributedLockFactory в свою очередь — это фасад над механизмом распределенных блокировок, наподобие встроенного в ServiceStack механизма RedisLocks.
Я уже писал, что мы используем подход: абстрактная инфраструктура — “чисто конкретная” бизнес-логика, именно поэтому мы стараемся оборачивать сторонние библиотеки врапперами и фасадами, чтобы всегда иметь возможность заменять элементы инфраструктуры, не переписывая бизнес-логику проекта.

“Смешанная” концепция разбиения на модули

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

Как устроен наш код. Серверная архитектура одного проекта - 8

Отказ от идеи сделать систему на основе полностью независимых модулей, общающихся друг с другом на основе одного простого протокола, дался нам непросто. Изначально при проектировании мы планировали делать настоящее SOA решение, но в последний момент, взвесив все за и против в рамках нашей компактной (не в смысле замкнутой и ограниченной, а просто очень небольшой) команды решили использовать “смешанную” концепцию разбиения на модули: общая инфраструктура и бизнес-логика — независимые сервисы хранения. Сейчас я понимаю, что это решение было верным. Время и силы, не потраченные на полное дублирование инфраструктуры модулей, мы смогли направить на улучшение других аспектов приложения.

Вместо заключения

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

Автор: steamru

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js