У широко известной соцсети ВКонтакте есть чуть менее известный API, насчитывающий немалое количество методов, а также различные способы загрузки файлов для использования в методах - и даже long-polling для получения сообщений из мессенджера ВКонтакте. В этой статье я попытаюсь рассказать о том, как работать в ruby с этим API в асинхронном режиме.
Для этого напишем собственный web-based мессенджер, умеющий отправлять сообщения, принимать и помечать сообщения прочитанными. Большая часть приложения будет оперировать на клиенте: фронт-энд будет устанавливать постоянное соединение с бэк-эндом, отправлять ему запросы и получать ответы; а бэк-энд займется непосредственной работой с API.
Самый примечательный момент в таком приложении - асинхронность: бэк-энд должен постоянно ждать от ВКонтакте новых сообщений, и параллельно с этим обрабатывать данные, присылаемые фронт-эндом, после чего вызывать нужный метод API (например, фронт-энд сообщает, что 2 входящих сообщения прочитаны - необходимо вызвать API-метод messages.markAsRead).
Задача
Итак, попробуем описать схему работы всего приложения.
браузер устанавливает WebSocket-соединение с сервером
бэк-энд запрашивает у ВКонтакте список друзей и непрочитанных сообщений
получив друзей и сообщения, бэк-энд отправляет данные на фронт-энд, и происходит первоначальный рендеринг интерфейса
отправив данные в браузер, бэк-энд получает параметры long-polling и начинает опрашивать ВКонтакте в вечном цикле
в конце каждой удачной итерации бэк-энд отправляет полученные данные в веб-сокет
в браузере каждая полученная от сервера порция обновлений разбирается по типам и рендерится
когда пользователь открывает вкладку одного из своих друзей, фронт-энд запрашивает у бэк-энда последние сообщения от этого пользователя
бэк-энд запрашивает сообщения через API, получает и отправляет в веб-сокет, после чего фронт-энд рендерит их в нужной вкладке
Дальнейшие действия происходят по следующей схеме: фронт-энд сообщает о действии пользователя бэк-энду, тот вызывает нужный метод API, после чего соответствующее обновление приходит от ВКонтакте в основном цикле, передается обратно на фронт-энд и рендерится. Таким образом происходит отправка сообщений из формы, а также пометка входящих сообщений прочитанными (при заходе пользователя в соответствующую вкладку интерфейса). Например, при отправке сообщения мы не рисуем его сразу после сабмита формы, а ждем, когда оно придет как обновление из основного цикла.
Реализация
Инструментарий
Существует определенное количество ruby-врапперов для ВКонтакте API, но из известных мне библиотек только vkontakte_api позволяет работать с API асинхронно, поэтому в данном приложении будем использовать его. Для организации асинхронной работы на бэк-энде возьмем eventmachine, избежим callback spaghetti с помощью em-synchrony, а общаться с фронт-эндом будем посредством WebSocket (для чего используем em-websocket).
Файловую иерархию организуем следующим образом: в lib/ положим ruby-код, в public/ отправится единственный нужный нам html-файл index.html, а в public/css/, public/img/ и public/js/ соответственно стили, картинки и яваскрипт + кофескрипт.
Бэк-энд
Чтобы не усложнять себе жизнь, подключим все необходимые гемы через bundler (не забывая выполнить bundle install после этого):
Для упрощения локального запуска и деплоя приложения используем библиотеку foreman, которая позволит описать процесс запуска в Procfile. Основной рабочий скрипт будет находиться в lib/main.rb:
lib/main.rb нужен для обработки запросов, приходящих по WebSocket; тут мы конфигурируем VkontakteApi, создаем клиент API в глобальной переменной (т.к. для всех запросов будем использовать именно его) и делегируем основную работу классу Messenger (передавая ему параметром объект WebSocket-а, дабы тот смог отправлять данные на фронт-энд):
require'bundler'Bundler.requirerequire_relative'messenger'# нужно выключить буферизацию вывода,# дабы видеть логгирование в реальном времени$stdout.sync=trueVkontakteApi.configuredo|config|# совершаем запросы через em_synchrony-адаптерconfig.adapter=:em_synchrony# в основном цикле получения сообщений соединения будут# висеть до 25 секунд, поэтому ставим таймаут на полминутыconfig.faraday_options={request:{timeout:30}}end# создаем клиент API, через него будем отправлять все запросы к ВКонтакте$client=VkontakteApi::Client.new(ENV['TOKEN'])EM.synchronydoEventMachine::WebSocket.start(host:'0.0.0.0',port:8080)do|ws|ws.onopendo# при открытии соединения с браузером создаем новый мессенджерVkontakteApi.logger.debug'Connection open'$messenger=Messenger.new(ws)$messenger.startendws.onclosedo# при закрытии соединения останавливаем мессенджер,# чтобы он перестал запрашивать обновления$messenger.stopVkontakteApi.logger.debug'Connection closed'endws.onmessagedo|msg|# сообщение приходит в формате uid=12345&message=abcde# парсим его в Hashie::Mash и отправляем мессенджеруdata=CGI.parse(msg).inject(Hashie::Mash.new)do|mash,(key,value)|mash.merge(key=>value.first)endVkontakteApi.logger.debug"Received message: #{data.inspect}"action=data.delete(:action)if%w[send_message load_previous_messages mark_as_read].include?(action)$messenger.send(action,data)endendendend
Long-polling в исполнении ВКонтакте работает следующим образом: приложение делает HTTP-запрос на определенный URL с определенными параметрами (и URL, и параметры нужно предварительно получить специальным API-методом messages.getLongPollServer), и соединение зависает на время не более указанного в параметре wait кол-ва секунд (документация рекомендует устанавливать этот параметр на 25). Если до истечения этого интервала происходит какое-то событие (пришло новое сообщение, кто-то из друзей вышел в онлайн), немедленно приходит ответ; если же за 25 секунд ничего не происходит, то по истечении этого времени приходит пустой ответ. После получения ответа нужно отправить новый запрос, и так до бесконечности.
В описанном выше запросе на получение обновлений используется параметр key - ключ, время действия которого ограничено 3-4 часами; когда это время проходит, запрос начинает возвращать в ответ {"failed": 2}. В этом случае необходимо снова получить параметры запроса API-методом messages.getLongPollServer. Также присутствует параметр ts - что-то вроде идентификатора последней полученной порции обновлений; каждый long-polling запрос возвращает это значение, и его нужно использовать в следующем запросе, чтобы не получать обновления повторно.
Итак, основной класс. Сетевые запросы, пропатченные в em-synchrony, нельзя выполнять в корневом файбере, поэтому приходится создавать отдельный файбер и запускать код в нем - для этого добавлен хелпер #in_fiber.
classMessenger# сохраняем объект EventMachine::WebSocket::Connection# в инстанс-переменной, чтобы отправлять данные на фронт-эндdefinitialize(ws)@ws=wsend# запуск бесконечного цикла получения обновленийdefstartin_fiberdo# получаем список друзейfriends=$client.friends.get(fields:[:screen_name,:photo])# и кол-во непрочитанных сообщений, разбитое по отправителямunread=get_unread_messages# складываем в один хэшfriends.eachdo|friend|friend.unread=unread[friend.uid]end# и отправляем фронт-энду, чтобы тот отрендерил интерфейсsend_to_websocket(friends_list:friends)# получаем параметры long-pollingurl,params=get_polling_params# и делаем запросы, пока мессенджер не остановленwhileself.running?&&response=VkontakteApi::API.connection.get(url,params).bodyifresponse.failed?# время действия ключа истекло, нужно получить новыйurl,params=get_polling_paramsnextelse# все нормально - отправляем обновления на фронт-эндsend_to_websocket(updates:response.updates)# и обновляем параметр ts для использования# в следующем запросе к ВКонтактеparams.ts=response.tsendendendend# остановка мессенджераdefstop@stopped=trueend# запущен ли мессенджерdefrunning?!@stoppedend# отправка сообщения пользователюdefsend_message(params={})in_fiberdo# мини-костыль для вызова $client.messages.send# т.к. метод :send определен в KernelVkontakteApi::Method.new('send',resolver:$client.messages).call(params)endend# загрузка сообщений, отправленных# до запуска мессенджераdefload_previous_messages(params={})in_fiberdo# выбрасываем первый элемент массива (там будет общее кол-во сообщений)# и сортируем в хронологическом порядкеmessages=$client.messages.get_history(uid:params.uid).tap(&:shift).reversedata={uid:params.uid,messages:messages}# отправляем сообщения на фронт-энд с типом previous_messagessend_to_websocket(previous_messages:data)endend# пометка сообщений прочитаннымиdefmark_as_read(params={})in_fiberdo$client.messages.mark_as_read(mids:params.mids)# на фронт-энд тут ничего отправлять не нужно,# т.к. изменение статуса "прочитано" придет в основном циклеendendprivate# хелпер для отправки данных в веб-сокетdefsend_to_websocket(messages)messages.eachdo|type,data|# если data - хэш, то преобразовываем его символьные ключи в строковые# (дабы не получить после JSON-кодирования ":messages")data=data.inject({})do|hash,(key,value)|hash[key.to_s]=valuehashendifdata.is_a?(Hash)json=Oj.dump('type'=>type.to_s,'data'=>data)@ws.sendjsonendend# получение параметров для long-polling запросаdefget_polling_paramsparams=$client.messages.get_long_poll_server['http://'+params.delete(:server),params.merge(act:'a_check',wait:25,mode:2)]end# кол-во непрочитанных входящих сообщений, разбитое по отправителямdefget_unread_messagesmessages=$client.messages.get(filters:1)# снова выбрасываем первый элемент за ненадобностьюmessages.shift# и складываем все в хэш, проиндексированный по id отправителяcounts=Hash.new(0)messages.inject(counts)do|hash,message|hash[message.uid]+=1hashendend# хелпер для запуска кода в отдельном файбереdefin_fiber(&block)Fiber.new(&block).resumeendend
Как видно, Messenger#start запускает основной цикл получения обновлений, Messenger#stop его останавливает (это нужно при закрытии вебсокет-соединения, иначе после обновления страницы будет уже два бесконечных цикла), Messenger#send_message отправляет сообщение указанному пользователю, Messenger#load_previous_messages загружает последнюю историю сообщений от и к указанному пользователю, а Messenger#mark_as_read помечает сообщения прочитанными.
На этом бэк-энд готов, переходим к фронт-энду.
Фронт-энд
Во-первых, недолго думая, возьмем Twitter Bootstrap для создания интерфейса, дабы не тратить на это лишнее время и нервы. Во-вторых, по тем же причинам, используем CoffeeScript для программирования клиентской части.
Интерфейс будет выглядеть следующим образом: слева будет сайд-бар, в котором будет располагаться меню, содержащее весь список друзей пользователя; справа разместим основную контентную область, переключаемую с помощью меню. Т.е. по клику на друге в меню справа будет открываться блок с сообщениями от/к этому другу, а также с его переходами в онлайн/оффлайн.
Также в меню добавим метки: для друга, который сейчас онлайн, и для счетчика непрочитанных входящих сообщений от этого человека.
Верстка основной страницы выглядит следующим образом:
Теперь займемся обработкой данных, приходящих из веб-сокета, а также повесим обработчики на переключение вкладок пользователей (нужно подгружать предыдущие сообщения при первом открытии вкладки, и помечать все непрочитанные сообщения прочитанными) и сабмит формы отправки сообщения.
# хелпер для логгированияlog = (param) ->console.logparam$(document).ready-># обработчик перехода в табу юзера$('#navbar').on'shown','li.user a[data-toggle="tab"]',(e) ->user_id = e.target.id.split('_')[1]user = usersList.list[user_id]# если предыдущие сообщения еще не загружены, грузимuser.loadPreviousMessages()unlessuser.previousMessagesLoaded# если есть непрочитанные сообщения - помечаем прочитаннымиuser.markAllAsRead()# обработчик сабмита формы$(document).on'submit','form.message',(e) ->form = $(e.target)message =action: 'send_message'uid: form.data('user-id')message: form[0].message.valuews.send$.param(message)form[0].message.value = ''falsewindow.ws = newWebSocket('ws://0.0.0.0:8080')# обработчик сообщений от бэк-эндаws.onmessage = (event) ->message = $.parseJSONevent.dataswitchmessage.typewhen'friends_list'usersList.loadmessage.datawhen'previous_messages'usersList.list[message.data.uid].loadPreviousMessagesmessage.data.messageswhen'updates'feed.processmessage.dataelselog'received unknown message:'logmessage$('#debug').append'<pre>'+event.data+'</pre>'ws.onopen = ->log'connected...'ws.onclose = ->log'socket closed'
Список друзей и связанные с ним методы будем хранить в глобальной переменной usersList, а работу с обновлениями из основного цикла организуем через глобальный объект feed.
При получении списка друзей вызываем usersList.load, который в свою очередь удаляет все элементы .loading и рендерит интерфейс:
При загрузке списка пользователей создается массив объектов класса User. Объекты этого класса должны уметь запрашивать историю сообщений, следить за кол-вом непрочитанных входящих сообщений от соответствующего друга, а также помечать их прочитанными (все сразу, т.к. это будет происходить при заходе пользователя на вкладку с этим другом).
classUserconstructor: (data) ->@uid = data.uid@name = [data.first_name,data.last_name].join(' ')@online = data.online# счетчик непрочитанных сообщений,# нужный на то время, пока @previousMessagesLoaded = false;# потом подсчет идет по @messages@unread = data.unread@messages = {}@previousMessagesLoaded = false# перегруженная функция:# без параметров запрашивает сообщения у бэк-энда;# когда тот отвечает, эта же функция вызывается# с полученными сообщениями, и происходит рендерингloadPreviousMessages: (messages) ->ifmessages?# очищаем все уже загруженные сообщения@messages = {}$("#user_#{@uid} ul.feed").html('')# и рендерим полученные сообщенияformessageinmessagesunread = if(message.read_state==1)then0else1flags = unread+message.out*2params = [message.midflags@uidmessage.datenullmessage.bodymessage.attachments]# сообщение рендерится в конструкторе MessagenewMessage(params...)@previousMessagesLoaded = true# если открыта таба этого пользователя,# нужно сразу пометить все сообщения прочитанными@markAllAsRead()if@paneActive()else# запрашиваем сообщения из вебсокетаdata =action: 'load_previous_messages'uid: @uidws.send$.param(data)unreadCount: -># кол-во непрочитанных входящих сообщений считается по-разному# в зависимости от @previousMessagesLoaded:if@previousMessagesLoaded# когда предыдущие сообщения уже загружены в @messages,# обходим их и подсчитываем@unreadMessagesIds().lengthelse# до загрузки предыдущих сообщений считаем по @unread, полученной# при изначальной загрузке списка пользователей@unreadhasUnread: ->@unreadCount()>0paneActive: ->$("#user_#{@uid}").hasClass('active')unreadMessagesIds: -># возвращаем id непрочитанных входящих сообщений из @messagesidforid,messageof@messageswhenmessage.unreadAndIncoming()addMessage: (message) ->@messages[message.id]=message# если кол-во непрочитанных входящих изменилось, рендерим менюifmessage.unreadAndIncoming()# если предыдущие сообщения еще не загружены, увеличиваем счетчик@unread+=1if!@previousMessagesLoadedusersList.renderMenu()# если панель активна, сразу помечаем все сообщения прочитанными@markAllAsRead()if@paneActive()markAllAsRead: ->if@previousMessagesLoadedand@hasUnread()message =action: 'mark_as_read'mids: @unreadMessagesIds().join(',')ws.send$.param(message)
Все сообщения пользователя сохраняются в @messages, это происходит в методе addMessage - как и работа со счетчиком и меню. Классу Message остается лишь вызвать @user.addMessage и отрендерить сообщение:
classMessageconstructor: (@id, flags, from_id, timestamp, @subject, @text, @attachments) ->@unread = !!(flags&1)@outgoing = !!(flags&2)@user = usersList.list[from_id]@date = newDate(timestamp*1000)@user.addMessagethis@render()unreadAndIncoming: ->@unreadand!@outgoing# помечаем сообщение прочитанным в интерфейсе приложения# (используется, когда ВКонтакте сообщает, что это сообщение прочитано)read: ->@unread = false# если сообщение входящее, нужно заново отрендерить менюunless@outgoing# а если у юзера еще не загружены предыдущие сообщения,# надо еще и вычесть единицу из счетчика@user.unread-=1unless@user.previousMessagesLoadedusersList.renderMenu()render: ->classes = ['message']classes.push'pull-right'if@outgoingsender = if@outgoingthen'Я'else@user.namemessageString = '<blockquote id="'+@id+'" class="'+classes.join(' ')+'">'messageString+="<p>#{@text}</p>"messageString+='<small><i class="icon-user"></i> '+sendermessageString+=' | '+feed.formatDate(@date)+'</small>'messageString+='</blockquote>'messageString+='<div class="clearfix"></div>'$("#user_#{@user.uid} ul.feed").appendmessageString
И последнее, что остается - обработка обновлений, постоянно поступающих с сервера.
window.feed =process: (updates) ->forupdateinupdatescode = update.shift()switchcode# изменение флагов сообщенияwhen3[message_id,flags,user_id]=updateusersList.list[user_id].messages[message_id].read()ifflags&1# добавление нового сообщенияwhen4message = newMessage(update...)# друг стал онлайн(8) / оффлайн(9)when8,9user_id = update[0]user = usersList.list[-user_id]user.online = ifcode==8then1else0usersList.renderMenu()date = '<span class="badge">'+@formatDate()+'</span>'ifcode==8label = '<span class="label label-info">online</span>'elselabel = '<span class="label label-important">offline</span>'@addStatus[date,label].join(' '),user# форматирование даты в стиле "04.09.2012 в 23:50:16" или "сегодня в 01:46:23"formatDate: (date = new Date) ->dateParts = [date.getDate()date.getMonth()+1date.getFullYear()]today = newDateifdateParts[0]==today.getDate()anddateParts[1]==today.getMonth()+1anddateParts[2]==today.getFullYear()dateString = 'сегодня'elsedateParts = forpartindatePartsifpart.toString().lengthis1then"0#{part}"elsepartdateString = dateParts.join'.'timeParts = [date.getHours()date.getMinutes()date.getSeconds()]timeParts = forpartintimePartsifpart.toString().lengthis1then"0#{part}"elseparttimeString = timeParts.join':'"#{dateString} в #{timeString}"addStatus: (statusString, user) -># добавляем полную запись (лейблы плюс юзернейм) в общий фид$('#feed ul.feed').append"<li>#{statusString} #{user.name}</li>"# и укороченную запись (только лейблы) в персональный фид$("#user_#{user.uid} ul.feed").append"<li>#{statusString}</li>"
Мессенджер готов. И вот как он выглядит:
Запуск
К сожалению, ВКонтакте позволяет использовать API-методы для работы с сообщениями только десктопным и мобильным приложениям, поэтому придется получить токен доступа вручную и хранить его в конфигурации приложения. Благодаря foreman можно положить токен в файл .env в корне приложения, и он будет передаваться в main.rb как переменная окружения.
Итак, чтобы получить токен, сначала нужно зарегистрировать свое приложение на ВКонтакте. На этой странице нужно выбрать тип приложения “Standalone-приложение” и задать имя. Далее видим страницу настроек приложения, здесь нам нужно только поле “ID приложения”.
Теперь можно получать токен. Запускаем любую ruby-REPL (я использую pry, можно также взять irb) и подключаем гем vkontakte_api:
12345678
$ pry -r vkontakte_api
# в app_id вписываем ID приложения[1] pry(main)> VkontakteApi.app_id ='3074156'# тут нужно обязательно указать http://oauth.vk.com/blank.html[2] pry(main)> VkontakteApi.redirect_uri ='http://oauth.vk.com/blank.html'# получаем URL авторизации[3] pry(main)> VkontakteApi.authorization_url(type: :client, scope: [:friends, :messages, :offline])"https://oauth.vk.com/authorize?response_type=token&client_id=3074156&scope=friends%2Cmessages%2Coffline&redirect_uri=http%3A%2F%2Foauth.vk.com%2Fblank.html"
Дальше просто копируем полученный URL и идем по нему в браузере. Там будет страница, предлагающая подтвердить права приложения на доступ к друзьям и личным сообщениям, а также доступ в любое время (это важно, поскольку позволяет получить “вечный” токен - его больше не придется обновлять). После нажатия на кнопку “Разрешить” идет редирект на страницу с текстом “Login success” - при этом в URL страницы будет параметр access_token, который нам и нужен.
Копируем токен и вставляем в файл .env в следующем формате:
1
TOKEN=1234567890
Теперь при запуске приложения foreman положит токен в переменную окружения TOKEN, и в lib/main.rb он будет доступен как ENV['TOKEN'] - откуда мы его и берем при создании клиента API.
Остается лишь открыть в браузере файл public/index.html. Сразу после загрузки страницы фронт-энд открывает вебсокет-соединение, получает список друзей, рендерит интерфейс и начинает ожидать новых сообщений. Мессенджер работает :)
В получившемся приложении остается еще довольно много возможностей для доработки - показывать аттачменты к сообщениям (картинки, аудио, видео), находить и рендерить URL-ы в виде ссылок; но проект показывает, что асинхронно работать с ВКонтакте API довольно удобно.