Как написать MVC веб-фреймворк на Ruby

Illustration of a person with a startled expression riding a small orange tricycle with a flag labeled "TRAILS" attached to the back. Illustration of a person with a startled expression riding a small orange tricycle with a flag labeled "TRAILS" attached to the back.

Привет, котятки! А давайте запилим приложение, похожее на типичное rails-приложение. Ну т.е. там будут MVC, роутинги, миграции, конфиги какие-то, всё как положено (или покладено, кому как больше нравится). Но это будет сильно-сильно упрощенная версия любимого нашего фреймворка. Не будет генераторов и интерактивной консоли, красивых лейаутов и возни с ассетами. Короче, погнали!

Rack

Начнем с того, что создадим пустую папку blog и будем в ней работать.

$ mkdir blog
$ cd blog
$ bundle init

В гемфайл пропишем:

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

Итак, я подключил репозиторий гемов, указал версию руби и мне потребуется gem 'rack'. Rack предоставляет минималистичный интерфейс между веб-приложениями. Таким образом одно веб приложение может быть встроенно в другое, а другое в третье.

Оффтопик 1. Немного предыстории про Rack

Rack уже скоро стукнет 10 лет. В те времена каждый разработчик фреймворка писал свой обработчик веб-сервера, надеясь, что пользователи смирятся с выбором автора.

Хотя, работа с HTTP достаточно проста. В конце-концов, ты получаешь запрос и выдаешь ответ. Самый общий случай обработчика работает так: на вход получает CGI-подобный запрос и в ответе возвращает три значения: http-статус, набор http-заголовков и тело ответа.

Вот пример типичного rack-приложения:

app = Proc.new do |env|
     ['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']]
end

Proc умеет отвечать на вызов метода call, и вернет в данном случае строку http-статуса, хеш http-заголовков и массив с контентом.

Поверх этого минимального API строятся библиотеки для поддержки типичных задач, таких как: парсинг запроса, работа с куками, удобная работа с Rack::Request(запросом) и Rack::Response(ответом) на уровне объектов, а не HTTP-протокола.

Таким образом Rack становится небольшой прослойкой между веб-сервером и нашим фактическим приложением.

  • Rails использует rack в actionpack — это гем в rails, который отвечает за обработку запросов и ответов.
  • Sinatra, всем известный мини DSL, для написания route-driven приложений, использует rack (см. gemspec)
  • Hanami использует rack в hanami-router, геме, который отвечает за роутинги.
  • Grape, rest-like фреймворк для создания API, использует rack (см. gemspec).
  • Goliaph, асинхронный веб-фреймворк, используется rack (см. gemspec)

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

Rack. Продолжаем

Ну хорошо. Напишем тогда наше rack-приложение:

# app.rb
class App
  def call(env)
    [200, {}, ['Hello world']]
  end
end

Всем требованиям rack-приложения отвечает. Теперь нужно сделать config.ru файл, который собственно и будет запускать наше приложение.

#config.ru
require 'rubygems'
require 'bundler'
Bundler.require

require "./app"

run App.new

Ничего особенного тут не происходит. Подключаем рубигемс, подключаем бандлер, потом Bundler.require вычитывает наш Gemfile и подключает всё что в нем лежит. Потом мы подключаем наше приложение, которое лежит здесь же рядом в корне папки, и после мы запускаем его командой run App.new.

Ну всё! Приложение готово. Давайте запустим и проверим.

$ bundle install
$ rackup

Наше приложение поднялось и мы можем посмотреть на него вживую по адресу http://localhost:9292.

Так, быстренько, одно дополнение. Давайте в качестве апп-сервер вместо webrick использовать puma. Для этого допишем в Gemfile gem 'puma' и сделаем $ bundle install. Теперь при перезапуске приложения, rackup будет использовать puma. (Ваня, блин, что за фигня?! Почему puma? — Прим. ред.)

Отвечаю: по моему мироощущению, puma запускается и работает быстрее webrick'a. Во время разработки совершенно не важно, что вебрик однопоточный, а пума может иначе. Гораздо важнее, что вебрик подходит только для разработки, а пума и для разработки и для публикации. А значит не придется вспоминать, что нужно веб-сервер поменять. Так-то!

Router

Простецкое приложение сделали. Оно всегда отвечает одним и тем же статусом, и одним и тем же "хеловорлдом". Поехали дальше. Нам нужно, чтобы по разным URL, были доступны разные страницы. В Rails это решается через роутинги. Именно в роутингах мы прописываем соответствие урла и обработчика. Будем использовать что-то похожее и у нас. И сразу два нововведения: всё что касается нашего "фреймворка" будем складировать в папку lib, всё что касается конечного приложения — в папку app.

Сначала изобразим некое подобие конфига роутингов и я написал его в yaml'e.

#./app/routes.yml
"/" : "main#index"
"/hello": "main#hello"
"/posts": "posts#index"

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

# app.rb
require 'yaml'
ROUTES = YAML.load(File.read(File.join(File.dirname(__FILE__), "app", "routes.yml"))) # 1

require "./lib/router" # 2

class App
  attr_reader :router

  def initialize 
    @router = Router.new(ROUTES) # 3
  end

  def call(env)
    result = router.resolve(env) # 4
    [result.status, result.headers, result.content] # 5
  end
end

Что же я тут натворил, поэтапно:

  1. Создаю константу ROUTES, в которую сохраняю хеш ключ-значений из нашего routes.yml файла
  2. Подключаю класс роутера (его еще у нас нет, но это не значит, что мы не можем о нем думать)
  3. Роутер создается во время инициализации приложения, получает на вход наш хеш роутингов и сохраняется в инстанс-переменную
  4. В момент, когда происходит вызов call в env находится вся информация о запросе, в том числе о том, какой урл был указан. Поэтому здесь мы передаем эту информацию в наш роутер, чтобы он уже решил, что с ней делать.
  5. Ну и в конечном результате, нам нужно соответствовать Rack-интерфейсу, а следовательно мы вернем статус, хедеры и контент.

Остается написать сам класс роутера. И вот каким он будет:

# ./lib/router.rb
class Router
  attr_reader :routes

  def initialize(routes)
    @routes = routes # 1
  end

  def resolve(env)
    path = env['REQUEST_PATH'] # 2 
    if routes.key?(path) # 3 
      ctrl(routes[path]).call # 4
    else
      Controller.new.not_found # 5
    end
  rescue Exception => error
    puts error.message
    puts error.backtrace
    Controller.new.internal_error # 6
  end

  private def ctrl(string) 
    ctrl_name, action_name = string.split('#')
    klass = Object.const_get "#{ctrl_name.capitalize}Controller"
    klass.new(name: ctrl_name, action: action_name.to_sym)
  end
end

А тут всё достаточно просто.

  1. Сохранили, значит, наш хеш роутингов
  2. Получаем путь из запроса
  3. Если у нас по этому пути какое-то значение имеется,
  4. то будем его обрабатывать, как полагается (подробно объясню ниже)
  5. Если нет пути, то мы ответим 404 (Not Found)
  6. А если случилась какая-то беда, то выведем в консольку информацию и будет отвечать 500 (Internal Server Error)

Что же происходит в пункте 4? А вот что: мы находим в наших роутах значение по указанному урлу. Допустим, что это запрос на корень, то в наших routes.yml мы указали "/" : "main#index". А следовательно мы в качестве значения получаем "main#index".

Из этой строки нам нужно получить контроллер, но каким образом? Это делается в методе def ctrl(string).

  1. Сроку string разбиваем по знаку решетки. Получаем два значения: имя контроллера, и имя экшена("main" и "index").
  2. Роутинги пишет пользователь нашего а-ля фреймворка, как и контроллеры. Поэтому мы из строки собираем имя контроллера (MainController)
  3. иницируем его (MainController.new(name: "main", action: :index))
  4. и в методе resolve делается call для этого контроллера.

Вот так быстро мы добрались до контроллеров. Продолжим?

Записаться Хочешь узнать ещё больше?
Запишись на обучение к автору!

Оффтопик 2. Первый контроллер

О! Теперь можно создать наш первый собственный контроллер

# app/controllers/main_controller.rb
class MainController < Controller
  def index
    @test = "Some dump text here"
    @arr = %w(one two three)
  end
end

Мы не знаем, как пользователь нашей нехитрой библиотеки будет называть контроллеры. Их контроллеры будут наследоваться от нашего, но их все равно нужно подключить в наше приложение. Поэтому в app.rb допишем следующую строчку. А заодно сделаем такое же и для папки lib, чтобы не указывать отдельно, что подключить:

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

Controller

Так, что же нам нужно от контроллеров? Любой уважающий себя контроллер должен уметь отвечать на вызов call, знать своё имя и экшн, с которым будет вызван.

Разбирая роутинги, я забыл упомянуть о следующем: как это видно из кода в result будет возвращаться объект контроллера.

  def call(env)
    result = router.resolve(env)
    [result.status, result.headers, result.content]
  end

Помните? Вот тут роутер зарезолвил наш запрос и вернет нам в result контроллер. А дальше мы будем у него спрашивать про статус, заголовки и контент. ОК, приступим!

# ./lib/controller.rb
class Controller
  attr_reader :name, :action
  attr_accessor :status, :headers, :content

  def initialize(name: nil, action: nil)
    @name = name
    @action = action # 1
  end

  def call
    send(action) # 2
    self.status = 200 # 3
    self.headers = {"Content-Type" => "text/html"}
    self.content = ["Hello world"]
    self
  end

  def not_found # 4
    self.status = 404
    self.headers = {}
    self.content = ["Nothing found"]
    self
  end

  def internal_error # 5
    self.status = 500
    self.headers = {}
    self.content = ["Internal error"]
    self
  end
end

Одна проза и никакой романтики:

  1. Сохраняем экшн, который будет вызван
  2. Собственно вызываем этот экшн
  3. Исключительно из-за желания упростить, но получить работающий вариант, я говорю, что все успешные вызовы у нас будут со статусом 200, хедерами на HTML, а в теле будет уже всем надоевший "Hello world".
  4. Сделал отдельный метод для не найдено. Вернуть мы должны именно контроллер, а не контент, поэтому self на конце
  5. Такая же история и с пятисоткой.

После всех этих манипуляций, при запуске приложения и переходу во пустому урлу, мы должны увидеть Hello world. Всё работает!

Views

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

# GEmfile
...
gem 'slim'
$ bundle install

Далее мы контроллере изменим наш контент:

# ./lib/controller.rb
  ...  
  def call
    send(action) # 1
    self.status = 200
    self.headers = {"Content-Type" => "text/html"}
    self.content = [template.render(self)] # 2
    self
  end

  def template # 3
    Slim::Template.new(File.join(App.root, 'app', 'views', "#{self.name}", "#{self.action}.slim"))  
  end
  ...

Пояснения:

  1. Наш кастомный контроллер (MainController), когда произойдет вызов call, а соответственно будет выполнен метод index, определит несколько новых инстанс-переменных. Следуя нашему примеру, появятся новые переменные @test и @arr.
  2. Именно для этого мы в наш шаблонизатор передаем сам инстанс контроллера. Таким образом определенные переменные в контроллере будут доступны из шаблона
  3. В этом методе мы создаем сам объект шаблона, подтаскиваем нашу вьюшку для конкретно этого контроллера и этого экшене (app/views/main/index.slim)

Всё достаточно просто. Последнее, что осталось сделать, это добавить метод root в наше приложение.

# app.rb
...
class App
...
  def self.root
    File.dirname(__FILE__)
  end
end

Готово! Теперь мы можем прописывать свои роуты, делать минимальные контроллеры и рисовать вьюшки. И первая вьюшка будет такой:

h1 = @test
- @arr.each do |elem|
  li = elem

В результате после запуска нашего приложения мы увидим:

Model

Эй, ты еще здесь? Тебе что, интересно еще и про модели узнать? Ну ладно, только чур, никакого ActiveRecord'a. Мы будем использовать замечательный гем, который может тоже самое и немного больше sequel и саму базу sqlite.

# Gemfile
...
gem 'sequel'
gem 'sqlite3'

Самое классное в нём, что нам не придется создавать какую-то нашу модель, от которой будут наследоваться другие модели. А потому сразу будем рассматривать на примере модели постов. В нашем routes файле уже есть запись про "/posts" => "posts#index". А потому создадим контроллер, модель и вьюшку.

# ./app/models/post.rb
class Post < Sequel::Model(DB)
end

Типичная модель в rails-приложении (только наследуется от Sequel::Model).

# ./app/controllers/posts_controller.rb
class PostsController < Controller
  def index
    @posts = Post.all
  end
end

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

/ ./app/views/posts/index.slim
- @posts.each do |post|
  h2 = post.title
  div = post.content
  hr

И простецкая вьюшка. А что собственно еще нужно?

А нужно вот что! Внимательный читатель увидел, что наследуем мы нашу модель от Sequel::Model(DB). А в свою очередь константа DB у нас нигде не определена. О чем и скажет приложение при попытке запуска. Более того, что это за фреймворк, если мы нигде не определили конфиги доступа к базе данных. С этого и начнём:

# app.rb
db_config_file = File.join(File.dirname(__FILE__), "app", "database.yml")
if File.exist?(db_config_file)
  config = YAML.load(File.read(db_config_file))
  DB = Sequel.connect(config)
end
...

Я опять дописал в app.rb небольшой кусок кода. Первая строчка проверяет есть ли файл конфига. Если есть то мы читаем файлик, парсим, создаем подключение к базе данных и сохраняем в контанту DB.
А вот как будет выглядеть конфиг в нашем случае:

# ./app/database.yml
adapter: sqlite
database: "./app/db/dev.sqlite3"

Логично, что нам нужно чтобы этот файл базы данных существовал. Выполним несколько команд.

$ mkdir -p app/db
$ touch app/db/dev.sqlite3

Узнать подробности Автор этой статьи ведёт
обучение основам Ruby on Rails

Migrations

Осталось совсем немного. Посты у нас уже есть, но у нас нет ни таблицы, ни самих постов. Это решается не так уж сложно. Миграции будут запускаться автоматически в момент запуска приложения. Для этого нам понадобится экстеншн для Sequel'a. Добавим его одной строкой в блоке, где определяется коннект к базе данных.

# Создаем коннект к базе
db_config_file = File.join(File.dirname(__FILE__), 'app', 'database.yml')
if File.exist?(db_config_file)
  config = YAML.load(File.read(db_config_file))
  DB = Sequel.connect(config)
  Sequel.extension :migration
end

# Подключаем все классы нашего фреймоворка
Dir[File.join(File.dirname(__FILE__), 'lib', '*.rb')].each {|file| require file }

# Подключаем все файлы нашего приложения
Dir[File.join(File.dirname(__FILE__), 'app', '**', '*.rb')].each {|file| require file }

# Если есть коннект к базе данных, то прогоняем миграции
if DB
  Sequel::Migrator.run(DB, File.join(File.dirname(__FILE__), 'app', 'db', 'migrations'))
end

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

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

И после всего этого мы просто выполняем команду мигратора run. Определяем, что наши миграции будут лежать в папке app/db/migrations/.

Ну и на последок напишем две миграции. Одна создаст табличку постов, другая создаст несколько постов.

# ./app/db/migrations/001_create_table_posts.rb
class CreateTablePosts < Sequel::Migration
  def up
    create_table :posts do
      primary_key :id
      column :title, :text
      String :content
      index :title
    end
  end

  def down
    drop_table :posts
  end
end

# ./app/db/migrations/002_add_some_posts.rb
class AddSomePosts < Sequel::Migration
  def up
    Post.create(
      title: 'Что такое Lorem Ipsum?',
      content: 'Lorem Ipsum - это текст-"рыба"...'
    )
    Post.create(
      title: 'Почему он используется?',
      content: 'Давно выяснено, что при оценке дизайна...'
    )
    Post.create(
      title: 'Откуда он появился?',
      content: 'Многие думают, что Lorem Ipsum - взятый с потолка...'
    )
  end

  def down
  end
end

Запускаем наше приложение и вот что получилось: Список постов

Оффтопик 3. Наводим красоту

На самом деле, всё это подключение значительно разрослось и я решил вынести его в отдельный файлик lib/boot.rb

# ./lib/boot.rb
# Создаем коннект к базе
db_config_file = File.join(File.dirname(__FILE__), '..', 'app', 'database.yml')
if File.exist?(db_config_file)
  config = YAML.load(File.read(db_config_file))
  DB = Sequel.connect(config)
  Sequel.extension :migration
end

# Подключаем все классы нашего фреймоворка
Dir[File.join(File.dirname(__FILE__), '..', 'lib', '*.rb')].each {|file| require file }

# Подключаем все файлы нашего приложения
Dir[File.join(File.dirname(__FILE__), '..', 'app', '**', '*.rb')].each {|file| require file }

# Если есть коннект к базе данных, то прогоняем миграции
if DB
  Sequel::Migrator.run(DB, File.join(File.dirname(__FILE__), '..', 'app', 'db', 'migrations'))
end

# Читаем роутинги
ROUTES = YAML.load(File.read(File.join(File.dirname(__FILE__), '..', 'app', 'routes.yml')))

Обратите внимание, что во всех путях добавилось '..', это потому что сам boot.rb лежит в lib, а все пути раньше были относительно корня.

That's all folks!

Да, на этом всё. Как видите, написать подобное приложение не так уж и сложно. А подобные упражнения дают понимание, как же оно всё таки работает.

Не могу отпустить вас без домашнего задания. Попробуйте реализовать что-нибудь из этого списка:

  • Сказать по правде — это задание с одного из моих собеседований. Так интервьюер после каждого написанного мной метода троллил меня фразами: "Может быть тестик напишем?", "А вот если бы мы были настоящими программистами, мы бы ведь это протестировали?", "Представь, что как будто наше приложение работает. Нам ведь нужны тесты?". Так что задание Раз — покрыть код тестами.
  • У нас очень скудные роутинги — все запросы обрабатываются как GET. Было бы интересно посмотреть на реализацию полного RESTful приложения
  • Поддержка лейаутов не была бы лишней для приложения
  • Папка public могла бы отдавать ассеты, минуя создание контроллера
  • Как насчет реализовать интерактивную консоль на подобии rails c. Было бы здорово общаться с моделями через неё.
  • Генераторы! Миграций, моделей, контроллеров, вьюшек — генерация этих файликов любима нами с rails-младенчества.
  • Ну и суперзадача для суперпрограммиста: вынести всё это дело в гем, чтобы можно было одной командой сгенерировать приложение и заняться уже только наполнением папочки app.

Прикладываю ссылку на репозиторий с конечным вариантом. Вуаля!

Дерзайте!