Асинхронный телеграм бот на bash, глазами C# программиста
В интернете много статей о том, как создавать простых bash-телеграм ботов, которые бы выполняли, например, алертинг. Часто это сводиться к вечному циклу, который раз в несколько секунд дергает tg-api. А что, если у меня хотелок больше чем может предоставить такое решение? Хотелки:
Беседа ведется асинхронно в нескольких чатах
Чатов больше чем процессов в приложении
Бот помнит на каком этапе находится каждый разговор
Процесс написания бота должен хотя бы напоминать работу с популярными ООП языками
Бот должен легко масштабироваться на большее число пользователей
Это история, о попытке поизвращаться создать небольшой, но удобный инструмент для написания ботов, удовлетворяющих моим требованиям.
Зачем, а главное зачем?
Почему?
Мной руководил спортивный интерес: «А можно ли, спроектировать инструмент так, чтобы он был максимально удобен мне, человеку который просто любит bash, но не работает на нем, плохо разбирается в bash-практиках программирования и вообще привык к C#»
Подготовка к написанию
Особенности языка
Для начала, я бы выделил те вещи в bash, которые, по моему субъективному мнению, могут создать трудности во время кодинга:
Нет поддержки многопоточности
Нет классов или структур
Всего две области видимости переменных, глобальные и локальные (внутри функций)
Не полная поддержка словарей
Вспомогательные фичи
Обработка ошибок
Перед написанием, я решил реализовать инструменты для удобной работы с ошибками.
Фичи которые я посчитал необходимыми:
# Глобальная функция получения стектрейса.
trace_call() {
local frame=0
# Цикл с перебором всей глубины стека.
while caller $frame; do
local info=$(caller $frame)
# Сохранение стека в одну строку через "->".
stack_trace="$stack_trace -> $info"
((frame++))
done
echo "$stack_trace"
}
export -f trace_call
# Пример использования.
trace_call
# Глобальная функция логирования.
default_logger(){
# Получение сообщения и уровня важности через параметры.
local message=$1
local level=$2
local current_time=$(date +"%Y-%m-%d %H:%M:%S")
local stack_trace=`trace_call`
# Сохранение в формате JSON
local json=$(cat <> $LOG_FILE
}
export -f default_logger
# Пример использования
default_logger "Sum func error" "ERROR"
Проверка на успешность выполнения какого-либо скрипта и последующий запуск обработчика (Аналог catch), тоже в одну функцию
# Глобальная функция обработки ошибок.
catch() {
# Получение кода завершения предыдущей функции.
local exit_code=$?
local catch_function=$1
# Проверка кода на успешность.
if [ $exit_code -ne 0 ]; then
# Запуск переданного скрипта обработки.
eval $catch_function
fi
}
export -f catch
# Пример использовани.
sum_func
catch '
# Код который запуститься в случае ошибки.
default_logger "Sum func error" "ERROR"
'
(Вероятно, вместо функции catch, можно использовать оператор ||, но catch был симпатичнее)
Автоматическая инициализация всех функций внутри проекта
Мне хотелось добиться того, чтобы во время написания проекта, всегда можно было использовать любую функцию из остальной части проекта. Например написать func1, и точно знать, что она уже инициализирована и никакой «func1: command not found» не появиться. Значит, мне требовалось создать механизм инициализации сразу всех функций из всех файлов перед запуском самого бота.
Оказалось, существует простая реализация. Я ввел себе за правило писать исключительно весь код только внутри функций, а также разделил проект на некоторые «пакеты». Пакеты являлись директориями, в которых лежали .sh файлы с набором функций. Тогда, для подключения «пакета» мне было достаточно запустить каждый файл внутри директории в любом порядке.
Таким образом, весь инструмент стал представлять из себя набор функций, которые используют друг друга. А для запуска всего бота, стало необходимо подключить все «пакеты», а затем просто запустить несколько из предоставляемых «пакетами» функций.
Ниже представлена функция, которая получает адрес директории и рекурсивно запускает каждый из .sh файлов внутри нее.
using() {
local directory=$1
for path in "$directory"/*
do
if [[ -f "$path" && "$path" == *.sh ]]; then
source "$path"
elif [[ -d "$path" ]]; then
using "$path"
fi
done
}
export -f using
В будущем оказалось, что решение писать весь код только внутри функций было правильно и по другим причинам:
Больше не засорялась глобальная область видимости локальными переменными
Прочитав название функции, всегда можно было понять, что делает конкретный кусочек кода
Код стал чаще переиспользоваться
Упрощенная генерация фоновых процессов
Мне хотелось быстро добавлять цикличные фоновые процессы, а также автоматически их чистить по завершении программы.
Были реализованы функции add_job и job_runner_start. Одна из которых добавляет переданную ей функцию и параметры в массив JOBS_LIST, а вторая запускает функции внутри JOBS_LIST, как отдельные процессы.
Были написаны add_new_pid_for_killing и process_killer_start для автоматического удаления всех дочерних процессов, после закрытия приложения. Функция add_new_pid_for_killing добавляет PID в массив CHILD_PIDS. А process_killer_start подключает обработчик сигналов завершения, который будет убивать процессы из массива.
JOBS_LIST=()
add_job(){
# Получение функции которая должна стать отдельным процессом.
local function="$1"
# Информация о том, как много одновременных процессов нужно.
local num_of_process=$2
# Таймаут в секундах, между перезапусками функции.
local timeout=$3
# Запись в массив всей переданной информации с разделителем ":".
combined_record="$function:$num_of_process:$timeout"
JOBS_LIST+=("$combined_record")
}
job_runner_start(){
for job in "${JOBS_LIST[@]}"
do
IFS=':'
read -r func num_of_process timeout <<< "$job"
for ((i=1; i<=num_of_process; i++))
do
# Запуск функции как отдельного процесса.
job_start "$func" $timeout &
catch 'default_logger "error of run job: $func" "ERROR"'
local pid=$!
# Добавление дочернего процесса в список тех, кого нужно отключать.
add_new_pid_for_killing $pid
catch 'default_logger "error of write pid" "ERROR"'
done
done
}
job_start(){
func="$1"
timeout=$2
while true; do
$func
catch 'default_logger "error of start of $func" "ERROR"'
sleep $timeout
done
}
CHILD_PIDS=()
add_new_pid_for_killing(){
local pid=$1
CHILD_PIDS+=("$pid")
}
export -f add_new_pid_for_killing
cleanup(){
for pid in "${CHILD_PIDS[@]}"
do
kill $pid
done
}
# Подключение обработчика события EXIT.
process_killer_start(){
trap cleanup EXIT
}
Архитектура решения
Для обработки сообщений я выдвинул следующие условия.
В рамках одного чата не должен нарушаться порядок обработки сообщений
Каждый чат должен храниться в виде двух структур: FIFO канала с сообщениями и некоторого текущего состояния чата
Должна существовать возможность добавлять неограниченное количество процессов обработчиков, так, чтобы каждый из них был в равной степени загружен обработкой сообщений
Наиболее оптимальная архитектура, которую я смог придумать, заключалась в добавлении процесса балансировщика. Балансировщик будет давать команды конкретным воркерам, об обработке сообщений из определенных чатов.
В такой архитектуре, распределение нагрузки ложиться на плечи балансировщика, а обработчики превращаются в подобие state-машин.
Взаимодействие процессов
Алгоритм работы Message-Reader process:
Получил список новых сообщений из API
Распределил новые сообщения в FIFO файлы конкретных чатов
Бросил балансировщику уведомления о каждом новом сообщении
Алгоритм работы балансировщика:
Получил уведомление о новом сообщениии или о освобождении какого-либо обработчика
Случайно выбрал ожидающий обработки чат (Если такие есть)
Случайно выбрал свободного обработчика (Если такие есть)
Передал команду об обработке
Алгоритм работы воркера:
Ожидание команды от балансировщика
Получение id чата
Считывание текущего состояния чата и последнего необработанного сообщения
Выполнение бизнес-логики, сохранение нового состояния
Отправка уведомления балансировщику, о том, что обработчик свободен
Процесс написания
К сожалению, я не рискнул вставлять сразу весь код и разбирать все детали реализации, однако, я упомяну самые значимые моменты. Также, существует ссылка на GitHub проекта, где присутствует весь код и инструкция для запуска.
Метод балансировки:
balance() {
# Перебор существующих workers
# WORKER_FIFO_LIST это словарь вида worker_pid:current_chat_id.
for i in "${!WORKER_FIFO_LIST[@]}"; do
IFS=":" read -r worker_pid chat_id <<< "${WORKER_FIFO_LIST[i]}"
# Проверка, привязан ли какой либо чат.
if [[ "$chat_id" == "0" ]]; then
# Перебор текущих чатов
# CHAT_DICTIONARY словарь вида chat_id:num_of_new_messages
for current_chat_id in "${!CHAT_DICTIONARY[@]}"; do
# Поиск чата с необработанными сообщениями,
# который на данный момент еще не обрабатывается.
if [[ "${CHAT_DICTIONARY[$current_chat_id]}" -gt 0
&& ! "${WORKER_FIFO_LIST[*]}" =~ "$current_chat_id" ]]; then
echo "$worker_pid:$current_chat_id"
WORKER_FIFO_LIST[i]="$worker_pid:$current_chat_id"
((CHAT_DICTIONARY[$current_chat_id]--))
if [[ "${CHAT_DICTIONARY[$current_chat_id]}" -le 0 ]]; then
unset CHAT_DICTIONARY[$current_chat_id]
fi
# Отправка команды.
local fifo="$WORKERS_FIFO_DIR/$worker_pid"
echo "$current_chat_id" > "$fifo"
echo "Назначен chat_id $current_chat_id воркеру $worker_pid"
break
fi
done
fi
done
}
Тривиальная обработка сообщений, в которой существует три состояния:
Чат не начат
«SOME_STATE»
«OTHER_STATE»
process_message_base() {
local chat_id=$1
local state_file="$CHAR_STATES_DIR/${chat_id}.json"
local fifo_file="$BASE_FIFO_DIR/$chat_id"
local state=()
# Попытка получения состояния.
if [[ -f "$state_file" ]]; then
readarray -t state < <(jq -r '.[]' "$state_file")
else
state=("SOME_STATE")
// Сохранение нового состояния.
echo "$(jq -n --argjson arr "$(printf '%s\n' "${state[@]}" | jq -R . | jq -s .)" '$arr')" > "$state_file"
// Some Logic
fi
if [[ "${state[0]}" == "SOME_STATE"* ]]; then
// Some logic
fi
if [[ "${state[0]}" == "OTHER_STATE"* ]]; then
// Other logic
fi
}
Выводы
В результате, я получил инструмент примерно на 300 строк кода, позволяющий быстро писать масштабируемых и асинхронных телеграм ботов. С этой стороны, я достиг своей цели.
Также, этому проекту есть куда расти. В будущем можно заменить файлы и fifo каналы на Kafka или RabbitMQ, хранить состояния чатов можно в Redis. Еще можно подумать над распараллеливанием работы балансировщика.
Но есть нюанс.
В процессе написания я узнал много особенностей языка Bash, которые действительно очень затрудняют спокойное написание кода (например, что словарь нельзя вернуть из функции). По этой причине, даже используя текущий инструмент, написание сложной бизнес логики будет занимать непростительно много времени. Асинхронный телеграм бот на bash стал для меня немного доступнее, но все еще остается в разделе «поизвращаться».
Но это было увлекательно! =)