Пишем 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 для автоматического прогона тестов.
language: ruby
sudo: false
rvm:
- 2.3.1
- ruby-head
cache: bundler
script:
- bundle exec rubocop
- bundle exec rake spec
before_script:
- bundle exec hanami db prepare
env:
- HANAMI_ENV=test
matrix:
allow_failures:
- rvm: ruby-head
.travis.yml
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-адаптером, я не разбирался).
class Paste
include Hanami::Entity
attributes :content, :token, :created_at, :updated_at
end
lib/pastebin/entities/paste.rb
Для версионирования таблиц Hanami, как и Rails, использует миграции. Создадим миграцию для Paste:
$ hanami generate migration create_pastes
Откроем сгенерированную миграцию и добавим туда описания атрибутов вместе с их типами и ограничениями (внимательный читатель увидит, что миграции предоставлены гемом Sequel):
Hanami::Model.migration do
change do
create_table :pastes do
primary_key :id
column :content, String, null: false
column :token, String, null: false
column :created_at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP
column :updated_at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP
end
end
end
db/migrations/[TIMESTAMP]_create_pastes.rb
Hanami придерживается идеологии 12factor касаемо того, как хранить конфигурацию приложения, поэтому
такие настройки, как подключение к БД, задаются в .env.*
-файлах. Внесём следующие изменения в
файл .env.development
, чтобы использовать для пользователя, под которым мы сидим в системе,
peer аутентификацию к локальному серверу Postgres:
--- aaa 2016-11-10 20:15:09.219439160 +1000
+++ bbb 2016-11-09 18:40:03.139857824 +1000
@@ -1,5 +1,5 @@
# Define ENV variables for development environment
-DATABASE_URL="postgres://localhost/pastebin_development"
+DATABASE_URL="postgres:/pastebin_development"
SERVE_STATIC_ASSETS="true"
.env.development
Похожую правку нужно внести также в .env.test
.
Укажем временные зоны для базы данных и приложения, а также укажем соответствие между сущностью, репозиторием и таблицей базы данных вместе с преобразованиями типов для атрибутов:
# ...
Hanami::Model.configure do
# ...
Sequel.database_timezone = :utc
Sequel.application_timezone = :local
# ...
mapping do
collection :pastes do
entity Paste
repository PasteRepository
attribute :id, Integer
attribute :content, String
attribute :token, String
attribute :created_at, DateTime
attribute :updated_at, DateTime
end
end
# ...
end.load!
lib/pastebin.rb
Следующие две команды создадут базу данных и выполнят миграции:
$ 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. Создадим маршруты для этого ресурса (синтаксис будет
знаком рельсовикам):
resources :pastes, except: %i(index new edit)
apps/api/config/routes.rb
Следующий фрагмент устанавливает форматы запроса и ответа по умолчанию, а также отключает Layout для данного приложения (он нам всё равно не нужен, т. к. мы не генерируем HTML):
require 'hanami/helpers'
require 'hanami/assets'
module Api
class Application < Hanami::Application
configure do
# ...
# Default format for the requests that don't specify an HTTP_ACCEPT header
# Argument: A symbol representation of a mime type, default to :html
#
default_request_format :html
# Default format for responses that doesn't take into account the request format
# Argument: A symbol representation of a mime type, default to :html
#
default_response_format :text
# See https://gitter.im/hanami/chat?at=58235cbac2c2b0744e339ac9
controller.format text: 'text/plain'
# ...
layout nil
# ...
end
end
end
# ...
apps/api/application.rb
Строка 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 там, где это имеет смысл, мы начнём с написания тестов, которые будут описывать поведение нашего действия:
require_relative '../../../../apps/api/controllers/pastes/create'
RSpec.describe Api::Controllers::Pastes::Create do
let(:action) { described_class.new }
after do
PasteRepository.clear
end
context 'with invalid parameters' do
let(:params) { Hash[] }
it 'is 422 Unprocessable Entity' do
response = action.call(params)
expect(response[0]).to eq(422)
end
end
context 'with valid parameters' do
let(:params) { Hash[content: 'some content'] }
it 'is successful' do
response = action.call(params)
expect(response[0]).to eq(200)
end
end
end
spec/api/controllers/pastes/create_spec.rb
Как видно из тестов, создание Paste должно быть успешно в случае, если мы передадим содержимое Paste; если же попытаться создать Paste без содержимого, то мы должны получить ошибку (HTTP-статус 422).
Дальше опишем поведение представления: оно просто должно генерировать текст, который будет содержать ссылку на новый Paste и идентификатор (токен), который понадобится, если мы захотим отредактировать или удалить созданный Paste:
require_relative '../../../../apps/api/views/pastes/create'
RSpec.describe Api::Views::Pastes::Create do
let(:paste) { Paste.new(id: 42, content: 'some content', token: 'some token') }
let(:exposures) { Hash[paste: paste] }
let(:template) { Hanami::View::Template.new('apps/api/templates/pastes/create.text.erb') }
let(:view) { described_class.new(template, exposures) }
let(:rendered) { view.render }
it 'displays the URL of the paste' do
expect(rendered).to match(%r{/api/pastes/42})
end
it 'displays the access token' do
expect(rendered).to match('some token')
end
end
spec/api/views/pastes/create_spec.rb
Как мы видим из примеров выше, особенности архитектуры Hanami значительно упрощают юнит-тестирование компонентов приложения в изоляции. Чтобы протестировать действие, мы должны передать экземляру класса действия хеш с параметрами запроса и проверить сгенерированный ответ (ответ, кстати, совместим с Rack — это простой массив из трёх элементов: статус, заголовки, тело). То же самое с представлениями — мы передаём Exposures которые генерирует действие, экземляру класса представления и проверяем сгенерированный ответ. Те, у кого есть опыт тестирования Rails-приложений, должны оценить простоту и отсутствие магии.
Напишем действие для создания нового Paste:
module Api::Controllers::Pastes
class Create
include Api::Action
params do
required(:content).filled(:str?)
end
expose :paste
def call(params)
halt 422 unless params.valid?
@paste = PasteRepository.create(Paste.new(content: params[:content],
token: generate_token))
end
private
def generate_token
SecureRandom.hex(8)
end
end
end
apps/api/controllers/pastes/create.rb
Тут мы:
- указываем правила валидации параметров запроса;
- указываем, что переменная экземпляра
@paste
должна быть доступна (exposed) для представления; - выбрасываем ошибку, если параметры не проходят валидацию;
- создаём новый Paste, генерируем для него токен, сохранён в переменную экземпляра
@paste
, значение которой будет доступно в представлении.
Представление просто предоствляет два вспомогательных метода, которые возвращают URL созданного Paste
и созданный для него токен без HTML escaping (объект paste
здесь — это exposed-переменная
@paste
экземпляра действия):
module Api::Views::Pastes
class Create
include Api::View
def paste_url
raw routes.paste_url(paste.id)
end
def access_token
raw paste.token
end
end
end
apps/api/views/pastes/create.rb
Код шаблона использует определённые в представлении методы:
URL: <%= paste_url %>
Access token: <%= access_token %>
apps/api/templates/pastes/create.text.erb
Теперь написанные тесты должны проходить успешно. Проверим и мы создание 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:
require_relative '../../../../apps/api/controllers/pastes/show'
RSpec.describe Api::Controllers::Pastes::Show do
let(:action) { described_class.new }
let(:id) { 42 }
let(:params) { Hash[id: id] }
before do
allow(PasteRepository).to receive(:find).with(id).and_return(paste)
expect(PasteRepository).to receive(:find).with(id)
end
context 'with an existing paste' do
let(:paste) { Paste.new(id: id, content: 'some content', token: 'deadbeef') }
it 'is successful' do
response = action.call(params)
expect(response[0]).to eq(200)
end
end
context 'with a non-existing paste' do
let(:paste) { nil }
it 'is 404 Not Found' do
response = action.call(params)
expect(response[0]).to eq(404)
end
end
end
spec/api/controllers/pastes/show_spec.rb
Представление просто должно возвращать содержимое Paste (внимательный читатель может заметить, что
в качестве шаблона, который мы удалили, в конструктор представления передаётся nil
):
require_relative '../../../../apps/api/views/pastes/show'
RSpec.describe Api::Views::Pastes::Show do
let(:paste) { Paste.new(id: 42, content: 'some content', token: 'deadbeef') }
let(:exposures) { Hash[paste: paste] }
let(:template) { nil }
let(:view) { described_class.new(template, exposures) }
let(:rendered) { view.render }
it 'renders paste content' do
expect(rendered).to eq(paste.content)
end
end
spec/api/views/pastes/show_spec.rb
Код действия, я полагаю, не нуждается в комментариях:
module Api::Controllers::Pastes
class Show
include Api::Action
expose :paste
def call(params)
@paste = PasteRepository.find(params[:id])
halt 404 if @paste.nil?
end
end
end
apps/api/controllers/pastes/show.rb
Представление определяет один-единственный метод — render
. Наличие этого метода в представлении
означает, что оно берёт генерацию ответа на себя, не прибегая к помощи шаблонов.
Здесь мы просто возвращаем содержимое Paste:
module Api::Views::Pastes
class Show
include Api::View
def render
raw paste.content
end
end
end
apps/api/views/pastes/show.rb
Проверим, что мы можем читать созданные ранее 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):
require_relative '../../../../apps/api/controllers/pastes/update'
RSpec.describe Api::Controllers::Pastes::Update do
let(:action) { described_class.new }
let(:id) { 42 }
let(:access_token) { 'token' }
let(:content) { 'new content' }
let(:params) { Hash[id: 42, access_token: access_token, content: content] }
context 'with invalid params' do
context 'when access token is missing' do
let(:access_token) { nil }
it 'is 422 Unprocessable Entity' do
response = action.call(params)
expect(response[0]).to eq(422)
end
end
context 'when content is missing' do
let(:content) { nil }
it 'is 422 Unprocessable Entity' do
response = action.call(params)
expect(response[0]).to eq(422)
end
end
end
context 'with valid params' do
before do
allow(PasteRepository).to receive(:find).with(id).and_return(paste)
expect(PasteRepository).to receive(:find).with(id)
end
context 'with a non-existing paste' do
let(:paste) { nil }
it 'is 404 Not Found' do
response = action.call(params)
expect(response[0]).to eq(404)
end
end
context 'with an existing paste' do
let(:paste) { Paste.new(id: id, content: 'some content', token: 'deadbeef') }
context 'with a matching access token' do
let(:access_token) { 'deadbeef' }
it 'is 204 No Content' do
response = action.call(params)
expect(response[0]).to eq(204)
end
end
context 'with a non-matching access token' do
let(:access_token) { 'deadbabe' }
it 'is 403 Forbidden' do
response = action.call(params)
expect(response[0]).to eq(403)
end
end
end
end
end
spec/api/controllers/update_spec.rb
module Api::Controllers::Pastes
class Update
include Api::Action
params do
required(:id).filled(:int?)
required(:access_token).filled(:str?)
required(:content).filled(:str?)
end
def call(params)
halt 422 unless params.valid?
paste = PasteRepository.find(params[:id])
halt 404 if paste.nil?
halt 403 unless paste.token == params[:access_token]
paste.update(content: params[:content], updated_at: Time.now)
PasteRepository.update(paste)
self.status = 204
end
end
end
apps/api/controllers/update.rb
Проверим обновление 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
И тесты, и код действия схожи с предыдущими примерами, поэтому я их комментировать не буду.
require_relative '../../../../apps/api/controllers/pastes/destroy'
RSpec.describe Api::Controllers::Pastes::Destroy do
let(:action) { described_class.new }
let(:id) { 42 }
let(:access_token) { 'token' }
let(:params) { Hash[id: 42, access_token: access_token] }
context 'with invalid params' do
context 'when access token is missing' do
let(:access_token) { nil }
it 'is 422 Unprocessable Entity' do
response = action.call(params)
expect(response[0]).to eq(422)
end
end
end
context 'with valid params' do
before do
allow(PasteRepository).to receive(:find).with(id).and_return(paste)
expect(PasteRepository).to receive(:find).with(id)
end
context 'with a non-existing paste' do
let(:paste) { nil }
it 'is 404 Not Found' do
response = action.call(params)
expect(response[0]).to eq(404)
end
end
context 'with an existing paste' do
let(:paste) { Paste.new(id: id, content: 'some content', token: 'deadbeef') }
context 'with a matching access token' do
let(:access_token) { 'deadbeef' }
it 'is 204 No Content' do
response = action.call(params)
expect(response[0]).to eq(204)
end
end
context 'with a non-matching access token' do
let(:access_token) { 'deadbabe' }
it 'is 403 Forbidden' do
response = action.call(params)
expect(response[0]).to eq(403)
end
end
end
end
end
spec/api/controllers/destroy_spec.rb
Экшен:
module Api::Controllers::Pastes
class Destroy
include Api::Action
params do
required(:id).filled(:int?)
required(:access_token).filled(:str?)
end
def call(params)
halt 422 unless params.valid?
paste = PasteRepository.find(params[:id])
halt 404 if paste.nil?
halt 403 unless paste.token == params[:access_token]
PasteRepository.delete(paste)
self.status = 204
end
end
end
apps/api/controllers/create.rb
Попробуем удалить наш 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:
ruby '2.3.1'
# ...snip...
group :production do
gem 'puma'
end
Gemfile
Заставим Heroku запускать Puma:
web: bundle exec puma -C config/puma.rb
Procfile
Конфиг Puma:
threads_count = ENV.fetch('HANAMI_MAX_THREADS') { 5 }.to_i
threads threads_count, threads_count
port ENV.fetch('PORT') { 3000 }
environment ENV.fetch('HANAMI_ENV') { 'development' }
workers ENV.fetch('WEB_CONCURRENCY') { 2 }
config/puma.rb
Создадим новое приложение 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-приложения, которое мы создали минутой раньше):
# ...
module Api
class Application < Hanami::Application
# ...
##
# PRODUCTION
#
configure :production do
# ...
scheme 'https'
host 'hanami-pastebin.herokuapp.com'
port 443
# ...
end
# ...
end
end
apps/api/application.rb
Теперь можно отправить наше приложение на Heroku и убедиться, что всё работает!
$ git push heroku master
Заключение
В данной статье были рассмотрены основные особенности фреймворка Hanami на примере простого приложения. Некоторые моменты остались за кадром (например, Hanami Persistence Layer я почти не затронул, а Mailers и Form Helpers так и вообще не упомянул); в следующих частях (если они будут) я постараюсь уделить больше внимания остальным компонентам фреймворка.
Код приложения, соответствующего этой части серии, находится в своей ветке репозитория smaximov/pastebin.