В работе описана смоделированная ситуация по разработке простого 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 приложения.
Создаём классическое Ruby on Rails приложение и покрываем его Rspec-тестами.
Добавляем REST API и покрываем его Rspec-тестами.
Как профессиональные разработчики мы стараемся придерживаться лучших практик по использованию Rspec
В программной разработке также считается хорошей практикой следить за процентным отношением покрытия кода тестами. Фреймворк Ruby on Rails содержит терминальную команду, которая показывает условное покрытие через соотношение количества строк в коде к количеству строк в тестах.
bin/rails stats
...
Code LOC: 671 Test LOC: 647 Code to Test Ratio: 1:1.0
Хорошим значением для этого соотношения считает величина больше единицы. Однако такой показатель не раскрывает покрытие кода по функциональности. В последнем случае стоит прибегать к специализированным решениям. Мы воспользуемся гемом simplecov.
Для более глубокого тестирования моделей и реляционных отношений воспользуемся гемом shoulda-matchers. Несмотря на то, что тестирование реляционных отношений многими считается избыточным, однако эта составляющая тестового наполнения важна в качестве Спецификации приложения.
На данный момент мы получили рабочее приложение, но пока ещё совсем не отвечающее требованиям технического задания. Зачем нам это надо было?
Мы за один день написали ключевой функционал приложения. Который мы можем продемонстрировать заказчику и он в свою очередь может начать знакомиться с ним, чтобы детализировать требования по собственной постановке задачи. Таким образом мы актуализируем Agile методологию разработки и вовлекаем заказчика в процесс создания нового приложения в роли Дизайнера и Аналитика продукта.
С этого момента мы начнём демонстрировать заказчику в реальном времени весь перечень подзадач, с которыми мы будем разбираться в процессе всей разработки до момента её сдачи. Таким образом заказчик будет чётко понимать, за что именно он будет вам платить большие деньги.
Вы не даёте себе забыть, что такое legacy, и, как профессиональный разработчик, продолжаете поддерживать свою форму, чтобы в любой момент взяться за любой проект с legacy. Также это полезно для Junior разработчиков из своей команды, потому что так мы формируем материальную основу из причинно-следственных связей, объясняющих "магию современных высоких технологий".
II. Техническое проектирование. Проработка интерфейса приложения
Стилизуем приложение через любимый 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 мы воспользовались:
контейнерами https://getbootstrap.com/docs/5.3/layout/containers/#how-they-work;
горизонтальным выравниванием блоков друг относительно друга https://getbootstrap.com/docs/5.3/utilities/flex/;
переносом длинных слов внутри блока https://getbootstrap.com/docs/5.3/utilities/text/#word-break;
всплывающим сообщением «Toast» https://getbootstrap.com/docs/5.3/components/toasts/;
иконками https://icons.getbootstrap.com/;
стилизацией границ блоков https://getbootstrap.com/docs/5.3/utilities/borders/;
«липкими» прикреплениями к границам окна https://getbootstrap.com/docs/5.3/helpers/position/;
стандартной цветовой палитрой https://getbootstrap.com/docs/5.3/customize/color/;
колоночным режимом выравнивания блоков https://getbootstrap.com/docs/5.3/layout/columns/;
Превращаем приложение в 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 есть содержательный видеокурс от Ильи Крюковского. Далее пройдёмся по отдельным инструментам взятой нами на вооружение технологии.
Подробный пример реализации такой функциональности можно найти у Akshay Khot в статье «Building a To-Do List Using Hotwire and Stimulus» с видеопрезентацией и с исходными кодами.
После создания нового поста мы отображаем 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».
На данном этапе заказчик может заметить, что приложение не динамическое. То есть пользователи не могут видеть в реальном времени сообщения, отправляемые другими пользователями. Поэтому мы переходим к следующему шагу.
Добавляем в приложение 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'
. Подключите его.
C помощью гема Capybara покрываем своё приложение интеграционными тестами (features).
На данном этапе заказчик имеет почти готовое решение, и у него появляются дополнения к собственному ТЗ. Он хочет:
Чтобы список чатов и постов сортировался от последнего (наверху) до первого (внизу);
Чтобы списки чатов и постов не грузились сразу, а поступали порционно, по мере перемещения вниз по списку;
Получать flash-уведомление со звуковым сопровождением, о поступлении нового сообщения не только в свой чат, но и в любой другой;
Иметь возможность выделять важные сообщения звездочкой;
Разрешить удаление постов и чатов. (Мы уже имели этот пункт в первой версии ТЗ, но мы ещё не добрались до его реализации);
Разрешить редактирование названий чатов;
Перевести функцию добавления нового чата в модальное окно.
III. Техническое проектирование. Рефакторим, модернизируем и тестируем функциональную часть приложения
По собственным наблюдениям логов бэкенда мы обращаем внимание на то, что у нас происходят обращения в базу данных за каждой отдельной записью в списке сообщений. Это говорит о том, что мы натолкнулись на классическую проблему "N+1". Исправляем её применением ActiveRecord метода .includes.
Добавляем новые методы 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
Выделение важных сообщений в контексте архитектуры базы данных может быть решено двумя способами. Первый – это добавление булевого поля к таблице постов. Второй – это добавление таблицы выделенных сообщений. Воспользуемся более информационно насыщенным способом при минимальных накладных расходах на хранение реляционных зависимостей в БД. Это второй способ по добавлению сущности Highlights.
Для обслуживания этой сущности в представлениях воспользуемся action highlight у PostsController, который выполняет функцию переключения состояния поста "Выделенный"/"Невыделенный", а фактически – функцию создания или удаления записи в таблице Highlights, как это было сделано Эдемом Топузовым в видео ролике «Онлайн-чат на Ruby on Rails 7 с помощью Hotwire» с исходниками в репозитории. Реализуем широковещательный механизм об изменениях со статусом поста также через Turbo-Streams.
Чтобы непосредственно отобразить состояние выделения поста создадим хелпер, который по наличию записи в таблице 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
Для отображения 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
.
Для организации постраничного вывода данных через бесконечный скроллинг можно пользоваться гемом '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 страницы.
Перенос функционала из отдельной страницы на добавление нового чата в модальное окно мы снова воспользуемся 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
Для обеспечения чистоты работы приложения на некоторых открытых в нём маршрутов нам необходимо запретить отображение 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
Причину можно увидеть на скриншоте.
Настройка запрашиваемых прав может быть осуществлена из личного кабинета в секции управления персональным токеном доступа посредством активации пунктов 'workflow' и 'write:packages' (по умолчанию они отключены).
Но этого делать не обязательно, потому что существует альтернативная возможность дать эти разрешения непосредственно из конфигурационного файла по автоматическому созданию и публикации 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
…
Так мы можем быть уверены, что образ 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. В результате совместной работы заказчика с разработчиком заказчик получает работающее приложение и надёжного партнёра, а разработчик – закрепление профессиональных знаний и обретение навыка выстраивания производственных процессов. Есть надежда, что читатель также нашёл для себя аргументы в пользу познания огромного количества специальных технических деталей, и — систематизировал собственные знания по организации проектной деятельности с целью эффективного прохождения через регулярные технические проблемы, иногда сильно тормозящие разработку. А также обратил внимание на необходимость борьбы с соблазнами применения на проекте самых передовых решений ради выделения времени на углубление знаний по действительно необходимым в текущем проекте технологиям.