Slack Ruby App. Часть 2. Добавление чартов, или как делать рендер фронта на сервере


Привет, читатели, это вторая часть обучающих постов о написании Slack App с использованием чистого Ruby.

Если вы не знакомы со списком частей, то вот он (со ссылками):

  1. Написание приложения локально через Sinatra и ngrok.

  2. Добавление чартов, или как делать рендер фронта на сервере (Мы здесь). 

  3. Тусовка приложения с таким гостем, как Heroku.

Во время разработки своего приложения в качестве проекта внутри компании, поступил запрос на постройку графика по некоторой выборке данных, чтобы по команде Slash Command происходила постройка графика и его публикация в чат.

Первым делом я обратился к уже существующим решениям, графики через API Google, готовые гемы для Ruby. Основной минус в том, что не было возможности убрать или добавить легенду в том формате, который мне нужен, сложно кастомизировать внешний вид этих графиков и, к примеру, нет возможностей строить график по значению timestamp, а выводить уже значения в формате DateTime.

когда я говорю про сложность действия, то имею в виду в сравнении с альтернативным моим решением, где это делается проще и быстрее

Имея на вооружении тот метод, который освещает эта статья, любой сможет строить какую угодно страницу полностью на сервере, получать фото этой страницы и использовать её в коде. В будущем можно приспособить этот подход для, например, для предпоказа тем на своем движке, генерации каких-то изображений с подвязкой к внешнему API и заключении всего в html документ. В целом, применений реально много, собственно поэтому и решил поделится solution'ом.

Поэтому предлагаю вам скорее ознакомится с материалом, мы научимся сначала делать график с использованием своего js скрипта, а потом поймём как его рендерить в коде. Представляю содержание:

  1. Настройка модального окна через Slash Command для вызова построения графика

  2. Пишем html/js/css шаблон для графика

  3. Рендерим и получаем фото

  4. Вывод результата в модальное окно и отправка в чат

  5. Результаты

  6. Файл command.rb

  7. Ресурсы

Скажу сразу, что в этом уроке я буду использовать термины из прошлой части, и вообще описывать некоторые процессы так, как если бы вы уже прочитали первую часть.

Флоучарт для этого таска я вижу таким:

Изображение 1. Лучший флоучарт
Изображение 1. Лучший флоучарт

Настройка модального окна через Slash Command для вызова построения графика

Я вижу такую картину: сначала происходит запрос на Slash Command, мы его обрабатываем и в качестве ответа возвращаем view, в котором внутри уже находится график. Если пользователь хочет этот график пошарить в чат, то нажимает соответствующую кнопку, если нет - просто закрывает окно и уходит по своим делам.

Первым делом добавляем ендпоинт на панели управления приложением для команды : `/graph`.

Затем пишем простенький шаблон вывода, чтобы проверить, что всё норм работает, обработка вызова команды выглядит так:

  post '/graph' do
    get_input
    Database.init
    access_token = Database.find_access_token input['team_id']
    client = create_slack_client access_token

    triger_id = input['trigger_id']

    template = File.read './Components/View/graph.erb'
    view = ERB.new(template).result(binding)

    client.views_open(
        trigger_id: triger_id,
        view: view
    )

    status 200
  end

Сам файл шаблона graph.erb как-то так :

{
  "type": "modal",
  "title": {
    "type": "plain_text",
    "text": "Граф"
  },
  "blocks": [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "Тут будет график "
      }
    }
  ]
}

Собственно запускаем сервер, пишем в чате (в любом) `/graph` и видим такой результат:

Изображение 2. Превью модального окна с графом
Изображение 2. Превью модального окна с графом

Если у вас такой же результат, значит всё делаете правильно! Переходим к следующему этапу.


Пишем html/js/css шаблон для графика

Какая задумка дальше, опускаемся на уровень ниже. Пользователь имеет некоторые данные, по которым он хочет построить свой кастомный график, нужно решить:

В этом разделе я планирую рассмотреть первый и второй пункт.

Создадим папку в директории Components - Graph. В папке Graph нам нужно будет 3 файла : style.css, index.html и main.js (Изображение 3)

Изображение 3. Необходимые файлы для построение графика
Изображение 3. Необходимые файлы для построение графика

Данные проще всего сохранить в формате json, в файл. В js скрипте будем считать, что на момент запуска файл существует, так как мы сами и будем гарантировать существования данных через код. Значит - нужно написать функцию по получению данных в js скрипте:

function readTextFile(file, callback) {
    var rawFile = new XMLHttpRequest();
    rawFile.overrideMimeType("application/json");
    rawFile.open("GET", file, false);
    rawFile.onreadystatechange = function () {
        if (rawFile.readyState === 4 && rawFile.status == "200") {
            callback(rawFile.responseText);
        }
    }
    rawFile.send(null);
}

будем использовать функцию чтения из файла таким образом:

readTextFile('data.json', function (text) {
        dataJson = (JSON.parse(text))
  //Тут работа с данными которые записаны в dataJson переменную
});

Следующий шаг - написать функцию, которая бы сохраняла данные в промежуточный файл - data.json. Данные будут в виде:

{ 
  "2021":
  {
    "Jun":8207,
    "Jul":12455,
    "Aug":10086
  },
  "2022":
  {
    "Jul":1234,
    "Oct":5678,
    "Jan":9123
  }
}

Первый уровень обозначает год, второй уровень - месяц и значение месяца - timestamp какой-то операции. Я хочу увидеть статистику по месяцам, по данной операции. Если вы не против, я запишу эти данные как константу и не буду придумывать кейсы, где эти данные взять и что они означают :)

Запишем данные в файл, в директорию - Graph. Коротко это можно сделать так:

    DATA_TO_JSON = {"2021": {"Jun": 8207, "Jul": 12455, "Aug": 10086}, "2022": {"Jul": 1234, "Oct": 5678, "Jan": 9123}}
    


File.open('./Components/Graph/data.json', 'w') do |file|
      file.write DATA_TO_JSON.to_json
end

ОБЯЗ! Данные, не важно в каком они формате записаны в Ruby - переводим в json для того что бы js мог их считать.

Теперь опишу сам скрипт и что там к чему

В индекс файле лежит div, который имеет id = chart. В скрипте я строю Dom элемент, он и есть график, и просто засовываю его в этот div с чартом. В будущем можна в любом месте, на любом сайте поставить такой div=chart и туда подгрузится график (файл style.css и main.js тоже должны присутствовать, так как они отвечают за работоспособность).

Сначала создается элемент div, в нём таблица, добавляются по одному столбцы с динамической высотой, равной timestamp, потом в зависимости от того, в каком диапазоне находится время , столбцу присваивается цвет, потом легенда сверху и ленеда снизу. Перед тем, как ставить легенду сверху - превращаем её во время формата HH:MM:SS. Значение в нижней легенде равно ключу второго уровня + ключ первого уровня.

Собственно и всё. Получаем данные из файла, в цикле создаём по ним график, добавляем график на html.


Рендерим и получаем фото

Собственно ключевая вещь в этом туториале. Когда я захотел сделать такой чарт, то не задумался о том, как же собственно html будет обрабатыватся сервером. Ну, типо, есть теги, это всё круто отображается в браузере как надо.

И тут то я прозрел, что html - язык разметки браузера, а браузера то на сервере нет :)

Много времени лично я потратил, чтобы понять, что делать с этим вот всем и нашёл решение в лице Chrom Headless, что буквально значит хром, но без head (head-морда, фронтенд, оболочка). Работает всё очень классно и просто, движок хрома может отпарсить и рендерить любую страницу, js встроенный есть и все дела. Вообще всё то же самое, что и в вашем браузере, но общаетесь с ним на кликами, а командами.

Нам нужен новый гем - `gem "ferrum"`, добавьте его к себе в Gemfile и запустите bundle update.

Далее в обработчике команды пропишем:

    browser = Ferrum::Browser.new
    browser.go_to("https://#{request.host}/render_graph")
    browser.screenshot(path: "Components/Graph/result.png")

Собственно всё просто и понятно, вот только когда мы попытаемся попасть на адресс

"https://#{request.host}/render_graph"

то явно не попадём на index.html, чтобы это исправить - добавим в файл command.rb обработку get запроса для страницы render_graph, и тут же для остальных наших файлов, так как обращение к ним будет идти по такому же "неточному" пути.

  get '/render_graph' do
    send_file 'Components/Graph/index.html'
  end

  get '/main.js' do
    send_file 'Components/Graph/main.js'
  end

  get '/style.css' do
    send_file 'Components/Graph/style.css'
  end

  get '/data.json' do
    send_file 'Components/Graph/data.json'
  end

Всё готово к запуску, добавим require к классу, в котором будем вызывать библиотеку Headless браузера :

require 'sinatra/base'
require 'slack-ruby-client'

class API < Sinatra::Base
  require 'ferrum'

Перезапускаем сервер и пишем /graph, затем смотрим в папку Graph и наблюдаем файл result.png который выглядит вот так:

Изображение 4. Ну просто юхуууу
Изображение 4. Ну просто юхуууу

Вывод результата в модальное окно и отправка в чат

Теперь дело за малым:

  • Строим модальный блок

  • Отправляем этот блок пользователю

Строим модальный блок

Для изображений блок будет примерно таким :

{
 "type": "image",
 "image_url": "http://<%= host %>/graph_image.png",
 "alt_text": "Сер Граф"
}

Редактируем весь файл graph.erb к такому виду :

{
  "type": "modal",
  "title": {
    "type": "plain_text",
    "text": "Граф"
  },
  "blocks": [
    {
      "type": "image",
      "image_url": "http://<%= host %>/graph_image.png",
      "alt_text": "Сер Граф"
    }
  ]
}

Тут, по аналогии с примером index.html страницы, необходимо по запросу {host}/graph_image.png вернуть result.png, настроить этот запрос-ответ с помощью такого кода :

  get '/graph_image.png' do
    send_file 'Components/Graph/result.png'
  end

Так же, для заполнения <%= host %> , необходимо, перед вызовом шаблона, добавить инициализацию переменной host

host = request.host

Отправляем этот блок пользователю

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

Могу лишь добавить, что после отправки результата на Slack API, нам не нужен ни результат, ни data.json. Удаляем их:

    File.delete "./Components/Graph/result.png"
    File.delete "./Components/Graph/data.json"

Результаты

Запустим в чате команду /graph и понаблюдаем что будет в логе и что будет видно юзеру :

Изображение 5. График в модальном окне
Изображение 5. График в модальном окне
Изображение 6. Лог сервера
Изображение 6. Лог сервера

Собственно результат ровно тот, что мы ожидали. Ладно, не ровно такой, я говорил о кнопке отправки в чат, но есть ли смысл мне это реализовывать, если в этом и в прошлом уроке я подробно описал как постить сообщения, как обрабатывать модальные команды и всякие такие штуки. Даже блоки уже готовы, нужно лишь поменять в graph.erb хидер с модального окна на нужный вам.

В итоге мы научились :

  • Рендерить html/css/js c помощью Headless браузера на сервере.

  • Строить график с необходимыми нам параметрами.

  • Выводить изображения в модальные окна.

  • Отправлять файлы по запросу на сервер.

  • Сохранять данные в файл.


Файл command.rb

Поскольку, вся реализация происходит с постоянной модификацией файла command.rb, я считаю нужным выложить его финальный код, чтобы любой мог сверить результат :

require 'sinatra/base'
require 'slack-ruby-client'

class API < Sinatra::Base
  require 'ferrum'

  attr_accessor :access_token
  attr_accessor :input

  def get_input
    self.input = Rack::Utils.parse_nested_query(request.body.read)
    puts 'Получена входная строка'
  end

  get '/test' do
    status 302
    redirect "https://slack.com/oauth/v2/authorize?&client_id=#{SLACK_CONFIG[:slack_client_id]}&scope=app_mentions:read,channels:history,channels:read,chat:write,chat:write.customize,commands,files:write,groups:history,groups:read,im:history,im:read,mpim:read,users.profile:read,users:read&user_scope=channels:history,channels:read&redirect_uri=#{SLACK_CONFIG[:redirect_uri]}"
  end

  post '/who_am_i' do
    get_input
    Helper.displayUserInfo input['team_id'], input['user_id'], input['channel_id']
    status 200
  end

  post '/menu' do
    get_input
    Database.init
    access_token = Database.find_access_token input['team_id']
    client = create_slack_client access_token

    triger_id = input['trigger_id']
    metadata = input['channel_id']

    template = File.read './Components/View/menu.erb'
    view = ERB.new(template).result(binding)

    client.views_open(
        trigger_id: triger_id,
        view: view
    )

    status 200
  end

  get '/render_graph' do
    send_file 'Components/Graph/index.html'
  end

  get '/main.js' do
    send_file 'Components/Graph/main.js'
  end

  get '/style.css' do
    send_file 'Components/Graph/style.css'
  end

  get '/data.json' do
    send_file 'Components/Graph/data.json'
  end

  get '/graph_image.png' do
    send_file 'Components/Graph/result.png'
  end

  post '/graph' do
    get_input

    DATA_TO_JSON = {"2021": {"Jun": 8207, "Jul": 12455, "Aug": 10086}, "2022": {"Jul": 1234, "Oct": 5678, "Jan": 9123}}
    File.open('./Components/Graph/data.json', 'w') do |file|
      file.write DATA_TO_JSON.to_json
    end

    browser = Ferrum::Browser.new
    browser.go_to("https://#{request.host}/render_graph")
    browser.screenshot(path: "Components/Graph/result.png")

    Database.init
    access_token = Database.find_access_token input['team_id']
    client = create_slack_client access_token

    triger_id = input['trigger_id']

    host = request.host
    template = File.read './Components/View/graph.erb'
    view = ERB.new(template).result(binding)

    client.views_open(
        trigger_id: triger_id,
        view: view
    )

    File.delete "./Components/Graph/result.png"
    File.delete "./Components/Graph/data.json"

    status 200
  end
end
  • Часть кода осталась с прошлого урока :)


Ресурсы

Ссылка на Github репозиторий с реализацией этой части + прошлой части

(ветка my_graph)




К сожалению, не доступен сервер mySQL