В работе описана смоделированная ситуация по разработке простого web-приложения на заказ. Для приложения за основу взят фреймворк Ruby on Rails 7 с фреймворком Hotwire и СУБД PostgreSQL. Описание процесса разработки разбито на этапы проектной деятельности, максимально приближенной к жизненному циклу web разработки по методологии Agile. Для максимальной реалистичности в описании упомянуты всевозможные проблемы, которые могут приводить в ступор начинающих Ruby разработчиков. В задачу публикации входит максимальное погружение читателя в процесс разработки. Поэтому работа насыщена ссылками на лучшие образцы методических материалов для экосистемы RoR 7.1 + Hotwire.

Любая реальная разработка сопровождается рядом организационных мероприятий, которые распределяются между разработчиками, аналитиками, тестировщиками и DevOps. В заказных разработках часто всё делает один человек. Вот для таких разработчиков, которые хотят всё знать, и предназначена данная статья. Поэтому здесь вы также найдёте дополнительные сведения об особенностях тестового покрытия fullstack разработки, полноценное решение по документированию Rest API, подробное описание процесса докеризации приложения, и инструкцию по использования GitHub Actions по методологии Continuous Integration.

Данная работа демонстрирует попытку подсветить скрытые ценности у участников проектной деятельности на примере web-разработки. Это попытка описать симбиоз и взаимопроникновение технических навыков и организационных мероприятий в совокупности продуцирующими нечто новое, способное порадовать всех субъектов жизненного цикла разработки, несмотря на многочисленные проблемы, с которыми сталкивается и которые разрешает непосредственных разработчик программного решения. Чтобы легче было следить за ходом сюжетной линии повествования смоделированной проектной ситуации, многие технические детали скрыты за многочисленными ссылками на документацию и публикации по каждой решаемой проблеме. За ходом реальной разработки можно также проследить по коммитам опубликованного проекта.

Введение. Техническое задание

Представим, что мы получили от некоего заказчика Техническое задание (ТЗ) на создание двухстраничного чат подобного приложения с возможностью создания топиков, а также добавления постов как вручную, так и из внешнего источника по REST API. Авторизацию реализовывать не требуется, потому что приложение будет эксплуатироваться ограниченным кругом доверенных лиц по динамическому ip-адресу. Основная цель приложения – сбор сырых данных и регистрация событий по дате-времени. Топики и сообщения можно только добавлять и удалять. Остальные предложения поступят по мере готовности разработки.

Интерфейсы финальной версии разработки
Интерфейсы финальной версии разработки

I. В добрый путь. Прототипирование

У Ruby разработчика есть два способа решения задачи. Первый, создание монолитного fullstack приложения, мы берем за основу фреймворк Ruby on Rails. Второй, создание отдельно frontend и backend приложений. Поскольку ожидаемое решение не предполагает сложных правил взаимодействия пользователя с приложением, останавливаемся на первом варианте и приступаем к написанию прототипа будущего Minimum Viable Product приложения.

  1. Создаём классическое Ruby on Rails приложение и покрываем его Rspec-тестами.

  2. Добавляем REST API и покрываем его Rspec-тестами.

Как профессиональные разработчики мы стараемся придерживаться лучших практик по использованию Rspec

В программной разработке также считается хорошей практикой следить за процентным отношением покрытия кода тестами. Фреймворк Ruby on Rails содержит терминальную команду, которая показывает условное покрытие через соотношение количества строк в коде к количеству строк в тестах.

bin/rails stats
...
Code LOC: 671     Test LOC: 647     Code to Test Ratio: 1:1.0

Хорошим значением для этого соотношения считает величина больше единицы. Однако такой показатель не раскрывает покрытие кода по функциональности. В последнем случае стоит прибегать к специализированным решениям. Мы воспользуемся гемом simplecov.

  1. Для более глубокого тестирования моделей и реляционных отношений воспользуемся гемом shoulda-matchers. Несмотря на то, что тестирование реляционных отношений многими считается избыточным, однако эта составляющая тестового наполнения важна в качестве Спецификации приложения.

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

  • Мы за один день написали ключевой функционал приложения. Который мы можем продемонстрировать заказчику и он в свою очередь может начать знакомиться с ним, чтобы детализировать требования по собственной постановке задачи. Таким образом мы актуализируем Agile методологию разработки и вовлекаем заказчика в процесс создания нового приложения в роли Дизайнера и Аналитика продукта.

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

  • Вы не даёте себе забыть, что такое legacy, и, как профессиональный разработчик, продолжаете поддерживать свою форму, чтобы в любой момент взяться за любой проект с legacy. Также это полезно для Junior разработчиков из своей команды, потому что так мы формируем материальную основу из причинно-следственных связей, объясняющих "магию современных высоких технологий".

II. Техническое проектирование. Проработка интерфейса приложения

  1. Стилизуем приложение через любимый css фреймворк. У нас это будет Bootstrap.

Запрашивая доступные команды в Rails, замечаем, что у нас есть несколько предложений по использованию CSS:

rails -T
...
bin/rails css:build                          # Build your CSS bundle
bin/rails css:clobber                        # Remove CSS builds
bin/rails css:install                        # Install JavaScript de...
bin/rails css:install:bootstrap              # Install Bootstrap
bin/rails css:install:bulma                  # Install Bulma
bin/rails css:install:postcss                # Install PostCSS
bin/rails css:install:sass                   # Install Sass
bin/rails css:install:shared                 # Install shared elemen...
bin/rails css:install:tailwind               # Install Tailwind
...

Берём на заметку набирающий популярность фреймворк Tailwind, но не отвлекаемся от нашего практического опыта и запускаем команду установки bin/rails css:install:bootstrap

На данном проекте из Bootstrap мы воспользовались:

  1. Превращаем приложение в Single-Page Application (SPA) с помощью Turbo-Frames и Stimulus.

Установка нового фреймворка Hotwire Turbo производится несложно https://github.com/hotwired/turbo-rails#installation . На установочном этапе нам понадобится ещё динамический сборщик Javascript. На текущий момент рекомендуется использовать ESBuild На эту тему есть содержательная видео инструкция. Если во время инсталляции у вас вылетает ошибка:

✘ [ERROR] Could not resolve "@hotwired/turbo-rails"

то добавьте вручную в файл package.json две зависимости:

"dependencies": {
  "@hotwired/stimulus": "^3.2.2",
  "@hotwired/turbo-rails": "^7.3.0",
  ...

Теперь всё должно быть готовым для запуска приложения.

Поскольку дальнейшая имплементация в Ruby on Rails инструментов из Hotwire только лишь по родной документации https://hotwired.dev не представляется возможной, то наше повествование будет насыщено ссылками на полноценные примеры решённых задач с помощью данного фреймворка. Так, например, на тему использования Hotwire в Rails есть содержательный видеокурс от Ильи Крюковского. Далее пройдёмся по отдельным инструментам взятой нами на вооружение технологии.

Благодаря Turbo-Frames нам удалось реализовать добавление нового сообщения, не выходя из страницы приложения.
Благодаря Turbo-Frames нам удалось реализовать добавление нового сообщения, не выходя из страницы приложения.

Подробный пример реализации такой функциональности можно найти у Akshay Khot в статье «Building a To-Do List Using Hotwire and Stimulus» с видеопрезентацией и с исходными кодами.

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

Flash уведомление об успешности операции добавления нового поста
Flash уведомление об успешности операции добавления нового поста

Здесь нужно предпринять меры, чтобы эти уведомления не тиражировались на странице. Для этого надо для начала не добавлять, а заменять прежнее сообщение из Turbo-Streams в app/helpers/application_helper.rb

turbo_stream.replace 'flash', partial: 'shared/flash'

и удалять его по таймеру из контроллера Stimulus. Вызываем флэш-сообщение из app/views/shared/_flash.html.erb

<div  id="flash">
  <div data-controller="autohide">
    <% flash.each do |k, v| %>
      <%= tag.div v, class: "alert alert-#{k}", role: 'alert' %>
    <% end %>
  </div>
</div>

Описываем событие в app/javascript/controllers/autohide_controller.js

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  connect() {
    setTimeout(() => { this.dismiss() }, 10000)
  }

  dismiss() {
    this.element.remove()
  }
}

Хороший пример данной техники представлен у Ярослава Шмарова в статье «Turbo Streams - Create and stream records. Flash messages. Reusable Streams».

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

  1. Добавляем в приложение Turbo-Streams и таким образом с минимальными усилиями организуем web-socket каналы для обмена данными в реальном времени между пользователями.

Наша задача — показать пользователю все добавляемые сообщения в списке сообщений под топиком, и все добавляемые топики в своём списке без перезагрузки страницы. Есть достаточно подробный курс по frontend фреймворку Hotwire от Alexandre Ruban на ресурсе https://hotrails.dev. Мы с удовольствием воспользовались предложенными на нём решениями.

Если во время выполнения приложения вы столкнётесь с ошибкой исполнения

[Turbo] There was an exception - Gem::LoadError(Error loading the 'redis' Action Cable pubsub adapter. Missing a gem it depends on? redis is not part of the bundle. Add it to your Gemfile.)

то это будет означать, что у вас в файле Gemfile отсутствует gem 'redis'. Подключите его.

  1. C помощью гема Capybara покрываем своё приложение интеграционными тестами (features).

На данном этапе заказчик имеет почти готовое решение, и у него появляются дополнения к собственному ТЗ. Он хочет:

  • Чтобы список чатов и постов сортировался от последнего (наверху) до первого (внизу);

  • Чтобы списки чатов и постов не грузились сразу, а поступали порционно, по мере перемещения вниз по списку;

  • Получать flash-уведомление со звуковым сопровождением, о поступлении нового сообщения не только в свой чат, но и в любой другой;

  • Иметь возможность выделять важные сообщения звездочкой;

  • Разрешить удаление постов и чатов. (Мы уже имели этот пункт в первой версии ТЗ, но мы ещё не добрались до его реализации);

  • Разрешить редактирование названий чатов;

  • Перевести функцию добавления нового чата в модальное окно.

III. Техническое проектирование. Рефакторим, модернизируем и тестируем функциональную часть приложения

  1. По собственным наблюдениям логов бэкенда мы обращаем внимание на то, что у нас происходят обращения в базу данных за каждой отдельной записью в списке сообщений. Это говорит о том, что мы натолкнулись на классическую проблему "N+1". Исправляем её применением ActiveRecord метода .includes.

  2. Добавляем новые методы CRUD из обновлённой версии ТЗ по удалению постов и чатов, а также по редактированию названий чатов https://www.hotrails.dev/turbo-rails/turbo-frames-and-turbo-streams. С помощью Turbo-Frames и Turbo-Streams оповещаем всех пользователей о вносимых изменениях в чатах https://www.hotrails.dev/turbo-rails/turbo-streams.

9.1. На интеграционных тестах Capybara обнаруживаем, что у нас не всё так гладко с функцией редактирования названия чата. Там, где приложение работает как «по маслу», тесты проваливаются с ошибками, указывающими на неверный формат передаваемых данных:

ActionController::UnknownFormat in ChatsController#update
ChatsController#update is missing a template for this request format and variant.

Мы исправляем эту ситуацию добавлением скрытых HTML-элементов с явным указанием для редактируемого поля в <input> необходимого формата выходных данных в app/views/chats/_edit_form.html.erb

<input type="hidden" name="format" value="turbo_stream">

9.2. У функции отмены редактирования названия чата обнаруживаем проблему в работе Turbo-Frames. Мы не можем вернуть исходное содержание шаблона с содержимым чата. Проблема разрешается добавлением в роутер config/routes.rb HTML-метода POST на action show для контроллера ChatsController.

post 'chats/:id', controller: :chats, action: :show

app/controllers/chats_controller.rb

def show
  if request.method == 'GET'
    @cursor = (params[:cursor] || ((@chat.posts.last&.id || 0) + 1)).to_i
    @posts = @chat.posts
                  .where('posts.id < ?', @cursor)
                  .includes(:highlight)
                  .order(id: :desc)
                  .take(POSTS_PER_PAGE)
    @next_cursor = @posts.last&.id
    @more_pages = @next_cursor.present? && @posts.count == POSTS_PER_PAGE

    render 'show_scrollable_list' if params[:cursor]
  else
    render turbo_stream: turbo_stream.update(@chat)
  end
end
  1. Выделение важных сообщений в контексте архитектуры базы данных может быть решено двумя способами. Первый – это добавление булевого поля к таблице постов. Второй – это добавление таблицы выделенных сообщений. Воспользуемся более информационно насыщенным способом при минимальных накладных расходах на хранение реляционных зависимостей в БД. Это второй способ по добавлению сущности Highlights.

Для обслуживания этой сущности в представлениях воспользуемся action highlight у PostsController, который выполняет функцию переключения состояния поста "Выделенный"/"Невыделенный", а фактически – функцию создания или удаления записи в таблице Highlights, как это было сделано Эдемом Топузовым в видео ролике «Онлайн-чат на Ruby on Rails 7 с помощью Hotwire» с исходниками в репозитории. Реализуем широковещательный механизм об изменениях со статусом поста также через Turbo-Streams.

  1. Чтобы непосредственно отобразить состояние выделения поста создадим хелпер, который по наличию записи в таблице Highlights будет выбирать HTML-шаблон для выделяемой записи на странице.

Здесь можно ещё упомянуть о двух разных подходах к подготовке данных для выгрузки на фронтенд. Если данные из моделей предполагают варианты логики отображения в разных местах приложения, или допускают дополнительные визуально ориентированные преобразования самих данных, то в этом случае прибегают, например, к паттерну Декоратор или паттерну Презентер.. В нашем случае у нас есть только текстовое поле, подлежащее одинаковой стилизации с авто переносом слов в длинных текстах от разных моделей. Поэтому для наших целей достаточно воспользоваться Хелпером. Вызывается из разных представлений:

<%= html_line_wrapping(@chat.topic) %>
<%= html_line_wrapping(post.body) %>
<%= link_to html_line_wrapping(resource.topic), chat_path(resource.id),
    class:"text-decoration-none link-secondary", data: { turbo_frame: "_top" } %>

и реализуется в app/helpers/application_helper.rb

def html_line_wrapping(text, style = :br)
  text = h(text)
  text = case style
         when :br
           text.gsub("\n", '<br>')
         when :pre
           "<pre style=\"white-space: pre-wrap;\">#{text}</pre>"
         when :p
           text.split("\n").inject('') { |res, el| res + "<p>#{el}</p>" }
         else
           text
         end
  "<div class=\"text-break col\">#{text}</div>".html_safe
end
  1. Для отображения flash уведомлений о публикациях воспользуемся Bootstrap-шаблоном "Toast". Собственно вещание в реальном времени также происходит с помощью Turbo-Streams. Звуковую реакцию реализуем в нашем js-контроллере, оформленном с помощью Stimulus в. app/javascript/controllers/toast_controller.js

import { Controller } from "@hotwired/stimulus"
import { Toast } from 'bootstrap'

export default class extends Controller {
  static targets = ['toast']
  static values = {
    id: String
  }

  initialize() {
    this.audio = new Audio(
      window.location.origin + '/513269__zhr__tl-light-on-e.mp3'
    )
    this.toast = new Toast(
      document.getElementById('receiveToast')
    )
  }

  idValueChanged(value, previousValue) {
    if (!(value === '')) {
      this.toast.show()
      this.audio.play()
    }
  }
}

Всплывающие уведомления реализуем как у Alexandre Ruban «Flash messages with Hotwire» На эту тему есть также хорошее разъяснение в видео ролике от Ильи Крюковского.

Чтобы воспроизвести звуковое оповещение, надо иметь ссылку на звуковой ресурс. В нашем случае это будет файл внутри приложения с доступом по прямой ссылке http://localhost:3322/513269__zhr__tl-light-on-e.mp3 из public/513269__zhr__tl-light-on-e.mp3.

  1. Для организации постраничного вывода данных через бесконечный скроллинг можно пользоваться гемом 'pagy'. Однако у него есть известные проблемы при отображении динамически изменяемых данных (это как раз наш случай), связанные с пропуском записей из очередного пакета догружаемых данных. Поэтому воспользуемся альтернативным подходом, связанным с использованием "курсора". Это динамически вычисляемое значение либо последней отображенной записи из базы данных — 'cursor', либо набор параметров URL-запроса к следующей странице — 'cursor', 'opened', 'newly_created_at'. У нас реализованы оба подхода, потому что у нас у одного из списков имеется зависимость от состояния отредактированности его элементов (@chats), а у другого нет такой зависимости (@posts).

app/controllers/chats_controller.rb

POSTS_PER_PAGE = 10
CHATS_PER_PAGE = 10

def index
  @cursor = params[:cursor].to_i
  @used = params[:opened].to_a
  # define @created_at To exclude newly created chats
  @created_at = (params[:newly_created_at] || Time.now).to_time
  @chats = Chat.all
               .where.not(id: @used)
               .where('created_at < ?', @created_at)
               .order(updated_at: :desc)
               .take(CHATS_PER_PAGE)
  @next_cursor = @cursor + 1
  @more_pages = @next_cursor.present? && @chats.count == CHATS_PER_PAGE
  # define @used To show updated but not presented chats
  @used += @chats.map(&:id)

  render 'index_scrollable_list' if params[:cursor]
end

def show
  if request.method == 'GET'
    @cursor = (params[:cursor] || ((@chat.posts.last&.id || 0) + 1)).to_i
    @posts = @chat.posts
                  .where('posts.id < ?', @cursor)
                  .includes(:highlight)
                  .order(id: :desc)
                  .take(POSTS_PER_PAGE)
    @next_cursor = @posts.last&.id
    @more_pages = @next_cursor.present? && @posts.count == POSTS_PER_PAGE

    render 'show_scrollable_list' if params[:cursor]
  else
    render turbo_stream: turbo_stream.update(@chat)
  end
end

app/views/chats/show_scrollable_list.html.erb

<%= turbo_frame_tag "posts-page-#{@cursor}" do %>
  <%= render partial: "posts/post", collection: @posts %>
  <%= render partial: "chats/next_show_page" %>
<% end %>

app/views/chats/_next_show_page.html.erb

<% if @more_pages %>
  <%= turbo_frame_tag(
        "posts-page-#{@next_cursor}",
        autoscroll: true,
        loading: :lazy,
        src: chat_path(id: @chat.id, cursor: @next_cursor),
        target: "_top"
      ) do %>
    Loading...
  <% end %>
<% end %>

app/views/chats/index_scrollable_list.html.erb

<%= turbo_frame_tag "chats-page-#{@cursor}" do %>
  <%= render partial: 'chats/chat', collection: @chats %>
  <%= render partial: 'chats/next_index_page' %>
<% end %>

app/views/chats/_next_index_page.html.erb

<% if @more_pages %>
  <%= turbo_frame_tag(
        "chats-page-#{@next_cursor}",
        autoscroll: true,
        loading: :lazy,
        src: chats_path(cursor: @next_cursor,
                        opened: @used,
                        newly_created_at: @created_at),
        target: "_top"
      ) do %>
    Loading...
  <% end %>
<% end %>

При тестировании таких динамических состояний на страницах приложения в интеграционных тестах из Capybara у нас должен быть подключён особый режим прогона RSpec тестов, через добавления js-тега:

describe '...', js: true do 

В некоторых случаях нам также пригодится Ruby оператор sleep, позволяющий дождаться отработки JavaScript по добавлению на страницу динамически подгруженных данных. Например, в spec/features/chats/show.html.erb_spec.rb

context 'when scrolling the chat page' do
  it 'autoloads the entire list of posts' do
    expect(page).to have_css("#post_#{posts[-1].id}")
    page.execute_script 'window.scrollBy(0,document.documentElement.scrollHeight)'
    sleep 0.5
    page.execute_script 'window.scrollTo(0,document.documentElement.scrollHeight)'
    sleep 0.5
    # the next expectation is interrupted periodically. No idea :(
    expect(page).to have_css("#post_#{posts[0].id}")
  end
end

Чтобы на страницах приложения в тестах работал обработчик JavaScript, в конфигурационном файле необходимо указать драйвер для их исполнения. Для нашего случая мы добавили в файл spec/rails_helper.rb следующую конфигурацию:

require 'capybara/rspec'
RSpec.configure do |config|
  Capybara.javascript_driver = case ENV['HEADLESS']
                               when 'true', '1'
                                 :selenium_chrome_headless
                               else
                                 :selenium_chrome
                               end
end

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

bundle exec rspec ./spec
HEADLESS=true bundle exec rspec ./spec

Первый режим открывает браузер во время прогона тестов, что обычно используется разработчиком на этапе отладки кода с тестом. Второй режим запуска тестов с "безголовым" браузером полезен для этапа автоматического прогона тестов перед коммитом и при деплое приложения. Отладочным режим тестов показывает в окне браузера в автоматическом режиме события на тестируемой странице приложения. Однако окно браузера закрывается после завершения теста. Поэтому, чтобы оценить любое промежуточное состояние окна во время прохождения теста, можно ещё пользоваться вспомогательным методом сессии Capybara - save_and_open_page, который сохраняет в папку tmp/capybara файл со снимком html страницы.

  1. Перенос функционала из отдельной страницы на добавление нового чата в модальное окно мы снова воспользуемся Turbo-Frames и Stimulus. В Stimulus мы также реализуем функционал по закрытию модального окна по нажатию клавиши Escape как у Ярослава Шмарова в статье «How to build modals with Hotwire (Turbo Frames + StimulusJS)».

При ручном тестировании данного функционала скорее всего вы заметите известную проблему задваивания любых событий во фреймворке Hotwire. Наиболее критична эта проблема у нас на клике с целью вызова нашей модальной формы, когда Bootstrap создаёт на странице в двух экземплярах невизуальный элемент:

<div class="modal-backdrop fade show"></div>

От нажатия клавиши Escape в некоторых случаях у нас остаётся один из fade-элементов, блокирующих доступ к основному контенту страницы, что исправляется только перезагрузкой страницы. Поэтому мы добавили «костыль» для исправления критической ситуации в app/javascript/controllers/remote_modal_controller.js.

hideBeforeRender(event) {
  if (this.isOpen()) {
    console.log('hideBeforeRender.isOpen')
    event.preventDefault()
    this.element.addEventListener('hidden.bs.modal', event.detail.resume)
    this.modal.hide()
    // eliminate the consequences of double clicks after closeWithKeyboard
    let elements = document.getElementsByClassName('modal-backdrop');
    while(elements.length > 0){
        elements[0].parentNode.removeChild(elements[0]);
    }
  }
}

Разумеется, что мы протестируем корректность работы нашего скрипта от нажатия клавиши Escape в spec/features/chats/new.html.erb_spec.rb

it 'cancels a new chat with the Escape pressed', js: true do
  click_link 'Add new topic'

  within '#modal_content' do
    el = find(:css, '#chat_topic')
    el.fill_in with: 'Canceled Chat'
    sleep 1
    el.send_keys(:escape)
  end
  sleep 1
  expect(page).to have_no_selector('#modal_content')

  within '#chats' do
    expect(page).not_to have_content('Canceled Chat')
  end
  # puts page.html # check out how it works
end
  1. Для обеспечения чистоты работы приложения на некоторых открытых в нём маршрутов нам необходимо запретить отображение html-страниц на действия создания и редактирования поста или чата. Поскольку мы вынесли эту функциональность в response_format: turbo_stream, то зафиксируем это ограничение на уровне ApplicationController. Описываем событие в

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :null_session

  private

  def ensure_frame_response
    redirect_to root_path unless turbo_frame_request?
  end
end

Вызываем событие из

# app/controllers/chats_controller.rb
class ChatsController < ApplicationController
  before_action :ensure_frame_response, only: %i[new edit]
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action :ensure_frame_response, only: [:new]

Мы почти готовы к сдаче полностью работающего продукта. На текущий момент заказчик хочет получить понятную инструкцию по развертыванию и использованию нового приложения в своём окружении. Поэтому нам осталось:

  • Описать REST API;

  • Завернуть приложение в docker для последующего его быстрого развертывания в любом окружении;

  • Автоматизировать тестирование по методологии Continuous Integration.

IV. Описываем REST API. Публикация.

Хорошей практикой считается поставлять подробное описание REST API. Для этого существует спецификация OpenAPI, которая появилась благодаря проекту Swagger, разрабатывающему язык описания интерфейсов https://swagger.io/resources/open-api/. Однако существуют совершенно оправданные претензии по поводу многословности этой спецификации. Об этом можно прочитать в статье Константина Малышева «What’s Wrong With OpenAPI?». Поэтому для нашей цели мы воспользуемся альтернативным инструментом JSight. Этот проект позволяет размещать у себя публичные интерфейсы приложений. В нашем случае он выглядит следующим образом по ссылке и покрывает два ендпоинта:

$ http -f get ":3000/api/v1/chats"
$ http -f post ":3000/api/v1/chats/1/posts" "post[body]=New message" "highlight="

V. Заворачиваем приложение в Docker. Публикация.

Перед созданием контейнера нам понадобится немного дополнить файл Dockerfile, создаваемый в Rails7 по умолчанию. В нём мы добавим установку модулей node и yarn для активации возможности использования JavaScript из контейнера.

# Install JavaScript dependencies
ARG NODE_VERSION=14.20.1
ARG YARN_VERSION=1.22.19

ENV PATH=/usr/local/node/bin:$PATH
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
/tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
npm install -g yarn@$YARN_VERSION && \
rm -rf /tmp/node-build-master

# Install node modules
COPY --link package.json yarn.lock ./
RUN yarn install --frozen-lockfile

В контейнере мы запускаем приложение в окружении 'production', где нам понадобится `secret_key_base`. В Rails приложении его мы можно создать консольной командой:

EDITOR="subl --wait" bin/rails credentials:edit --environment production

В результате этого действия появится файл config/credentials/production.key, содержимое которого далее перенесём в переменную окружения SECRET_KEY_BASE. Таким образом мы предотвращаем будущую проблему запуска приложения из контейнера с ошибкой:

ArgumentError: Missing `secret_key_base` for 'production' environment, set this string with `bin/rails credentials:edit` (ArgumentError)

Учётные данные (credentials) для доступа к базе данных тоже принято задавать через переменные окружения, В целях минимизации организационных расходов на обработку переменных окружения в среде 'production' мы можем создать для них файл .env. На самом деле мы создадим демо файл env-example и добавим в Dockerfile инструкцию по его копированию:

COPY env-example .env

Теперь можно создать контейнер с нашим приложением с помощью консольной команды:

docker build . -t blah-blah-chat:1.0

Следующая консольная команда покажет нам вновь созданный контейнер в списке:

docker image ls                                                
REPOSITORY     TAG      IMAGE ID       CREATED          SIZE
blah-blah-chat 1.0      cdfc31ac1a19   35 minutes ago   508MB
<none>         <none>   b47fb5763107   45 minutes ago   508MB

Так как процесс создания образа контейнера может повторяться несколько раз, то все наши забракованные итерации по его созданию оставили мусор в списке REPOSITORY с именем, равным <none>. Мы можем исправить это дело командой:

docker rmi -f b47fb5763107

Создадим файл docker-compose.yml. Делается это несложным образом по инструкции или по множеству примеров из сети Интернет. В нашем случае добавим healthcheck проверки на доступность сервисов postgres, redis и app.

services:
  postgres:
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "bbc", "-p", "5432", "-d", "blah_blah_chat_production"]
      interval: 5s

  redis:
    healthcheck:
      test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
      interval: 5s

  app:
    healthcheck:
      test: ["CMD", "curl", "-i", "-f", "http://localhost:3000/up"]
      interval: 10s
      timeout: 10s
      retries: 3

Redis появился у нас в списке контейнеров из-за того, что на нём основывается Turbo-Streams из Hotwire. Теперь можно развернуть приложение из контейнера командой:

docker compose up

Если в терминале не зафиксировано ошибок, то мы можем запустить в отдельном окне терминальную службу по наблюдению за состоянием здоровья наших сервисов:

watch -n1 docker ps

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

HTTP parse error, malformed request: #<Puma::HttpParserError: Invalid HTTP format, parsing fails. Are you trying to open an SSL connection to a non-SSL Puma?>

Исправляется она настройкой в файле config/environments/production.rb параметра config.force_ssl = true на config.force_ssl = false

Полноценная работа нашего приложения с Redis и PostgreSql с их размещением в разных контейнерах становится возможной только при случае их объединения в одну локальную сеть https://docs.docker.com/engine/reference/run/#network-settings. Эта сеть настраивается в файле docker-compose.yml

services:
  postgresql:
    networks:
      - postgres
  redis:
    networks:
      - redis
  app:
    networks:
      - postgres
      - redis

networks:
  postgres:
    driver: bridge
  redis:
    driver: bridge

С нашей финальной конфигурацией из файла docker-compose.yml можно ознакомиться в репозитории. Она получилась довольно минималистична для закрытия текущих потребностей проекта. Чтобы раздвинуть границы способов охвата проблемы докеризации с дополнительным решением, например, таких задач, как конфигурирование сервера приложения Puma, тестирование приложения внутри контейнеров, предварительная подготовка Assets, и другие моменты автоматизации в Rails приложении, рекомендую перейти к статье от Nick Janetakis «A Guide for Running Rails in Docker». К ней прилагаются исходные коды из GitHub репозитория и видео ролик на Youtube.

К технологии докеризации окружения Rails приложения также часто прибегают в среде разработки. Наиболее подробно о настройках контейнеров в development environment освещено в статье Владимира Дементьева «Ruby on Whales: Dockerizing Ruby and Rails development».

Перед финализированием работ по освещённому в данной статье проекту у нас остался один вопрос, имеющий важную историческую коннотацию, связанную с развитием проекта в краткосрочной перспективе. Поскольку мы работаем над web-приложением, у нас фактически под руками живой организм, который постоянно меняется, а поэтому требует особого внимание к способам его сопровождения. Причинами изменений являются не только наши личные вклады как непосредственного разработчика, но иногда и модификации библиотек, которые мы активно эксплуатируем в своём приложении. Эти постоянные микроизменения исходного кода могут иногда приводить к катастрофическим изменениям функциональности приложения, которые мы хотели бы предотвратить в обозримой перспективе.Технология интеграции тестирования в процесс непрерывной доставки изменений в production (об этом мы поговорим в последней главе этой статьи) позволяет нам держать на контроле состояние работоспособности приложения в известном нам диапазоне функциональных use cases. Однако это практика носит лишь уведомительный характер. Иногда может складываться ситуация, что выявленное нерабочее состояние требует отката до некоторых рабочих состояний на несколько итераций разработки назад. Для этого мы пользуемся историей разработки приложения из репозитория. Анализ коммитов позволяет на практике найти источник проблемы. По этой причине существует рекомендация для разработчиков делать коммиты как можно чаще, а их названия должны характеризовать ключевое изменение в коде, заменяя собой внешнюю документацию приложения.

Однако может возникать ситуация, что откат не даёт работоспособного состояния, когда «раньше всё работало». Эта проблема появляется из-за проявившейся зависимости кода приложения от внешних библиотек. Чтобы предотвратить эту потенциальную проблему, настоятельно рекомендуется фиксировать версии всех gems, на момент их подключения к приложению как это представлено по ссылке. И сопутствующая рекомендация по обновлению версий всех библиотек – проводите эту процедуру однократно для всего приложения одним коммитом. Так вы сэкономите время на будущие поиски проблем с внешними зависимостями.

И последнее. На web проектах вы невольно будете использовать зависимости от Операционной системы. Для примера, такой зависимостью является подключение к базе данных, но их обычно несколько больше. Такая зависимость устанавливается один раз при первичной настройке проекта на конкретном устройстве. Не углубляясь в детали такой настройки, отметим, что поскольку разработчик редко занимается такой системной настройкой, он может потерять бдительность и подтвердить предложение об установке каких-нибудь обновлений в операционной системе. Редко, но обязательно в неподходящий момент, такое обновление у вас сломает конфигурацию среды разработки. И вам вместо того, чтобы думать о выпуске очередного коммита, придётся ломать голову и потратить значительное количество времени над то, как вернуть рабочее состояние своего системного окружения. Для предотвращения таких потенциальных проблем разработчику стоит потратить время на изучение DevOps технологий. В нашем случае разобрана технология Docker-контейнеров. Именно поэтому в данной статье уделено большое внимание данному вопросу. Чтобы ознакомиться с другими предлагаемыми решениями по данному вопросу, рекомендуется посмотреть видео курс «Dockerless: Deep Dive Into What Containers Really are About» от Кирилла Ширинкина

Контейнер web-приложения, собираемый из Dockerfile, можно опубликовать в репозитории контейнеров https://ghcr.io. Это можно сделать в автоматизированном режиме посредством инструментов Github Actions . Для этого создадим в папке проекта с нашим приложением конфигурационный файл. Предусмотрим ручное управление версиями контейнеров

- name: Extract metadata (tags, labels) for Docker
  id: meta
  uses: docker/metadata-action@v5
  with:
    images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
    tags: |
      type=semver,pattern={{major}}.{{minor}},value=v1.1.0 

Так мы сможем сохранять историю версий web-приложения при переходе между этапами разработки.

Ещё раз повторимся, что задача настроенного конфигурационного файла .github/workflows/ci.yml состоит в автоматической сборке контейнера приложения и его публикации на https://ghcr.io. Но это действие требует для себя специальной настройки прав доступа на GitHub. Без соответствующих прав во время сборки контейнера на GitHub у вас вылетит ошибка:

ERROR: denied: installation not allowed to Write organization package
Error: buildx failed with: ERROR: denied: installation not allowed to Write organization package

Причину можно увидеть на скриншоте.

Отсутствие  прав на запись packages
Отсутствие прав на запись packages

Настройка запрашиваемых прав может быть осуществлена из личного кабинета в секции управления персональным токеном доступа посредством активации пунктов 'workflow' и 'write:packages' (по умолчанию они отключены).

Управление разрешениями workflow
Управление разрешениями для workflow

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

jobs:
  push_to_registry:
    name: Push Docker image to Docker Hub
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

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

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

  • Установить и запустить в операционной системе Docker Desktop;

  • Создать папку под конфигурационные файлы приложения;

  • Загрузить из репозитория в созданную папку файл docker-compose.yml;

  • Загрузить из репозитория в созданную папку файл env-example и переименовать его на .env;

  • В терминале из папки с файлами docker-compose.yml и .env выполнить команду:

sudo docker compose up
  • В браузере набрать адрес localhost:3322

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

VI. Интеграция. Автоматизация тестирования по методологии Continuous Integration

Итак, у нас организована автоматизация по методологии сборки и публикации приложений в реестре контейнеров GitHub. Теперь нам осталось только предусмотреть защиту от потенциальных ошибок в будущих модификациях программы. Для решения этой задачи существует методология CI/CD, из которой нам достаточно в части Непрерывной интеграции (Continuous Integration) подготовить конфигурацию тестового окружения в папке приложения .github/workflows с использованием GitHub Actions https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-ruby . Чтобы убедиться, что мы правильно понимаем изложенные в документации тезисы интеграции тестирования Ruby приложений с помощью GitHub Actions, взглянем на случайно выбранный публичный проект с реализацией подобной интеграции или на статью «Github Actions. Простой пример для уверенного знакомства». Там продемонстрировано полезное для нас дополнение по подключению к приложению базы данных PostgreSQL в тестовом окружении GitHub Actions.

Здесь также можно упомянуть для разработчиков, привыкших в тестовом окружении организовывать подключение к базе данных с правами доступа `POSTGRES_HOST_AUTH_METHOD: trust`, что в тестах придётся явно указывать имя пользователя и пароль, как для GitHub:в .github/workflows/ci.yml

jobs:
  tests:
    steps:
      - name: Build and Create PostgreSQL Database
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          RAILS_ENV: test
        run: |
          bin/rails db:create
          bin/rails db:migrate

так и для локали в config/database.yml

test:
  <<: *default
  host: localhost
  username: <%= ENV["POSTGRES_USER"]  'postgres' %>
  password: <%= ENV["POSTGRES_PASSWORD"]  'postgres' %>
  database: blah_blah_chat_test

Если помимо прогона тестов есть желание осуществлять проверку синтаксиса кода из линтера rubocop, то стоит обратить внимание на дискуссию «RuboCop pulls in .rubocop.yml files from vendor directories». В ней обсуждается способ исправления проблемы с ошибкой:

Unable to find gem panolint; is the gem installed? Gem::MissingSpecError

В нашем случае для исправления проблемы в файл .rubocop.yml нужно добавить дополнительную настройку:

inherit_mode:
  merge:
    - Exclude

Этого должно быть достаточно для успешного прогона тестов на GitHub.

В итоге у нас есть две задачи в GitHub workflow. Одна занимается тестированием приложения, а другая — созданием и публикацией контейнера (разобрана в предыдущей главе этой статьи). По умолчанию все задачи на Github Actions выполняются параллельно, поэтому, чтобы задачи выстроить в очередь, надо в одном файле настроить контекст workflow.

jobs:
  tests:
    ...
  push_to_registry:
    needs: tests
    …
Успешное состояние связанных задач в workflow
Успешное состояние связанных задач в workflow

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

Заключение. Итоги по проекту

Разобранный в данной статье проект построен на технологическом стеке Ruby on Rails 7 + Hotwire + PostgreSql + Docker. С исходным кодом можно ознакомиться в репозитории GitHub.

В итоге мы имеем:

  • историю разработки в репозитории GitHub;

  • код, проверенный линтером rubocop;

  • задокументированную спецификацию приложения в Rspec тестах;

  • покрытие кода тестами (на 99%);

  • краткое описание приложения в README.md;

  • опубликованный на ghcr.io docker-контейнер с web-приложением;

  • опубликованную спецификацию Application Programming Interface по стандарту JSight.

Во время работы над приложением мы также немного поработали над архитектурой базы данных. Проанализировали и предприняли соответствующие шаги в целях обеспечения оптимальности и эффективности работы с базой данных из ORM ActiveRecord. Мы также реализовали дизайн оконного интерфейса с помощью CSS фреймворка Bootstrap. И в заключение мы реализовали автоматизацию тестирования по методологии Continuous Integration.

P.S. В результате совместной работы заказчика с разработчиком заказчик получает работающее приложение и надёжного партнёра, а разработчик – закрепление профессиональных знаний и обретение навыка выстраивания производственных процессов. Есть надежда, что читатель также нашёл для себя аргументы в пользу познания огромного количества специальных технических деталей, и — систематизировал собственные знания по организации проектной деятельности с целью эффективного прохождения через регулярные технические проблемы, иногда сильно тормозящие разработку. А также обратил внимание на необходимость борьбы с соблазнами применения на проекте самых передовых решений ради выделения времени на углубление знаний по действительно необходимым в текущем проекте технологиям.

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


  1. Shersh
    20.12.2023 12:06

    Вы путаете MVP и Прототип. Не надо так.


  1. niko1aev
    20.12.2023 12:06

    Обожаю Ruby, Ruby on Rails, но никому не рекомендую связываться Hotwire и Stimulus в production

    Рубистов и так мало, рубистов знающих фронт еще раз в 10 меньше.
    Завести в Rails приложение Vue не составляет никакого труда, а фронтенд разработчиков знающих Vue в десятки раз больше, чем бэкендеров хорошо знающих Hotwire, Stimulus, Turbo-Streams

    Еще понимаю такой стек в своем проекте, но на заказ - IMHO это прям подстава на будущее


    1. It_Architect Автор
      20.12.2023 12:06

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