Разработка приложения для Битрикс24 от А до Я. Часть 4 - завершаем разработку
Наконец-то мы подобрались к финальной части разработки нашего приложения для Битрикс24. В прошлых трех частях мы проделали огромное количество работы: определились с задачами и функциями приложения, реализовали установку и дали возможность настроить приложение. Сегодня мы закончим разработку, и наше приложение можно будет устанавливать на порталы Битрикс24. Слово Вадиму Солуянову - руководителю отдела web-разработки.
Завершение разработки
Вернемся к позабытым доступам (скоупам)
Доступы (scope)
Права приложения в Битрикс24 ограничены запрошенными доступами. Эти доступы видит пользователь, когда устанавливает приложение на свой портал. Видит, правда, лишь когда установка идет из маркетплейса (МП) Битрикс24, в другом случае видеть их и не нужно. Какие же это случаи? Есть два способа разместить приложение на портале: установка из МП и локальная установка.
Для размещения приложения в МП вам прежде всего требуется стать партнером Битрикс24. Затем вы получаете доступ в личный кабинет (ЛК) т.н. вендора, где сможете размещать приложения в регионах, которые доступны вам согласно договору и прочим подписанным документам. При размещении первой или очередной версии вашего приложения вы через визуальный интерфейс указываете, какие доступы ей требуются для выполнения своей задачи. Приложение может быть платным или бесплатным и, после проверки модераторами, становится всем доступным для установки в тех регионах, где вы его опубликовали.
Портал Битрикс24 на платных тарифах позволяет размещать приложения локально. Это исключительно ваша установка на вашем портале, и при размещении вы сами указываете права, какие необходимы приложению.
Какие же права требуются нашему приложению? Как уже заметили при отправке SMS, требуется право (scope) im - Chat and notifications (im поскольку instant messages). Для регистрации SMS-провайдера требуется messageservice - так и есть Message Service. И, поскольку провайдер используется в CRM (не уверен, правда, действительно ли он нужен...) - crm.
Также, вероятно, будет не такой плохой идеей обращаться к пользователю персонально, когда мы отсылаем ему уведомления, а потому нам потребуется еще доступ user, чтобы получить по идентификатору Ф.И.О пользователя. Но здесь мы не будем этим заморачиваться, хотя и стоило бы.
Где и какие права требуются, можно посмотреть в документации по rest Битрикс24.
Обработка событий (event_handler)
Теперь наше приложение обрабатывает уже два события OnAppInstall и OnAppUninstall. Рассмотрим их подробнее.
OnAppInstall (по секрету - тут же и OnAppUninstall)
Здесь задача простая - проверить валидность запроса и сохранить application_token. Так что сразу ее опишем, показав предварительно сам запрос:
event = ONAPPINSTALL
data = [
VERSION = 2
LANGUAGE_ID = en
]
ts = 1597134111
auth = [
access_token = ...
expires = 1597137111
expires_in = 3600
scope = crm,im,user,department,bizproc
domain = ...
server_endpoint = https://oauth.bitrix.info/rest/
status = F
client_endpoint = .../rest/
member_id = ...
user_id = 8
refresh_token =
application_token = ...
]
Собственно, событие OnAppUninstall будет мало отличаться от первого. Лишь event будет содержать другое значение, и не будет никаких токенов, кроме application_token, поскольку событие к нам приходит постфактум, и приложение уже удалено, а стало быть, и прав никаких не имеет. Поэтому в пcевдокод можно включить стразу всю логику обработки событий:
Схема 8. Обработка событий
event = getRequest('event')
auth = getRequest('auth')
switch event
case 'ONAPPINSTALL'
if not isAuthValid(auth)
exit
logCurrentRequest()
saveAppToken(auth)
break
case 'ONAPPUNINSTALL'
if not isAppTokenValid(auth.application_token)
exit
logCurrentRequest()
deletePortalMessageRecords()
deletePortalUserRecords()
deletePortalRecord()
logUninstall()
break
Конечно, где-то там, в методах, фиксируются в логах и все действия вместе с данными в ключевых точках. Все ближе и ближе мы к завершению нашей программы. Перейдем к обновлению статусов.
Обновление статусов сообщений
API SMS-провайдера дает нам некую точку входа для получения текущего статуса сообщений. При отправке мы сохранили в базу все данные отправленной SMS для дальнейшей проверки. Это идентификатор на портале, идентификатор у провайдера, идентификатор пользователя, которому в случае чего следует отсылать уведомление, и по которому также мы забираем авторизационные данные из таблицы пользователей (фильтруя записи, конечно, не только по BX_USER_ID, но и по MEMBER_ID, поскольку записи-то относятся к разным порталам).
При периодической проверке мы выбираем из базы сообщения, отсортировывая их по дате обновления в прямом порядке и фильтруя по статусу. Все возможные статусы провайдера желательно загнать куда-нибудь в константы:
- const STATUS_QUEUED = 'queued'
- const STATUS_SENT = 'sent'
- const STATUS_DELIVERED = 'delivered'
Добавим сюда еще и собственные статусы:
- const STATUS_DONE = 'done'
- const STATUS_ERROR = 'error'
ими мы будем отмечать записи при успешной или окончательно провальной попытке передать изменение статуса на портал.
Получая в цикле записи (и ограничив выборку числом, что не даст нашей задаче выполняться слишком долго, нагружая сервер), мы по каждой делаем запрос к SMS провайдеру и проверяем изменение статуса. Даже если ничего не изменилось, мы обновляем запись, меняя DATE_UPDATE, чтобы сдвинуть ее в конец очереди, иначе мы можем каждый раз проверять одни и те же сообщения, в то время, как другие будут безнадежно долго ожидать. Если статус изменился, меняем его на портале.
Статусы провайдера, скорее всего, отличаются от статусов портала, и нам потребуется их как-то "намаппить" друг на друга. Статусы сообщения на портале:
- delivered
- undelivered
- failed
как только вызван наш обработчик, у сообщения будет статус sent - отправлено.
Очевидно, что в нашей ситуации, STATUS_QUEUED - изначальный статус провайдера и соответствует sent портала, пока он такой, делать ничего не нужно. STATUS_SENT - также ничего не значит, поскольку на портале это изначальный статус. STATUS_DELIVERED - имеет полное соответствие. Вероятно у провайдера будет еще и сообщение об ошибке, которое не статус, но которое придется интерпретировать либо в failed, либо в undelivered.
Еще пару слов про обработку очередей. Вероятно, лучшим решением для этого будет задействовать какой-нибудь менеджер, но тогда мы упустим шанс поговорить про сам процесс обработки очередей. Как же их обрабатывать и в чем, собственно, проблема? Дело в том, что запуская задачу по крону, мы можем оказаться в ситуации, когда запустился новый процесс, а старый еще не завершил свою работу. В этом случае есть вероятность, что будут обрабатываться одни и те же данные, создавая бестолковую нагрузку, а то и вызывая ошибки. Кроме того, в этом есть и другой нехороший момент. Многие API, как и Битрикс24, накладывают ограничение на кол-во запросов в минуту, тогда получится, что мы сами у себя зазря отжираем свои возможности. Конечно, можно особо не парится, а запускать скрипт с таким интервалом, который вряд ли даст наложиться одному выполнению на предыдущее, или проверять, нет ли в запущенных процессах нас самих, и прекращать выполнение, если мы уже есть. При выборе последнего, однако, можно оказаться в ситуации, когда ранее запущенный процесс завис окончательно, и, поскольку один выполняемый уже есть, ни одного следующего не стартует. Но можно поступить и другим образом.
В таблице сообщений можно создать еще одно поле, которое будет хранить id процесса. Наша программа в начале обработки получает свой собственный proc_id в операционной системе, делает пакетное обновление записей, подходящих для следующей выборки, проставляя в них свой id. Затем, уже не торопясь, обрабатывает их. А в конце обработки очищает в записях поле с id. Поскольку мы так и так обновляем каждую запись независимо от результата, то можно тут же и очищать это поле. Таким образом, наложившийся на первое выполнение второй запуск промаркирует собой лишь те записи, в которых поле id процесса пустое, и наложения никакого не случится. Итак, можно уже расписать логику. Лишь пара слов о безопасности. Данная часть приложения вроде не требует проверки, поскольку запускается локально. Но это справедливо лишь в том случае, когда нет никакой возможности достучаться до нее через УРЛ. Решение здесь простое: крон всегда запускается от имени какого-то пользователя, достаточно разрешить доступ на чтение к файлу только ему (ну, и его группе, вероятно).
Схема 9. Проверка статуса сообщения
const MAX_ATTEMPTS_NUM = 3 // макс. кол-во попыток
procId = getMyProcessId()
rowsNum = markMessage(procId) // возвращает кол-во промаркированных записей
if not rowsNum
exit
res = loadMessagesByProcId(procId)
lastMemberId = ''
providerAuth = []
errors = []
while msg = res.fetch()
// если поменялся портал, загрузим новые авторизационные данные
if empty lastMemberId or msg.MEMBER_ID not equal lastMemberId
providerAuth = getProviderAuth(msg.MEMBER_ID)
lastMemberId = msg.MEMBER_ID
procIdCleaned = false; // был ли снят procId у записи
// получим значение нового статуса
statusRes = getMessageStatus(providerAuth, msg)
switch statusRes.status
case STATUS_DELIVERED
// получим токены для пользователя портала
portalUserAuth = getPortalUserAuth(msg.MEMBER_ID, msg.BX_USER_ID)
if not portalUserAuth
procIdCleaned = updateMessageStatus(msg.ID, STATUS_ERROR, procId)
logFailMessage(msg.MEMBER_ID)
break
setRes = setDeliveredStatus(portalUserAuth, msg)
if setRes.success()
// меняем в базе на успешный и фиксируем в логах
procIdCleaned = updateMessageStatus(msg.ID, STATUS_DONE, procId)
logSuccessMessage(msg.MEMBER_ID)
else
error = setRes.getError()
logError(setRes.getError())
// если ошибка соединения
if isConnectionError(error)
num = incrementMessageAttempts(msg.ID)
// если кол-во попыток превысило лимит
if num >= MAX_ATTEMPTS_NUM
procIdCleaned = updateMessageStatus(msg.ID, STATUS_ERROR, procId)
logFailMessage(msg.MEMBER_ID)
break
case STATUS_ERROR
error = statusRes.getError()
logError(error)
if isConnectionError(error)
num = incrementMessageAttempts()
if num >= MAX_ATTEMPTS_NUM
procIdCleaned = updateMessageStatus(msg.ID, STATUS_ERROR, procId)
logFailMessage(msg.MEMBER_ID)
else
portalUserAuth = getPortalUserAuth(msg.MEMBER_ID, msg.BX_USER_ID)
if not portalUserAuth
procIdCleaned = updateMessageStatus(msg.ID, STATUS_ERROR, procId)
break
setRes = setErrorStatus(portalUserAuth, msg)
if setRes.success()
procIdCleaned = updateMessageStatus(msg.ID, STATUS_ERROR, procId)
else
logError(setRes.getError())
num = incrementMessageAttempts(msg.ID)
if num >= MAX_ATTEMPTS_NUM
procIdCleaned = updateMessageStatus(msg.ID, STATUS_ERROR, procId)
logFailMessage(msg.MEMBER_ID)
break
// если запись не обновлялась, нам все еще нужно удалить ИД нашего процесса
if not procIdCleaned
updateMessageStatus(msg.ID, false, procId)
unmarkOldMessages()
Здесь непонятные места я прокомментировал, так что и пояснять нечего. Разве что unmarkOldMessages(). Эта функция удаляет ид процесса у записей, чье время обновления превышает некий лимит. Программа не застрахована от зависания, и тогда промаркированные процессом записи никогда не будут обработаны другими. Для этого следует их освободить от зависшего процесса. Еще про MAX_ATTEMPTS_NUM. В момент обращению к удаленному серверу он может подвиснуть, перегружаться, быть просто временно недоступен из-за ошибок на одном из серверов, через которые проходит наш запрос. Поэтому в подобных случаях мы даем сообщению несколько попыток достучаться. Упс.... Почему же мы так не делали в момент отправки самого сообщения? Ведь и тогда сервер SMS-провайдера мог находиться в отключке... Чтобы не переписывать все заново, придется оставить это на вторую версию))) И, пожалуй, не только это.
Как говорится, нет предела совершенству, и наша программа (я решил закруглиться и взвалить продление токенов, чистку старых логов и сбор статистики на вас) очень от него далека. Как только что выяснилось, потребуется подкорректировать код отправки и код обновления статуса. Хорошо, что изменений потребуется немного. При отправке мы добавляем еще один статус STATUS_NOT_SENT - не удалось связаться с провайдером - и, как и раньше, сохраняем в базу. При обновлении же статуса, перед получением нового, проверяем, а было ли вообще отправлено сообщение, и, если нет, отправляем отсюда. Но помимо этого уже видится такая настройка, как время отправки сообщений, чтобы человек не получал их среди ночи. Отображение на странице приложения текущего баланса пользователя тоже было бы удобно. Словом, есть еще над чем поработать.
Теперь же вернемся к тому, с чего начинали и объединим все поправки в новый, полный список действий и данных.
Точки входа в приложение
install - установка
фиксируем факт установки/обновления, подписываемся на события OnAppInstall, OnAppUninstall
settings - настройки
возможность редактировать авторизационные ключи, тестовая СМС, регистрация провайдера
handler - отправка сообщений
TODO: добавить статус для неотправленных
event_handler - обработка событий портала
OnAppInstall - запомить application_token
OnAppUninstall - почистить базу от данных портала
task - проверка статуса сообщений
TODO: добавить отправку неотосланных сообщений
statistic - сбор статистики за предыдущие сутки и продление токенов (это оставил на вас)
Действия, выполняемые приложением
- При установке подписаться на событие OnAppUninstall.
- Зафиксировать факт установки или обновления в логах.
- Сделать где-то запись о портале, на котором произошла установка, чтобы в дальнейшем мы могли отличать установку от обновления.
- Получить текущие данные по авторизации из хранилища, если есть, и вывести в виде формы пользователю.
- Проверить новые авторизационные данные и, если они валидны, сохранить у себя.
- При первом получении валидных данных зарегистрировать на портале SMS-провайдера.
- В случае получения невалидных данных вывести пользователю сообщение об этом.
- При получении запроса на отправку тестового SMS выполнить отправку.
- После отправки тестового SMS вывести пользователю результат отправки.
- В hanlder при получении запроса выполнить отправку
- При отправке запомнить все необходимые данные для дальнейшей проверки статуса.
- При получении уведомления об удалении приложения (OnAppUninstall) зафиксировать данный факт в логах и очистить хранилище от записей, связанных с данным порталом, за исключением логов и статистики.
- Периодически проверять статусы отправленных сообщений. Например раз в 10 минут.
- Раз в сутки запускать сбор статистики, складывая факты интересуемых событий, произошедших за предыдущие сутки.
- Раз в сутки очищать хранилище от: 1) логов, старее времени, в течение которого мы их собираемся хранить; 2) данных о сообщениях, которые были успешно/неуспешно доставлены, т.е. уже не требующие дальнейшей проверки статуса (их мы храним какое-то время на случай, если они потребуются для техподдержки).
- Обновление refresh_token'ов раз в сутки, у которых срок жизни приближается к 30 дням.
- Проверка валидности запроса с портала.
- Обработка OnAppInstall. Проверить валидность запроса через сервер авторизации, при успехе сохранить присланный application_token в хранилище 1 (Сведения о портале).
Данные
1. Сведения о портале.
ID - уникльный идентификатор записи
DOMAIN - доменное имя портала
MEMBER_ID - уникальный идентификатор портала на сервере авторизации
LICENSE_TYPE - тип лицензии (напр., чтобы знать бесплатники пользуются или не только)
... - другие интересуемые о портале данные
API_LOGIN - авторизация у провайдера
API_KEY - авторизация у провайдера
APP_TOKEN - токен для проверки валидности событий и запросов на отправку SMS
DATE_INSTALL - дата установки приложения
DATE_UPDATE - дата обновления версии приложения
VERSION - текущая установленная версия
2. Логирование
KEY - уникльный идентификатор записи (хэш)
DOMAIN - домен портала (все-таки при техподдержке с ним работать удобнее)
MEMBER_ID - ид портала (домен может быть переименован, но ид - нет)
EVENT_TYPE - тип события (напр., факт установки - INSTALL)
EVENT_DATA - любые интересуемые данные в виде текста
DATE_CREATE - дата и время события в понятном человеку написании
DATE_POINT - дата, время и миллисекунды для сортировки и получения точной последовательности событий (просто DATE_CREATE для этого не хватит, поскольку за одну секунду может произойти несколько событий)
3. Хранение авторизации СМС-провайдера
Для простоты добавим поля к п.1 в те самые другие поля
API_LOGIN - логин у СМС-провайдера
API_KEY - АПИ ключ
4. Отправленные сообщения
ID
DOMAIN
MEMBER_ID
BX_USER_ID - униальный в рамках портала Ид пользователя
MESSAGE_ID - идентификатор сообщения на портале
EXT_MESSAGE_ID - идентификатор на стороне СМС-провайдера
STATUS - текущий статус у СМС-провайдера
DATE_CREATE - дата создания записи
DATE_UPDATE - дата обновления записи
VERSION - версия приложения
PROC_ID - маркер ИД процесса, который работает сейчас с записью
ATTEMPTS_NUM - кол-во попыток обновить статус или отправить сообщение
5. Авторизация пользователя портала
ID
DOMAIN
MEMBER_ID
BX_USER_ID - униальный в рамках портала Ид пользователя
ACCESS_TOKEN - собственно тот токен, с которым будем стучаться на портал
REFRESH_TOKEN - токен для обновления access_token
REFRESH_DATE_END - последняя дата-время валидности refresh_token
DATE_CREATE - дата создания записи
DATE_UPDATE - дата обновления записи (понадобится для продления токенов через крон)
VERSION - версия приложения
6. Статистика
ID
DOMAIN
MEMBER_ID
EVENT_TYPE - тип события
DAY_VALUE - кол-во событий за день
DATE_CREATE - дата, на которую собрана статистика
Планы на вторую версию
- Добавить повторную отправку при неудаче. Для этого доработать отправку и проверку статуса. При проверке отправлять повторно неотправленные сообщения.
- Добавить показ текущего баланса пользователю в настройках приложения.
- Может быть, добавить настройку, указывающую на диапазон времени, в который разрешена отправка? Хотя это наверняка можно сделать в личном кабинете у SMS-провайдера...
Конец истории.
Ох, совсем забыл, совсем забыл, что при смене версии нужно не только обновить подписку на события, но и сменить URL нашего handler-а.., вот голова садовая!