Весь проект выложен на github, ссылка будет в конце статьи.
Основная проблема
Изначально я хотел для себя написать простенького телеграмм бота счетчика калорий, который получает число от пользователя и возвращает сколько калорий осталось до нормы на день. То есть нужно хранить грубо говоря пару переменных для каждого пользователя.
В итоге нужно было выбрать способ хранить эти данные.
- Вариант — глобальные переменные, оперативная память. Вариант сразу провальный, так как при падении программы мы теряем все
- Вариант — запись в файл на диске. Для такого проекта может и пойдет, но я планировал деплой бота на heroku, который каждый день стирает все данные с диска. Так что этот вариант не подошел
- Вариант — Google-таблицы. Изначально я хотел остановится на этом варианте, но начал разбираться и понял, что есть ограничение на количество запросов к таблице, и чтобы только начать использовать таблицу нужно написать кучу строк кода и разобраться в их не самом простом апи
- Вариант — база данных. Да, это наилучший вариант во всем. Но для такого проекта это даже смешно использовать. Также развертывание и поддержка базы данных на стороннем сервере обойдется в копеечку.
В итоге ни один из этих вариантов не подошел. Конечно же есть и десятки других способов, но мне хотелось бы, чтобы было бесплатно, быстро и минимум кода.
Решение
Идея очень простая, для хранения данных мы будем использовать in memory базу данных sqllite, так как она уже встроена в python 3 и будем делать бэкапы нашей таблицы на сервера Telegram с небольшим интервалом (примерно каждые 30 секунд) и бэкап при закрытие процесса программы.
Если сервер упал, то при первом запросе мы автоматически загрузим нашу таблицу с сервера Telegram и восстановим данные в sqllite.
Можно использовать и любую другую in memory бд, кому как нравится.
Плюсы
- Быстродействие — за счет работы с данными в оперативной памяти скорость выполнения программы даже быстрее, чем при использовании бд на стороннем сервере (графики скорости выполнения и тестирования будут в конце)
- Бесплатно — не нужно покупать сторонние сервера для баз данных и все данные хранятся в виде бэкапа бесплатно на серверах Telegramа
- Относительно надежно — если сервер падает по непонятным причинам, то мы максимум теряем данные за последние 30 секунд (время интервала бэкапов), для рабочего прототипа или небольшого проекта будет достаточно.
- Минимальные затраты при переходе на обычную бд — нужно заменить данные подключения, убрать код бекапов и перенести данные таблицы из бэкапа на новую бд.
Минусы
- Отсутствие горизонтального масштабирования
- Нужно два аккаунта в Telegramе (один для администратора, другой для тестирования пользователя)
- Сервер не будет работать в России из-за блокировок
- В комментариях я думаю Вы найдете еще десяток других нюансов.
Время говнокодить
Напишем простой кликер и проведем тесты на скорость выполнения.
Бот будет написан на языке программирования python с использованием асинхронной библиотеки взаимодействия с api телеграмма aiogram.
Первым делом нужно заполнить настройки бота, не буду рассказывать как получить токен от BotFather, уже сотни статей есть на эту тему.
Также нам нужен второй аккаунт в телеграмме для админа, в котором будут сохраняться наши бекапы.
Для того, чтобы получить admin_id и config_id нам нужно запустить бота с аккаунта администратора и написать боту «admin», после чего он создаст первый бекап, и напишет ваш admin_id, config_id. Заменяем и запускаем бота заново.
#--------------------Настройки бота-------------------------
# Ваш токен от BotFather
TOKEN = '1234567:your_token'
# Логирование
logging.basicConfig(level=logging.INFO)
bot = Bot(token=TOKEN)
dp = Dispatcher(bot)
# Ваш айди аккаунта администратора и айди сообщения где хранится файл с данными
admin_id=12345678
config_id=12345
conn = sqlite3.connect(":memory:") # настройки in memory бд
cursor = conn.cursor()
Так теперь пройдемся по основной логике бота
Если боту приходит сообщение со словом «admin», то мы создаем таблицу пользователей с такой моделью данных:
- chatid — уникальный чат айди пользователя
- name — имя пользователя
- click — количество кликов
- state — значение для машины состояний, в данном проекте не используется, но в более сложных без него не обойтись
Добавляем тестового пользователя, и отправляем документ на сервер Telegram с нашей таблицей. Так же отправляем admin_id и config_id администратору в виде сообщений. После получения айдишников, данный код нужно закомментировать.
# Логика для администратора
if message.text == 'admin':
cursor.execute("CREATE TABLE users (chatid INTEGER , name TEXT, click INTEGER, state INTEGER)")
cursor.execute("INSERT INTO users VALUES (1234, 'eee', 1,0)")
conn.commit()
sql = "SELECT * FROM users "
cursor.execute(sql)
data = cursor.fetchall()
str_data = json.dumps(data)
await bot.send_document(message.chat.id, io.StringIO(str_data))
await bot.send_message(message.chat.id, 'admin_id = {}'.format(message.chat.id))
await bot.send_message(message.chat.id, 'config_id = {}'.format(message.message_id+1))
Логика для пользователя
Первым делом пытаемся получить из in memory бд данные пользователя, который отправил сообщение. Если ловим ошибку, то загружаем данные с бекапа сервера Telergam, заполняем нашу бд данными с бекапа и повторно пытаемся найти пользователя.
# Логика для пользователя
try:
sql = "SELECT * FROM users where chatid={}".format(message.chat.id)
cursor.execute(sql)
data = cursor.fetchone() # or use fetchone()
except Exception:
data = await get_data()
cursor.execute("CREATE TABLE users (chatid INTEGER , name TEXT, click INTEGER, state INTEGER)")
cursor.executemany("INSERT INTO users VALUES (?,?,?,?)", data)
conn.commit()
sql = "SELECT * FROM users where chatid={}".format(message.chat.id)
cursor.execute(sql)
data = cursor.fetchone() # or use fetchone()
Если мы нашли пользователя в бд, то обрабатываем кнопки:
- При нажатие «Клик» мы обновляем количество кликов у данного пользователя
- При нажатие «Рейтинг» мы выводим список пятнадцати человек у которых наибольшее количество кликов.
Если не нашли пользователя, то написать ему ошибку.
#При нажатии кнопки клик увеличиваем значение click на один и сохраняем
if data is not None:
if message.text == 'Клик':
sql = "UPDATE users SET click = {} WHERE chatid = {}".format(data[2]+1,message.chat.id)
cursor.execute(sql)
conn.commit()
await bot.send_message(message.chat.id, 'Кликов: {} '.format(data[2]+1))
# При нажатии кнопки Рейтинг выводим пользователю топ 10
if message.text == 'Рейтинг':
sql = "SELECT * FROM users ORDER BY click DESC LIMIT 15"
cursor.execute(sql)
newlist = cursor.fetchall() # or use fetchone()
sql_count = "SELECT COUNT(chatid) FROM users"
cursor.execute(sql_count)
count=cursor.fetchone()
rating='Всего: {}\n'.format(count[0])
i=1
for user in newlist:
rating=rating+str(i)+': '+user[1]+' - '+str(user[2])+'\n'
i+=1
await bot.send_message(message.chat.id, rating)
else:
await bot.send_message(message.chat.id, 'Вы не зарегистрированы')
Напишем логику для регистрации пользователя
Пытаемся найти пользователя в бд, если его нет, то добавляем новую строку в таблицу и делаем бэкап.
Если ловим ошибку, то подгружаем последний бэкап, заполняем таблицу и повторяем попытку регистрации.
sql_select = "SELECT * FROM users where chatid={}".format(message.chat.id)
sql_insert = "INSERT INTO users VALUES ({}, '{}', {},{})".format(message.chat.id,message.chat.first_name, 0, 0)
try:
cursor.execute(sql_select)
data = cursor.fetchone()
if data is None:
cursor.execute(sql_insert)
conn.commit()
await save_data()
except Exception:
data = await get_data()
cursor.execute("CREATE TABLE users (chatid INTEGER , name TEXT, click INTEGER, state INTEGER)")
cursor.executemany("INSERT INTO users VALUES (?,?,?,?)", data)
conn.commit()
cursor.execute(sql_select)
data = cursor.fetchone()
if data is None:
cursor.execute(sql_insert)
conn.commit()
await save_data()
# Создаем кнопки
button = KeyboardButton('Клик')
button2 = KeyboardButton('Рейтинг')
# Добавляем
kb = ReplyKeyboardMarkup(resize_keyboard=True).add(button).add(button2)
# Отправляем сообщение с кнопкой
await bot.send_message(message.chat.id,'Приветствую {}'.format(message.chat.first_name),reply_markup=kb)
Так, ну и самое интересное.
Сохранение и получение данных с сервера Telergam
Мы выгружаем все данные с таблицы пользователей, переводим словарь в строку и изменяем наш файл, который хранится на серверах Telegram.
#--------------------Сохранение данных-------------------------
async def save_data():
sql = "SELECT * FROM users "
cursor.execute(sql)
data = cursor.fetchall() # or use fetchone()
try:
# Переводим словарь в строку
str_data=json.dumps(data)
# Обновляем наш файл с данными
await bot.edit_message_media(InputMediaDocument(io.StringIO(str_data)), admin_id, config_id)
except Exception as ex:
print(ex)
Для того, чтобы получить бэкап нам нужно переслать сообщение с файлом от админа к админу. Затем получить путь к файл, считать данные по url и вернуть весь бэкап.
# #--------------------Получение данных-------------------------
async def get_data():
# Пересылаем сообщение в данными от админа к админу
forward_data = await bot.forward_message(admin_id, admin_id, config_id)
# Получаем путь к файлу, который переслали
file_data = await bot.get_file(forward_data.document.file_id)
# Получаем файл по url
file_url_data = bot.get_file_url(file_data.file_path)
# Считываем данные с файла
json_file= urlopen(file_url_data).read()
# Переводим данные из json в словарь и возвращаем
return json.loads(json_file)
Ну вот почти и все, осталось только написать таймер, чтобы делал бэкапы и протестировать бота.
Создаем поток, который каждые 30 секунд выполняет наш метод save_data()
def timer_start():
threading.Timer(30.0, timer_start).start()
try:
asyncio.run_coroutine_threadsafe(save_data(),bot.loop)
except Exception as exc:
pass
Ну и в главной программе мы запускаем таймер и самого бота.
#--------------------Запуск бота-------------------------
if __name__ == '__main__':
timer_start()
executor.start_polling(dp, skip_updates=True)
Так с кодом вроде бы разобрались, вот ссылка рабочего проекта на github.
Как запустить
- Скачиваем проект с гитхаба. Запускаем проект в любой среде разработки для python (Например: PyCharm).
- Среда разработки автоматически подгрузит необходимые библиотеки с файла requirements.
- Заменяем Token от BotFather в файле main.py
- Запускаем проект
- Со второго аккаунта нажимаем /start и пишем слово «admin»
- Выключаем проект и заполняем admin_id и config_id в файле main.py
- Запускаем проект и с аккаунта пользователя нажимаем старт
- Профит
Тестирование и графики
Тесты проводились на серверах heroku с минимальными характеристиками инстансов. Так, что можно считать, что все тесты были выполнены в более менее равных условиях.
Графики сделаны по выборкам из ~100 запрос-ответов. И представлены средние показатели выборки.
В качестве базы данных на стороннем сервере использовался PostgreSQL на Amazon RDS с минимальными характеристиками.
При одном миллионе пользователей время бэкапов становится проблемой.
Размер бэкапа полностью зависит от вашей модели данных, в моем случае при одном миллионе пользователей получился файл с данными на 21 мегабайт.
Вывод
Данный метод хранения данных имеет смысл для проектов до миллиона пользователей. То есть для прототипа или личного стартапа данный способ имеет право на жизнь.
В итоге мы получили полностью автономного кликера, независящий от удаленных баз данных.
Вот выше описанный проект, развернутый на heroku: @Clicker_fast_bot
Так же я реализовал более сложный проект с данной идеологией: @Random_friend_bot
Подобие чатвдвоем и чатрулет, но только в телеграмме.
Он ищет в радиусе 100 км человека противоположного пола для общения и реализует закрытый чат с новым собеседником.
Если будет интересно могу скинуть исходный код проекта. Так же если данная тема будет актуальна, то в следующей статье могу описать создание Rest api без внешних БД. То есть такой стек django-sqllite-Telegram.
Буду рад любой критике, спасибо за внимание!
Комментарии (17)
APXEOLOG
24.08.2019 16:38Также развертывание и поддержка базы данных на стороннем сервере обойдется в копеечку.
В AWS / Google Cloud предоставляются многие сервисы (в том числе и БД) в рамках free tier. Там можно и более серьезные проекты бесплатно хостить, не только счетчик калорий
Aracon
24.08.2019 18:20+1Кажется, на heroku есть и база данных, бесплатная с лимитом по количеству хранимых строк. Чем она не подошла?
Dreyk
24.08.2019 20:00+1есть. 1500 часов приложения в месяц. база бесплатная до 10000 строк
valery1707
24.08.2019 21:131500 часов это 62.5 дня — в месяц можно два одновременных приложения крутить.
А вот 10 000 строк может хватить не всем, да.
Taraflex
24.08.2019 23:00-1Сервер не будет работать в России из-за блокировок
habr.com/ru/company/ods/blog/462141/#comment_20474789
gecube
25.08.2019 00:34+1Опять телеграм в качестве хранилища?
Ну-ну.
Такое уже было https://habr.com/ru/company/vds/blog/456290/#first_unread
rSedoy
25.08.2019 07:04Если ловим ошибку, то подгружаем последний бэкап, заполняем таблицу и повторяем попытку регистрации.
При этом ловите Exception, т.е. вообще все возможные ошибки, не делайте больше так.
Azan
25.08.2019 19:15Чтобы выдерживать 1млн и более, нужно просто перед отправкой сжимать данные. Уверен что сжатая sqlite база будет в разы меньше занимать.
telpos
27.08.2019 16:47Кажется я где-то подобную задачу видел: данные в памяти, бекапы на диск… Ах, да! https://ru.alxschwarz.com/posts/naively-about-redis-backups/ (https://redis.io/topics/persistence)
majorius
Есть небольшая проблема, телеграм не позволяет ботам отправлять файлы больше 50 мегабайт, когда база будет больше — придется придумывать всевозможные ухищрения.
zartarn
Вроде было вообще 10мб
zelenin
1500 мб через tdlib