Пара слов о построении архитектуры распределённого веб-сервиса

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

Компоненты должны общаться между собой на основе транзакционного протокола

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

Более правильный, на мой взгляд, подход базируется на принципах событийно-ориентированной архитектуры, которая реализуется с помощью очередей сообщений. Он предполагает, что компонент-отправитель – отправляет сообщение в некую общую шину, а компонент-получатель – соответственно получает сообщение из неё. Подобная развязка позволяет более легко строить отказоустойчивые и распределённые приложения (см. подробнее про SOA-архитектуру).

Каждый компонент должен заниматься только своим делом

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

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

Язык общения между компонентами должен быть максимально близок к внешнему API

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

Когда ваши компоненты говорят на языке API, вы буквально вынуждены делать ваше API хорошим, потому что иначе вам самим будет противно им пользоваться. Хорошее API – это не тупо список методов с описанием ответов, оно начинается с грамотно организованных сущностей и взаимодействий между ними. Читателям рекомендуется познакомиться с Facebook Graph API.

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

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

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

Компоненты должны быть асинхронны

Типичный образ мышления 99% веб-разработчиков: пользователь нажал кнопку в интерфейсе – пошёл GET/POST запрос – бэкэнд выполнил действие – бэкэнд сформировал страничку с результатом или с информацией об ошибке. При этом первый же вопрос – а что будет, если бэкэнд нагружен? – вызывает у разработчика грусть и уныние.

Дескать, пусть пользователь подождёт отклика 30-60 секунд (типичный таймаут в apache+php), ну а если не повезёт – получит ошибку 504. Стоит ли говорить, что пользователь после этого может навсегда покинуть ряды ваших клиентов?

Сегодняшние реалии требуют быстрых и отзывчивых интерфейсов, поэтому (с учётом вышеприведённых принципов) – до последнего избегайте применения синхронных запросов, ожидающих обязательного отклика. Действия пользователя на сайте должны порождать асинхронные запросы, не блокирующие его работу. Вот пример, как это реализуется:

Используйте очереди команд и ответов

Например, пользователь нажал на кнопку – вы отправляете асинхронный http-запрос к текущему или другому компоненту. Там он ложится в очередь (стэк) команд, далее выполняется, при этом генерируется ответ, и он направляется обратно также асинхронным путём. Подробнее об этом ниже.

Компоненты должны наследовать друг друга

Кроме привычного всем нам наследования классов в языке программирования, для аналогичных целей применяется наследование компонентов распределённого сервиса. Это помогает избежать разрастания функционала ваших “кирпичиков” и помогает следовать тому же принципу “единой ответственности” в SOLID.

Предположим, у вас возникла задача значительно нарастить фунцкионал одного компонента. Например, сделать новую версию API V2. Обычный программист полез бы в код и наворотил там ветвлений, а потом поимел бы геморрой с поддержкой старых клиентов, которые ещё год не соберутся обновляться. Мы же поступим чуточку умнее:

Поскольку компоненты общаются между собой по HTTP, при необходимости можно всегда влезть в обмен между двумя компонентами и поставить там новый “кирпичик”. Он будет транслировать запросы между ними, при необходимости дополняя их чем-то новым. Таким образом, у вас а) сохраняется слабосвязанность, б) остаётся чёткая модульность, в) вы не трогаете старый код, г) старые клиенты работают со старым компонентом без проблем, д) вы пишете минимум кода и соблюдаете DRY & KISS.

Разделяйте наблюдателей за ошибками и реакцию на них

В контексте возникновения и обработки ошибок сам язык PHP немного “учит плохому”. Обычно, когда заходит речь об обработке ошибок, разработчик начинает думать в стиле “тут я выброшу эксепшен, дальше его обработаю, и покажу сообщение”. Что здесь плохого? – то, что здесь наблюдение за возникновением факта ошибки тесно связывается с процессом её обработки – то есть действием, реакцией. А как вы помните, сильная связанность между компонентами до добра не доводит.

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

Например, сбой корзины интернет-магазина в 6 часов утра в понедельник в августе – это совсем не то же самое, что сбой той же корзины 30 декабря в 22:00. И обрабатывать их нужно по-разному. Задумались бы вы об этом, рассуждая в контексте “здесь я брошу эксепшен”?

Pull вместо Push

В данном случае речь идёт не о командах Git, а о принципах передачи команд и нотификаций между компонентами. Вот два основных принципа:

1) Push: компонент-источник команды принудительно отправляет запросы в компонент-исполнитель, сразу же по мере их возникновения;
2) Pull: компонент-исполнитель забирает команды у компонента-источника по мере выполнения предыдущих.

Первый способ прост и применяется повсеместно: пользователь нажал кнопку – пошёл запрос в исполняющий алгоритм на бэкэнде. Бэкэнд отработал – отдал пользователю страничку с сообщением. Минусы очевидны: как только исполняющий бэкэнд прилёг под нагрузкой (или остановлен для обслуживания) – все запросы начали тормозить вплоть до полной их потери.

Второй способ чуть сложнее и требует очереди и демона-обработчика, но слава богу на это есть куча готовых решений. При его использовании между источником и исполнителем ставится две очереди: в одном направлении отправляются команды, а в другом – сообщения об их выполнении (или об ошибке).

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

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

Вы даже можете “выключить” исполняющий бэкэнд (я утрирую, конечно же, подразумевая падение или период maintenance), а пользователь может временно потерять связь с интернетом или выйти из интерфейса на какое-то время – при такой архитектуре проекта никогда не потеряются ни его запросы, ни ответы на них.