Refactoring Walkthrough
Refactoring is the process of restructuring, re-architecting and modifying existing code without affecting the output of the code. The aim is to reduce complexity simultaneously improving the ability to modify the code at a later date. I want to walk you through my own refactoring process with a real world example.
We will be refactoring shtirlic/sinatra-jsonp. This is a small sinatra extension that adds support for older browsers when returning json as it provides callback function support. I have selected this as I have used it in a project and I know that the main bulk of the functionality is implemented in a rather complex method making it a prime candidate for refactoring.
So to begin with I am going to fork the repository so my starting point is
7436fd1fa3
. If I now clone this locally I can start setting up my
baselines and checking the tests.
Tests are perhaps the single most important thing to have in place when refactoring. Without them it is extremely difficult to assert that the code is producing the same results both before and after the refactoring.
$ git clone git@github.com:dcoxall/sinatra-jsonp.git
$ cd sinatra-jsonp
$ bundle install
$ bundle exec rake
You will see that I installed any dependencies and then ran the default rake task. In most ruby programs this is the standard way of running the complete test suite. In this case it was right and all the tests passed but there were some deprecations.
stdlib is deprecated. Use :test_unit or :minitest instead
Now this is a good place to start. If I can fix any issues like this now I will have a nicer environment to work with later so at this point I will create a branch.
$ git checkout -b refactor
With a branch created I can start making my changes. The offending line causing
the deprecation warning is in spec/spec_helper.rb
so my first action was to
remove it and see what effect it had on the tests by running them again.
Low-and-behold everything passed but without any warnings. Awesome. So I can commit this change.
$ git add spec/spec_helper.rb
$ git ci -m "Fix deprecated use of stdlib"
Now I want to introduce a tool I like to use called rubocop which can analyse ruby code to check that it follows particular conventions but more importantly in this case it also includes metrics that report on estimated complexity and an ABC rating (Assignments, branching and conditions) where a higher number often indicates complex or difficult to follow code. This will provide me a baseline and I can work on reducing these metrics providing me a tangible target.
$ gem install rubocop
$ rubocop -f s --only \
> Metrics/AbcSize,Metrics/MethodLength,Metrics/PerceivedComplexity
In the above command I limit rubocop to look only for ABC rating, method length and the perceived complexity. These will be the metrics I want to improve. The result is the following:
== lib/sinatra/jsonp.rb ==
C: 6: 5: Assignment Branch Condition size for jsonp is too high. [19.34/15]
C: 6: 5: Method has too many lines. [19/10]
C: 6: 5: Perceived complexity for jsonp is too high. [8/7]
Now I have a starting point and passing tests. It’s time to look at the code.
All the reported issues are in lib/sinatra/jsonp.rb
which is where all
the main logic is kept.
My first instinct is to go through the method and document what it is actualy doing. I find it much easier to re-structure the code once I understand what functions it is serving. It is then a case of moving pieces to their own methods.
def jsonp(*args)
# requires 1 or more arguments
if args.size > 0
# The first argument is the object to serialize
data = MultiJson.dump(
args[0],
:pretty => settings.respond_to?(:json_pretty) && settings.json_pretty
)
# If we have another argument it is the callback function name
if args.size > 1
callback = args[1].to_s
else
# If not then determine the callback based on the following parameters
['callback','jscallback','jsonp','jsoncallback'].each do |x|
callback = params.delete(x) unless callback
end
end
# If we have a callback perform some basic sanitization and set the
# response content type and the eventual response body
if callback
callback.tr!('^a-zA-Z0-9_$\.', '')
content_type :js
response = "#{callback}(#{data})"
else
# If no callback then set the response content type to json and return
# the serialized data
content_type :json
response = data
end
response
end
end
With this view I am going to work top-down and so first is to implement a guard
condition by returning early if we have in-sufficient arguments. I then decide
that I can make the pretty
flag into a different method.
# requires 1 or more arguments
return if args.size < 1
# The first argument is the object to serialize
data = MultiJson.dump(args[0], :pretty => display_pretty_json?)
# ...
private
def display_pretty_json?
!!(settings.respond_to?(:json_pretty) && settings.json_pretty)
end
Now running the tests and rubocop again reveal that everything is still passing but I have already corrected the perceived complexity of the method as well as improved the ABC score (by 3.03).
== lib/sinatra/jsonp.rb ==
C: 6: 5: Assignment Branch Condition size for jsonp is too high. [16.31/15]
C: 6: 5: Method has too many lines. [18/10]
Time to commit these changes and then tackle the callback calculation.
$ git add lib/sinatra/jsonp.rb
$ git commit -m "Introduce guard condition and extract setting checks"
I move the logic for determining the callback name into a new method and I also move the list of parameter keys to check into a constant. This leaves us with the following…
CALLBACK_PARAMS = %w( callback jscallback jsonp jsoncallback ).freeze
def jsonp(*args)
return if args.size < 1
data = MultiJson.dump(args[0], :pretty => display_pretty_json?)
callback = extract_callback_name(args[1])
# If we have a callback perform some basic sanitization and set the
# response content type and the eventual response body
if callback
callback.tr!('^a-zA-Z0-9_$\.', '')
content_type :js
response = "#{callback}(#{data})"
else
# If no callback then set the response content type to json and return
# the serialized data
content_type :json
response = data
end
response
end
alias JSONP jsonp
private
def display_pretty_json?
!!(settings.respond_to?(:json_pretty) && settings.json_pretty)
end
def extract_callback_name(name = nil)
if name.nil?
callback = nil
CALLBACK_PARAMS.each do |key|
callback = params.delete(key) unless callback
end
callback ? callback.to_s : nil
else
name.to_s
end
end
Again running the tests and rubocop tell me that everything is still working and I have now also corrected the ABC score to an acceptable level. The only metric that rubocop is complaining about now is the method length. Once again, it’s time to commit our changes and move onto finalizing the response.
$ git add lib/sinatra/jsonp.rb
$ git commit -m "Callback name extraction moved to new method"
Now for this next refactor I don’t want the method to have any side-effects and
so I don’t want the new method to call content_type
explicitly but instead
return both the response body and the correct content type which the main method
can use.
The result of this is shown below
CALLBACK_PARAMS = %w( callback jscallback jsonp jsoncallback ).freeze
def jsonp(*args)
return if args.size < 1
data = MultiJson.dump(args[0], :pretty => display_pretty_json?)
callback = extract_callback_name(args[1])
type, response = determine_response(data, callback)
content_type(type)
response
end
alias JSONP jsonp
private
def determine_response(data, callback = nil)
return [:json, data] if callback.nil?
callback.tr!('^a-zA-Z0-9_$\.', '')
[:js, format("%s(%s)", callback, data)]
end
def display_pretty_json?
!!(settings.respond_to?(:json_pretty) && settings.json_pretty)
end
def extract_callback_name(name = nil)
if name.nil?
callback = nil
CALLBACK_PARAMS.each do |key|
callback = params.delete(key) unless callback
end
callback ? callback.to_s : nil
else
name.to_s
end
end
Running the tests and rubocop now reveal all tests continue to pass and rubocop has nothing to complain about! I can commit this and create a pull request.
I’ve demonstrated how I like to refactor with the aim of making the code easier to work with. What once was a large single method is now several smaller more succinct methods. This also provides an additional benefit in that the method names now also document the behaviour of the code making it easier for others to read and understand.
Thank you for reading. You can see the final pull request here. Now go out and improve some code!