Piotr Sarnacki home

Mountable engines

Jak prawdopodobnie niektórzy wiedzą, podczas wakacji pracowałem nad projektem “Rails mountable apps” w ramach Ruby Summer of Code. Projekt zakładał umożliwienie montowania aplikacji napisanych w Railsach w innych aplikacjach Railsowych. W tym momencie nie jest to jeszcze niestety możliwe, ale istnieją już solidne podwaliny w postaci “mountable engines”. Wspólnie z osobami z rails core team zdecydowaliśmy, że najlepiej będzie na razie przetestować w boju montowalne engine’y i zobaczyć w jaki sposób programiści ich używają, a później popracować na montowalnymi aplikacjami.

Czym są montowalne engine’y? Po co właściwie przez większą część wakacji siedziałem przy kodzie railsów skoro engine’y już istnieją w railsach 2.x i stosunkowo łatwo można napisać plugin z kontrolerami, modelami i widokami? Implementacja engine’ów w railsach 2.x ma kilka sporych wad. Pierwszą z nich jest zarządzanie routesami. Użytkownik nie ma żadnego wyboru w kwestii tego pod jakim urlem engine będzie się znajdował. Jeżeli twórca engine’u użyje routesów, to jedynym prostym sposobem na zmianę zachowania jest sforkowanie i ręczna zmiana kodu. Następnym problemem jest enkapsulacja. Kontrolery w obecnej wersji engine’ów zachowują się tak, jakby po prostu przekopiować je ręcznie do aplikacji. Oznacza to, że każdy kontroler dostaje automatycznie wszystkie helpery z aplikacji. W niektórych przypadkach może to być pożądane zachowanie, ale przy użyciu engine’ów napisanych przez innych, wiąże się to z ryzykiem kolizji nazw. Kolejne ograniczenie to rack middlewares. Nie można w prosty sposób dodać middleware’ów, które będą podpięte tylko do engine’u. Jedyny sposób to ręczne sprawdzanie ścieżki (np. env["PATH_INFO"] =~ /^\/tolk/, niezbyt wygodne). W railsach 3.0 można co prawda używać aplikacji racka w kontrolerach, ale jest to dość ograniczone, tzn. wpinamy się już po przejściu przez router i cały middleware stack. Ostatnią rzeczą, o której chciałbym wspomnieć, jest wygoda. Ze względu na konflikty nazw, najbezpieczniej jest umieścić wszystkie modele, kontrolery i helpery w module. Pociąga to za sobą jednak kilka niedogodności. Np. w tym momencie wszystkie helpery generujące urle trzeba prefixować, np. blog_posts_path. Tak samo jest z modelami, domyślna nazwa wygenerowana przez ActiveModel::Naming, to blog_post dla klasy Blog::Post.

Podczas pracy w RSoC próbowałem poprawić te problemy. W efekcie zaszło dość dużo zmian w zachowaniu engine’ów:

Co się zmieniło?

Izolowany lub współdzielony engine

Do tej pory pliki z katalogu ‘app’, znajdującego się w katalogu z engine’em, zachowywały się mniej więcej tak, jakby wkleić je do katalogu app w aplikacji. Skutek tego był taki, że dostawały wszystkie helpery (w tym także helpery do routesów) z aplikacji. Dla niektórych engine’ów jest to pożądane zachowanie (np. Devise), ale istnieje wiele przypadków, w których nie działa to zbyt dobrze. W przypadku użycia jakiegoś dużego engine’u, takiego jak np. forum, istnieje duża szansa wystąpienia konfliktów (np. takie same nazwy metod w helperach). Dlatego od railsów 3.1 wprowadzona zostanie możliwość oznaczenia, że Engine jest izolowany używając metody isolate_namespace:

module Blog
  class Engine < ::Rails::Engine
    isolate_namespace Blog
  end
end

Przy takiej konfiguracji kontrolery znajdujące się w module Blog, dostaną tylko i wyłącznie helpery i routesy znajdujące się w tym samym namespace.

Montowanie engine’u

Engine można zamontować w aplikacji tak jak każdą aplikację Rack, używając metody mount:

mount Blog::Engine => "/blog", :as => "blog"

Przy takiej definicji engine będzie dostępny w /blog.

Routes

Z powodu tego, że Engine może teraz posiadać swoje własne routes’y, czasami zajdzie potrzeba odwołania się do routesów engine’u z aplikacji i na odwrót. Dlatego stworzone zostały helpery, ktore to umożliwiają. Helper main_app służy do odwołania się do routesów aplikacji, a helpery dla zamontowanych engine’ów zależą od opcji :as użytej w powyższym przykładzie. Np. dla powyższego przykładu, helper do engine’u Blog::Engine będzie nazywał się blog. Dzięki temu można napisać np.:

blog.posts_path
blog.root_path

main_app.login_path

Oczywiście takie helpery muszą być użyte tylko przy odwoływaniu się do innych routesów, np. kiedy chcemy wygenerować ścieżkę do routesów bloga z kontrolera aplikacji.

Namespacing

Żeby uniknąć konfliktów, montowalny engine musi być zamknięty w namespace. W normalnych warunkach skutkuje to tym, że wszędzie trzeba dodawać prefix (np. nazwa dla modelu Blog::Post, to w normalnych warunkach blog_post, route to blog_posts_path itp.

Tego typu prefixy w zwykłym enginie są używane w celu uniknięcia konfliktów. W izolowanym enginie najczęściej nie ma takiego problemu. Np. nie opłaca się w środku engine’u pisać blog_posts_path zamiast posts_path. Tak samo podczas wysyłania parametrów z formularza, nie opłaca się używać params[:blog_post] zamiast params[:post]. Engine oznaczony jako izolowany pozwala na ominięcie tych prefixów w większości miejsc.

Migracje

Jeżeli używacie w swojej aplikacji ActiveRecorda, to przydadzą się też migracje. Obecne podejście do migracji engine’ów, to kopiowanie migracji do katalogu aplikacji z przepisaniem timestampów. Dla przykładu mamy w enginie Blog::Engine migrację 20091212_create_posts.rb. Po wykonaniu w dniu 2010-10-10 komendy:

rake railties:copy_migrations

zostanie ona przekopiowana do katalogu aplikacji z nazwą 20101010_create_posts.blog.rb. Wybraliśmy ten sposób z kilku powodów:

Pliki statyczne

Ostatni problem jaki trzeba było rozwiązać, to serwowanie statycznych plików. Są na to obecnie 2 sposoby:

Tutorial

Najłatwiej pokazać wszystkie zmiany w praktyce, więc pokuszę się o napisanie prostego engine’u. Do wygenerowania tego engine’u użyjemy enginex napisanego przez José Valima. Niech nie zmyli was nazwa. Enginex został co prawda stworzony w celu prostego generowania engine’ów, ale z powodzeniem można go używać do wygenerowania szkieletu dla dowolnego gema, który będzie miał za zadanie działać z railsami 3. Na początek zainsalujmy enginex:

gem install enginex

Po wykonaniu komendy enginex --help pokażą się dostępne opcje. W chwili obecnej można ustawić jedynie test framework (rspec lub test unit). Zacznijmy z domyślnym test unit:

enginex blog

Enginex powinien stworzyć w tym momencie katalog blog, w którym będą znajdować się pliki z reguły pojawiające się w gemach (lib, test, README itp) oraz coś nowego – aplikacja railsów w katalogu test/dummy. Po co nam aplikacja railsów? Ze względu na to, że engine (czy też inny rodzaj gemu) będzie pracował z railsami i najłatwiej testować go w realnym środowisku railsów.

Pierwsze co trzeba będzie zmienić, to plik Gemfile. Obecna wersja enginex zakłada, że użyte będą railsy 3.0. Całkiem słuszne założenie dla większości projektów, ale żeby korzystać z dobrodziejstw najnowszej wersji engine’ów, trzeba będzie użyć wersji z githuba. Co więcej, trzeba będzie użyć wersji z mojego forka railsów na githubie. Co prawda większość rzeczy, nad którymi pracowałem, jest już w oficjalnym repozytorium railsów, ale cały czas poprawiam różne rzeczy i ze względu na to, że nie mam możliwości commitowania, trzeba czasami poczekać trochę na akceptację i dorzucenie do oficjalnej wersji.

Dlatego zmieniam Gemfile, żeby wyglądało w ten sposób (przy okazji dorzuciłem też factory_girl_rails, przyda się później):

source "http://rubygems.org"

gem "rails", :git => "git://github.com/drogus/rails.git", :branch => "engines"
gem "arel", :git => "git://github.com/rails/arel.git"

gem "factory_girl"
gem "capybara", ">= 0.3.9"
gem "sqlite3-ruby", :require => "sqlite3"

if RUBY_VERSION < '1.9'
  gem "ruby-debug", ">= 0.10.3"
end

Teraz tylko bundle install w konsoli i można zacząć pracować. Dla pewności, że środowisko się poprawnie wczytuje, można jeszcze odpalić testy: rake test

Jeżeli testy przechodzą, to można się zabrać za pisanie kodu. Od czego zacząć? Od testów oczywiście ;-) Po przejrzeniu katalogu test, łatwo zauważyć, że znajduje się tam katalog integration. Wygenerowane przez enginex testy korzystają z Capybary, dzięki czemu można łatwo pisać testy integracyjne. Zacznijmy od wyświetlania postów na blogu. Stwórzmy plik test/integration/posts_integration_test.rb:

require 'test_helper'

class PostsIntegrationTest < ActiveSupport::IntegrationCase
  test "I can see blog posts on blog's root page" do
    Factory.create(:post, :title => "Awesome!", :body => "All work and no fun makes Jack a dull boy")

    visit blog.root_path
    assert page.has_content?("Awesome!")
    assert page.has_content?("All work and no fun makes Jack a dull boy")
  end
end

Po uruhomieniu rake test zobaczymy: ArgumentError: No such factory: post. Brakuje nam wpisu post w factories. Dodajmy go więc:

# test/factories.rb

Factory.define :post, :class => "Blog::Post" do |f|
  f.title "My post"
  f.body "It's just a post, man..."
end

Do pliku test/test_helper.rb dodajemy również require "factories".

Tym razem po uruchomieniu testów, błąd powinien wyglądać mniej więcej tak: NameError: uninitialized constant Blog::Post. Nic dziwnego, nie ma jeszcze w kodzie żadnego modelu. Model jest oczywiście namespace’owany i dlatego używamy Blog::Post zamiast normalnego Post. Nie pozostaje nic innego jak dodać taki model:

# app/models/blog/post.rb
module Blog
  class Post < ::ActiveRecord::Base
  end
end

Musimy dodać także migrację i sprawić, żeby była uruchamiana przy każdym odpaleniu testów (między innymi przez takie problemy uważam, że mongodb, to przyszłość modularnych aplikacji ;-). Powstają tutaj dwa problemy. Pierwszym z nich są generatory. W tej chwili niestety generatory nie działają w katalogu engine’u. Drugi problem jest związany z uruchamianiem migracji, nie ma obecnie żadnego mechanizmu, który automatyzuje wykonywanie migracji dla engine’u w testach. Ale co to dla nas?

Stwórzmy migrację:

# db/migrate/20100631120813_create_blog_posts.rb
class CreateBlogPosts < ActiveRecord::Migration
  def self.up
    create_table :blog_posts do |t|
      t.string :title
      t.text :body

      t.timestamps
    end
  end

  def self.down
    drop_table :blog_posts
  end
end

Teraz wystarczy stworzyć plik test_helper i wkleić pod komentarzem “#Run any available migrations” poniższy kod:

# Run any available migrations
FileUtils.rm(Dir[File.expand_path("../dummy/db/test.sqlite3", __FILE__)])
FileUtils.rm(Dir[File.expand_path("../dummy/db/migrate/*.blog.rb", __FILE__)])
FileUtils.mkdir_p(File.expand_path("../dummy/db/migrate/", __FILE__))
ActiveRecord::Migration.copy File.expand_path("../dummy/db/migrate/", __FILE__), { :blog => File.expand_path("../../db/migrate/", __FILE__) }
ActiveRecord::Migrator.migrate File.expand_path("../dummy/db/migrate/", __FILE__)

Kod ten usuwa wszystkie wcześniej skopiowane migracje i bazę sqlite3, po czym kopiuje aktualne migracje z katalogu db/migrate i je uruchamia.

Ok… Uruchamiany testy i: NameError: uninitialized constant Blog::Post. Pomimo tego, że model jest już stworzony, w testach dalej go nie widać. Dlaczego tak się dzieje? Nigdzie nie powiedzieliśmy, że aplikacja ma wczytać nasz Engine… a właściwie nawet nie mamy jeszcze engine’u! Po sprawdzeniu pliku lib/blog.rb można zauważyć, że jest tam jedynie:

module Blog
end

Enginex nie robi żadnych założeń co do tego jakiego rodzaju gem chcemy zrobić. Trzeba to skorygować, w pliku lib/blog/engine.rb wstawmy kod engine’u:

module Blog
  class Engine < ::Rails::Engine
    namespace Blog
  end
end

a w lib/blog.rb załadujmy powyższy kod:

require 'blog/engine'

Pojawia się tutaj opisywana wcześniej metoda namespace, która sprawia, że Engine jest izolowany.

Tym razem po uruchomieniu testów dostaniemy taki błąd: NameError: undefined local variable or method `blog' for #<PostsIntegrationTest:0x000001041136e0>. Chodzi o linijkę blog.root_path w teście. Brakuje nam helperow do routesów zamontowanych engine’ów. W przyszłości takie helpery będą prawdopododbnie automatycznie dołączane do testów, ale w tym momencie niestety trzeba będzie zrobić to samemu. W pliku test/support/integration_case.rb w ciele klasy ActiveSuppoer::IntegrationCase należy wkleić:

include Rails.application.routes.mounted_helpers

W tym momencie helpery powinny już być dostępne, ale po uruchomieniu testów dalej dostaniemy ten sam błąd. Dlaczego? Jak już pisałem są to helpery do wszystkich zamontowanych engine’ów. A tak się składa, że Blog::Engine nie jest jeszcze nigdzie zamontowany. Poprawmy to montując engine w aplikacji w pliku test/dummy/config/routes.rb:

mount Blog::Engine => "/blog", :as => "blog"

Po uruchomieniu testów powyższy błąd powinien zniknąć, ale za to dostajemy kolejny związany z routesami: NoMethodError: undefined method `root_path' for #<ActionDispatch::Routing::RoutesProxy:0x00000101e9bd10>. Wynika to z faktu, że nie zdefiniowaliśmy jeszcze żadnych routesów w enginie. Aby to zrobić wystarczy wkleić taki oto kod w pliku config/routes.rb:

Blog::Engine.routes.draw do
  root :to => "posts#index"
end

Routesy powinny już działać poprawnie, ale testy podpowiadają nam, że brakuje kontrolera: ActionController::RoutingError: uninitialized constant Blog::PostsController. Kontroler powinien wyglądać mniej więcej tak:

module Blog
  class PostsController < ActionController::Base
    def index
      @posts = Post.all
    end
  end
end

i znajdować się w pliku app/controllers/blog/posts_controller.rb. Ciekawe w tym kodzie jest to, że nie musimy używać Blog::Post, wystarczy samo Post, ponieważ już jesteśmy w odpowiednim namespace. Dlatego właśnie używam dłuższej formy: module Foo; class Bar; end; end. Przy zastosowaniu skróconego zapisu nie dałoby się tak zrobić:

class Blog::PostsController < ActionController::Base
  def index
    @posts = Post.all #=> ArgumentError: Blog is not missing constant Post!
  end
end

A wracając do samego engine’u… Jak pewnie niektórzy się domyślą, tym razem testy będą krzyczeć o braku template’u. Stwórzmy więc app/views/blog/posts/index.html.erb, a w nim wyświetlmy pobrane w kontrolerze posty:

<h2>Posts</h2>

<% @posts.each do |post| %>
  <h3><%= post.title %></h3>
  <div class="post">
    <%= post.body %>
  </div>
<% end %>

W tym momencie testy powinny przechodzić! Aplikacja już działa, ale przydałby się jakiś layout. Do pliku app/views/layouts/blog.html.erb skopiujcie poniższy kod:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Blog</title>
</head>
    <header>
      <h1>Blog</h1>
    </header>

    <div id="main">
      <%= yield %>
    </div>

    <footer>
    </footer>
  </div>
</body>
</html>

Teraz trzeba ten layout ustawić w kontrolerze. Z reguły robi się to w ApplicationController i żeby pozostać DRY, w enginie też można tak zrobić (z tą różnicą, że tutaj będzie to Blog::ApplicationController, który będzie wyglądał tak:

module Blog
  class ApplicationController < ActionController::Base
    layout "blog"
  end
end
end

Wystarczy jeszcze tylko zmienić Blog::PostsController, żeby dziedziczył z ApplicationController i layout powinien poprawnie się wyświetlać:

class PostsController < ApplicationController

Ok, super, ale nie można przecież rozwijać aplikacji opierając się tylko na testach. Tutaj również możemy skorzystać z testowej aplikacji. Wejdźcie do katalogu test/dummy, wykonajcie migracje rake db:migrate, włączcie konsolę script/rails c, a w konsoli stwórzcie jakieś posty:

Blog::Post.create(:title => "First post", :body => "This is awesome!")
Blog::Post.create(:title => "Missed missy", :body => "Missy needs your help... :(")

Teraz można już włączyć serwer, zobaczyć jak to wygląda i dodać style. Wystarczy uruchomić serwer (script/rails s) i wejść na http://localhost:3000/blog. Powinniście zobaczyć tam dodane przed chwilą posty. W layoucie blog.html.erb można teraz wstawić link do cssów:

  <%= stylesheet_link_tag "style" %>

a w pliku public/stylesheets/style.css (w katalogu engine’u, nie aplikacji) na przykład:

header h1 {
  color: #A80000;
}

Po uruchomieniu serwera tytuł bloga powinien zmienić kolor na czerwony. Serwowanie plików statycznych działa bez wprowadzania żadnych zmian, ponieważ w trybie development domyślnie uruchamiany jest ActionDispatch::Static. W środowisku production, trzeba ustawić config.serve_static_assets na true, lub wykonać rake railties:create_symlinks w celu stworzenia symlinków do katalogów public w engine’ach.

Na tym chciałbym zakończyć ten tutorial. W razie czego aplikację umieściłem na W kolejnych odsłonach napiszę trochę więcej o tym jak dalej obchodzić się z tego typu engine’ami. Dla podsumowania, po przeczytaniu tej notki powinniście wiedzieć jakie są różnice pomiędzy starszą wersją engine’ów, a tym co obecnie siedzi w rails/master oraz umieć stworzyć prosty montowalny engine.


If you liked this post consider following me on twitter.
blog comments powered by Disqus
Fork me on GitHub