Некоторое время назад я сел за разработку текстовой многопользовательской стратегии на основе API бота Telegram, а спустя уже месяц запустил первый релиз со скудным, но играбельным функционалом. По цепочке знакомств игра быстро набрала небольшую активно играющую аудиторию и продолжала набирать в последующие дни благодаря внутриигровой реферальной программе. И вроде бы все хорошо, пока дневной онлайн не перевалил за отметку в двести пользователей. Вот тут и начались проблемы. Все чаще пользователи обращались ко мне с вопросом, почему бот не отвечает по несколько минут. Дискомфорт игрокам доставлялся сильнейший, особенно во время войн, когда пользователь пытался быстро восстановить армию для контратаки, а игра предательски висла и не отвечала ни на какие действия. Причем Telegram мог забанить как отсылку всех сообщений, так и только сообщения конкретного содержания при частом его использовании, например, покупка ресурсов и вербовка воинов, где использовались кнопки кастомной клавиатуры со стандартными количественными значениями.
Опыт работы с API бота уже имелся, однако на меньшей аудитории и с меньшей интенсивностью отправки. Про лимиты тоже было известно, но реально сталкивался с ними только при работе с группами. Там все намного жестче, чем при работе с персональными чатами. Чтобы узнать больше о лимитах, достаточно обратится к FAQ на официальном сайте Telegram.
If you're sending bulk notifications to multiple users, the API will not allow more than 30 messages per second or so. Consider spreading out notifications over large intervals of 8—12 hours for best results.
Из указанного выше имеем, что нельзя отправлять конкретному пользователю сообщения чаще чем раз в секунду и не более 30 сообщений в секунду при массовой рассылке разным пользователям. Но допускаются некоторые погрешности. Следовательно нам необходимо каждые 1/30 секунд отправлять сообщение пользователю, проверяя, не посылали ли мы уже ему сообщение в течении текущей секунды, иначе послать сообщение для следующего пользователя, если тот прошел эту же проверку.
Так как разработка изначально велась на языке Go, где имеются каналы и сопрограммы, (они же горутины), то на ум пришла сразу же идея отправки отложенных сообщений. Сначала складываем обработанный ответ в канал, а в отдельном потоке разгребаем этот канал каждые дозволенные нам 1/30 секунд. Но идея с одним каналом для всех сообщений не сработала. Достав из канала сообщение и убедившись, что этому пользователю мы пока слать сообщения не можем, нам необходимо это сообщение куда-то деть. Отправлять снова в этот же канал не хорошо, потому что мы сломаем хронологический порядок сообщений пользователя, а так же сильно отложим доставку этого сообщения при большом количестве активных игроков. Проверить сообщение, не доставая его из канала и перейти к следующему, насколько я знаю, мы тоже не можем.
Тогда появляется идея завести по каналу на пользователя. С этого момента поподробнее.
// Мап каналов для сообщений, где ключом является id пользователя
var deferredMessages = make(map[int]chan deferredMessage)
// Здесь будем хранить время последней отправки сообщения для каждого пользователя
var lastMessageTimes = make(map[int]int64)
// chatId – id пользователя, которому шлем сообщения
// method, params, photo – заранее подготовленные параметры для запроса согласно bot API Telegram
// callback будем вызывать для обработки ошибок при обращении к API
type deferredMessage struct {
chatId int
method string
params map[string]string
photo string
callback func (SendError)
}
// Метод для отправки отложенного сообщения
func MakeRequestDeferred(chatId int, method string, params map[string]string, photo string, callback func (SendError)) {
dm := deferredMessage{
chatId: chatId,
method: method,
params: params,
photo: photo,
callback: callback,
}
if _, ok := deferredMessages[chatId]; !ok {
deferredMessages[chatId] = make(chan deferredMessage, 1000)
}
deferredMessages[chatId] <- dm
}
// error.go, где ChatId – id пользователя
type SendError struct {
ChatId int
Msg string
}
// Имплементация интерфейса error
func (e *SendError) Error() string {
return e.Msg
}
Теперь с ходу для обработки получившегося набора каналов хочется воспользоваться select case конструкцией, однако проблема в том, что она описывает фиксированный набор каналов для каждого case, а в нашем случае набор каналов динамический, так как пользователи добавляются в процессе игры, создавая новые каналы для своих сообщений. В противном случае не обойтись без блокировок. Тогда, обратившись к Google, как обычно, на просторах StackOverflow нашлось отличное решение. А заключается оно в использовании функции Select из пакета reflect.
Если коротко, то эта функция позволяет нам извлечь из заранее сформированного массива SelectCase'ов, каждый из которых содержит канал, сообщение, готовое для отправки. Принцип тот же, что и в select case, но с неопределенным числом каналов. То что нам и нужно.
func (c *Client) sendDeferredMessages() {
// Создаем тикер с периодичностью 1/30 секунд
timer := time.NewTicker(time.Second / 30)
for range timer.C {
// Формируем массив SelectCase'ов из каналов, пользователи которых готовы получить следующее сообщение
cases := []reflect.SelectCase{}
for userId, ch := range deferredMessages {
if userCanReceiveMessage(userId) && len(ch) > 0 {
// Формирование case
cs := reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(ch)}
cases = append(cases, cs)
}
}
if len(cases) > 0 {
// Достаем одно сообщение из всех каналов
_, value, ok := reflect.Select(cases)
if ok {
dm := value.Interface().(deferredMessage)
// Выполняем запрос к API
_, err := c.makeRequest(dm.method, dm.params, dm.photo)
if err != nil {
dm.callback(SendError{ChatId: dm.chatId, Msg: err.Error()})
}
// Записываем пользователю время последней отправки сообщения.
lastMessageTimes[dm.chatId] = time.Now().UnixNano()
}
}
}
}
// Проверка может ли уже пользователь получить следующее сообщение
func userCanReceiveMessage(userId int) bool {
t, ok := lastMessageTimes[userId]
return !ok || t + int64(time.Second) <= time.Now().UnixNano()
}
Теперь по порядку.
- Для начала мы создаем таймер, который будет «тикать» каждые необходимые нам 1/30 секунд, и запускаем на нем цикл for.
- После чего начинаем формировать необходимый нам массив SelectCase'ов, перебирая наш мап каналов, и складывая в массив только те непустые каналы, пользователи которых уже могут получать сообщения, то есть прошла одна секунда с момента прошлой отправки.
- Создаем для каждого канала структуру reflect.SelectCase, в которой нам нужно заполнить два поля: Dir – направление (отправка в канал или извлечение из канала), в нашем случае устанавливаем флаг reflect.SelectRecv (извлечение) и Chan – собственно сам канал.
- Закончив формировать массив SelectCase'ов, отдаем его в reflect.Select() и получаем на выходе id канала в массиве SelectCase'ов, значение, извлеченное из канала и флаг успешного выполнения операции. Если все хорошо, делаем запрос на API и получаем ответ. Получив ошибку, вызываем callback и передаем туда ошибку. Не забываем записать пользователю дату последней отправки сообщения
Вот так, вроде бы все просто. Теперь Telegram не придерется к нашему боту из-за частой отправки сообщений пользователю, а игрокам будет комфортно играть. Конечно, понятно, что при огромном количестве пользователей сообщения будут отправляться игроку все медленнее и медленнее, но это будет делаться равномерно, создавая меньше неудобств, чем при единичных блокировках на несколько минут, если не следовать лимитам.
Кстати вспомним об оговореных в FAQ погрешностях. Я в своей реализации шлю пользователям два сообщения в секунду вместо одного и не раз в 1/30 секунду, а раз в 1/40, что намного чаще чем рекомендуется. Но пока проблем не возникало.
Исходный код клиента можно посмотреть на gitlab
Ну а если кому-то стало интересно о чем речь, то в Telegram @BastionSiegeBot
Комментарии (8)
uyuri
15.12.2016 18:10Хорошая статья, спасибо! Не сравнивали как использование рефлекта повлияло на производительность приложения? Возможно ли его не использовать вовсе?
DorianPeregrim
15.12.2016 18:18Ну замеров никаких, честно говоря не производилось, но ощутимых ухудшений не было замечено. В моем случае только море радости как у меня, так и у игроков. Избежать использования этого пакета не получилось, хоть и искал достаточно. Но инструмент мне очень понравился.
otchetnik
15.12.2016 18:23Также советую обратить внимание на лимиты одновременных подключений к вебхуку
backmeupplz
16.12.2016 09:19Если кому нужно, для своих ботов написал на Node.js небольшой модуль, который рекурсивно рассылает всем юзерам сообщение в рамках лимитов. Удобно для апдейтов по функционалу.
Жми меня!/** * Used to send messages to all users * * @module sendout * @license MIT */ /** Dependencies */ const db = require('./db'); /** * Used to send messages to all chats * @param {Telegram:Bot} bot Bot that should send messages * @param {String} text Text to be sent */ function sendAll(bot, text) { db.getChats() .then((chats) => { sendMessage(text, chats, bot, {}); }) .catch(/** todo: handle error */); } /** * Recursive function to send text to an array of chats; please don't use this * function twice at any given point of time. Sends at most 30 messages/sec * * @param {String} text - Text to be sent * @param {[Mongoose:Chat]} chats - Chats to get this message * @param {Telegram:Bot} bot - Bot that should respond */ function sendMessage(text, chats, bot, results) { if (chats.length <= 0) { const keys = Object.keys(results); const successKeyIndex = keys.indexOf('success'); if (successKeyIndex > -1) { keys.splice(successKeyIndex, 1); } let message = `All messages were sent, here are the results:\n\nSuccess: ${results.success || 0}`; keys.forEach((key) => { message = `${message}\n${key}: ${results[key]}`; }); bot.sendMessage('ваш id', message); return; } /** Get current users and users for the next loop */ const nextUsers = Object.create(chats); const currentUsers = nextUsers.splice(0, 30); const resultsCopy = Object.create(results); const promises = []; currentUsers.forEach((user) => { promises.push(new Promise((resolve) => { bot.sendMessage(user.id, text, { disable_web_page_preview: 'true', }) .then(() => resolve('success')) .catch((err) => { resolve(String(err.message)); }); })); }); Promise.all(promises) .then((values) => { values.forEach((value) => { if (resultsCopy[value]) { resultsCopy[value] += 1; } else { resultsCopy[value] = 1; } }); setTimeout(() => { sendMessage(text, nextUsers, bot, resultsCopy); }, 1500); }) .catch(err => bot.sendMessage('ваш id', err.message)); } module.exports = { sendAll, };
catanfa
23.12.2016 18:51Я так понимаю, что 30 сообщений в секунду — это лимит на бота в целом, незавимимо от количества пользователей онлайн? А что, если у моего бота 1 млн. человек онлайн, с таким лимитом они будут ждать вечность??
avdien
Ссылка на gitlab не работает.
DorianPeregrim
Поправил. Спасибо.