Demystifying Rails Plugin Developmentptgmedia.pearsoncmg.com/.../ruby2008/.../Plante.pdf ·...
Transcript of Demystifying Rails Plugin Developmentptgmedia.pearsoncmg.com/.../ruby2008/.../Plante.pdf ·...
Demystifying Rails Plugin Development
Nick Plante ::Voices That Matter
Professional Ruby Conference
November 18th, 2008
Obligatory Introduction
Plugins are generalized, reusable code libraries
Extend or override core functionality of Rails
Can save you a lot of time
Provide standard hooks, helpers… Rails scripts, hooks Generators, etc
vs.
core plugin
but more like …
Plugin Examples
User Authentication Restful Authentication, Closure
Pagination Will Paginate, Paginating Find
Asynchronous Processing Workling, Background Job, etc
View Helpers Lightbox helper, Flash media player helper, etc
Why Develop Plugins?
Internal re-use, productivity boosterOpportunity to refactor, clean up
project code is_rateable vs. gobs of in-line ratings code
Contribute to the Ruby OSS ecosystem Get feedback, contributions, inspire
others Profit^h^h^h^h^h^h“Marketing”
Is Plugin Development ‘Hard’?
Is developing software in Ruby/Rails hard?
Depends on what you’re trying to accomplish, right?
The plugins system itself is simple Writing plugins can be hard, but it doesn’t
have to be
Plugin Genesis: Extraction
Most plugins don’t start out as plugins
Usually extracted & generalized from a useful feature created in a larger project
What interesting problems have you solved lately?
Validating ISBNs
How would we implement thisin a Book modelfor a plain old Rails project?
ISBN-13: 978-1-59059-993-8ISBN-10: 1-59059-993-4
class Book < ActiveRecord::Base validates_presence_of :title, :author, :isbn
def validate unless self.isbn_valid? errors.add(:isbn, "is not a valid ISBN code") end end
ISBN10_REGEX = /^(?:\d[\ |-]?){9}[\d|X]$/ ISBN13_REGEX = /^(?:\d[\ |-]?){13}$/
def isbn_valid? !self.isbn.nil? && (self.isbn10_valid? || self.isbn13_valid?) end
# ...end
Wait! There’s More!
# more code in your model…def isbn10_valid? if self.isbn.match(ISBN10_REGEX) isbn_values = self.isbn.upcase.gsub(/\ |-/, '').split('') check_digit = isbn_values.pop # last digit is check check_digit = (check_digit == 'X') ? 10 : check_digit.to_i
sum = 0 isbn_values.each_with_index do |value, index| sum += (index + 1) * value.to_i end
(sum % 11) == check_digit else false endend
# and yet more code in your model…def isbn13_valid? if self.isbn.match(ISBN13_REGEX) isbn_values = self.isbn.upcase.gsub(/\ |-/, '').split('') check_digit = isbn_values.pop.to_i # last digit is check
sum = 0 isbn_values.each_with_index do |value, index| multiplier = (index % 2 == 0) ? 1 : 3 sum += multiplier * value.to_i end
(10 - (sum % 10)) == check_digit else false endend
Your Model Code
Extract!
Let’s clean up that messy model Encapsulation & Information Hiding Makes our model easier to read
We can move this code to a module in lib Or yank it all out into a plugin!
Either way, we need to build a validation module, right?
Encapsulation
Designing the Interface
We need a clean interface DON’T judge a book by its cover But DO judge a plugin by its interface KISS -- there is beauty in simplicity / minimalism
Goal is often to extend the Rails DSL in a natural way We have pre-existing examples to guide our hand ActiveRecord::Validations (see api.rubyonrails.org)
ActiveRecord Examples
acts_as_list :scope => :todo_list
validates_http_url :link
is_indexed :fields => ['created_at', 'title’]
has_attached_file :cover_image, :styles => { :medium => "300x300>", :thumb => "100x100>” }
ActionController Examples
class BooksController < ApplicationController sidebar :login, :unless => :logged_in? permit "rubyists and wanna_be_rubyists" include SomePluginModule def index @books = Book.paginate :page => params[:page], :per_page => 20 endend
ActionView Examples (View Helpers)
<%= lightbox_link_to “My Link”, “image.png” %>
class Book < ActiveRecord::Base validates_isbn :isbn, :with => :isbn13, :unless => :skip_validationend
A Little Cleaner, Right? Yeah.
validates_isbn
We need to create a validates_isbn class method on ActiveRecord::Base Generalize our code a bit
No longer married to a particular model attribute
Hide it behind a Rails-ish DSL
Extend AR::Base with our own module
module IsbnValidation # may want to namespace this def validates_isbn(*attr_names) config = { :message => "is not a valid ISBN code" } config.update(attr_names.extract_options!)
validates_each(attr_names, config) do |record,attr_name,value| valid = case config[:with] when :isbn10; validate_with_isbn10(value) when :isbn13; validate_with_isbn13(value) else validate_with_isbn10(value) || validate_with_isbn13(value) end
record.errors.add(attr_name, config[:message]) unless valid end end
# other methods, constants go here tooend
Making of the Module
Why is it a module, and not a class? A module is like a degenerate abstract class You can mix a module into a class
Include with a module to add instance methods Extend with a module to add class methods
Also use modules for organization Group similar things together, namespacing
Mixing It Up with Modules
Don’t have to stash this module in a plugin Can use it directly from lib, too…
require ‘isbn_validation’
class Book < ActiveRecord::Base extend IsbnValidation validates_isbn :isbnend
Pluginizing
But why not go the extra step?
So you can easily reuse it across projectsAnd share with others
Generate a Plugin Skeleton
Use the supplied plugin generator The less we have to do, the better!
$ ruby script/generate plugin isbn_validation
in vendor/plugins/isbn_validation:- lib/
- isbn_validation.rb- tasks/
- isbn_validation_tasks.rake- test/
- isbn_validation_test.rb- README- MIT-LICENSE- Rakefile- init.rb- install.rb- uninstall.rb
Plugin Hooks: Install.rb
Auto-run when plugin is installed via script/plugin install
Potential Uses Display README Copy needed images, styles, scripts
Remove them with uninstall.rb
Plugin Hooks: Init.rb
Runs whenever your application is startedUse it to inject plugin code into the
framework Add class methods in IsbnValidation module
to AR::Base
ActiveRecord::Base.class_eval do extend IsbnValidation end
Adding Instance Methods?
Use include instead of extend
self.included class method is special Executed when the module is mixed in with
include Gives us access to the including class Common Ruby idiom allows us to extend the
base class with a new set of class methods here, too
def self.included(base) base.extend(ClassMethods)end
Should I Test My Plugin?
If you’re extracting a plugin, you probably already have tests for a lot of that functionality, right?
Testing Strategies
Varies depending on the type of plugin Mock/stub out your environment if possible
Test the behavior of the system with plugin installed
Rather than the eccentricities of the plugin code itself
For model plugins, consider creating an isolated in-memory database (sqlite3)
Rake testing tasks already provided See Rakefile and sample test provided by
generator rake test:plugins
Test Helper
$:.unshift(File.dirname(__FILE__) + '/../lib')RAILS_ROOT = File.dirname(__FILE__)
require 'rubygems'require 'test/unit'require 'active_record'require "#{File.dirname(__FILE__)}/../init"
config = YAML::load(IO.read( File.dirname(__FILE__) + '/database.yml'))ActiveRecord::Base.logger = Logger.new( File.dirname(__FILE__) + "/debug.log")ActiveRecord::Base.establish_connection( config[ENV['DB'] || 'sqlite3'])
load(File.dirname(__FILE__) + "/schema.rb") if File.exist?( File.dirname(__FILE__) + "/schema.rb")
Dummy Test Models (models.rb)
class Book < ActiveRecord::Base validates_isbn :isbn, :message => 'is too fantastical!'end
class Book10 < ActiveRecord::Base set_table_name 'books' validates_isbn :isbn, :with => :isbn10end
class Book13 < ActiveRecord::Base set_table_name 'books' validates_isbn :isbn, :with => :isbn13end
Unit Testing
require File.dirname(__FILE__) + '/test_helper'require File.dirname(__FILE__) + '/models'
class IsbnValidationTest < Test::Unit::TestCase def setup @book = Book.new end
def test_isbn10_should_pass_check_digit_verification @book.isbn = '159059993-4' assert @book.valid? end
# ...
end
Rspec fan?
Use Pat Maddox’s RSpec plugin generator Uses RSpec stubs instead of Test::Unit Also sets up isolated database for you!
--with-database
Install the pluginruby script/generate rspec_plugin
isbn_validation
http://github.com/pat-maddox/rspec-plugin-generator
Distributing Plugins
Use a publicly visible Subversion or Git repository.
It’s that easy.
Options: Google Code (Subversion) RubyForge (Subversion) GitHub (Git) <= Recommended!
ruby script/plugin install \ git://github.com/zapnap/isbn_validation.git
Distributing Plugins as Gems?
Can also package plugins as RubyGems In environment.rb:
config.gem “isbn_validation”, :source => “gems.github.com”, :version => “>= 0.1.0”
Then, to install it in the project: rake gems:install rake gems:unpack rake gems:unpack:dependencies
Gem Advantages
Reasons to prefer Gems for packaging Proper versioning Dependency management
GitHub makes Gem creation easy Gems will be automatically created for
you Installable via gems.github.com
Gemify!
GitHub workflow Create a rails/init.rb file in your repository Change original init.rb to include only:
require File.dirname(__FILE__) + ‘/rails/init.rb’
Create a Gemspec in the root of your repository Rake task to generate a Gemspec!
Check RubyGem box on GitHub project edit page
Can now install as either a RubyGem or a Plugin!
spec = Gem::Specification.new do |s| s.name = %q{isbn_validation} s.version = "0.1.0" s.summary = %q{adds an isbn validation...} s.description = %q{adds an isbn validation...}
s.files = FileList['[A-Z]*', '{lib,test}/**/*.rb'] s.require_path = 'lib' s.test_files = Dir[*['test/**/*_test.rb']]
s.authors = ["Nick Plante"] s.email = %q{[email protected]}
s.platform = Gem::Platform::RUBY s.add_dependency(%q<activerecord>, [">= 2.1.2"])end
desc "Generate a gemspec file"task :gemspec do File.open("#{spec.name}.gemspec", 'w') do |f| f.write spec.to_ruby endend
What Else?
Rake tasks Put them in plugin tasks directory
Note: this does not work for GemPlugins yet Namespace them appropriately
namespace :isbn do …
Automatically made available in the host Rails project’s list of Rake tasks
Test and rdoc tasks are free
PDI
Every plugin will require different strategies for development & testing Model, Controller, View Plugins Plugins that generate code Plugins that wrap third party daemons & libraries
Fortunately, lots of OSS plugins to look to for examples -- no better way to learn! http://github.com http://agilewebdevelopment.com/plugins http://railslodge.com - http://railsify.com Good luck & don’t forget to let us know about your
new plugin!
Thanks!
Nick Plante. @zapnap
Partner, Software DeveloperUbikorp Internet Services
[email protected]://blog.zerosum.org
[email protected]://ubikorp.com
http://github.com/zapnap/isbn_validation