Как написать MVC веб-фреймворк на Ruby
Привет, котятки! А давайте запилим приложение, похожее на типичное rails-приложение. Ну т.е. там будут MVC, роутинги, миграции, конфиги какие-то, всё как положено (или покладено, кому как больше нравится). Но это будет сильно-сильно упрощенная версия любимого нашего фреймворка. Не будет генераторов и интерактивной консоли, красивых лейаутов и возни с ассетами. Короче, погнали!
Начнем с того, что создадим пустую папку blog и будем в ней работать.
В гемфайл пропишем:
Итак, я подключил репозиторий гемов, указал версию руби и мне потребуется gem 'rack' . Rack предоставляет минималистичный интерфейс между веб-приложениями. Таким образом одно веб приложение может быть встроенно в другое, а другое в третье.
Оффтопик 1. Немного предыстории про RackRack уже скоро стукнет 10 лет. В те времена каждый разработчик фреймворка писал свой обработчик веб-сервера, надеясь, что пользователи смирятся с выбором автора.
Хотя, работа с HTTP достаточно проста. В конце-концов, ты получаешь запрос и выдаешь ответ. Самый общий случай обработчика работает так: на вход получает CGI-подобный запрос и в ответе возвращает три значения: http-статус, набор http-заголовков и тело ответа.
Вот пример типичного rack-приложения:
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-приложение:
Всем требованиям rack-приложения отвечает. Теперь нужно сделать config.ru файл, который собственно и будет запускать наше приложение.
Ничего особенного тут не происходит. Подключаем рубигемс, подключаем бандлер, потом Bundler.require вычитывает наш Gemfile и подключает всё что в нем лежит. Потом мы подключаем наше приложение, которое лежит здесь же рядом в корне папки, и после мы запускаем его командой run App.new .
Ну всё! Приложение готово. Давайте запустим и проверим.
Наше приложение поднялось и мы можем посмотреть на него вживую по адресу http://localhost:9292 .
Так, быстренько, одно дополнение. Давайте в качестве апп-сервер вместо webrick использовать puma. Для этого допишем в Gemfile gem 'puma' и сделаем $ bundle install . Теперь при перезапуске приложения, rackup будет использовать puma. (Ваня, блин, что за фигня?! Почему puma? — Прим. ред.)
Отвечаю: по моему мироощущению, puma запускается и работает быстрее webrick'a. Во время разработки совершенно не важно, что вебрик однопоточный, а пума может иначе. Гораздо важнее, что вебрик подходит только для разработки, а пума и для разработки и для публикации. А значит не придется вспоминать, что нужно веб-сервер поменять. Так-то!
RouterПростецкое приложение сделали. Оно всегда отвечает одним и тем же статусом, и одним и тем же "хеловорлдом". Поехали дальше. Нам нужно, чтобы по разным URL, были доступны разные страницы. В Rails это решается через роутинги. Именно в роутингах мы прописываем соответствие урла и обработчика. Будем использовать что-то похожее и у нас. И сразу два нововведения: всё что касается нашего "фреймворка" будем складировать в папку lib, всё что касается конечного приложения — в папку app.
Сначала изобразим некое подобие конфига роутингов и я написал его в yaml'e.
Очень простое соответствие. Корневой урл ведет на MainController index-экшн. Нам нужно подключить этот конфиг в нашем приложении и я делаю это следующим образом.
Что же я тут натворил, поэтапно:
- Создаю константу ROUTES, в которую сохраняю хеш ключ-значений из нашего routes.yml файла
- Подключаю класс роутера (его еще у нас нет, но это не значит, что мы не можем о нем думать)
- Роутер создается во время инициализации приложения, получает на вход наш хеш роутингов и сохраняется в инстанс-переменную
- В момент, когда происходит вызов call в env находится вся информация о запросе, в том числе о том, какой урл был указан. Поэтому здесь мы передаем эту информацию в наш роутер, чтобы он уже решил, что с ней делать.
- Ну и в конечном результате, нам нужно соответствовать Rack-интерфейсу, а следовательно мы вернем статус, хедеры и контент.
Остается написать сам класс роутера. И вот каким он будет:
А тут всё достаточно просто.
- Сохранили, значит, наш хеш роутингов
- Получаем путь из запроса
- Если у нас по этому пути какое-то значение имеется,
- то будем его обрабатывать, как полагается (подробно объясню ниже)
- Если нет пути, то мы ответим 404 (Not Found)
- А если случилась какая-то беда, то выведем в консольку информацию и будет отвечать 500 (Internal Server Error)
Что же происходит в пункте 4? А вот что: мы находим в наших роутах значение по указанному урлу. Допустим, что это запрос на корень, то в наших routes.yml мы указали "/" : "main#index" . А следовательно мы в качестве значения получаем "main#index" .
Из этой строки нам нужно получить контроллер, но каким образом? Это делается в методе def ctrl(string) .
- Сроку string разбиваем по знаку решетки. Получаем два значения: имя контроллера, и имя экшена( "main" и "index" ).
- Роутинги пишет пользователь нашего а-ля фреймворка, как и контроллеры. Поэтому мы из строки собираем имя контроллера ( MainController )
- иницируем его ( MainController.new(name: "main", action: :index) )
- и в методе resolve делается call для этого контроллера.
Вот так быстро мы добрались до контроллеров. Продолжим?
Записаться Хочешь узнать ещё больше? Запишись на обучение к автору!
Оффтопик 2. Первый контроллерО! Теперь можно создать наш первый собственный контроллер
Мы не знаем, как пользователь нашей нехитрой библиотеки будет называть контроллеры. Их контроллеры будут наследоваться от нашего, но их все равно нужно подключить в наше приложение. Поэтому в app.rb допишем следующую строчку. А заодно сделаем такое же и для папки lib, чтобы не указывать отдельно, что подключить:
ControllerТак, что же нам нужно от контроллеров? Любой уважающий себя контроллер должен уметь отвечать на вызов call, знать своё имя и экшн, с которым будет вызван.
Разбирая роутинги, я забыл упомянуть о следующем: как это видно из кода в result будет возвращаться объект контроллера.
Помните? Вот тут роутер зарезолвил наш запрос и вернет нам в result контроллер. А дальше мы будем у него спрашивать про статус, заголовки и контент. ОК, приступим!
Одна проза и никакой романтики:
- Сохраняем экшн, который будет вызван
- Собственно вызываем этот экшн
- Исключительно из-за желания упростить, но получить работающий вариант, я говорю, что все успешные вызовы у нас будут со статусом 200, хедерами на HTML, а в теле будет уже всем надоевший "Hello world".
- Сделал отдельный метод для не найдено. Вернуть мы должны именно контроллер, а не контент, поэтому self на конце
- Такая же история и с пятисоткой.
После всех этих манипуляций, при запуске приложения и переходу во пустому урлу, мы должны увидеть Hello world. Всё работает!
ViewsЕще немного доработаем напильником наш контроллер. В качестве шаблонизатора я выбрал Slim. Он очень просто прикручивается к нашему фреймворку:
Далее мы контроллере изменим наш контент:
- Наш кастомный контроллер (MainController), когда произойдет вызов call, а соответственно будет выполнен метод index, определит несколько новых инстанс-переменных. Следуя нашему примеру, появятся новые переменные @test и @arr .
- Именно для этого мы в наш шаблонизатор передаем сам инстанс контроллера. Таким образом определенные переменные в контроллере будут доступны из шаблона
- В этом методе мы создаем сам объект шаблона, подтаскиваем нашу вьюшку для конкретно этого контроллера и этого экшене ( app/views/main/index.slim )
Всё достаточно просто. Последнее, что осталось сделать, это добавить метод root в наше приложение.
Готово! Теперь мы можем прописывать свои роуты, делать минимальные контроллеры и рисовать вьюшки. И первая вьюшка будет такой:
В результате после запуска нашего приложения мы увидим:
ModelЭй, ты еще здесь? Тебе что, интересно еще и про модели узнать? Ну ладно, только чур, никакого ActiveRecord'a. Мы будем использовать замечательный гем, который может тоже самое и немного больше sequel и саму базу sqlite .
Самое классное в нём, что нам не придется создавать какую-то нашу модель, от которой будут наследоваться другие модели. А потому сразу будем рассматривать на примере модели постов. В нашем routes файле уже есть запись про "/posts" => "posts#index" . А потому создадим контроллер, модель и вьюшку.
Типичная модель в rails-приложении (только наследуется от Sequel::Model).
Типичный рельсовый контроллер, ни дать, ни взять.
И простецкая вьюшка. А что собственно еще нужно?
А нужно вот что! Внимательный читатель увидел, что наследуем мы нашу модель от Sequel::Model(DB) . А в свою очередь константа DB у нас нигде не определена. О чем и скажет приложение при попытке запуска. Более того, что это за фреймворк, если мы нигде не определили конфиги доступа к базе данных. С этого и начнём:
Я опять дописал в app.rb небольшой кусок кода. Первая строчка проверяет есть ли файл конфига. Если есть то мы читаем файлик, парсим, создаем подключение к базе данных и сохраняем в контанту DB. А вот как будет выглядеть конфиг в нашем случае:
Логично, что нам нужно чтобы этот файл базы данных существовал. Выполним несколько команд.
Узнать подробности Автор этой статьи ведёт обучение основам Ruby on Rails
MigrationsОсталось совсем немного. Посты у нас уже есть, но у нас нет ни таблицы, ни самих постов. Это решается не так уж сложно. Миграции будут запускаться автоматически в момент запуска приложения. Для этого нам понадобится экстеншн для Sequel'a. Добавим его одной строкой в блоке, где определяется коннект к базе данных.
Стоит обратить внимание на порядок выполнения подключаемых файлов. Сначала мы проверяем, есть ли база данных и подключаемся к ней, потому что для моделей нашего приложения важно, чтобы DB -коннект был определен.
После чего мы вычитываем наш фреймворк, потому что иначе контроллеры из приложения не смогут найти к чему обратиться. Потом вычитываем папку app . Теперь определены и контроллеры и база данных.
И после всего этого мы просто выполняем команду мигратора run . Определяем, что наши миграции будут лежать в папке app/db/migrations/ .
Ну и на последок напишем две миграции. Одна создаст табличку постов, другая создаст несколько постов.
Запускаем наше приложение и вот что получилось:
Оффтопик 3. Наводим красотуНа самом деле, всё это подключение значительно разрослось и я решил вынести его в отдельный файлик lib/boot.rb
Обратите внимание, что во всех путях добавилось '..' , это потому что сам boot.rb лежит в lib , а все пути раньше были относительно корня.
That's all folks!Да, на этом всё. Как видите, написать подобное приложение не так уж и сложно. А подобные упражнения дают понимание, как же оно всё таки работает.
Не могу отпустить вас без домашнего задания. Попробуйте реализовать что-нибудь из этого списка:
- Сказать по правде — это задание с одного из моих собеседований. Так интервьюер после каждого написанного мной метода троллил меня фразами: "Может быть тестик напишем?", "А вот если бы мы были настоящими программистами, мы бы ведь это протестировали?", "Представь, что как будто наше приложение работает. Нам ведь нужны тесты?". Так что задание Раз — покрыть код тестами.
- У нас очень скудные роутинги — все запросы обрабатываются как GET. Было бы интересно посмотреть на реализацию полного RESTful приложения
- Поддержка лейаутов не была бы лишней для приложения
- Папка public могла бы отдавать ассеты, минуя создание контроллера
- Как насчет реализовать интерактивную консоль на подобии rails c . Было бы здорово общаться с моделями через неё.
- Генераторы! Миграций, моделей, контроллеров, вьюшек — генерация этих файликов любима нами с rails-младенчества.
- Ну и суперзадача для суперпрограммиста: вынести всё это дело в гем, чтобы можно было одной командой сгенерировать приложение и заняться уже только наполнением папочки app .
Прикладываю ссылку на репозиторий с конечным вариантом. Вуаля!
Мы рассказываем, как стать более лучшим разработчиком, как поддерживать и эффективно применять свои навыки. Информация о вакансиях и акциях эксклюзивно для более чем 8000 подписчиков. Присоединяйся!