Eventr.com как смесь веб-технологий

Недавно состоялся запуск проекта Evantr.com. Коротко скажу, что по функционалу это социальный RSS-ридер, гибрид twitter и GoogleReader. Кстати, очень занятная получилась штука. Можно импортировать RSS к себе в ленту, добавлять в неё ленты друзей, и удобно читать всё это дело. Чем мне сразу понравился проект, так это тем, что разработчики не пытаются объять необъятное, а делают небольшой функционал, но зато на отлично. Из минусов я бы выделил то, что нельзя получить RSS-поток своей ленты. (оказывается можно).А ведь было бы здорово импортировать её к себе на сайт например, благо WordPress’овский плагин LifeStream это поддерживает. Даже внес предложение через Reformal, посмотрим, что будет.

Как веб-разработчику мне в первую очередь было интересно посмотреть, а на чем же всё таки он работает. Был приятно удивлен, что проект основан на Zend Framework. Хотя “основан” не то слово, т.к. там целый зоопарк из технологий. Как пишет сам автор используются: No-SQL, mongodb, node.js, Evented I/O, очереди, выводы, git, nginx, memcached, Google Reader, Atom, TTL, PHP, ZF, jQuery, выводы.

Дальше будет цитата из оригинальной статьи на хабре:

I. Технологии

1. PHP/ZendFramework + кое-что еще
Все, что я имел с самого начала, это небольшой каркас, делающий работу с zf слегка удобней. В нем Dependency Injection, Table Data Gateway, Transfer Object, более удобная работа с конфигами, настроенный Phing с уже прописанными тасками почти на все случаи жизни. В общем, работать с этим всем весьма приятно.

Архитектурно php приложение состоит из следующих слоев:

  1. Routing/Controller/View — понятно..
  2. Service — тут ACL, валидация, кэширование, логгинг. К нему можно спокойно прикрутить REST и будет отличный API.
  3. Gateway — тело бизнес-логики. У каждой сущности в системе свой gateway. Абсолютно абстрагирован от БД.
  4. Mapper — тут, собственно, прямая работа с базой.

Еще несколько моментов, о которых я старался помнить при проектировании:

  • KISS
  • Без велосипедов
  • Любая логическая часть системы должна иметь возможность масштабироваться горизонтально
  • Все процедуры, имеющие дело с внешними источниками, должны выполняться в фоновом режиме и не морозить пользователю интерфейс
  • Любой хай-лоад должен упираться в очередь, процессорную мощность и таймауты, а не в количество процессов, либо открытых соединений
  • Любые данные можно восстановить
  • Протоколировать нужно абсолютно все, не только ошибки
  • «Это можно закэшировать»
  • Дэвид: «Нужно, чтобы эта плашка была на 1 пиксель левее..» =)

2. nginx без комментариев

3. git Когда-то дал понять, что я не такой умный, как мне казалось.

4. Mongodb
Ранее мы использовали его на продакшн в другом проекте, но весьма осторожно, поэтому так и не удалось его опробовать по полной. В последнее время особо сильно развивается мода No-SQL, шардинга, map-reduce и No-SPOF. Решил я, пора уже вылазить из своих детских штанишек. По крайней мере, это очень сильно разбавило общую рутину и слегка меня встряхнуло.
Документация у ребят очень подробная, по этому вникнуть во всю глубину mongodb получилось за первые две недели. Пришлось слегка вывернуть на изнанку свой мозг после лет работы с реляционными бд. Но все нетривиальные задачи получалось решать самостоятельно, не прибегая к задаванию вопросов на форумах.
Слегка побаиваюсь его запуска на продакшн. Чтобы быть в курсе возможных проблем, регулярно читаю группы, изучаю вопросы, с которыми сталкиваются другие люди.
На данный момент, он настроен как master-master, который поддерживается не полноценно, но в нашем случае должен работать как надо. В дальнейшем будем шардить, и это будет однозначно легче, чем с той же mysql.

5. Memcached
Нечего тут сказать. Простой как дверь. Разве что, в дальнейшем хочу его попробовать на UDP… просто по приколу.

6. Memcacheq
Вот этому есть много альтернатив на сегодняшний день, но могу сказать, что он себя очень хорошо показал на продакшн в предыдущем проекте.
А еще приятно то, что для него не нужен специальный драйвер — он работает поверх memcached (помогло в следующем пункте).

7. node.js
Вот это, наверное, самое интересное, что со мной произошло за эти четыре месяца. Серверный Evented I/O очень возбуждает, даже больше чем дифференциал. Сразу захотелось под настроение весь php переписать на ruby. Вот такие у меня мечты.
Дело в том, что я ее обнаружил совсем недавно и совсем случайно. После чего довольно много вещей стали на свои места, как в самой системе, так и у меня в голове. Переписать пришлось довольно много, зато результат очень радует душу, и, надеюсь, порадует будущих пользователей.
Выкурил до фильтра вот эту страницу, на данный момент использую: mongoose, kiwi, step, memcache, streamlogger, hashlib, consolelog, eyes, daemon
Из своих библиотек написал jsonsocket, который, по моему, говорит сам за себя. Руки не доходят его github-нуть. И вот уже мечтаю из него сделать bsonsocket. Конечно же, пришлось написать штуки для работы с очередями, и прослойку для работы с Gateway слоем в php (об этом потом).
Еще добавил prowl, теперь бекграунд шлет мне push сообщением на телефон случайные цитаты из башорга раз в час (заодно и небольшую статистику в виде memory usage, etc.)
Многие библиотеки (модули) очень сырые, по этому, иногда приходилось править руками прямо в чужом коде (времени нет патчи делать). А уважаемые господа node.js какали на backward-compatibility, по этому, можно часто встретить просто не работающие библиотеки.

8. jQuery
Для меня это уже почти синоним клиентсайдового javascript.
Используемые плагины: blockUI, validate, form, tooltip, hotkeys, easing, scrollTo, text-overflow и еще парочку мелких.

II. Разработка

Не буду углубляться в специфику самого сервиса, технически, это почти Google Reader (GR).
Пока Дэвид «водил» серыми квадратиками по фотошопу, думая о бизнес-логике, я начал с базового моделирования, после чего сразу перешел к системе выкачки фидов.

1. Feed Pull

Казалось бы, тут все просто — дергаем адрес, выкачиваем xml, парсим, пишем в базу. Но есть нюансы.

  • Каждый фид нужно уникально идентифицировать, чтобы иметь возможность сохранить его в системе
  • Так же, нужно идентифицировать каждую запись, чтобы избежать дубликатов
  • Поддержка таких вещей, как if-modified-since и etag
  • Обработка редиректов
  • Разные версии RSS/Atom
  • Расширения различных сервисов, например gr:date-published
  • HTML внутри каждой записи нужно чистить, но не полностью, оставляя хорошие теги, фильтруя всякую ересь
  • Поиск и обработка иконки оказалось не самым приятным занятием… например, livejournal не отдает content-type, приходится использовать magic.mime
  • Спецификацию, по всей видимости, мало кто читает, по этому xml может быть не валидным, или СОВСЕМ не валидным

Выводы:
Внешние источники очень разные, многие просто плевали на стандарты. А еще им нельзя доверять — валидация контента должна быть такая же строгая, как и при взаимодействии с пользователем.
Идеальный код не получился. Он оброс множеством условий и исключений.
Каждый пункт отнял немало времени. Больше, чем может показаться с первого взгляда.

2. Обновлялка

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

Начал думать над собственной системой определения частоты обновления потоков, и вот, что получилось:

  • TTL — среднее временное расстояние в секундах между записями в потоке в течение часа (минимум 2 минуты, максимум 1 час)
  • Каждый поток имеет список средних TTL на каждый из 24 часов за последние 10 дней
  • На основе фактических данных за последние 10 дней, формируется прогноз на следующий день, который представляет средние значения TTL на каждый час
  • При каждом обновлении потока, система пересчитывает его средний фактический TTL за текущий час (0-23)

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

Собственно, сама процедура обновления — это ранее описанный Feed Pull, которому, как бэ все равно, что обновлять.
И тут мы плавно понимаем, что хотелось бы все это натянуть на очередь. Но про их организацию я расскажу чуть позже.

Кстати, в планах прикрутить PubSub, а так же, запустить свой хаб.

3. Discovery

Однозначно, в список умений удобного rss ридера должен входить поиск rss/atom фидов на любой html странице. Когда пользователь просто вбивает адрес сайта (например, www.pravda.ru), система должна пойти и поискать там, собственно, фиды, на которые можно его подписать.

Но эта процедура усложняется тем, что такие вещи нельзя делать прямо в запросе пользователя, поскольку это вовсе не задача веб-сервера — это нужно делать асинхронно. По запросу пользователя, мы сначала проверяем напрямую, существует ли такой поток в базе, потом смотрим в discovery кеш (который живет 2 часа), и если ничего не нашли, то кладем это дело в очередь и ждем максимум 5 секунд (о том, как именно ждем, расскажу позже). Если в течение этого времени задача не успела выполниться, мы завершаем сценарий, возвращая json в стиле {wait:true}. После чего, через некоторый таймаут, клиентсайд обращается с тем же запросом на сервер. Как только задача будет доделана на бекграунде, ее результат окажется в discovery кеше.

Несколько нюансов, связанных с данной процедурой:

  • Разные кодировки — иногда кодировка не указана ни в заголовках, ни в шапке… приходится определять ее побайтово (что не всегда работает)
  • На одной странице может быть два идентичных фида, один RSS другой Atom — в такой ситуации нужно выбрать один из них
  • Нужно дополнительно запрашивать каждый из фидов, дабы убедиться, что он работает, и взять истинный его title и description
  • Редиректы
  • Иконки (те же проблемы)
  • Стандарты и валидность (the same)

Вот эту часть я считаю довольно сырой, поскольку мы пытаемся сделать сервис для людей, которые не обязаны знать, что такое RSS или Atom. Например, если я, как самый обычный пользователь, вдруг захочу подписаться на мой любимый и единственный vkontakte.ru, то увижу я дулю с черничным вареньем. Как минимум, в дальнейшем хотим реализовать что-то а ля gr generated feed. Как больше, чем минимум, сделаем удобный, человеческий поиск.
Кстати, нередко встречается, что конкретно на данной странице нету alternate-ов, а где-то на других страницах этого же сайта — есть. Появилась мысль написать бекргаундный crawler, который будет себе тихонько искать rss/atom потоки на тех сайтах, которые часто вводятся пользователями.

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

4. Интерфейс

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

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

По возможности старался использовать css спрайты (там, где получалось).
Весь js и css собираются в один файл, минифицируется и сжимается gzip-ом. Средняя страница (со всей статикой) весит 300кб. С кешем — 100кб.
А для ie6 у нас есть специальная страница.

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

5. Прочитанные / непрочитанные записи

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

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

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

Но вот выбрать только непрочитанное в папке — это уже проблема. Не хочу уточнять нюансы, но это связано с тем, что join-ов в mongodb попросту нет. Простым запросом или несколькими — это нельзя решить, только через CodeWScope. Индексировать это невозможно, масштабировать — m/r. На данный момент это потенциально узкое место.

5.1. Непрочитанное сверху

Если кто-то из вас пользовался Google Reader, то наверняка знаете о функции «смотреть только непрочитанное». Так вот, если в потоке не остается записей, которые вы не читали — вы смотрите на пустую страницу. Сначала мы тоже сделали именно так, но тестирование показало, что пользователи даже не догадываются, что у них включена эта функция. Они не понимают, почему поток пустой, почему на нем нет записей, и куда они деваются.
Дэвид предложил очень интересное решение, где непрочитанные записи просто оказываются сверху, а прочитанные уходят вниз. И это стоило мне нескольких дней ломания мозга над тем, как это оптимально реализовать, именно в папках.

Выводы:
No-SQL хорош с точки зрения скорости и масштабируемости. Но некоторые, казалось бы, тривиальные вещи, с ним оказалось сделать довольно сложно.
Денормализация это добро. Не нужно считать проблемой то, что собьется какой-нибудь счетчик. Но на любые денормализованные данные нужно иметь функцию полного пересчета (на бекграунде, естественно).
M/R в mongodb еще сырой для продакшна. После небольшого тестирования оказалось, что он блокирует все к чертям во время работы. В версии 1.6 разработчики обещают его улучшить. Пока что обошелся без него.
Schema-less решает.

8. Sharing

Это функция, позволяющая зашейрить любую запись из читаемого потока на свою страницу. Коротко, это означает, что любой авторитетный парень А, читая разные фиды, мгновенно может сохранять конкретные записи (конечно, самые интересные и полезные) в свой поток(и) — похоже на Shared Items в GR. А у других пользователей есть возможность прямой подписки на его «Shared Items» поток, так же, как и на любой фид.

Одна из основных концепций нашего сервиса — удобное распространение информации. Довольно интересной с технической точки зрения задачей для меня было реализовать построение цепочек шейринга. Очень помог mongodb с его schema-less свойствами.

Интересный момент:
Недавно Google анонсировал новую фичу ReShare в Buzz. Так вот в этой статье (по ссылке), там где «A little more background», я наткнулся на пункты, которые мы с Дэвидом плотно обсуждали 4 месяца назад, при чем, пришли к тем же выводам. Наша реализация шейринга очень близка.

9. Node.js, background, очереди

Изначально демоны были написаны на PHP, при чем, весьма криво. И, не считая mongodb, это было самым стремным для меня местом в приложении, поскольку эрэнэр не предназначен для таких вещей.
Но когда я наткнулся на node.js (это было всего две недели назад), моя душа запела, и я снова смог «неспать» спокойно. Но делема заключалась в том, что переписывать на него весь бекграундный код, который уже был реализован на PHP (feed-pull, discovery, feed-info) — было совсем не время.
Весьма недолгое ковыряние в возможностях node меня привело к компромиссному решению — child-process.

9.1. Queue Manager

Это первый node демон. Его задача — читать очереди, раздавать задачи воркерам и отслеживать процесс их работы.

  • Один менеджер может обслуживать много очередей
  • Менеджеров в системе может быть запущено сколько угодно, по одному на сервер
  • Каждый может быть сконфигурирован по своему, например, разные менеджеры могут работать с разным набором очередей
  • Конфигурация очередей может отличаться и имеет следующие параметры
    • Максимальное количество одновременно работающих воркеров (фактическое количество регулируется в зависимости от нагрузки)
    • Размер буфера задач (нужно настраивать в зависимости от типа задачи и количества воркеров)
    • Максимальное время простоя воркера (автоматически убивает и освобождает память в случае простоя)
    • Максимальное время жизни воркера (если это php-cli, не стоит ему жить долго, лучше иногда перезапускать)
    • Максимальный memory usage у воркера (как только превышает, убиваем)
    • Таймаут на выполнение задачи (если воркер залип во время выполнения задачи, убиваем его, возвращаем задачу в очередь)
    • Количество раз, которое задача может сфейлиться
  • Когда задача выбирается из очереди, на нее ставится Lock (для блокировок используется memcache)
  • Если задача имеет результат, он будет сохранен в memcache
  • Для каждой очереди есть свой worker, это должен быть js класс с определенным интерфейсом
    • На данный момент работает только один такой — import (про него дальше будет)
  • Так же, существует WorkerPhp.js, который запускает php-cli как child-process и общается с ним на json
    • Жизнь такого воркера (процесса) не заканчивается на выполнении одной задачи — он может из выполнять поочередно, пока менеджер не увидит, что тот заметно «растолстел» и не уволит его
    • На практике, больше чем 4 php процесса на очередь одновременно не запускается
  • Понимает POSIX сигналы
  • В случае корректного завершения (не kill -9) аккуратно возвращает все выполняемые задачи из памяти обратно в очередь
  • Каждый менеджер открывает порт с REPL интерфейсом, на него можно зайти и спросить, как дела. Так же, можно без перезагрузки на лету менять его конфигурацию.

Кстати, любой uncaught exception ложит только один поток, но не весь процесс, что не может не радовать.
И все это — 500 строк кода (с комментариями).

Выводы:
Evented I/O — то, как обязаны работать большинство серверных приложений. Блокировка должна быть только там, где она действительно необходима.
Проксирование php через node показало хорошие результаты и сэкономило время.
Кучу работы обслуживает всего один процесс (не считая php-cli). JS воркеры работают там же, асинхронно и очень резко.

9.2. Controller — Publish/Subscribe hub

Часто бывает, что нужно выполнить балк задач (например, 100) параллельно, да еще и асинхронно. Но очередь — это черная дыра. Отправить туда 100 задач… и даже раз в секунду обращаться в memcache за результатами — накладно.
Можно еще в обход очереди можно было по сокету обращаться напрямую к менеджеру и просить его выполнить эти задачи, дожидаясь ответа в этом же соединении. Но этот вариант не подходит, так как менеджеров может быть десяток, и мы не знаем, к кому из них можно обратиться… короче, это неправильно.

И создал я Контроллер (node). Он вообще один на всю систему, при этом, простой как табуретка:

  • Все менеджеры открывают с контроллером постоянное соединение
  • В случае какого-либо результата или фейла любой задачи, менеджер детально сообщает об этом контроллеру
  • К нему можно подключиться «с другой стороны» и подписаться на конкретную задачу или список задач
  • По мере поступления информации о задачах, контроллер оповещает всех подписчиков
  • Если подписчик ожидает много задач, контроллер оповещает его по мере их поступления
  • Есть клиент для PHP (блокирующий)
  • Garbage Collection

Собственно, именно в процедуре «discovery», которую я описывал выше, PHP сценарий как раз обращается к контроллеру и ожидает результат задачи в течение 5 секунд, после чего, возвращает пользователя в интерфейс.

Выводы:
Publish/Subscribe схема очень эффективна в неблокирующих окружениях.
Стопроцентный результат не обязателен. Если в итоге 5 задач из 100 не выполнились по каким-то причинам, как правило, это не страшно и мы продолжаем работать.

9.3. Feed-updater (фоновая обновлялка)

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

9.4. Очереди

Чтобы избежать race-conditions, для каждой задачи формируется уникальный md5 идентификатор. Именно этот идентификатор кладется в очередь, а сами данные этой задачи — в memcached. Потому, что практически все задачи имеют нефиксированный размер, а memcacheq с этим не дружит — и не должна. Когда менеджер берет задачу, он ставит на нее lock, что является тоже записью в memcached. Это позволяет избежать повторного попадания в очередь одинаковых задач непосредственно во время их выполнения.
Планирую рассмотреть Redis, как альтернативу всему этому, потому что memcached в данном случае используется не по назначению. Если он упадет — потеряется вся очередь.

Так же, разделил очереди на две группы: юзерские и системные. Первые — в приоритете.
Это просто привело к добавлению «feed-pull-sys», которая используется фоновой обновлялкой, не мешаясь при этом с пользовательскими задачами.

Выводы:
Данная реализация еще очень сырая.
Очередь должна быть восстанавливаемой при любом падении.
Нужно использовать более продвинутую систему блокировок — mutex?
Пользовательские и фоновые процессы должны иметь разный приоритет.

10. Импорт/Экспорт

Вот еще один интересный момент, о котором хочется рассказать. Все порядочные ридеры обязаны поддерживать импорт/экспорт в формате OPML. Но дело в том, что некоторые пользователи могут загрузить свой opml с сотней фидов, которых еще нет в нашей системе. И тогда ему придется подождать, пока они все загрузятся. А еще, таких желающих может вполне оказаться десяток одновременно.

Node спасает. Появился новый воркер под названием «import» (на данный момент может работать до 10 одновременно). После загрузки и валидации opml файлика, php кидает в очередь задачу и возвращает пользователя в интерфейс, к прогресс-бару. Тем временем, «import» подхватывает и разбрасывает более мелкие задачи в очередь «feed-pull», после чего ждет их выполнения от контроллера, параллельно обновляя счетчик. А пользователь видит плавно ползущий прогресс-бар. При чем, он может уйти с этой страницы, погулять, а потом вернуться. Это приятно.

III. Выводы

  • Не делайте велосипедов. Практически на любую задачу уже существует готовое решение, которое просто нужно слегка адаптировать.
  • Чем проще продукт для пользователя в конечном итоге, тем сложнее его реализация. Потребитель, в прочем, вряд ли это заметит.
  • Не переоценивайте себя. Самостоятельно сделать взрослый продукт «за неделю» не возможно (хотя, не люблю я это слово).
  • Мотивация, все же, временами делает невозможное.
  • Продукт никогда не будет идеален. Работающее приложение — это всегда компромисс между временем и качеством.
  • Если работаешь сам, очень не хватает brainstorminga в команде. Стоит использовать коллективный разум при любой возможности.
  • На переключение контекста тратится много времени. Значительно эффективней, когда один разработчик занимается более однотипными задачами.
  • Если намерены делать свой стартап и продвинуться дальше идеи, забудьте про личную жизнь и пятничное пиво.

Leave a Comment