Пишем Pastebin на Hanami (часть 1)
Содержание
Не так давно узнал про новый Ruby full-stack веб-фреймворк под названием Hanami — от яп. 花見, или flower viewing (любование цветами). Фреймворк был создан итальянским программистом по имени Luca Guidi совсем недавно (где-то в начале 2014 г.) и разрабатывается им же и постепенно растущим сообществом. Ещё недавно фреймворк назывался «Lotus», однако из-за возможных трений с IBM (которые владеют правами на одноимённую торговум марку), было принято стратегическое решение переименовать фреймворк.
Данная запись — первая часть из (я надеюсь) серии, в которой я собираюсь рассмотреть возможности фреймворка на примере создания игрушечного приложения — сервиса для хранения текста (Pastebin). В первой части мы создадим интерфейс, похожий на интерфейс pastebin'а IX.io, которым можно управлять с консоли с помощью утилиты curl.
Hanami
При создании Hanami преследовались следующие цели:
- Легковесность.
- Скорость.
- Простота.
- Безопасность.
Архитектурные решения, которые были приняты при разработке Hanami, направлены на то, чтобы упростить разработку модульных приложений. Hanami поощряет создание нескольких микроприложений вместо одного монолитного приложения.
Также Hanami предусматривает чёткое разделение логики, заложенной в компоненты архитектуры MVC (дальше в основном идёт сравнение с Rails + ActiveRecord):
- Моделей в том смысле, в котором их понимают пользователи ActiveRecord, теперь нет — вместо них используются сущности (Entities). Entities представляют собой Plain Old Ruby Objects (PORO), выступающие как хранилища атрибутов объектов предметной области. Вся логика по созданию, сохранению, выборке Entities, теперь принадлежит отдельной сущности - репозиторию (Repository). Кроме того, всё запросы, которые предоставляет абстрактный репозиторий, закрыты (private) в терминах ООП; это заставляет программиста описывать все запросы как методы конкретного репозитория, заставляя его создавать дополнительный слой абстракциию
- Контроллеров тоже нет! На самом деле, привычный Rails-контроллер — это обычный Ruby-модуль, который
предоставляет пространство имён для своих действий (Actions). Action в Hanami — это класс с нуль-арным
конструктором и методом
#call
, принимающим единственный аргумент — параметры запроса. Предназначение Actions — обработать запрос и предоставить объекты Exposures для Views (см. ниже). Именно Actions (а не Entities) ответственны за описание правил валидации параметров (внимательный читатель увидит по ссылке сходство правил с валидациями dry-validation из коллекции гемов dry-rb и будет совершенно прав — Hanami использует dry-validation начиная с версии 0.8). - Представления (Views) в Hanami соответствуют объектам, которые называют View Objects в Rails. Представления получают от действий Exposures, которые будут превращены шаблонами (Templates, или Views в терминах Rails), в тело ответа.
Такой подход к MVC значительно упрощает тестирование отдельных частей приложения в изоляции, в чём мы и убедимся в дальнейшем.
Setup
Создадим новый gemset, установим гем hanami
и создадим новое приложение, указав, что мы хотим использовать
RSpec для тестов и Postgres в качестве РСУБД:
$ rvm gemset create hanami-pastebin
$ rvm gemset use hanami-pastebin
$ gem install hanami rubocop heroku
$ hanami new pastebin --test=rspec --db=postgres
Установим зависимости и создадим первый коммит:
$ cd pastebin
$ bundle install
$ rvm --create --ruby-version 2.3.1@hanami-pastebin
$ git add .
$ git commit -m 'Initial commit'
Travis
Я буду использовать Travis CI для автоматического прогона тестов.
Paste
Приступим к описанию объектов (одного объекта, на самом деле) предметной области, с которыми мы будем работать: это Paste (от англ. «paste» — вставлять), фрагменты текста, которые хранятся в Pastebin'ах. Paste обладает следующими атрибутами:
-
id
- уникальный идентификатор (число), используемое как первичный ключ в соответствующей таблице Postgres.
-
content
- хранимый текст.
-
token
- идентификатор, который генерируется при создании Paste и используется для разрешения операций удаления и редактирования Paste.
-
created_at
,updated_at
- временные штампы, соответственно, создания и последнего изменения Paste.
Следующая команда сгенерирует класс для нашей модели (или Entity, тут создатели Hanami не очень последовательны):
$ hanami generate model paste
Отредактируем lib/pastebin/entities/paste.rb
и добавим туда вышеописанные атрибуты
content
, token
, created_at
, updated_at
(id
добавлен неявно самим фреймворком или
SQL-адаптером, я не разбирался).
Для версионирования таблиц Hanami, как и Rails, использует миграции. Создадим миграцию для Paste:
$ hanami generate migration create_pastes
Откроем сгенерированную миграцию и добавим туда описания атрибутов вместе с их типами и ограничениями (внимательный читатель увидит, что миграции предоставлены гемом Sequel):
Hanami придерживается идеологии 12factor касаемо того, как хранить конфигурацию приложения, поэтому
такие настройки, как подключение к БД, задаются в .env.*
-файлах. Внесём следующие изменения в
файл .env.development
, чтобы использовать для пользователя, под которым мы сидим в системе,
peer аутентификацию к локальному серверу Postgres:
Похожую правку нужно внести также в .env.test
.
Укажем временные зоны для базы данных и приложения, а также укажем соответствие между сущностью, репозиторием и таблицей базы данных вместе с преобразованиями типов для атрибутов:
Следующие две команды создадут базу данных и выполнят миграции:
$ hanami db create
$ hanami db migrate
Для этих двух действий существует шорткат: команда hanami db prepare
.
То же самое нужно выполнить для тестовой базы, т. е. с переменной окружения HANAMI_ENV=test
:
$ HANAMI_ENV=test hanami db prepare
API app
Как я уже упомянул выше, Hanami поощряет микросервисную архитектуру. Мы создадим приложение api
, через
которое будем осуществлять управление Paste'ами:
$ hanami generate app api
Наше приложение будет предоствлять ресурс pastes
с четырьмя действиями Create, Show, Update и Delete,
соответствующими классическим операциям CRUD. Создадим маршруты для этого ресурса (синтаксис будет
знаком рельсовикам):
Следующий фрагмент устанавливает форматы запроса и ответа по умолчанию, а также отключает Layout для данного приложения (он нам всё равно не нужен, т. к. мы не генерируем HTML):
Строка controller.format ...
— это костыль, чтобы обойти проблему, связанную с тем, что Hanami хочет генерировать
ответ для формата text
с помощью шаблонов *.conf.erb
; подробности по ссылке.
Так как мы отключили Layout для данного приложения, то можно удалить представление и шаблон для Layout:
$ rm apps/api/templates/application.html.erb
$ rm apps/api/views/application_layout.rb
Create
Приступим к созданию действий; первое действие — это создание нового Paste:
$ hanami generate action api pastes#create --method=post
Генератор действий создаёт класс для действия, представление и шаблон (и тесты для действия и представления), а также добавляет новый маршрут. Т. к. мы уже заранее указали все маршруты, то его нужно удалить. То же самое нужно будет выполнить при генерации остальных действий, в дальнейшем я про это говорить уже не буду.
Генератор создаёт шаблон для формата html
, а нам нужен text
, так что переименуем его:
$ mv apps/api/templates/pastes/create.{html,text}.erb
Как хорошие программисты, которые придерживаются концепции BDD там, где это имеет смысл, мы начнём с написания тестов, которые будут описывать поведение нашего действия:
Как видно из тестов, создание Paste должно быть успешно в случае, если мы передадим содержимое Paste; если же попытаться создать Paste без содержимого, то мы должны получить ошибку (HTTP-статус 422).
Дальше опишем поведение представления: оно просто должно генерировать текст, который будет содержать ссылку на новый Paste и идентификатор (токен), который понадобится, если мы захотим отредактировать или удалить созданный Paste:
Как мы видим из примеров выше, особенности архитектуры Hanami значительно упрощают юнит-тестирование компонентов приложения в изоляции. Чтобы протестировать действие, мы должны передать экземляру класса действия хеш с параметрами запроса и проверить сгенерированный ответ (ответ, кстати, совместим с Rack — это простой массив из трёх элементов: статус, заголовки, тело). То же самое с представлениями — мы передаём Exposures которые генерирует действие, экземляру класса представления и проверяем сгенерированный ответ. Те, у кого есть опыт тестирования Rails-приложений, должны оценить простоту и отсутствие магии.
Напишем действие для создания нового Paste:
Тут мы:
- указываем правила валидации параметров запроса;
- указываем, что переменная экземпляра
@paste
должна быть доступна (exposed) для представления; - выбрасываем ошибку, если параметры не проходят валидацию;
- создаём новый Paste, генерируем для него токен, сохранён в переменную экземпляра
@paste
, значение которой будет доступно в представлении.
Представление просто предоствляет два вспомогательных метода, которые возвращают URL созданного Paste
и созданный для него токен без HTML escaping (объект paste
здесь — это exposed-переменная
@paste
экземпляра действия):
Код шаблона использует определённые в представлении методы:
Теперь написанные тесты должны проходить успешно. Проверим и мы создание Paste вручную; для этого запустим
development-сервер (hanami server
) и воспользуемся утилитой curl
(для подробностей использования curl
предлагается почитать мануал):
$ curl -F 'content=<-' http://localhost:2300/api/pastes <<CONTENT
class Greeter
def initialize(salutation: 'Hello')
@salutation = salutation
end
def greet(who)
puts "#{@salutation}, #{who}!"
end
end
CONTENT
URL: http://localhost:2300/api/pastes/32
Access token: a4444f889d9dd4cb
Show
Теперь напишем код, которые будет отображать созданные Paste. Воспользуемся генератором:
$ hanami generate action api pastes#show
Для действия Show нам не понадобится шаблон, поэтому удалим его:
$ rm apps/api/templates/pastes/show.html.erb
Тесты для действия проверяют, что если передать идентификатор несуществующего Paste, мы получим ошибку 404:
Представление просто должно возвращать содержимое Paste (внимательный читатель может заметить, что
в качестве шаблона, который мы удалили, в конструктор представления передаётся nil
):
Код действия, я полагаю, не нуждается в комментариях:
Представление определяет один-единственный метод — render
. Наличие этого метода в представлении
означает, что оно берёт генерацию ответа на себя, не прибегая к помощи шаблонов.
Здесь мы просто возвращаем содержимое Paste:
Проверим, что мы можем читать созданные ранее Paste:
$ curl http://localhost:2300/api/pastes/32
class Greeter
def initialize(salutation: 'Hello')
@salutation = salutation
end
def greet(who)
puts "#{@salutation}, #{who}!"
end
end
Update
Для действия Update (и Delete дальше) нам не нужны представления, поэтому мы запускаем генератор с ключём
--skip-view
, при этом генерация шаблона также пропускается:
$ hanami generate action api pastes#update --skip-view
При тестировании контроллера мы проверяем, что если передать неправильный токен мы получим ошибку (403 Forbidden):
Проверим обновление Paste (воспользуемся ключём -v
чтобы убедиться, что сервер отвечает с кодом 204 No Content,
который мы возвращаем в случае успеха):
$ curl -X PATCH -F 'content=<-' -F 'access_token=a4444f889d9dd4cb' http://localhost:2300/api/pastes/32 -v <<CONTENT
puts 'Hello, World!'
CONTENT
,* Trying 127.0.0.1...
,* Connected to localhost (127.0.0.1) port 2300 (#0)
,* Initializing NSS with certpath: sql:/etc/pki/nssdb
> PATCH /api/pastes/32 HTTP/1.1
> Host: localhost:2300
> User-Agent: curl/7.47.1
> Accept: */*
> Content-Length: 280
> Expect: 100-continue
> Content-Type: multipart/form-data; boundary=------------------------ec240f3405d3c952
>
,* Done waiting for 100-continue
< HTTP/1.1 204 No Content
< Server: WEBrick/1.3.1 (Ruby/2.3.1/2016-04-26)
< Date: Thu, 10 Nov 2016 12:22:28 GMT
< Connection: Keep-Alive
<
,* Connection #0 to host localhost left intact
Убедимся, что Paste действительно обновился:
$ curl http://localhost:2300/api/pastes/32
puts 'Hello, World!'
А вот что будет, если указать неправильный токен:
$ curl -X PATCH -F 'content=<-' -F 'access_token=deadbabe' http://localhost:2300/api/pastes/32 -v <<CONTENT
puts 'Hello, World!'
CONTENT
,* Trying 127.0.0.1...
,* Connected to localhost (127.0.0.1) port 2300 (#0)
,* Initializing NSS with certpath: sql:/etc/pki/nssdb
> PATCH /api/pastes/32 HTTP/1.1
> Host: localhost:2300
> User-Agent: curl/7.47.1
> Accept: */*
> Content-Length: 272
> Expect: 100-continue
> Content-Type: multipart/form-data; boundary=------------------------656dca4be12196e9
>
,* Done waiting for 100-continue
< HTTP/1.1 403 Forbidden
< X-Frame-Options: DENY
< X-Content-Type-Options: nosniff
< X-Xss-Protection: 1; mode=block
< Content-Security-Policy: form-action 'self'; frame-ancestors 'self'; base-uri 'self'; default-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self' https: data:; style-src 'self' 'unsafe-inline' https:; font-src 'self'; object-src 'none'; plugin-types application/pdf; child-src 'self'; frame-src 'self'; media-src 'self'
< Content-Type: text/plain; charset=utf-8
< Server: WEBrick/1.3.1 (Ruby/2.3.1/2016-04-26)
< Date: Thu, 10 Nov 2016 12:25:25 GMT
< Content-Length: 9
< Connection: Keep-Alive
,* HTTP error before end of send, stop sending
<
,* Closing connection 0
Forbidden
Destroy
Сгенерируем заключительное действие из четвёрки CRUD:
$ hanami generate action api pastes#destroy --skip-view
И тесты, и код действия схожи с предыдущими примерами, поэтому я их комментировать не буду.
Экшен:
Попробуем удалить наш Paste с помощью curl
(статус 204 No Content — признак успешного результата):
curl -X DELETE -F 'access_token=a4444f889d9dd4cb' http://localhost:2300/api/pastes/32 -v
,* Trying 127.0.0.1...
,* Connected to localhost (127.0.0.1) port 2300 (#0)
,* Initializing NSS with certpath: sql:/etc/pki/nssdb
> DELETE /api/pastes/32 HTTP/1.1
> Host: localhost:2300
> User-Agent: curl/7.47.1
> Accept: */*
> Content-Length: 163
> Expect: 100-continue
> Content-Type: multipart/form-data; boundary=------------------------7e4cdb7d87023317
>
,* Done waiting for 100-continue
< HTTP/1.1 204 No Content
< Server: WEBrick/1.3.1 (Ruby/2.3.1/2016-04-26)
< Date: Thu, 10 Nov 2016 12:29:43 GMT
< Connection: Keep-Alive
<
,* Connection #0 to host localhost left intact
Heroku
А теперь развернём наше приложение на Heroku. Для начала настроим Puma в качестве веб-сервера для продакшена.
Добавим гем puma
в Gemfile:
Заставим Heroku запускать Puma:
Конфиг Puma:
Создадим новое приложение Heroku, добавим аддон Postgres и укажем окружение по умолчанию:
$ heroku create hanami-pastebin # pick yourself a unique name
$ heroku addons:create heroku-postgresql
$ heroku config:add HANAMI_ENV=production
$ heroku config:add SERVE_STATIC_ASSETS=true
Установив значение SERVE_STATIC_ASSETS
в true
, мы говорим Hanami, что статику должно отдавать
само приложение, а не какой-нибудь сервер типа Nginx (которые на Heroku не доступен).
Укажем хост и HTTPS-соединение для продакшена (хост должен соответствовать имени Heroku-приложения, которое мы создали минутой раньше):
Теперь можно отправить наше приложение на Heroku и убедиться, что всё работает!
$ git push heroku master
Заключение
В данной статье были рассмотрены основные особенности фреймворка Hanami на примере простого приложения. Некоторые моменты остались за кадром (например, Hanami Persistence Layer я почти не затронул, а Mailers и Form Helpers так и вообще не упомянул); в следующих частях (если они будут) я постараюсь уделить больше внимания остальным компонентам фреймворка.
Код приложения, соответствующего этой части серии, находится в своей ветке репозитория smaximov/pastebin.