Добрый день, сегодня я хотел бы поделится с Вами проблемами и их необычными решениями, которые встретились при написании небольших IT проектов. Сразу скажу, что статья для тех, кто хоть немного разбирается в разработке телеграмм ботов, баз данных, SQL и в языке программировании python.

Весь проект выложен на github, ссылка будет в конце статьи.

image

Основная проблема


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

В итоге нужно было выбрать способ хранить эти данные.

  1. Вариант — глобальные переменные, оперативная память. Вариант сразу провальный, так как при падении программы мы теряем все
  2. Вариант — запись в файл на диске. Для такого проекта может и пойдет, но я планировал деплой бота на heroku, который каждый день стирает все данные с диска. Так что этот вариант не подошел
  3. Вариант — Google-таблицы. Изначально я хотел остановится на этом варианте, но начал разбираться и понял, что есть ограничение на количество запросов к таблице, и чтобы только начать использовать таблицу нужно написать кучу строк кода и разобраться в их не самом простом апи
  4. Вариант — база данных. Да, это наилучший вариант во всем. Но для такого проекта это даже смешно использовать. Также развертывание и поддержка базы данных на стороннем сервере обойдется в копеечку.

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

Решение


Идея очень простая, для хранения данных мы будем использовать in memory базу данных sqllite, так как она уже встроена в python 3 и будем делать бэкапы нашей таблицы на сервера Telegram с небольшим интервалом (примерно каждые 30 секунд) и бэкап при закрытие процесса программы.

Если сервер упал, то при первом запросе мы автоматически загрузим нашу таблицу с сервера Telegram и восстановим данные в sqllite.

Можно использовать и любую другую in memory бд, кому как нравится.

Плюсы


  1. Быстродействие — за счет работы с данными в оперативной памяти скорость выполнения программы даже быстрее, чем при использовании бд на стороннем сервере (графики скорости выполнения и тестирования будут в конце)
  2. Бесплатно — не нужно покупать сторонние сервера для баз данных и все данные хранятся в виде бэкапа бесплатно на серверах Telegramа
  3. Относительно надежно — если сервер падает по непонятным причинам, то мы максимум теряем данные за последние 30 секунд (время интервала бэкапов), для рабочего прототипа или небольшого проекта будет достаточно.
  4. Минимальные затраты при переходе на обычную бд — нужно заменить данные подключения, убрать код бекапов и перенести данные таблицы из бэкапа на новую бд.

Минусы


  1. Отсутствие горизонтального масштабирования
  2. Нужно два аккаунта в Telegramе (один для администратора, другой для тестирования пользователя)
  3. Сервер не будет работать в России из-за блокировок
  4. В комментариях я думаю Вы найдете еще десяток других нюансов.

Время говнокодить


Напишем простой кликер и проведем тесты на скорость выполнения.

Бот будет написан на языке программирования 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.

Как запустить


  1. Скачиваем проект с гитхаба. Запускаем проект в любой среде разработки для python (Например: PyCharm).
  2. Среда разработки автоматически подгрузит необходимые библиотеки с файла requirements.
  3. Заменяем Token от BotFather в файле main.py

  4. Запускаем проект
  5. Со второго аккаунта нажимаем /start и пишем слово «admin»

  6. Выключаем проект и заполняем admin_id и config_id в файле main.py
  7. Запускаем проект и с аккаунта пользователя нажимаем старт

  8. Профит

Тестирование и графики


Тесты проводились на серверах heroku с минимальными характеристиками инстансов. Так, что можно считать, что все тесты были выполнены в более менее равных условиях.

Графики сделаны по выборкам из ~100 запрос-ответов. И представлены средние показатели выборки.

В качестве базы данных на стороннем сервере использовался PostgreSQL на Amazon RDS с минимальными характеристиками.



При одном миллионе пользователей время бэкапов становится проблемой.



Размер бэкапа полностью зависит от вашей модели данных, в моем случае при одном миллионе пользователей получился файл с данными на 21 мегабайт.



Вывод


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

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

Вот выше описанный проект, развернутый на heroku: @Clicker_fast_bot

Так же я реализовал более сложный проект с данной идеологией: @Random_friend_bot

Подобие чатвдвоем и чатрулет, но только в телеграмме.

Он ищет в радиусе 100 км человека противоположного пола для общения и реализует закрытый чат с новым собеседником.

Если будет интересно могу скинуть исходный код проекта. Так же если данная тема будет актуальна, то в следующей статье могу описать создание Rest api без внешних БД. То есть такой стек django-sqllite-Telegram.

Буду рад любой критике, спасибо за внимание!

Комментарии (17)


  1. majorius
    24.08.2019 15:17

    Есть небольшая проблема, телеграм не позволяет ботам отправлять файлы больше 50 мегабайт, когда база будет больше — придется придумывать всевозможные ухищрения.


    1. zartarn
      24.08.2019 18:38
      +1

      Вроде было вообще 10мб


    1. zelenin
      26.08.2019 15:18

      1500 мб через tdlib


  1. APXEOLOG
    24.08.2019 16:38

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

    В AWS / Google Cloud предоставляются многие сервисы (в том числе и БД) в рамках free tier. Там можно и более серьезные проекты бесплатно хостить, не только счетчик калорий


    1. Dreyk
      24.08.2019 19:59
      +1

      у AWS всего лишь на год


      1. APXEOLOG
        24.08.2019 20:04

        Можно использовать DynamoDB, если укладываться в Read/Write Capacity


  1. Aracon
    24.08.2019 18:20
    +1

    Кажется, на heroku есть и база данных, бесплатная с лимитом по количеству хранимых строк. Чем она не подошла?


    1. Dreyk
      24.08.2019 20:00
      +1

      есть. 1500 часов приложения в месяц. база бесплатная до 10000 строк


      1. valery1707
        24.08.2019 21:13

        1500 часов это 62.5 дня — в месяц можно два одновременных приложения крутить.
        А вот 10 000 строк может хватить не всем, да.


  1. Taraflex
    24.08.2019 23:00
    -1

    Сервер не будет работать в России из-за блокировок

    habr.com/ru/company/ods/blog/462141/#comment_20474789


  1. gecube
    25.08.2019 00:34
    +1

    Опять телеграм в качестве хранилища?
    Ну-ну.
    Такое уже было https://habr.com/ru/company/vds/blog/456290/#first_unread


    1. sim2q
      25.08.2019 05:57

      как я понял тут даже не инкрементальный бэкап


  1. rSedoy
    25.08.2019 07:04

    Если ловим ошибку, то подгружаем последний бэкап, заполняем таблицу и повторяем попытку регистрации.

    При этом ловите Exception, т.е. вообще все возможные ошибки, не делайте больше так.


  1. v1z
    25.08.2019 10:44

    Зачем, если есть Firebird?


  1. Azan
    25.08.2019 19:15

    Чтобы выдерживать 1млн и более, нужно просто перед отправкой сжимать данные. Уверен что сжатая sqlite база будет в разы меньше занимать.


  1. fedorro
    26.08.2019 12:09

    Может просто хранить базу в папке синхронизации Dropbox?


  1. telpos
    27.08.2019 16:47

    Кажется я где-то подобную задачу видел: данные в памяти, бекапы на диск… Ах, да! https://ru.alxschwarz.com/posts/naively-about-redis-backups/ (https://redis.io/topics/persistence)