Сервис доставки сообщений (концепт)

Опубликовано 13.12.2008

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

Задача

В моем случае задачу можно сформулировать следующим образом:

  1. Персональные данные пользователя (email, имя, все персональные настройки) хранятся в сервисе Паспорт
  2. Отправка сообщений пользователю должна осуществляться всеми сервисами портала, коих на данный момент 4
  3. Сервисы могут отправлять следующие типы сообщений:
    • Разовые сообщения о системных событиях адресованные конкретному пользователю - регистрация, восстановление пароля, уведомление о блокировке и т.д.
    • Периодические сообщения для группы пользователей - рассылки, подписка на комментарии и прочие
  4. Доставка сообщений может быть как по почте, так и через Jabber, ICQ, SMS и прочие
  5. Должна формироваться очередь отправки сообщений, позволяющая распределять загрузку по времени, чтобы не убивать канал отправки. Например, чтобы не попасть в spam-листы из-за большого количества исходящих сообщений.

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

Сервис сообщений должен состоять из двух частей:

  • Web-интерфейса для добавления сообщений в очередь, регистрации пользователей и подписок
  • Скрипта или демона для отправки сообщений

HTTP-интерфейс

HTTP-интерфейс используется сервисами для выполнения операций с пользователями и сообщениями. Интерфейс должен обладать следующим набором функций:

  • Добавление, изменение и удаление пользователя

    Очевидно, что для пользователя должны передаваться основные параметры, используемые при рассылке (полное имя, email, jabber и другие контактные данные), а так же дополнительные данные, которые могут быть использованы при формировании сообщений. Например, половая принадлежность, город проживания, дата рождения и прочие. Эти данные в Сервис сообщений могут передать различные сервисы. Например, паспорт может передать пол и возраст, а доска объявлений - количество добавленных пользователем объявлений. Данные пользователя привязываются к уникальному системному ID (в моем случае это ID пользователя в Паспорте)

  • Добавление пользователя в листы рассылки и удаление из них

    Фактически, листами рассылки может являться что угодно - почтовая подписка на новые материалы, уведомления о комментариях, сообщения в community, системные рассылки и так далее. Детали о том, что это за рассылка и каким образом она формируется, Сервиса сообщений не касаются - он занимается исключительно отправкой. Поэтому единственное что ему нужно знать о рассылке - это ее уникальный ID. В качестве такового можно использовать, например, комбинацию из названия сервиса и внутреннего ID объекта, к которому привязана рассылка. Например, уведомления о комментариях к статье номер 123 - publishing_article_123_comments, новые объявления раздела Недвижимость - classifieds_section_realty_ads.

  • Отправка персонального сообщения

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

  • Отправка сообщения для листа рассылки

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

  • Получение списка рассылок, на которые подписан пользователь

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

Скрипт рассылки

Чтобы разделить добавление и отправку сообщений, стоит вынести непосредственно рассылку в отдельный процесс. Запускать сервис можно как по крону (для небольших объемов сообщений), так в виде демона. Скрипт должен выполнять ряд функций, которые стоит вынести из HTTP-интерфейса:

  • Отправка сообщений

    Скрипт должен пачками забирать сообщения из очереди, отмечать их как находящиеся на обработке, а затем поочередно их отправлять используя указанные средства доставки - email, Jabber, ICQ или другие. В случае успешной отправки сообщения отмечаются как отправленные, в случае неудачи результат тоже сохраняется. Объем пакетов должен регулироваться для каждого средства доставки индивидуально.

  • Формирование очереди рассылок

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

  • Подключаемые средства отправки

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

Технические средства

Для реализации HTTP-интерфейса можно использовать Rails или один из альтернативных Ruby-фреймворков. Для скрипта рассылки - rake (для запуска через cron), BackgroundRB, delayed_job или rudeq.

Что думаете по этому поводу?

Кратко о проектах

Опубликовано 09.12.2008

Я последние несколько месяцев занимаюсь разработкой системы управления информационными порталами. Буквально несколько дней назад открыли в общий доступ один из клиентских проектов - портал МедиаЗавод, дочерний проект ООО “ЧР-Менеджер” (газеты Челябинский Рабочий, Тумба и еще несколько газет Челябинской области).

Проект сделан на рельсах, используется Single Sign-on для единой авторизации на всех сервисах. Всего в портал пока что включены 4 сервиса - система публикаций, доска объявлений, паспорт и баннерная система. Сервисы включают от 4 до 10 desert-модулей. В качестве JavaScript-библиотеки используется jQuery.

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

Развертывается все под nginx+passenger, используется кэширование в статику и вставка блоков через SSI. Сессии хранятся в memcache, через него же передается единая авторизацая.

Single Sign-On: Расшаривание сессии

Опубликовано 20.08.2008

При разработке крупного портала или сети сайтов, объединенных общей базой пользователей, возникает вопрос о том, как реализовать авторизацию на всех сайтах через одно окно. Грубо говоря, если Вася авторизовался на сайте A, то при входе на сайт B он должен видеть сообещение “Привет, Вася!”.

В чем состоит основная сложность? В том, что встроенными средствами браузера мы не можем передавать cookie от одного сайта к другому. Если мы авторизовались на сайте A, то на сайте B мы не можем об этом узнать, не применив бубна и парочки заклинаний. Для обхода этой проблемы были придуманы такие технологии, как CAS. Возможно, это именно то, что вам нужно для решения вашей проблемы.

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

Если вы разрабатываете сайты с использованием Ruby on Rails, то знаете, что рельсы могут хранить сессии в различных типах хранилищ - напрямую в cookie, в базе данных, в файловой системе и в memcached. Во всех вариантах, исключая cookie-хранилище, рельсы передают браузеру только ключ сессии, а сами данные хранит на сервере.

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

Давайте посмотрим поближе на техническую реализацию. Предположим, у нас есть два сервиса - Фотоальбом и Блоги, на которых мы хотим авторизоваться. Единая авторизация делается через Паспорт, который отвечает за регистрацию пользователей и хранение персональной информации. Условия задачи такие: пользователь заходит на Фотоальбом, авторизуется через Паспорт, а затем переходит на Блоги, где он должен авторизоваться автоматически.

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

  1. Пользователь в первый раз входит на Фотоальбомы
  2. Проверяется наличие ключа общей сессии, в связи с ее отсутствием пользователь перенаправляется на Паспорт
  3. Паспорт стартует общую сессию, пользователь перенаправляется обратно на Фотоальбомы
  4. Проверяется наличие общей сессии, для Фотоальбомов устанавливается общий ключ сессии

Для проверки наличия ключа и его установки на всех страницах сервиса Фотоальбомов в самом верху устраницы подключен Javascript, который генерируется Паспортом для каждого конкретного запроса. Если на Паспорте не запущена сессия, то Javascript перенаправляет пользователя на страницу, на которой сессия создается. Если сессия запущена, то Javascript говорит браузеру установить общий ключ сессии. Код примерно такой:

fucntion setSharedSession(key, value){
  if(getCookie(key) != value) {
    setCookie(key, value); // Устанавливаем cookie сессии
    window.location = window.location; // Обновляем страницу
  }
}
setSharedSession('_my_session_key', '83498234abc4586def90586abc');

Код Javascript-а выполняется уже в пространстве cookie Фотоальбомов, устанавливается сессионная кука и происходит обновление страницы. При обновлении запрос отправляется уже с новой сессией. Так как Javascript мы ставим в самом верху страницы, для пользователя все этим махинации практически незаметны - перенаправление на Паспорт, переход обратно на Фотоальбом и обновление страницы происходит практически незаметно для глаз.

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

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

Есть несколько тонких моментов, которые надо учитывать при использовании данной методики.

  1. Выбор хранилища сессий. Из тех вариантов, которые есть в рельсах, для такого подхода удобнее всего использовать memcached - он очень легко масштабируется. Менее удобен, но все же приемлем метод хранения сессий в базе данных. Все остальные я исползовать не рекомендую. Само собой, у всех сервисов должны быть абсолютно идентичные настройки хранилища.
  2. Сохранение объектов в сессию. Если вы в одном сервисе сохраните в сессию экземпляр класса, которого нет в другом сервисе, ваша психика может оказаться под угрозой :) Поэтому в сессию рекомендую сохранять только объекты базовых типов - строки, массивы, хэши.
  3. Производительность. Так как волшебный Javascript подключается на всех страницах сервисов, количество его загрузок будет равно количеству загрузок всех остальных страниц. Поэтому такой скрипт я бы рекомендовал реализовать на чем-то более быстром, нежели рельсы или ruby как таковой. Как вариант можно использовать Perl или PHP, благо серверный функционал довольно прост - в зависимости от наличия куки возвращать редирект или инструкцию на установку куки.
  4. Безопасность. Так как на сайте явно присутствует готовая функция для установки cookie, вам нужно быть предельно внимательными к вопросам защиты от XSS-атак. Фактически, любая дырка, в которую можно вставить инструкцию <script>, является для вас смертельно опасной. Чтобы устранить эту опасность можно использовать либо специальные плагины (например, xss_terminate), либо обработчики шаблонов со встроенной защитой (HAML, erubis).
  5. Сфера применимости. Такая методика может быть применена при довольно жестких условиях, описанных в начале статьи. Поэтому стоит заранее задуматься, какова вероятность того, что ваше приложение выйдет за обозначенные рамки, и насколько быстро это произойдет. Кроме того, стоит задуматься, будет ли участвовать в процессе работы с сессиями стороннее ПО - десктопные программы, приложения на других языках, что-то еще. Если да, то я рекомендую смотреть в сторону более универсальных решений.

Ваше мнение?

Порталы: От приложений к сервисам

Опубликовано 22.07.2008

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

В этой заметке я расскажу, какие ответы я нашел на заданные вопросы. В связи с тем, что мы ведем разработку на Ruby on Rails, многие методики ориентируются в первую очередь на эту платформу. Однако, я уверен, что на других платформах тоже возможно решить задачу тем же путем. Итак, давайте смотреть:

Вопрос №1: Из каких частей состоит проект?

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

  • Паспорт - управление регистрацией и авторизацией пользователей, редактирование персональной информации, используемой во всех частях системы, просмотр профилей, управление правами доступа;
  • Публикации - размещение текстовых материалов, видео- и графических материалов администраторами и штатными журналистами сайта
  • Комментарии - обсуждение тех или иных материалов, размещенных в различных модулях системы
  • Рейтинги - выставление оценок материалам, размещенным в различных модулях
  • Изображения - управление загрузкой графических материалов и их привязка к материалам из других модулей, упорядочивание и редактирование изображений

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

Вопрос №2: Каким образом разделены части проекта?

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

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

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

Вопрос №3: Какой функционал является общим и как он используется?

Наверное, самый сложный вопрос. Путем проб и ошибок я пришел к выводу, что общим для всех сервисов является следующее:

  • Конфигурация - позволяет сервисам знать друг о друге (адреса, порты, пароли), хранение общих настроек
  • Методика авторизации - схема получения информации о текущем авторизованном пользователе, ограничение доступа к закрытым разделам
  • Общие классы - например, ресурсы, которые используются сервисами для работы друг с другом
  • Расширения - модули acts_as_*, подключаемые в модели и контроллеры
  • Хелперы - общий набор, используемый во всех сервисах; например, хелперы для вывода информации о пользователе, блока комментариев и прочие.

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

Чтобы избавиться от этого бестолкового геморроя, я решил оформить функционал в формате gem’а, который подключается на ранних стадиях загрузки rails-приложения (до вызова boot.rb). Сразу посли подключения гема становится доступна конфигурация - набор констант, разбитых по модулям. Эти константы используются при инициализации приложения, например, при задании ключа для хранения сессий и прочих рельсовых настроек. А после того, как приложение инициализированно, вызывается метод, который подключает хелперы, методы для контроллеров и моделей.

Gem хорош тем, что он ставится на сервер и становится доступен всем сервисам, работающим на этом сервере. Плюс появляется возможность задавать требования к версии.

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