Więcej na rubyonrails.pl: Start | Pobierz | Wdrożenie | Kod (en) | Screencasty | Dokumentacja | Ekosystem | Forum | IRC

Podstawy tworzenia pluginów Railsowych

Plugin w Rails to rozszerzenie lub modyfikacja oryginalnego frameworka. Co dają nam pluginy?

Po przeczytaniu tego przewodnika będziesz potrafił:

Ten przewodnik opisuje jak stworzyć plugin, który będzie:

Aby było nam łatwiej posłużymy się przykładem. Skup się i wyobraź sobie, że pasjonujesz się obserwowaniem ptaków. Twoim ulubionym gatunkiem jest… dzięcioł. Postanawiasz stworzyć plugin, który pozwoli innym developerom doświadczyć wspaniałych chwil z owym dzięciołem. Wczułeś się w rolę? Gotowy? No to zaczynamy od konfiguracji.

1 Konfiguracja

1.1 Tworzenie podstawowej aplikacji

Wszystkie zawarte dalej przykłady wymagają działającej aplikacji railsowej. Aby ją stworzyć, wykonajmy następujące polecenia:

gem install rails rails yaffle_guide cd yaffle_guide script/generate scaffold bird name:string rake db:migrate script/server

Następnie uruchamiamy w przeglądarce adres http://localhost:3000/birds. Jeśli nie otrzymamy żadnych komunikatów o błędach to nasza aplikacja działa i możemy przystąpić do pracy.

Powyższe komendy zakładają użycie sqlite3. Szczegółowe instrukcje tworzenia aplikacji Rails dla innych silników bazodanowych znajdują się w dokumentacji API.

1.2 Generowanie szkieletu pluginu

Railsy są wyposażone w generator, którzy tworzy podstawowy szkielet pluginu. Jako argument należy podać nazwę pluginu, w ‘NotacjiWielbłądziej’ lub ‘z_podkreśleniem’. Aby automatycznie dodać przykładowy generator należy użyć parametru \--with-generator.

Polecenie to tworzy plugin w katalogu ‘vendor/plugins’, pliki ‘init.rb’ i ‘README’ oraz standardowe katalogi ‘lib’, ‘task’ i ‘test’.

Przykłady:

./script/generate plugin yaffle ./script/generate plugin yaffle --with-generator

Aby dowiedzieć się o pozostałych możliwościach generatora pluginów, wystarczy wywołać go bez argumentów: ./script/generate plugin.

W dalszej części tego przewodnika opiszemy jak posługiwać się generatorami. Póki co, utworzymy nasz plugin z opcją \--with-generator:

./script/generate plugin yaffle --with-generator

Efekt polecenia powinien być następujący:

create vendor/plugins/yaffle/lib create vendor/plugins/yaffle/tasks create vendor/plugins/yaffle/test create vendor/plugins/yaffle/README create vendor/plugins/yaffle/MIT-LICENSE create vendor/plugins/yaffle/Rakefile create vendor/plugins/yaffle/init.rb create vendor/plugins/yaffle/install.rb create vendor/plugins/yaffle/uninstall.rb create vendor/plugins/yaffle/lib/yaffle.rb create vendor/plugins/yaffle/tasks/yaffle_tasks.rake create vendor/plugins/yaffle/test/core_ext_test.rb create vendor/plugins/yaffle/generators create vendor/plugins/yaffle/generators/yaffle create vendor/plugins/yaffle/generators/yaffle/templates create vendor/plugins/yaffle/generators/yaffle/yaffle_generator.rb create vendor/plugins/yaffle/generators/yaffle/USAGE

1.3 Organizacja plików

Aby ułatwić organizację plików i sprawić, aby plugin był bardziej kompatybilny z GemPlugins, zaczniemy od lekkiej modyfikacji systemu plików. Powinien wyglądać następująco:

|-- lib | |-- yaffle | `-- yaffle.rb `-- rails | `-- init.rb

vendor/plugins/yaffle/rails/init.rb

require 'yaffle'

Teraz możesz umieszczać wszystkie deklaracje ‘require’ do ‘lib/yaffle.rb’, dzięki czemu ‘init.rb’ pozostanie bardziej czytelny.

2 Testy

W tym przewodniku nauczymy się jak testować nasz plugin pod kątem kilku różnych adapterów baz danych przy użyciu modułu Active Record. Aby łatwo testować nasz plugin musimy dodać 3 pliki:

  • ‘database.yml’, w którym znajdzie się wszystko, co jest związane z połączeniem z bazą
  • ‘schema.rb’ z definicjami tabel
  • helpera, który będzie tworzył bazę danych

2.1 Konfiguracja testowa

vendor/plugins/yaffle/test/database.yml:

sqlite: :adapter: sqlite :dbfile: vendor/plugins/yaffle/test/yaffle_plugin.sqlite.db sqlite3: :adapter: sqlite3 :dbfile: vendor/plugins/yaffle/test/yaffle_plugin.sqlite3.db postgresql: :adapter: postgresql :username: postgres :password: postgres :database: yaffle_plugin_test :min_messages: ERROR mysql: :adapter: mysql :host: localhost :username: root :password: password :database: yaffle_plugin_test

Będziemy potrzebowali 2 tabeli/modeli. Jako że po polsku dzięcioł to po prostu dzięcioł, a my musimy jakoś rozróżnić tworzone modele, będziemy się posiłkować językiem Szekspira, w którym tę ptaszynę można nazwać na co najmniej 10 sposobów. W naszym przykładzie będą to: Hickwalls i Wickwalls. Modyfikujemy plik:

vendor/plugins/yaffle/test/schema.rb:

ActiveRecord::Schema.define(:version => 0) do create_table :hickwalls, :force => true do |t| t.string :name t.string :last_squawk t.datetime :last_squawked_at end create_table :wickwalls, :force => true do |t| t.string :name t.string :last_tweet t.datetime :last_tweeted_at end create_table :woodpeckers, :force => true do |t| t.string :name end end

vendor/plugins/yaffle/test/test_helper.rb:

ENV['RAILS_ENV'] = 'test' ENV['RAILS_ROOT'] ||= File.dirname(__FILE__) + '/../../../..' require 'test/unit' require File.expand_path(File.join(ENV['RAILS_ROOT'], 'config/environment.rb')) def load_schema config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml')) ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log") db_adapter = ENV['DB'] # jeśli baza danych nie jest zdefiniowana, to spróbujemy użyć sqlite lub sqlite3, zamiast od razu sypać błędami db_adapter ||= begin require 'rubygems' require 'sqlite' 'sqlite' rescue MissingSourceFile begin require 'sqlite3' 'sqlite3' rescue MissingSourceFile end end if db_adapter.nil? raise "No DB Adapter selected. Pass the DB= option to pick one, or install Sqlite or Sqlite3." end ActiveRecord::Base.establish_connection(config[db_adapter]) load(File.dirname(__FILE__) + "/schema.rb") require File.dirname(__FILE__) + '/../rails/init' end

Od tej pory, za każdym razem kiedy będziemy pisać test, który wymaga połączenia z bazą danych, możemy wywoływać ‘load_schema’.

2.2 Testujemy plugin

Kiedy wszystkie pliki są gotowe możemy przystąpić do pisania naszego pierwszego testu, który sprawdzi, czy nasza konfiguracja jest poprawna. Domyślnie railsy generują plik ‘vendor/plugins/yaffle/test/yaffle_test.rb’ z przykładowym testem. Zmodyfikujemy zawartość tego pliku:

vendor/plugins/yaffle/test/yaffle_test.rb:

require File.dirname(__FILE__) + '/test_helper' class YaffleTest < Test::Unit::TestCase load_schema class Hickwall < ActiveRecord::Base end class Wickwall < ActiveRecord::Base end def test_schema_has_loaded_correctly assert_equal [], Hickwall.all assert_equal [], Wickwall.all end end

Aby go uruchomić, przechodzimy do katalogu pluginów i uruchamiamy rake:

cd vendor/plugins/yaffle rake

Powinniśmy zobaczyć coś takiego:

/opt/local/bin/ruby -Ilib:lib "/opt/local/lib/ruby/gems/1.8/gems/rake-0.8.3/lib/rake/rake_test_loader.rb" "test/yaffle_test.rb" -- create_table(:hickwalls, {:force=>true}) -> 0.0220s -- create_table(:wickwalls, {:force=>true}) -> 0.0077s -- initialize_schema_migrations_table() -> 0.0007s -- assume_migrated_upto_version(0) -> 0.0007s Loaded suite /opt/local/lib/ruby/gems/1.8/gems/rake-0.8.3/lib/rake/rake_test_loader Started . Finished in 0.002236 seconds. 1 test, 1 assertion, 0 failures, 0 errors

Powyższa konfiguracja domyślnie przeprowadza testy przy użyciu sqlite lub sqlite3. Aby przeprowadzić testy dla innego adaptera bazy danych określonego w ‘database.yml’ musimy przekazać odpowiedni argument przy wywołaniu rake:

rake DB=sqlite rake DB=sqlite3 rake DB=mysql rake DB=postgresql

Świetnie, jesteśmy gotowi przetestować nasz plugin!

3 Rozszerzanie klas wbudowanych

Z tego rozdziału dowiemy się jak do klasy String dodać metodę, która będzie dostępna w całej naszej aplikacji.

W tym przykładzie dodamy do klasy String metodę to_squawk, dzięki której nasz dzięcioł będzie potrafił skrzeczeć. Na początek utwórzmy nowy plik z testem:

vendor/plugins/yaffle/test/core_ext_test.rb

# vendor/plugins/yaffle/test/core_ext_test.rb require File.dirname(__FILE__) + '/test_helper' class CoreExtTest < Test::Unit::TestCase def test_to_squawk_prepends_the_word_squawk assert_equal "squawk! Hello World", "Hello World".to_squawk end end

Przechodzimy do katalogu z naszym pluginem i uruchamiamy rake test:

cd vendor/plugins/yaffle rake test

Powyższy test powinien zakończyć się niepowodzeniem:

1) Error: test_to_squawk_prepends_the_word_squawk(CoreExtTest): NoMethodError: undefined method `to_squawk' for "Hello World":String ./test/core_ext_test.rb:5:in `test_to_squawk_prepends_the_word_squawk'

Bez paniki – o to nam chodziło. Zaczynamy właściwą pracę.

W ‘lib/yaffle.rb’ musimy dołączyć ‘lib/core_ext.rb’:

vendor/plugins/yaffle/lib/yaffle.rb

require "yaffle/core_ext"

Na koniec tworzymy plik ‘core_ext.rb’ i dodajemy metodę ‘to_squawk’:

vendor/plugins/yaffle/lib/yaffle/core_ext.rb

# vendor/plugins/yaffle/lib/yaffle/core_ext.rb String.class_eval do def to_squawk "squawk! #{self}".strip end end

Czas sprawdzić czy nasza metoda robi to, co chcemy aby robiła. Uruchamiamy przez rake testy jednostkowe z katalogu naszego pluginu. Aby zobaczyć nasz plugin w akcji, odpalamy konsolę i możemy zacząć skrzeczeć:

$ ./script/console >> "Hello World".to_squawk => "squawk! Hello World"

3.1 Praca z init.rb

Kiedy railsy ładują pluginy, szukają pliku o nazwie ‘init.rb’ lub ‘rails/init.rb’. Inaczej jest w przypadku inicjalizacji pluginu – wtedy ‘init.rb’ jest wywoływany przez metodę eval (a nie require), co skutkuje troszkę innym zachowaniem.

W pewnych sytuacjach, jeśli ponownie otworzymy klasy lub moduły w ‘init.rb’, możemy omyłkowo utworzyć nową klasę zamiast ponownie otworzyć istniejącą. Lepszym sposobem jest ponowne otworzenie klasy w innym pliku i załączenie go w init.rb, tak jak w powyższym przykładzie.

Jeśli już koniecznie musimy ponownie otworzyć klasę w init.rb możemy użyć metody module_eval lub class_eval, co pozwoli uniknąć problemów:

vendor/plugins/yaffle/rails/init.rb

Hash.class_eval do def is_a_special_hash? true end end

Innym sposobem jest dokładne zdefiniowanie przestrzeniu modułu najwyższego poziomu (top-level module space) dla wszystkich modułów i klas, np. ::Hash:

vendor/plugins/yaffle/rails/init.rb

class ::Hash def is_a_special_hash? true end end

4 Dodawanie metody ‘acts_as’ do modułu Active Record

Przy tworzeniu pluginów często będziemy dodawać do modeli metodę ‘acts_as_something’. W naszym przykładzie napiszemy metodę ‘acts_as_yaffle’, która sprawi, że nasze modele będą zachowywać się tak jak nasz ulubiony dzięcioł – czyli będą potrafiły skrzeczeć dzięki metodzie ‘squawk’.

Na początek troszkę konfiguracji:

vendor/plugins/yaffle/test/acts_as_yaffle_test.rb

require File.dirname(__FILE__) + '/test_helper' class ActsAsYaffleTest < Test::Unit::TestCase end

vendor/plugins/yaffle/lib/yaffle.rb

require 'yaffle/acts_as_yaffle'

vendor/plugins/yaffle/lib/yaffle/acts_as_yaffle.rb

module Yaffle # tutaj będzie nasz kod end

Zauważmy, że po dołączeniu ‘acts_as_yaffle’ musimy go włączyć również do ActiveRecord::Base, aby metody naszego pluginu były dostępne w modelach Railsów.

Najpopularniejszym schematem pluginów typu ‘acts_as_yaffle’ jest użycie następującej struktury pliku:

vendor/plugins/yaffle/lib/yaffle/acts_as_yaffle.rb

module Yaffle def self.included(base) base.send :extend, ClassMethods end module ClassMethods # każda zdefiniowana tutaj metoda będzie się odnosiła do klas, np. Hickwall def acts_as_something send :include, InstanceMethods end end module InstanceMethods # każda zdefiniowana tutaj metoda będzie się odnosiła do instancji, np. @hickwall end end

Przy takiej strukturze możemy łatwo oddzielić metody klasowe (np. Hickwall.jakas_metoda) od instancyjnych (np. @hickwall.jakas_metoda).

4.1 Dodawanie metody klasowej

Nasz plugin oczekuje, że stworzyliśmy w naszym modelu metodę ‘last_squawk’. Może się jednak zdarzyć, że przyszły użytkownik pluginu też jest wielbicielem ptactwa leśnego i zdefiniował sobie własną metodę ‘last_squawk’, która robi coś innego. Dlatego nasz plugin będzie pozwalał na zmianę jej nazwy przy użyciu metody klasowej, którą nazwiemy ‘yaffle_text_field’.

Na początek napiszemy test, który będzie symulował taką (niepożądaną) sytuację:

vendor/plugins/yaffle/test/acts_as_yaffle_test.rb

require File.dirname(__FILE__) + '/test_helper' class Hickwall < ActiveRecord::Base acts_as_yaffle end class Wickwall < ActiveRecord::Base acts_as_yaffle :yaffle_text_field => :last_tweet end class ActsAsYaffleTest < Test::Unit::TestCase load_schema def test_a_hickwalls_yaffle_text_field_should_be_last_squawk assert_equal "last_squawk", Hickwall.yaffle_text_field end def test_a_wickwalls_yaffle_text_field_should_be_last_tweet assert_equal "last_tweet", Wickwall.yaffle_text_field end end

Aby test wypadł poprawnie musimy zmodyfikować nasz plik acts_as_yaffle.rb w taki sposób:

vendor/plugins/yaffle/lib/yaffle/acts_as_yaffle.rb

module Yaffle def self.included(base) base.send :extend, ClassMethods end module ClassMethods def acts_as_yaffle(options = {}) cattr_accessor :yaffle_text_field self.yaffle_text_field = (options[:yaffle_text_field] || :last_squawk).to_s end end end ActiveRecord::Base.send :include, Yaffle

4.2 Dodawanie metody instancyjnej

Nasz plugin będzie dodawał metodę ‘squawk’ do wszystkich obiektów modułu Active Record, które będą wywoływać ‘acts_as_yaffle’. Metoda ‘squawk’ będzie po prostu ustawiać wartość jakiegoś pola w bazie danych.

Na początek znowu stworzymy test, które będzie symulował błędy:

vendor/plugins/yaffle/test/acts_as_yaffle_test.rb

require File.dirname(__FILE__) + '/test_helper' class Hickwall < ActiveRecord::Base acts_as_yaffle end class Wickwall < ActiveRecord::Base acts_as_yaffle :yaffle_text_field => :last_tweet end class ActsAsYaffleTest < Test::Unit::TestCase load_schema def test_a_hickwalls_yaffle_text_field_should_be_last_squawk assert_equal "last_squawk", Hickwall.yaffle_text_field end def test_a_wickwalls_yaffle_text_field_should_be_last_tweet assert_equal "last_tweet", Wickwall.yaffle_text_field end def test_hickwalls_squawk_should_populate_last_squawk hickwall = Hickwall.new hickwall.squawk("Hello World") assert_equal "squawk! Hello World", hickwall.last_squawk end def test_wickwalls_squawk_should_populate_last_tweeted_at wickwall = Wickwall.new wickwall.squawk("Hello World") assert_equal "squawk! Hello World", wickwall.last_tweet end end

Po uruchomieniu dwa ostatnie testy powinny skończyć się błędnie. Aby to naprawić, zmodyfikujmy acts_as_yaffle.rb:

vendor/plugins/yaffle/lib/yaffle/acts_as_yaffle.rb

module Yaffle def self.included(base) base.send :extend, ClassMethods end module ClassMethods def acts_as_yaffle(options = {}) cattr_accessor :yaffle_text_field self.yaffle_text_field = (options[:yaffle_text_field] || :last_squawk).to_s send :include, InstanceMethods end end module InstanceMethods def squawk(string) write_attribute(self.class.yaffle_text_field, string.to_squawk) end end end ActiveRecord::Base.send :include, Yaffle

Użycie metody write_attribute w celu zapisania wartości do pola w modelu jest tylko jednym ze sposobów, w jakie plugin może współpracować z modelem i nie zawsze będzie właściwym rozwiązaniem. Równie dobrze można użyć np. send("#{self.class.yaffle_text_field}=", string.to_squawk).

5 Modele

Ten rozdział opisuje jak dodać model ‘Woodpecker’ do naszego pluginu. Będzie on zachowywał się tak samo jak model w naszej głównej aplikacji. Przy przechowywaniu w pluginie modeli, kontrolerów, widoków i helperów, dobrym zwyczajem jest trzymanie ich w katalogach, które odzwierciedlają strukturę Railsów. W naszym przykładzie, będzie to wyglądało tak:

vendor/plugins/yaffle/ |-- lib | |-- app | | |-- controllers | | |-- helpers | | |-- models | | | `-- woodpecker.rb | | `-- views | |-- yaffle | | |-- acts_as_yaffle.rb | | |-- commands.rb | | `-- core_ext.rb | `-- yaffle.rb

Jak zwykle rozpoczniemy od testu:

vendor/plugins/yaffle/test/woodpecker_test.rb:

require File.dirname(__FILE__) + '/test_helper' class WoodpeckerTest < Test::Unit::TestCase load_schema def test_woodpecker assert_kind_of Woodpecker, Woodpecker.new end end

Ten prościutki test sprawdza tylko, czy klasa jest załadowana poprawnie. Oczywiście, przy próbie uruchomienia rake sypnie nam radośnie błędami. Aby test wypadł pozytywnie musimy dokonać następujących modyfikacji:

vendor/plugins/yaffle/lib/yaffle.rb:

%w{ models }.each do |dir| path = File.join(File.dirname(__FILE__), 'app', dir) $LOAD_PATH << path ActiveSupport::Dependencies.load_paths << path ActiveSupport::Dependencies.load_once_paths.delete(path) end

Dodawanie katalogów do ścieżki ładowania sprawia, że są one widziane tak samo jak pliki w głównym katalogu aplikacji – z tą różnicą, że są ładowane tylko raz, więc jeśli chcemy widzieć zmiany to musimy zrestartować serwer. Usuwanie katalogów z ‘load_once_paths’ natomiast jest widoczne od razu po zapisaniu pliku, bez konieczności restartu – jest to bardzo przydatne podczas pracy nad pluginem.

vendor/plugins/yaffle/lib/app/models/woodpecker.rb:

class Woodpecker < ActiveRecord::Base end

Na koniec edytujmy plik ‘schema.rb’ naszego pluginu:

vendor/plugins/yaffle/test/schema.rb:

create_table :woodpeckers, :force => true do |t| t.string :name end

W tym momencie nasz test powinien przebiegać poprawnie i powinniśmy być w stanie używać modelu ‘Woodpecker’ z poziomu naszej aplikacji, a wszystkie dokonane w nim zmiany będą natychmiast widoczne przy pracy w trybie developerskim (development mode).

6 Kontrolery

W tym rozdziale dowiemy się, jak stworzyć kontroler dla naszych pluginowych dzięciołów. Nazwiemy go ‘woodpeckers’ i będzie zachowywał się tak samo jak kontroler w głównej aplikacji. Wszystko będzie przebiegać bardzo podobnie jak w przypadku dodawania modelu.

Możemy przetestować kontroler pluginu tak jak każdy inny kontroler:

vendor/plugins/yaffle/test/woodpeckers_controller_test.rb:

require File.dirname(__FILE__) + '/test_helper' require 'woodpeckers_controller' require 'action_controller/test_process' class WoodpeckersController; def rescue_action(e) raise e end; end class WoodpeckersControllerTest < Test::Unit::TestCase def setup @controller = WoodpeckersController.new @request = ActionController::TestRequest.new @response = ActionController::TestResponse.new ActionController::Routing::Routes.draw do |map| map.resources :woodpeckers end end def test_index get :index assert_response :success end end

Tak jak poprzednio, ten test sprawdza tylko czy poprawnie załadowaliśmy kontroler. Na chwilę obecną rake sypnie błędami, więc do pracy:

vendor/plugins/yaffle/lib/yaffle.rb:

%w{ models controllers }.each do |dir| path = File.join(File.dirname(__FILE__), 'app', dir) $LOAD_PATH << path ActiveSupport::Dependencies.load_paths << path ActiveSupport::Dependencies.load_once_paths.delete(path) end

vendor/plugins/yaffle/lib/app/controllers/woodpeckers_controller.rb:

class WoodpeckersController < ActionController::Base def index render :text => "Squawk!" end end

Teraz test powinien przebiegać poprawnie i powinniśmy być w stanie używać kontrolera ‘woodpeckers’ w naszej aplikacji. Jeśli dodamy ścieżkę (route) do ‘woodpeckers’ możemy uruchomić serwer i zobaczyć nasz kontroler w akcji pod adresem http://localhost:3000/woodpeckers.

7 Helpery

W tym rozdziale dowiemy się jak dodać helpera ‘WoodpeckersHelper’ do naszego pluginu. Będzie on zachowywał się tak samo jak helper w głównej aplikacji. Proces jest analogiczny do postępowania z modelami i kontrolerami.

Test dla helpera pluginu wygląda tak samo jak dla normalnego helpera:

vendor/plugins/yaffle/test/woodpeckers_helper_test.rb

require File.dirname(__FILE__) + '/test_helper' include WoodpeckersHelper class WoodpeckersHelperTest < Test::Unit::TestCase def test_tweet assert_equal "Tweet! Hello", tweet("Hello") end end

Sprawdza on czy nasz helper został załadowany poprawnie. Oczywiście rake zwróci błąd, więc aby wszystko grało edytujemy to i owo:

vendor/plugins/yaffle/lib/yaffle.rb:

%w{ models controllers helpers }.each do |dir| path = File.join(File.dirname(__FILE__), 'app', dir) $LOAD_PATH << path ActiveSupport::Dependencies.load_paths << path ActiveSupport::Dependencies.load_once_paths.delete(path) end

vendor/plugins/yaffle/lib/app/helpers/woodpeckers_helper.rb:

module WoodpeckersHelper def tweet(text) "Tweet! #{text}" end end

Teraz test powinien kończyć się bez błędów, a my możemy używać helpera ‘WoodpeckersHelper’ w naszej aplikacji.

8 Ścieżki (Routes)

W standardowym pliku ‘routes.rb’ używamy ścieżek typu ‘map.connect’ lub ‘map.resources’. Plugin umożliwia nam dodanie własnych ścieżek. W tym rozdziale dowiemy się jak dodać metodę, którą będziemy mogli wywoływać przez ‘map.yaffles’.

Testowanie ścieżek w pluginach lekko różni się od testów w standardowych aplikacjach railsowych. Na początek utworzymy test:

vendor/plugins/yaffle/test/routing_test.rb

require "#{File.dirname(__FILE__)}/test_helper" class RoutingTest < Test::Unit::TestCase def setup ActionController::Routing::Routes.draw do |map| map.yaffles end end def test_yaffles_route assert_recognition :get, "/yaffles", :controller => "yaffles_controller", :action => "index" end private def assert_recognition(method, path, options) result = ActionController::Routing::Routes.recognize_path(path, :method => method) assert_equal options, result end end

Po sprawdzeniu, że test kończy się błędem, wprowadzamy następujące poprawki:

vendor/plugins/yaffle/lib/yaffle.rb

require "yaffle/routing"

vendor/plugins/yaffle/lib/yaffle/routing.rb

module Yaffle #:nodoc: module Routing #:nodoc: module MapperExtensions def yaffles @set.add_route("/yaffles", {:controller => "yaffles_controller", :action => "index"}) end end end end ActionController::Routing::RouteSet::Mapper.send :include, Yaffle::Routing::MapperExtensions

config/routes.rb

ActionController::Routing::Routes.draw do |map| map.yaffles end

Możemy też sprawdzić czy nasze ścieżki działają przez uruchomienie rake routes z poziomu katalogu aplikacji.

9 Generatory

Wiele pluginów zawiera w sobie generatory. Przypomnijmy sobie, że tworzyliśmy nasz plugin z opcją \--with-generator, dzięki czemu mamy już zalążek generatora w ‘vendor/plugins/yaffle/generators/yaffle’.

Tworzenie generatorów to temat na osobny przewodnik, dlatego zajmiemy się tylko małym aspektem ich możliwości: generowaniem prostego pliku tekstowego.

9.1 Testowanie generatorów

Wielu programistów nie testuje generatorów w swoich pluginach, a jest to całkiem proste. Typowy test generatora powinien:

  • Tworzyć tymczasowy katalog główny Railsów, który będzie docelowym na potrzeby testu
  • Uruchomić generator
  • Sprawdzić, czy zostały wygenerowane poprawne pliki
  • Usunąć tymczasowy katalog główny

W tej części opiszemy jak stworzyć prosty generator, który dodaje plik. Dla naszego generatora test mógłby wyglądać np. tak:

vendor/plugins/yaffle/test/definition_generator_test.rb

require File.dirname(__FILE__) + '/test_helper' require 'rails_generator' require 'rails_generator/scripts/generate' class DefinitionGeneratorTest < Test::Unit::TestCase def setup FileUtils.mkdir_p(fake_rails_root) @original_files = file_list end def teardown FileUtils.rm_r(fake_rails_root) end def test_generates_correct_file_name Rails::Generator::Scripts::Generate.new.run(["yaffle_definition"], :destination => fake_rails_root) new_file = (file_list - @original_files).first assert_equal "definition.txt", File.basename(new_file) end private def fake_rails_root File.join(File.dirname(__FILE__), 'rails_root') end def file_list Dir.glob(File.join(fake_rails_root, "*")) end end

Próba uruchomienia ‘rake’ w katalogu pluginu zaowocuje błędem. O ile nie używamy bardziej zaawansowanych poleceń generatora, na ogół wystarczy przetestować skrypt ‘Generate’ i mieć nadzieję, że Railsy zajmą się poleceniami ‘Destroy’ i ‘Update’ za nas.

Aby test wypadł pozytywnie, utwórzmy generator:

vendor/plugins/yaffle/generators/yaffle_definition/yaffle_definition_generator.rb

class YaffleDefinitionGenerator < Rails::Generator::Base def manifest record do |m| m.file "definition.txt", "definition.txt" end end end

9.2 Plik USAGE

Jeśli postanowimy podzielić się naszym pluginem z całym światem, inni programiści będą oczekiwać choćby minimalnej dokumentacji. Możemy ją stworzyć przy użyciu pliku USAGE.

Railsy zawierają kilka wbudowanych generatorów. Aby zobaczyć ich listę, wykonajmy następujące polecenie:

./script/generate

Powinniśmy otrzymać coś w tym stylu:

Installed Generators Plugins (vendor/plugins): yaffle_definition Builtin: controller, integration_test, mailer, migration, model, observer, plugin, resource, scaffold, session_migration

Po wykonaniu script/generate yaffle_definition -h powinniśmy zobaczyć zawartość naszego pliku ‘vendor/plugins/yaffle/generators/yaffle_definition/USAGE’.

W naszym pluginie, zawartość pliku USAGE mogłaby wyglądać tak:

Opis: Dodaje plik z definicją Dzięcioła do głównego katalogu aplikacji

10 Polecenia generatora

Poniżej zauważysz, że możemy użyć jednego z wbudowanych w Railsy poleceń migracji migration_template. Jeśli nasz plugin ma dodawać i usuwać linie tekstu z istniejących plików to będziemy musieli napisać własne metody generatora.

Dowiemy się teraz jak stworzyć własne polecenia generatora, które będą dodawać i usuwać linie tekstu do/z pliku ‘config/routes.rb’.

Na początek, standardowo, zaczniemy od testu:

vendor/plugins/yaffle/test/route_generator_test.rb

require File.dirname(__FILE__) + '/test_helper' require 'rails_generator' require 'rails_generator/scripts/generate' require 'rails_generator/scripts/destroy' class RouteGeneratorTest < Test::Unit::TestCase def setup FileUtils.mkdir_p(File.join(fake_rails_root, "config")) end def teardown FileUtils.rm_r(fake_rails_root) end def test_generates_route content = <<-END ActionController::Routing::Routes.draw do |map| map.connect ':controller/:action/:id' map.connect ':controller/:action/:id.:format' end END File.open(routes_path, 'wb') {|f| f.write(content) } Rails::Generator::Scripts::Generate.new.run(["yaffle_route"], :destination => fake_rails_root) assert_match /map\.yaffles/, File.read(routes_path) end def test_destroys_route content = <<-END ActionController::Routing::Routes.draw do |map| map.yaffles map.connect ':controller/:action/:id' map.connect ':controller/:action/:id.:format' end END File.open(routes_path, 'wb') {|f| f.write(content) } Rails::Generator::Scripts::Destroy.new.run(["yaffle_route"], :destination => fake_rails_root) assert_no_match /map\.yaffles/, File.read(routes_path) end private def fake_rails_root File.join(File.dirname(__FILE__), "rails_root") end def routes_path File.join(fake_rails_root, "config", "routes.rb") end end

Rake oczywiście zgłosi protest, więc wprowadzamy poprawki:

vendor/plugins/yaffle/lib/yaffle.rb

require "yaffle/commands"

vendor/plugins/yaffle/lib/yaffle/commands.rb

require 'rails_generator' require 'rails_generator/commands' module Yaffle #:nodoc: module Generator #:nodoc: module Commands #:nodoc: module Create def yaffle_route logger.route "map.yaffle" look_for = 'ActionController::Routing::Routes.draw do |map|' unless options[:pretend] gsub_file('config/routes.rb', /(#{Regexp.escape(look_for)})/mi){|match| "#{match}\n map.yaffles\n"} end end end module Destroy def yaffle_route logger.route "map.yaffle" gsub_file 'config/routes.rb', /\n.+?map\.yaffles/mi, '' end end module List def yaffle_route end end module Update def yaffle_route end end end end end Rails::Generator::Commands::Create.send :include, Yaffle::Generator::Commands::Create Rails::Generator::Commands::Destroy.send :include, Yaffle::Generator::Commands::Destroy Rails::Generator::Commands::List.send :include, Yaffle::Generator::Commands::List Rails::Generator::Commands::Update.send :include, Yaffle::Generator::Commands::Update

vendor/plugins/yaffle/generators/yaffle_route/yaffle_route_generator.rb

class YaffleRouteGenerator < Rails::Generator::Base def manifest record do |m| m.yaffle_route end end end

Wypróbujmy nasz generator w akcji:

./script/generate yaffle_route ./script/destroy yaffle_route

Jeśli nie skonfigurowałeś wcześniej własnej ścieżki zgodnie z wcześniejszym opisem, to ‘script/destroy’ się nie powiedzie i będziesz musiał usunąć ją ręcznie.

11 Migracje

Jeśli nasz plugin wymaga zmian w bazie danych aplikacji to prawdopodobnie będziemy chcieli użyć migracji. Railsy nie mają wbudowanego wsparcia dla wywoływania migracji z pluginów, ale możemy ułatwić sobie i innym użytkownikom plugina ten proces.

Jeśli nasze wymagania są bardzo proste, np. tworzenie tabeli, która zawsze będzie miała taką samą nazwę i kolumny, możemy użyć jeszcze prostszego rozwiązania – stworzyć własne zadanie modułu rake lub metodę. Jeśli potrzebujemy czegoś więcej (np. użytkownik ma podać nazwę tabeli lub inne opcje) to lepszym rozwiązaniem będzie użycie migracji.

Powiedzmy, że w naszym pluginie mamy taką migrację:

vendor/plugins/yaffle/lib/db/migrate/20081116181115_create_birdhouses.rb:

class CreateBirdhouses < ActiveRecord::Migration def self.up create_table :birdhouses, :force => true do |t| t.string :name t.timestamps end end def self.down drop_table :birdhouses end end

Oto kilka sposobów na to, jak umożliwić deweloperom korzystanie z migracji naszego pluginu:

11.1 Tworzenie własnego zadania modułu rake

vendor/plugins/yaffle/tasks/yaffle_tasks.rake:

namespace :db do namespace :migrate do desc "Migrate the database through scripts in vendor/plugins/yaffle/lib/db/migrate and update db/schema.rb by invoking db:schema:dump. Target specific version with VERSION=x. Turn off output with VERBOSE=false." task :yaffle => :environment do ActiveRecord::Migration.verbose = ENV["VERBOSE"] ? ENV["VERBOSE"] == "true" : true ActiveRecord::Migrator.migrate("vendor/plugins/yaffle/lib/db/migrate/", ENV["VERSION"] ? ENV["VERSION"].to_i : nil) Rake::Task["db:schema:dump"].invoke if ActiveRecord::Base.schema_format == :ruby end end end

11.2 Bezpośrednie wywołanie migracji

vendor/plugins/yaffle/lib/yaffle.rb:

Dir.glob(File.join(File.dirname(__FILE__), "db", "migrate", "*")).each do |file| require file end

db/migrate/20081116181115_create_birdhouses.rb:

class CreateBirdhouses < ActiveRecord::Migration def self.up Yaffle::CreateBirdhouses.up end def self.down Yaffle::CreateBirdhouses.down end end

Niektóre frameworki, takie jak Desert czy Engines, umożliwiają użycie bardziej zaawansowanych funkcjonalności pluginów.

11.3 Generowanie migracji

Generowanie migracji ma pewną przewagę nad innymi metodami, a mianowicie pozwala innym programistom na łatwiejsze dostosowanie migracji. Proces wygląda następująco:

  • Wywołanie skryptu script/generate i podanie potrzebnych opcji
  • Analiza wygenerowanych migracji, dodawanie/usuwanie kolumn i inne potrzebne opcje

Ten przykład pokaże nam jak użyć jednej z wbudowanych metod generatora, ‘migration_template’, w celu stworzenia pliku migracji. Rozszerzenie railsowego generatora migracji wymaga pewnej wiedzy na temat jego wewnętrznej organizacji, więc najlepiej będzie na początku stworzyć test:

vendor/plugins/yaffle/test/yaffle_migration_generator_test.rb

require File.dirname(__FILE__) + '/test_helper' require 'rails_generator' require 'rails_generator/scripts/generate' class MigrationGeneratorTest < Test::Unit::TestCase def setup FileUtils.mkdir_p(fake_rails_root) @original_files = file_list end def teardown ActiveRecord::Base.pluralize_table_names = true FileUtils.rm_r(fake_rails_root) end def test_generates_correct_file_name Rails::Generator::Scripts::Generate.new.run(["yaffle_migration", "some_name_nobody_is_likely_to_ever_use_in_a_real_migration"], :destination => fake_rails_root) new_file = (file_list - @original_files).first assert_match /add_yaffle_fields_to_some_name_nobody_is_likely_to_ever_use_in_a_real_migrations/, new_file assert_match /add_column :some_name_nobody_is_likely_to_ever_use_in_a_real_migrations do |t|/, File.read(new_file) end def test_pluralizes_properly ActiveRecord::Base.pluralize_table_names = false Rails::Generator::Scripts::Generate.new.run(["yaffle_migration", "some_name_nobody_is_likely_to_ever_use_in_a_real_migration"], :destination => fake_rails_root) new_file = (file_list - @original_files).first assert_match /add_yaffle_fields_to_some_name_nobody_is_likely_to_ever_use_in_a_real_migration/, new_file assert_match /add_column :some_name_nobody_is_likely_to_ever_use_in_a_real_migration do |t|/, File.read(new_file) end private def fake_rails_root File.join(File.dirname(__FILE__), 'rails_root') end def file_list Dir.glob(File.join(fake_rails_root, "db", "migrate", "*")) end end

Generator migracji najpierw sprawdza, czy migracja już istnieje w katalogu ‘db/migrate’. W związku z tym, jeśli nasz test będzie generował migrację, która już istnieje, zostanie zwrócony błąd. Najprostszym rozwiązaniem jest nadanie generowanej migracji jakiejś nieprawdopodobnej nazwy, która na pewno nie pojawi się w aplikacji.

Jak zwykle, po próbie uruchomienia ‘rake’ zaprotestuje błędem. Aby błędu nie było, poprawiamy nasz generator:

vendor/plugins/yaffle/generators/yaffle_migration/yaffle_migration_generator.rb

class YaffleMigrationGenerator < Rails::Generator::NamedBase def manifest record do |m| m.migration_template 'migration:migration.rb', "db/migrate", {:assigns => yaffle_local_assigns, :migration_file_name => "add_yaffle_fields_to_#{custom_file_name}" } end end private def custom_file_name custom_name = class_name.underscore.downcase custom_name = custom_name.pluralize if ActiveRecord::Base.pluralize_table_names custom_name end def yaffle_local_assigns {}.tap do |assigns| assigns[:migration_action] = "add" assigns[:class_name] = "add_yaffle_fields_to_#{custom_file_name}" assigns[:table_name] = custom_file_name assigns[:attributes] = [Rails::Generator::GeneratedAttribute.new("last_squawk", "string")] end end end

Generator tworzy nowy plik w ‘db/migrate’ razem ze stemplem czasowym i deklaracją ‘add_column’. Używa wbudowanej w Railsy metody migration_template oraz wbudowanego schematu migracji.

Bardzo miłym zwyczajem przy tworzeniu generatora jest sprawdzanie czy nazwy tabeli są pluralizowane (są w liczbie mnogiej). Dzięki temu użytkownicy naszego generatora nie będą musieli ręcznie zmieniać wygenerowanych plików jeśli nie używają pluralizacji.

Aby uruchomić generator, wywołaj następujące polecenie:

./script/generate yaffle_migration bird

W efekcie zostanie utworzony nowy plik:

db/migrate/20080529225649_add_yaffle_fields_to_birds.rb

class AddYaffleFieldsToBirds < ActiveRecord::Migration def self.up add_column :birds, :last_squawk, :string end def self.down remove_column :birds, :last_squawk end end

12 Zadania modułu rake

Kiedy utworzyliśmy plugin przez wbudowany generator, został stworzony również plik rake w ‘vendor/plugins/yaffle/tasks/yaffle_tasks.rake’. Każde zadanie modułu rake, które tam dodamy, będzie dostępne dla całej aplikacji.

Wielu autorów pluginów włącza wszystkie własne zadania modułu rake do wspólnej z pluginem przestrzeni nazw (namespace), np. w ten sposób:

vendor/plugins/yaffle/tasks/yaffle_tasks.rake

namespace :yaffle do desc "Prints out the word 'Yaffle'" task :squawk => :environment do puts "squawk!" end end

Kiedy w naszym pluginie uruchomimy rake -T zobaczymy:

yaffle:squawk # Prints out the word 'Yaffle'

W katalogu zadań możemy umieścić dowolną ilość plików i, jeśli będą miały rozszerzenie .rake, to Railsy je rozpoznają.

Warto zauważyć, że zadania znajdujące się w ‘vendor/plugins/yaffle/Rakefile’ nie są dostępne dla głównej aplikacji.

13 PluginGems

Przekształcenie naszego plugina w gema jest bardzo prostym zadaniem. Właśnie tym zajmiemy się w tym rozdziale. Nie będziemy natomiast omawiać sposobu dystrybucji tego gema.

Tradycyjnie pluginy railsowe ładowały plik ‘init.rb’ pluginu. Faktycznie niektóre pluginy zawierają cały swój kod w tym jednym pliku. Dla kompatybilności z pluginami, ‘init.rb’ został przeniesiony do ‘rails/init.rb’.

Powszechną praktyką jest umieszczanie wszelkich deweloperskich zadań modułu rake (takich jak testy, rdoc i tworzenie paczek gemów) w ‘Rakefile’. Zadanie modułu rake, które tworzy gema, może wyglądać np. tak:

vendor/plugins/yaffle/Rakefile:

PKG_FILES = FileList[ '[a-zA-Z]*', 'generators/**/*', 'lib/**/*', 'rails/**/*', 'tasks/**/*', 'test/**/*' ] spec = Gem::Specification.new do |s| s.name = "yaffle" s.version = "0.0.1" s.author = "Gleeful Yaffler" s.email = "yaffle@example.com" s.homepage = "http://yafflers.example.com/" s.platform = Gem::Platform::RUBY s.summary = "Sharing Yaffle Goodness" s.files = PKG_FILES.to_a s.require_path = "lib" s.has_rdoc = false s.extra_rdoc_files = ["README"] end desc 'Turn this plugin into a gem.' Rake::GemPackageTask.new(spec) do |pkg| pkg.gem_spec = spec end

Aby zbudować i zainstalować gema lokalnie, należy wykonać następujące polecenia:

cd vendor/plugins/yaffle rake gem sudo gem install pkg/yaffle-0.0.1.gem

Aby to przetestować, należy stworzyć nową aplikację i dodać ‘config.gem “yaffle”’ do ‘environment.rb’, a wszystkie funkcjonalności plugina będą dostępne.

14 Dokumentacja RDoc

Kiedy nasz plugin jest już stabilny (czyt. częściej działa niż nie działa) i jesteśmy gotowi podzielić się nim z całym światem, wyświadczmy ludzkości przysługę i napiszmy dokumentację. Nie jest to ani trudne, ani męczące.

Pierwszym krokiem jest aktualizacja pliku README o szczegółowe informacje o tym, jak w ogóle używać naszego plugina. Kilka obowiązkowych punktów:

  • Autor (pamiętajmy – uznanie, sława i chwała)
  • Sposób instalacji
  • Jak dodać funkcjonalności do aplikacji (kilka typowych przypadków użycia)
  • Ostrzeżenia, wskazówki i generalnie wszystko, co może ułatwić użytkownikowi życie

Kiedy nasz plik README jest już gotowy, warto dodać komentarze rdoc do wszystkich metod, których będą używać inni programiści. Zwyczajowo dodaje się komentarz ‘#:nodoc:’ do tych części kodu, które nie są częścią publicznego API.

Kiedy komentarze są gotowe, przejdź do katalogu pluginu i wykonaj polecenie:

rake rdoc

15 Appendix

Jeśli wolisz używać frameworka RSpec zamiast Test::Unit, warto zajrzeć na stronę projektu RSpec Plugin Generator.

15.1 Odnośniki

  • http://nubyonrails.com/articles/the-complete-guide-to-rails-plugins-part-i
  • http://nubyonrails.com/articles/the-complete-guide-to-rails-plugins-part-ii
  • http://github.com/technoweenie/attachment_fu/tree/master
  • http://daddy.platte.name/2007/05/rails-plugins-keep-initrb-thin.html
  • http://www.mbleigh.com/2008/6/11/gemplugins-a-brief-introduction-to-the-future-of-rails-plugins
  • http://weblog.jamisbuck.org/2006/10/26/monkey-patching-rails-extending-routes-2.

15.2 Zawartość ‘lib/yaffle.rb’

vendor/plugins/yaffle/lib/yaffle.rb:

require "yaffle/core_ext" require "yaffle/acts_as_yaffle" require "yaffle/commands" require "yaffle/routing" %w{ models controllers helpers }.each do |dir| path = File.join(File.dirname(__FILE__), 'app', dir) $LOAD_PATH << path ActiveSupport::Dependencies.load_paths << path ActiveSupport::Dependencies.load_once_paths.delete(path) end # optionally: # Dir.glob(File.join(File.dirname(__FILE__), "db", "migrate", "*")).each do |file| # require file # end

15.3 Końcowa struktura katalogów pluginu

Postępując zgodnie z niniejszym przewodnikiem, struktura katalogów powinna wyglądać mniej więcej tak:

|-- MIT-LICENSE |-- README |-- Rakefile |-- generators | |-- yaffle_definition | | |-- USAGE | | |-- templates | | | `-- definition.txt | | `-- yaffle_definition_generator.rb | |-- yaffle_migration | | |-- USAGE | | |-- templates | | `-- yaffle_migration_generator.rb | `-- yaffle_route | |-- USAGE | |-- templates | `-- yaffle_route_generator.rb |-- install.rb |-- lib | |-- app | | |-- controllers | | | `-- woodpeckers_controller.rb | | |-- helpers | | | `-- woodpeckers_helper.rb | | `-- models | | `-- woodpecker.rb | |-- db | | `-- migrate | | `-- 20081116181115_create_birdhouses.rb | |-- yaffle | | |-- acts_as_yaffle.rb | | |-- commands.rb | | |-- core_ext.rb | | `-- routing.rb | `-- yaffle.rb |-- pkg | `-- yaffle-0.0.1.gem |-- rails | `-- init.rb |-- tasks | `-- yaffle_tasks.rake |-- test | |-- acts_as_yaffle_test.rb | |-- core_ext_test.rb | |-- database.yml | |-- debug.log | |-- definition_generator_test.rb | |-- migration_generator_test.rb | |-- route_generator_test.rb | |-- routes_test.rb | |-- schema.rb | |-- test_helper.rb | |-- woodpecker_test.rb | |-- woodpeckers_controller_test.rb | |-- wookpeckers_helper_test.rb | |-- yaffle_plugin.sqlite3.db | `-- yaffle_test.rb `-- uninstall.rb