Як влаштований наш код. Серверна архітектура одного проекту

Картинка для привернення увагиТак склалося, що до тридцяти років я змінював роботу лише один раз і не мав можливості на власному досвіді вивчити, як в різних компаніях влаштовані веб-проекти, розраховані на високу швидкість відгуку і велику кількість користувачів. <irony> Так що, дорогий хабраюзер, що потрапило в поле мого зору в офлайні, побачивши мене, краще біжи, поки я не почав докучати тобі питаннями на тему обробки помилок, логування та процесу оновлення на робочих серверах < /irony>. Мені цікавий не стільки набір використовуваних технологій, скільки принципи, на яких побудована кодова база. Як код розбито на класи, класи розподілені по верствам, як бізнес-логіка взаємодіє з інфраструктурою, які критерії за якими оцінюється якість коду та як організований процес розробки нового функціоналу. На жаль, подібну інформацію знайти непросто, в кращому випадку все обмежується перерахуванням технологій і коротким описом розроблених велосипедів, а хочеться, звичайно, більш деталізованою картинки. В цьому топіку я спробую як можна більш докладно описати, як влаштований код в компанії, де я працюю.

Наперед вибачаюсь, якщо мій тон кому-то здасться повчати — я не маю амбіцій навчати кого-небудь, максимум на що претендує цей пост — це розповідь про архітектуру серверної частини одного реального проекту. В моєму не найрозвиненішому з точки зору розробки ПО місті я не один і не два рази зустрічав розробників, яким був дуже цікавий наш досвід побудови серверної частини веб-додатків, так що, хлопці, я пишу цей пост в чому з-за вас, і я щиро сподіваюся, що у мене вийде задовольнити ваш інтерес.

Як і будь-який інший найманий розробник, я не є власником коду і не можу демонструвати лістинги робочого проекту, але і розповідати про те, як влаштована кодова база без лістингів, буде все-таки теж неправильно. Тому мені не залишається нічого іншого, як «придумати» абстрактний продукт і на його прикладі пройти весь процес розробки серверної частини: від отримання програмістами ТЗ до реалізації сервісів зберігання, використовуючи при цьому прийняті в нашій команді практики і проводячи паралелі з тим, як влаштований наш реальний проект.

MoneyFlow. Постановка завдання
Віртуальний замовник хоче створити хмарну систему для обліку витрачання коштів із сімейного бюджету. Він вже придумав їй назву — MoneyFlow і намалював макети UI. Він хоче, щоб у системі були веб, android і iOS версії, і додаток мало високу (<200 мс) швидкість відгуку для будь-яких дій користувача. Замовник збирається вкласти в розкрутку сервісу серйозні кошти і обіцяє відразу після запуску лавиноподібне зростання користувачів.

Розробка ПО — ітеративний процес, і кількість ітерацій при розробці головним чином залежить від того, наскільки точно задача була поставлена спочатку. У цьому сенсі нашій команді пощастило, у нас є два чудових аналітика і не менш чудовий дизайнер-верстальник (так, і така удача теж буває), так що до початку роботи ми зазвичай маємо фінальний варіант ТЗ і готову верстку, що значно спрощує життя і звільняє нас, розробників, від головного болю і розвитку екстрасенсорних навиків для читання думок замовника на відстані. Віртуальний замовник в моєму обличчі нас теж не підвів і надав макети 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.318389 Z"
},
{
"text":"створено веб версії програми",
"created":"2015-01-25T16:06:27.318389 Z"
}
]
}

У порівнянні з моделлю операції, запитуваної в списку, на сторінці редагування модель додалася інформація про історію створення/зміни запису. Як я вже писав, ми керуємося принципом необхідності і достатності і не створюємо єдину загальну модель даних для кожної сутності, яка могла б використовуватися в 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. Зіткнувшись з падінням продуктивності, ми оптимізували нашу систему зберігання, перенісши дані кожного модуля незалежні бази даних, а також ми перенесли частину часто використовуваних даних у Redis.

За легендою проект MoneyFlow тільки готується до запуску, тому ми залишимо його на ітерації номер два, залишивши собі простір для подальших поліпшень.

Синхронний і асинхронний стеки виконання
Крім збільшення продуктивності за рахунок зберігання даних в максимально зручному для читання вигляді ми намагаємося більшу частину роботи робити непомітно для користувача в асинхронному стеку виконання. Кожен раз, коли з боку клієнтського додатка до нас приходить запит, ми дивимося, яку частину роботи необхідно виконати до того, як відповідь буде повернутий клієнту. Дуже часто ми повертаємо відповідь майже відразу, виконавши лише необхідну частину кшталт перевірки валідності прийшли даних, переклавши більшу частину роботи на асинхронну обробку. Працює це наступним чином.



Асинхронним стеком виконання ми в команді називаємо фонові процеси, зайняті обробкою приходять з сервера черг повідомлень. Пристрій нашого фонового обробника я опишу докладно трохи нижче, а поки подивимося приклад взаємодії клієнтських додатків і серверного 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 ми будемо використовувати категорії витрат, а в якості значень — суму, витрачену на відповідну категорію.

Приклад звіту в базі даних.



З цієї рядку ясно, що користувач 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);
Словник<TKey, TValue> ExecuteScalarDictionary<TKey, TValue>(string query, params QueryParameter[] list);
}

Для генерації запитів було б цілком допустимо використовувати NHibernate або ще який-небудь ORM, але ми вирішили писати запити руками і тільки маппінг результатів виконання в DTO об'єкти перекласти на фреймворк, з чим BLToolkitсправляється просто чудово.

IDistributedLockFactory в свою чергу — це фасад над механізмом розподілених блокувань, зразок вбудованого в ServiceStack механізму RedisLocks.
Я вже писав, що ми використовуємо підхід: абстрактна інфраструктура — «чисто конкретна» бізнес-логіка, саме тому ми намагаємося обертати сторонні бібліотеки врапперами і фасадами, щоб завжди мати можливість замінювати елементи інфраструктури, не переписуючи бізнес-логіку проекту.

«Змішана» концепція розбиття на модулі
Є певна частка лукавства в словах, що ми виділили систему звітності додатки MoneyFlow в окремий, незалежний модуль. Так, ми зберігаємо дані звітів окремо від даних системи обліку, але в нашому додатку бізнес-логіка і частини інфраструктури, такі як сервісна шина або, наприклад, веб-сервер, є загальними ресурсами для всіх модулів програми. Для нашої невеликої компанії, в якій для перерахунку програмістів цілком достатньо пальців однієї руки фрезерувальника, подібний підхід більш ніж виправданий. У великих компаніях, де над різними модулями робота може вестися різними командами, ухвалено дбати про мінімізацію використання спільних ресурсів і про отстутствии єдиних точок відмови. Так, якщо б додаток MoneyFlow розроблялося б у великій компанії, його архітектура б являла собою класичне SOA.



Відмова від ідеї зробити систему на основі повністю незалежних модулів, що спілкуються один з одним на основі одного простого протоколу, дався нам непросто. Спочатку при проектуванні ми планували робити даний SOA рішення, але в останній момент, зваживши всі за і проти в рамках нашої компактною (не в сенсі замкнутої і обмеженою, а просто дуже невеликий) команди вирішили використати «змішану» концепцію розбиття на модулі: загальна інфраструктура і бізнес-логіка — незалежні сервіси зберігання. Зараз я розумію, що це рішення було вірним. Час і сили, не витрачені на повне дублювання інфраструктури модулів, ми змогли направити на поліпшення інших аспектів програми.

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

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.