Сергей Коба

Сергей Коба

Веб, блокчейн, мобильная разработка и интернет вещей

Веб тимлид в MobiDev. Цель: изучать и учить чему-то новому без остановки. Основные языки: PHP и Ruby. Также интересно: блокчейн, мобильная разработка, IoT и DevOps. Жизненное кредо: я жив, пока я учу что-то новое.

Как выжить без фреймверка в Ruby (Строим Rack Based MVC Framework)

6 июня 2017 00:00

Если вы программируете на Ruby on Rails, но всегда мечтали собрать свой фреймверк с блекджеком и всем прочим, но не знали с чего начать. То вам сюда!

Пишем фреймверк на ruby из понравившихся gem-ов и изоленты :) Хотя конечный результат вполне ничего, вы же читаете этот блог, а он работает как раз на таком движке.

Вступление

Начинается эта история с того, что мой хороший друг и коллега Евгений Кузьминов, заметил интересную разницу между Ruby и PHP разработчиками.

Почти каждый PHP разработчик начинал писать свои первые сайты на чистом PHP и затем в процессе обучения узнавал о фреймверках и эволюционировал до их уровня. Таким образом, вначале изучались основы, а затем более сложные подходы. В Ruby же все происходит иначе, первое о чем узнает веб разработчик - это фреймверк Ruby on Rails. И только потом ему становится интересно (будем оптимистами), как это все работает, что находится внутри Rails. Я надеюсь, что собрав хоть раз все кирпичики фреймверка самостоятельно, Вы гораздо лучше поймете, как они работают впринципе и Rails в часности.

Rack

Это основа основ почти каждого веб приложения на ruby. Наверняка фреймверк, который вы используете, и Ruby on Rails конечно тоже, использует Rack. Это интерфейс, который предоставляет API для создания веб-приложений на ruby. Он отвечает за приём и обработку Http запроса, парсинг параметров, данных формы, cookies и т.д. С него и начнём :)

Приложение с использованием Rack должно иметь метод call, который принимает объект Environment как параметр и в ответ должен вернуть объект Rack response. Например:

class MyBlog
def call (env)
[200, {"Content-Type" => "text/html; charset=utf-8"}, ["Hello World"]]
end
end

Метод должен вернуть массив из 3ех элементов: код HTTP овтета, HTTP заголовки и HTTP тело ответа. Для запуска приложения вам понадобится 3 файла:

my_blog/
├── Gemfile
├── config.ru
└── my_blog.rb


# Gemfile
source "https://rubygems.org"
gem 'rack'


# config.ru
require_relative 'my_blog'
run MyBlog.new

config.ru -  это файл конфигурации команды rackup, необходимой для запуска приложений на Rack. В нашем случае он выполняет одну функция, указывает класс MyBlog, который содержит метод call. Запустить приложение можно с помощью команды

bundle exec rackup --port 3000 --host 0.0.0.0

Перейдя поссылке http://localhost:3000 Вы должны увидеть наш заветный Hello World. Поздравляю!!! Вы написали первое веб приложение на ruby без использования Rails ;) ПС: детальнее про Rack можете почитать тут

Роутинг

Какое у вас возникло следующее желание? Правильно, добавить больше страниц в наш блог, например так:

# my_blog.rb
class MyBlog
def call(env)
req = Rack::Request.new(env)
case req.path_info
when /posts/
[200, {"Content-Type" => "text/html"}, ["<h1>Posts</h1>"]]
when /about/
[500, {"Content-Type" => "text/html"}, ["<h1>About</h1>"]]
else
[404, {"Content-Type" => "text/html"}, ["No one here..."]]
end
end
end

Данный код достаточно прост, мы анализируем часть url, котора хранится в свойстве path_info класса Rack::Request, и, используя оператор case, возвращаем различные ответы браузеру.

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

  1. Я бы хотел иметь возможность описывать пути в более универсальной и гибкой форме, чем оператор case, т.е. иметь некий Роутинг. 
  2. Нам нужен класс(ы), который принимает запрос и выполняет некую бизнес логику - Контроллер.
  3. Я хочу хранить генерируемый html код ответа в отдельных файлах, а не возвращать в виде захаркодженой строки. Иными словами, нам нужны Вьюхи (Views).

Как написать свой роутер с нуля вы можете прочесть в этой шикарной статье. Я же решил воспользоваться легковесным и быстрым роутером от Hanami.

# Gemfile
source "https://rubygems.org"
gem 'hanami-router'

Не забудьте запустить bundle...

# my_blog.rb
require 'hanami/router'
class MyBlog
def self.router
Hanami::Router.new do
get '/', to: ->(env) { [200, {}, ['Hello from My Blog']] }
get '/about', to: ->(env) { [200, {}, ['<h1>About</h1>']] }
get 'post/:id', to: ->(env) { [200, {}, ["Post #{env['router.params'][:id]}"]] }
end
end
end

Класс Hanami::Router содержит метод call, необходимый для Rack, поэтому мы можем использовать экземпляр роутера в качестве точки входа в нашем блоге

# config.ru
require_relative 'my_blog'
run MyBlog.router

Для проверки запускаем команду rackup --port 3000 и заходим на http://loclahost:3000/post/32. На экране должна появиться надпись "Post 32".

Контроллеры

Посмотрев на Hanami мне также понравилась их микро библиотеки для контроллеров. Я вижу в ней реализацию принципа Single Resposibility, т.к. каждый Action в ней является отдельным классом, например:

# app/controllers/post/show.rb
require 'hanami/controller'

module Post
class Show
include ::Hanami::Action
def call(params)
self.body = "Post #{params[:id]}"
end
end
end

В Ruby on Rails мы бы запихнули все actions для постов в один контроллер. И это хорошо с точки зрения группировки Actions по их прендалежности к постам. Я же решил эту задачу поместив все Actions относящиеся к постам в папку controllers/post.

my_blog/
 ├── app 
     ├── controllers
          ├── post
               ├── show.rb
├── Gemfile
├── config.ru
└── my_blog.rb

Автоладинг у нас отсутствует (УРА!), поэтому сделаем require папки controllers самостоятельно

# config.ru
require_relative 'my_blog'
# Load controllers
Dir[File.join(File.dirname(__FILE__), 'app/controllers', '**', '*.rb')].sort.each {|file| require file }
run MyBlog.router

И естественно установку gem-а никто не отменял

# Gemfile
source "https://rubygems.org"
gem 'hanami-router'
gem 'hanami-controller'

Ну и конечно контроллер Hanami легко интегрируется с роутером Hanami (it's amazing!!!)

# my_blog.rb
#...
    get 'post/:id', to: Post::Show
#...

Перегрузите сервер и еще раз зайдите на http://loclahost:3000/post/32, чтобы убедиться, что надпись "Post 32" все еще работает.

База данных

Ни одно уважающее себя приложение не может обойтись без базы данных. При рассмотрении возможных вариантов я поставил перед собой 3 цели (какое-то магическое число): поддержка миграций, модели, не ActiveRecord ;). Мой выбор пал на gem sequel. Он обладает очень большим набором фич и функционала. Советую взглянуть на него поближе, чем мы и займемся...

Не особо выдумывая я создал файл конфигурации config/database.yml со следующим содержимым:

adapter: postgres
host: db
database: blog
user: postgres
password: development

Далее необходимо поставить gem-ы sequel и pg инициализировать базу данных в файле config.ru

require 'yaml'
require 'sequel'
# Init Db
db_config_file = File.join(File.dirname(__FILE__), 'config', 'database.yml')
if File.exist?(db_config_file)
config = YAML.load(File.read(db_config_file))
DB = Sequel.connect(config)
Sequel.extension :migration
end

Таким образом активное соединение с базой данных у нас будет находится в глобальной переменной DB. Также я сразу подключил расширение Sequel для работы с миграциями. Не забудьте создать базу данных!

Следующим шагом мы создадим миграцию и модель поста. Создайте в корне проекта соответственно 2 папки migrations и app/models

# migrations/001_create_table_posts.rb

class CreateTablePosts < Sequel::Migration
def up
create_table :posts do
primary_key :id
column :title, :text
column :content, :text
column :created_at, :timestamp
column :updated_at, :timestamp
end
end
def down
drop_table :posts
end
end

И собственно модель

# app/models/post.rb

class Post < Sequel::Model(DB)
end

Ничего сложного, единственный нюанс, что нужно добавить загрузку моделей, аналогично контроллерам

# config.ru

# ...
# Load models
Dir[File.join(File.dirname(__FILE__), 'app/models', '**', '*.rb')].sort.each {|file| require file } # ...

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

# config.ru
# ...
# If there is a database connection, run all the migrations
if DB
Sequel::Migrator.run(DB, File.join(File.dirname(__FILE__), 'migrations'))
end

И еще исправим ошибку Post is not a module (TypeError), которая возникает из-за того, что мы ранее объявили сущность Post как модуль в контроллере

# app/controllers/post/show.rb
require 'hanami/controller'

class Post < Sequel::Model(DB)
class Show #...

Хорошо бы сейчас попробовать запустить консоль и создать хотя бы один пост. Для этого есть подходящий gem, который называется rack-console. Добавьте его в Gemfile и заупустите bundle. Вызывается консоль командой bundle exec rack-console. Чтобы создать тестовый пост напишите в консоли следующее:

Post.create(title: 'I can live without Rails', content: 'Or can\'t I?', created_at: Time.now)

Теперь можно добавить вывод поста в action Show

# app/controllers/post/show.rb
require 'hanami/controller'
class Post < Sequel::Model(DB)
class Show
include ::Hanami::Action
def call(params)
post = Post[params[:id]]
self.body = "<h1>#{post.title}</h1> <p>#{post.content}</p>"
end
end
end

Если вы готовы, переходите по ссылке http://localhost:3000/post/1 и наслаждайтесь результатом :)

Cells

Помните в начале я загадывал желания? Так вот 2 из 3 мы уже реализовали. Осталось вынести HTML код страниц в отдельные файлы (Views). Для этой цели я предлагаю использовать gem cells, который реализует паттерн View Model. Другими словами позволяет создавать специальные компоненты, которые занимаются рендерингом вьюшек или их частей.

# Gemfile
#...
gem 'cells'
gem 'cells-erb'

Gem cells может работать с различными шаблонизаторами, я выбрал привычный всем cells-erb. Cell-ы будут находится в папке app/cells. И снова - никакого магического автолоадинга

# config.ru
#...
# Load cells
Dir[File.join(File.dirname(__FILE__), 'app/cells', '**', '*.rb')].sort.each {|file| require file }

Давайте создадим два cell-а. Первый для шаблона html страницы в целом (layout) и второй для отображения поста. Начнем с общего шаблона

# app/cells/layout_cell.rb

class LayoutCell < Cell::ViewModel
include ::Cell::Erb
def show(&block)
render(&block)
end
end

Я переопределил метод show для LayoutCell, чтобы он принимал блок и рендерил его содержимое. Вьюшка для cell-а должна хранится в папке с одноименным названием

<!-- app/cells/layout/show.erb -->

<html>
<head>
<title>My Blog</title>
</head>
<body>
<%= yield %>
</body>

Обратите внимание, как мы с помощью yield выводим содержимое блока метода show. Воспользуемся layout-ом в контроллере

# controllers/post/show.rb
require 'hanami/controller'
class Post < Sequel::Model(DB)
class Show
include ::Hanami::Action
def call(params)
post = Post[params[:id]]
render_layout "<h1>#{post.title}</h1> <p>#{post.content}</p>"
end
def render_layout(content = '')
self.body = LayoutCell.new(nil).() { content }
end
end
end

Я создал метод render_layout, который в дальнейшем можно вынести в базовый контроллер, от него будут наследоваться все остальные контроллеры. Каждый cell принимает объект (модель), с которой он будет работать, в нашем случае передаем nil, т.к. никакой модели для общего шаблона не предполагается.

Остался маленький штрих, создаем cell для поста

# app/cells/post_cell.rb

class PostCell < Cell::ViewModel
include ::Cell::Erb
property :title
property :content
end

и вьюху

<!-- app/cells/post/show.erb -->

<h1><%= title %></h1>
<p><%= content %></p>

Фух, осталось только вызвать Cell в контроллере ( PostCell.new(post) )

# controllers/post/show.rb
require 'hanami/controller'
class Post < Sequel::Model(DB)
class Show
include ::Hanami::Action
def call(params)
post = Post[params[:id]]
render_layout PostCell.new(post)
end
def render_layout(content = '')
self.body = LayoutCell.new(nil).() { content }
end
end
end

Заключение

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

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

  1. Настройте автоперезапуск приложения, когда вы меняете код, с помощью gem-a rerun.
  2. Создайте свой аналог rake с помощью gem-а thor для командных утилит на руби.
  3. Ну и в конце концов используйте продвинутый API для Service Objects под названием Trailblazer.

Если у Вас возникнут вопросы или советы, пожалуйста напишите мне или посмотрите как я реализовал эти фичи при создании данного блога.

Особое спасибо Анне за красивый дудл :D

Назад