Takeaways From RailsConf 2014
May 14th, 2014Once everyone got passed the 'The Death To TDD' hysteria, this years most influential talks focused heavily on refactoring. This was my first Rails conference, and while I have alway attempted to write maintainable and clean code, I was always a little gun-shy when it came to massive refactoring. When do you break something into a new class, and when is a method too long?
Starting a little simpler, one of my favorite talks was actually a workshop on day one. It was with Tute Costa, where he took us through some short examples of some really chaotic code, and after giving us a few pointers, he set us loose on refactoring it.
##Taming the Flow of Control
We started out with one of my favorite refactoring techniques, mostly because it is so simple and the least disrupting of the refactoring techniques yet can still make a huge difference in readability. This is the code he gave us:
class ProjectsControllerdef index# When user is admitted at least a week ago we show it's active projectsif current_user && current_user.created_at < (Time.now - 7*24*3600)@projects = current_user.active_projects# If not admitted we show some featured projects, and set a marketing flash# message when user is newelseif current_user && current_user.created_at > (Time.now - 7*24*3600)@flash_msg = 'Sign up for having your own projects, and see promo ones!'end@projects = Project.featuredendendend
He told us that a simple technique he uses is to just take the comment, and convert it into the method name that is called by the if statement. Here is what I did:
class ProjectsControllerdef indexif user_created_more_than_a_week_ago?@projects = current_user.active_projectselse@flash_msg = 'Sign up for having your own projects, and see promo ones!'@projects = Project.featuredendenddef user_created_more_than_a_week_ago?current_user && current_user.created_at < weeks_ago(1)enddef weeks_ago(n)Time.now - n * 7*24*3600endend
As you can see the first long cryptic if statement was easily reduced to a much more readable sentence-like method. Further more through this process it became clear that the nested if check wasn't even needed.
##Polymorphism at its Finest
I had never used polymorphism quite like this before. Instead of constantly checking that the object we are dealing with can respond to a given method, we simply ensure that all objects at this point in the code have some way of responding to it. Now when I put it like that it sounds so obvious. But what about when we don't find the object we are looking for. Why shouldn't the same concept still apply here. This is the idea that was presented to use before we started to takle this next part.
class StatusReportJobdef performusers = {}User.all.map do |user|users[user.name] = {name: last_name(user),status: last_status(user),trial_days: last_trial_days(user)}endusersendprivatedef last_name(user)if user.last_subscription && user.last_subscription.respond_to?(:name)user.last_subscription.nameelse'none'endenddef last_status(user)if user.last_subscription && user.last_subscription.respond_to?(:status)user.last_subscription.statuselse'-'endenddef last_trial_days(user)if user.last_subscription && user.last_subscription.respond_to?(:trial_days)user.last_subscription.trial_dayselse'-'endendend
The basic idea behind this refactoring was to have a subscription object in preform
that would always be able to respond to the methods called on it. So when
null would have originally been returned, instead a NullSubscription
object which responds
to these methods is returned. Transforming all of that code to this:
class StatusReportJobdef performusers = {}User.all.map do |user|last_subscription = user.last_subscriptionusers[user.name] = {name: last_subscription.name,status: last_subscription.status,trial_days: last_subscription.trial_days}endusersendend
with a Subscription
, and NullSubscription
class defined like this:
class Subscriptiondef name'Monthly Subscription'enddef status'active'enddef trial_days14enddef cancel# telling payment gateway...trueendendclass User# ...def last_subscriptionsubscriptions.last || NullSubscription.newend# ...endclass NullSubscriptiondef cancelfalseenddef name'none'enddef status'-'enddef trial_days'-'endend
As you can see last_subscription tries to find your last subscription for you,
but when it fails it returns a new instance of NullSubscription. Then when last_subscription.name
is called, we get 'none'
returned if it dosen't exist, or 'Monthly Subscription'
if it does.
users[user.name] = {name: last_subscription.name, # => 'none' or Monthly Subscriptionstatus: last_subscription.status,trial_days: last_subscription.trial_days}class Subscriptiondef name'Monthly Subscription'end#...endclass NullSubscriptiondef name'none'end#...end
##We Can Rebuild Him, We Have the Technology
Now for some truly horrific code. This is the code one comes up with when they are wrestling to get something to work and just keep throwing lines at it hoping something will stick. Now it's time to refactor it and make it not only work, but also have it look like it works.
# 1. Create a class with same initialization arguments as BIGMETHOD# 2. Copy & Paste the method's body in the new class, with no arguments# 3. Replace original method with a call to the new class# 4. Apply "Intention Revealing Method" to the new class. Woot!class Formatter# More code, methods, and stuff in this big classdef row_per_day_format(file_name)file = File.open file_name, 'r:ISO-8859-1'# hash[NivelConsistencia][date] = [[value, status]]hash = { '1' => {}, '2' => {} }dates = []str = ''CSV.parse(file, col_sep: ';').each do |row|next if row.empty?next if row[0] =~ /^\/\//date = Date.parse(row[2])(13..43).each do |i|measurement_date = date + (i-13)# If NumDiasDeChuva is empty it means no datavalue = row[7].nil? ? -99.9 : row[i]status = row[i + 31]hash_value = [value, status]dates << measurement_datehash[row[1]][measurement_date] = hash_valueendenddates.uniq.each do |date|if !hash['1'][date].nil? && hash['2'][date].nil?# Only 'bruto' (good)value = hash['1'][date]str << "#{date}\t#{value[0]}\t#{value[1]}\n"elsif hash['1'][date].nil? && !hash['2'][date].nil?# Only 'consistido' (kind of good)value = hash['2'][date]str << "#{date}\t#{value[0]}\t#{value[1]}\n"else# 'bruto' y 'consistido' (has new and old data)old_value = hash['1'][date]new_value = hash['2'][date]str << "#{date}\t#{new_value[0]}\t#{old_value[1]}\t#{old_value[0]}\n"endendstrend# More code, methods, and stuff in this big classend
Now the idea here was to dismantel this method into its components, I gave it my best but his solution was a little more elagent so I've decided to use his instead.
class FormatAtoBSIN_DATOS = -99.9def initialize(file_name)@file_name = file_name@hash = { '1' => {}, '2' => {} }@dates = []enddef performload_format_a_fileformat_data_to_bendprivatedef load_format_a_filefile = File.open @file_name, 'r:ISO-8859-1'CSV.parse(file, col_sep: ';').each do |row|next if row.empty? || row[0] =~ /^\/\//load_month_in_hash(row)endenddef format_data_to_bformatted_rows = @dates.uniq.map { |date| formatted_row_for(date) }"#{formatted_rows.join("\n")}\n"enddef formatted_row_for(date)old_value = @hash['1'][date]new_value = @hash['2'][date]if dato_bruto?(date)day = [date, old_value[0], old_value[1]]elsif dato_consistido?(date)day = [date, new_value[0], new_value[1]]else # 'bruto' y 'consistido'day = [date, new_value[0], old_value[1], old_value[0]]endday.join("\t")enddef load_month_in_hash(row)@beginning_of_month = Date.parse(row[2])(13..43).each do |i|load_day_in_hash(row, i)endenddef load_day_in_hash(row, i)nivel_consistencia = row[1]date = @beginning_of_month + (i - 13)@dates << date@hash[nivel_consistencia][date] = datos(row, i)end# If NumDiasDeChuva is empty it means no datadef datos(row, i)data = row[7].nil? ? SIN_DATOS : row[i]status = row[i + 31][data, status]enddef dato_bruto?(date)@hash['1'][date] && @hash['2'][date].nil?enddef dato_consistido?(date)@hash['1'][date].nil? && @hash['2'][date]endendclass Formatterdef row_per_day_format(file_name)FormatAtoB.new(file_name).performendend
There could still be a lot more done here, I mean are you really ever done refactoring? But I think this grasps some of the main ideas. You want to make use of instance variables to allow for the method to be broken up without having to pass lists of variables from method to method. It is useful to find the variables that are inherent to the class itself. Make those your instance variables. From there your goal is simpler, you need to basically construct these variables to some final product, one chunk at a time. There is still a lot going on here, but it definitely looks slightly less overwhelming when looking at manageable chunks.
The best part is that you now have a FormatAtoB
class that can go in its own file. You shouldn't have to add much to this file
in the future since AtoB
is not likely to change since A should remain A and like wise with B. More importantly
you have a much cleaner Formatter
class, now when you need a new format you can add that class to its own file and
just worry about calling the right one in Formatter
. Now each class has a single responsibility
, and is much less dependent on future changes making your code much more SOLID.
##Getting the User Into Shape
For this last part, I need one to imagine a massive user model. This refactoring on its own may seem like it is making it more complicated, but when one realizes that they are moving all of this code out of a bloated class it makes more sense.
class Userattr_accessor :subscription# validations# callbacks# authentication logic# notifications logicdef subscribeapi_result = try_api { PaymentGateway.subscribe }if api_result == :successself.subscription = :monthly_planendapi_resultenddef unsubscribeapi_result = try_api { PaymentGateway.unsubscribe }if api_result == :successself.subscription = nilendapi_resultendprivate# Try API connection, trap and log it on failuresdef try_apiyieldrescue SystemCallError => e# log API connection failure:network_errorend# Other private methodsend
Now all of this code really should be part of some subscription class, but since it
depends on the user it seems to have just been crammed into this model. It doesn't
look like much, but remember this is a user model that typically has hundreds of other
lines of code, likely with more examples of logic that need to be moved out as well.
To move this code out though you are going to need pass along the user. Originally I wanted to make
an instance variable called user and pass him into the initialization, but I found out
that there is a slightly sexier way to go about it through the use of a Struct
.
class SubscriptionService < Struct.new(:user)def subscribeapi_result = try_api { PaymentGateway.subscribe }if api_result == :successuser.subscription = :monthly_planendapi_resultenddef unsubscribeapi_result = try_api { PaymentGateway.unsubscribe }if api_result == :successuser.subscription = nilendapi_resultendprivate# Try API connection, trap and log it on failuresdef try_apiyieldrescue SystemCallError => e# log API connection failure:network_errorendendclass Userattr_accessor :subscription# Podría ser delegate :subscribe, :unsubscribe, to: :subscription_servicedef subscribesubscription_service.subscribeenddef unsubscribesubscription_service.unsubscribeendprivatedef subscription_service@subscription_service ||= SubscriptionService.new(self)endend
This allows us to simply pass the user into SubscriptionService.new(self)
and
from there we call user instead of self to get the desired results.
This really cleans up the user model. I also want to point out how through
the use of @subscription_service ||= SubscriptionService.new(self)
we ensure
that a new instance is only created if we need it. We didn't even change the users
API since it still has a suscribe and unsubscribe method, they just employ the
subscription_service
class. This means that all of our original tests should
be completely unaffected.
##Summary
In summary the biggest favor you can do yourself before refactoring is to make sure
you have a comprehensive test suit. Once that is in place you are able to freely change
code without having to worry about secretly breaking functionality. To give this a try
for yourself fork the repository here https://github.com/tute/refactoring-workshop
,
and refactor away. Run the tests suit to ensure you didn't break anything. Good luck, and happy
coding.