Productize your Rails App
Duane Johnson posted some interesting code to the Rails mailing list last week. Duane’s been creating Rails applications for clients, but to help actually make a profit, he’ll “re-sell” the work to other clients with some custom-tailoring for each particular client. He dubs this “productizing” his application. You can read DHH’s original post highlighting this as well.
So, he was looking for a way to keep a common codebase for all clients and still have a ay to explicitly tailor site-specific items, even beyond images or stylesheets – but also to add or modify functionality. This post was particularly important to me, because this is exactly what I’ve been trying to do with my Rails app. The aim is to create a common database model and common code as much as possible but to allow for site specific controllers, actions, or assets.
I’d been setting up Apache to use the subdomains to determine which site to present to the user. Initially I had them running off one rails instance and swapping the DB underneath by getting the subdomain. I also used the subdomain to swap layouts and other cases where I wanted things to differ. The biggest problem was that FastCGI and swapping the DB per request based on subdomain didn’t play nice together. The DB connections stayed open forever and eventually it all choked and died.
Next I created two identical instances of the common code and had Apache just route to the right instance by virtual hosting. I just hard-coded the database.yml to point to the right database (and removed the swapping) for each instance. The rest of the code still looked at the subdomain to determine which layout to use, etc. Obviously my approaches left much to desire.
(Modified instructions for) "Productizing" your rails app
To anyone out there trying to follow Duane’s approach, go read his post first, and then come back here, because some things are missing from his original post and some of what he tells there is not correct. Go ahead, read it, and come on back…
OK, welcome back. If you’re actually playing along and are trying to get this working, follow Duane’s advice first and then go through what I have to get it working properly.
Open Issues / Caveats
- We haven’t worked out the work process of testing with this setup. (I also should note that I haven’t really tried playing with testing yet.)
- Routing will fail if you have a controller that only exists at the site level. For now, the workaround is to create an empty version of the controller at the top common-level.
- Some of what I give below is specific to Apache. I have not yet tried doing this with lighttpd, but I’m assuming that it would be fairly straight-forward to get this working with it.
Apache Setup
Second, the way he told you to set up your document roots? Yeah that’s not exactly correct. Here’s what you do to get set up with Apache and FastCGI:
Make sure you set your project folders up as Duane specified:
RAILS_ROOT/
app/
controllers/
views/
(etc.)
public/
sites/
best_ever_adoptions_co_inc/
app/
controllers/
views/
public/
yet_another_adoption_co/
app/
controllers/
views/
public/
Now, in each site’s public directory, copy over dispatch.fcgi. For each site-specific version of dispatch.fcgi, edit the line which includes environment.rb. You’ll be telling it to look up two directories higher than normal. Change the line to look like this:
require File.dirname(__FILE__) + "/../../../config/environment"
You’ll probably be hosting all of these sites off the same server and Apache install. So here’s an example of my httpd.conf file where I use Virtual Hosts for each site. This is on Windows XP with Apache 2.x.
LoadModule fastcgi_module modules/mod_fastcgi.so
<IfModule mod_fastcgi.c>
AddHandler fastcgi-script .fcgi
FastCgiServer C:/projects/svn/trunk/sites/site_one/public/dispatch.fcgi -initial-env site=site_one
-initial-env RAILS_ENV=production -initial-env PATH=C:/oracle/ora81/bin -idle-timeout 200
FastCgiServer C:/projects/svn/trunk/sites/site_two/public/dispatch.fcgi -initial-env site=site_two
-initial-env RAILS_ENV=production -initial-env PATH=C:/oracle/ora81/bin -idle-timeout 200
</IfModule>
NameVirtualHost *:80
<VirtualHost *:80>
ServerName site_one.myhost.com
DocumentRoot C:/projects/svn/trunk/sites/site_one/public
ErrorLog C:/projects/svn/trunk/sites/site_one/log/apache.log
<Directory C:/projects/svn/trunk/public/>
Options ExecCGI FollowSymLinks
AddHandler cgi-script .cgi
AllowOverride all
Order allow,deny
Allow from all
</Directory>
<Directory C:/projects/svn/trunk/sites/site_one/public/>
Options ExecCGI FollowSymLinks
AddHandler cgi-script .cgi
AllowOverride all
Order allow,deny
Allow from all
</Directory>
</VirtualHost>
<VirtualHost *:80>
ServerName site_two.myhost.com
DocumentRoot C:/projects/svn/trunk/sites/site_two/public
ErrorLog C:/projects/svn/trunk/sites/site_two/log/apache.log
<Directory C:/projects/svn/trunk/public/>
Options ExecCGI FollowSymLinks
AddHandler cgi-script .cgi
AllowOverride all
Order allow,deny
Allow from all
</Directory>
<Directory C:/projects/svn/trunk/sites/site_two/public/>
Options ExecCGI FollowSymLinks
AddHandler cgi-script .cgi
AllowOverride all
Order allow,deny
Allow from all
</Directory>
</VirtualHost>
Getting Caching working right
Next, we’re going to modify Duane’s productize.rb file. Make sure it looks like this:
# productize.rb
SITE = ENV['site'] || 'yet_another_adoption_co'
SITE_ROOT = File.join(RAILS_ROOT, 'sites', SITE)
module Dependencies
def require_or_load(file_name)
file_name = "#{file_name}.rb" unless ! load? || file_name [-3..-1] == '.rb'
load? ? load(file_name) : require(file_name)
if file_name.include? 'controller'
file_name = File.join(SITE_ROOT, 'app', 'controllers', File.basename(file_name))
if File.exist? file_name
load? ? load(file_name) : require(file_name)
end
end
end
end
ActionController::Base.page_cache_directory = "#{SITE_ROOT}/public"
module ActionView
class Base
private
def full_template_path(template_path, extension)
# Check to see if the partial exists in our 'sites' folder first
site_specific_path = File.join(SITE_ROOT, 'app', 'views', template_path + '.' + extension)
if File.exist?(site_specific_path)
site_specific_path
else
"#{@base_path}/#{template_path}.#{extension}"
end
end
end
end
What’s the difference? I’ve added a line which tells Rails to cache (and reload cached files) into the site specific directories. With Duane’s original code, the files would get cached at the top-level (so if the cached file was no generic enough, a site-specific version would be served up to every site).
Making the Assets Load in a Hierarchical Way
One of the initial problems I had was that the assets – images, javascripts, stylesheets – had to be copied to every site’s public directory. Obviously we want common assets to behave like the code does – try loading from the site specific directory, and then the common directory.
I posed this to Duane and he responded with the following. Please note that I haven’t tried this yet, but it looks promising:
I have an Apache solution to this problem:
First, add an "Alias" directive inside of each VirtualHost, something like this:
FastCgiServer /Users/clifffano/Projects/premier/sites/premier_adoption/public/dispatch.fcgi
-processes 1 -initial-env SITE=premier_adoption
<Directory "/Users/clifffano/Projects/premier/sites/premier_adoption/public/">
AllowOverride All
</Directory>
# Remember to make sure "NameVirtualHost *:80" is set in httpd.conf so we can use VirtualHosts
<VirtualHost *:80>
ServerName premier_adoption.localhost
DocumentRoot /Users/clifffano/Projects/premier/sites/premier_adoption/public/
# The following alias is important since it will allow this particular site to
# seemlessly use the generic app's resources, e.g. images, javascripts etc. without
# having to copy all files to the site-specific public/ folder:
Alias /generic /Users/clifffano/Projects/premier/public/
</VirtualHost>
Then, modify the site-specific dispatch.fcgi files, like so:
# Example:
# RewriteRule ^(.*)$ dispatch.fcgi [QSA,L]
RewriteEngine On
RewriteRule ^$ index.html [QSA]
# If the requested file does not exist in SITE_ROOT/public...
RewriteCond %{REQUEST_FILENAME} !-f
# ... then split its full path up in to manageable pieces ...
RewriteCond %{REQUEST_FILENAME} ^(.*)/sites/.+/public/(.*)$
# ... and check to see if the file exists in the RAILS_ROOT/public folder...
RewriteCond %1/public/%2 -f
# ... if so, rewrite our requested file to be the RAILS_ROOT one
RewriteRule ^(.*)$ /generic/$1 [NS,L]
RewriteRule ^([^.]+)$ $1.html [QSA]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ dispatch.fcgi [QSA,L]
This will skip Rails altogether and just send the appropriate files
in the case that images, javascripts or html files exist in the
generic public folder but not in the site-specific public folder.