Today we will provide you with a translation of the post of Steve Klabnik, a well-known developer, Ruby devotee, one of the winners of the Ruby Hero Award this year. What is this reward? It is awarded by the winners of last year to those community members who showed themselves the most: they created significant learning content, developed plugins and gems, participated in open source projects. Such an award was created to honor the most distinguished people and give them the recognition they deserve.
You can chat with Steve at the RubyC conference in Kiev on November 5-6 this year.I often tell people that I taught Ruby through Rails. This is one of the worst ways, but by that time I had already learned so many programming languages ​​that it did not bother me. However, it gave me a slightly distorted sense of how carefully I designed the classes needed for Rails applications. Fortunately, I biasedly look at the code written by others, and noticed that there is one important thing that is found in the development of many people I respect.
I think these people also consider this thing unique. This is not when people who cannot write good code try, but it still turns out badly. It's like a flag signal. Now, when I see someone injecting this thing, I immediately think: "he fumbles." Maybe I trust my feelings too much, but this advanced development technique offers many interrelated advantages to your Rails applications, is easy to apply and speeds up testing by an order of magnitude or more. Unfortunately, for many beginner Rails developers this is not obvious, but I would like
you to write the code better and here I am, so that, with your permission, “reveal the secret” and share this powerful technique with you.
')
It's called "Old Simple Ruby Object"
Yes exactly. Ruby is a class that does not inherit anything. It is so simple that it is hidden in a prominent place. Favorite Rails creators, old simple Ruby Objects, or “POROs” as some like to call them, are a hidden weapon against complexity. That's what I mean. Consider this “simple” model:
Copy Source | Copy HTML class Post < ActiveRecord::Base def self .as_dictionary dictionary = ( 'A' .. 'Z' ).inject({}) {|h, l| h[l] = []; h} Post .all.each do | p | dictionary[ p .title[ 0 ]] << p end dictionary end end
Copy Source | Copy HTML class Post < ActiveRecord::Base def self .as_dictionary dictionary = ( 'A' .. 'Z' ).inject({}) {|h, l| h[l] = []; h} Post .all.each do | p | dictionary[ p .title[ 0 ]] << p end dictionary end end
Copy Source | Copy HTML class Post < ActiveRecord::Base def self .as_dictionary dictionary = ( 'A' .. 'Z' ).inject({}) {|h, l| h[l] = []; h} Post .all.each do | p | dictionary[ p .title[ 0 ]] << p end dictionary end end
Copy Source | Copy HTML class Post < ActiveRecord::Base def self .as_dictionary dictionary = ( 'A' .. 'Z' ).inject({}) {|h, l| h[l] = []; h} Post .all.each do | p | dictionary[ p .title[ 0 ]] << p end dictionary end end
Copy Source | Copy HTML class Post < ActiveRecord::Base def self .as_dictionary dictionary = ( 'A' .. 'Z' ).inject({}) {|h, l| h[l] = []; h} Post .all.each do | p | dictionary[ p .title[ 0 ]] << p end dictionary end end
Copy Source | Copy HTML class Post < ActiveRecord::Base def self .as_dictionary dictionary = ( 'A' .. 'Z' ).inject({}) {|h, l| h[l] = []; h} Post .all.each do | p | dictionary[ p .title[ 0 ]] << p end dictionary end end
Copy Source | Copy HTML class Post < ActiveRecord::Base def self .as_dictionary dictionary = ( 'A' .. 'Z' ).inject({}) {|h, l| h[l] = []; h} Post .all.each do | p | dictionary[ p .title[ 0 ]] << p end dictionary end end
Copy Source | Copy HTML class Post < ActiveRecord::Base def self .as_dictionary dictionary = ( 'A' .. 'Z' ).inject({}) {|h, l| h[l] = []; h} Post .all.each do | p | dictionary[ p .title[ 0 ]] << p end dictionary end end
Copy Source | Copy HTML class Post < ActiveRecord::Base def self .as_dictionary dictionary = ( 'A' .. 'Z' ).inject({}) {|h, l| h[l] = []; h} Post .all.each do | p | dictionary[ p .title[ 0 ]] << p end dictionary end end
Copy Source | Copy HTML class Post < ActiveRecord::Base def self .as_dictionary dictionary = ( 'A' .. 'Z' ).inject({}) {|h, l| h[l] = []; h} Post .all.each do | p | dictionary[ p .title[ 0 ]] << p end dictionary end end
We want to show a pointer to the first letter for all our posts. To do this, we create a dictionary and put our posts in it. Suppose we do not need to break the list into pages, so do not pay attention to the request of all posts from the database. The idea is important: now we can show all posts by name:
Copy Source | Copy HTML
- - Post.as_dictionary do | letter, list |
- % p = letter
- % ul
- - list.each do | post |
- % li = link_to post
Of course. On the one hand, the code is not
bad . But he is also not good: we are worried about the representation inside the model designed for business logic. So let's fix this using the Presenter pattern:
Copy Source | Copy HTML
- class DictionaryPresenter
- def initialize (collection)
- @collection = collection
- end
- def as_dictionary
- dictionary = ( 'A' .. 'Z' ) .inject ({}) {| h, l | h [l] = []; h}
- @ collection.each do | p |
- dictionary [ p .title [ 0 ]] << p
- end
- dictionary
- end
- end
Now we can use DictionaryPresenter.new (Post.all) .as_dictionary. He has many advantages: we brought the logic of representation from the model. We
have already added a new feature: any collection can now be displayed with a pointer. We can easily write separate tests for this representative class and they will be fast.
Despite my love for the “Representative” pattern, this post is not about him. This principle also appears in other places, “this concept deserves its own class”. Before moving on to another example, let's expand this one: if we want to sort our posts by title, this class will work, but we will not be able to show, say, users, because users have no names (title fields). Moreover, we get a large number of posts on "A", because the names often begin with the article "a", and in fact we need the first letter of the second word. We can make 2 types of Representatives, but then we will lose generality and the concept of an “index” will again have 2 Representatives in our system. As you understood correctly: PORO will save us!
Let's slightly change our Representative to accept the policy object:
Copy Source | Copy HTML
- class DictionaryPresenter
- def initialize (policy, collection)
- @policy = policy
- @collection = collection
- end
- def as_dictionary
- dictionary = ( 'A' .. 'Z' ) .inject ({}) {| h, l | h [l] = []; h}
- @ collection.each do | p |
- dictionary [@ policy.category_for ( p )] << p
- end
- dictionary
- end
- end
Now we can add policies and make them different:
Copy Source | Copy HTML
- class UserCategorizationPolicy
- def self .category_for (user)
- user.username [ 0 ]
- end
- end
- class PostCategorizationPolicy
- def self .category_for (post)
- if post.starts_with? ( "A" )
- post.title. split [ 1 ] [ 0 ]
- else
- post.title [ 0 ]
- end
- end
- end
Bam!
Copy Source | Copy HTML
- DictionaryPresenter. new (PostCategorizationPolicy, Post.all) .as_dictionary
Yes, it gets a bit long. It happens :) But now you can see that each concept has one idea in our system. The representative does not care how things are arranged, and only politicians dictate how they are arranged. In fact, my names are a bit lame, perhaps it would be better to call UsernamePolicy or TitlePolicy, actually. We don't even care what class they are!
And so in everything. Combining Ruby's flexibility with my favorite example from Efficiently Working with Legacy Code, we can turn complex calculations into objects. Take a look at this code:
Copy Source | Copy HTML
- class Quote < ActiveRecord :: Base
- # <snip>
- def pretty_turnaround
- return "" if turnaround. nil ?
- if purchased_at
- offset = purchased_at
- days_from_today = (( Time .now - purchased_at.to_time) / 60/60/24) .floor + 1
- else
- offset = Time .now
- days_from_today = turnaround + 1
- end
- time = offset + (turnaround * 60 * 60 * 24 )
- if (time.strftime ( "% a" ) == "Sat" )
- time + = 2 * 60 * 60 * 24
- elsif (time.strftime ( "% a" ) == "Sun" )
- time + = 1 * 60 * 60 * 24
- end
- "# {time.strftime (" % A% d% B ")} (# {days_from_today} business days from today)"
- end
- end
Oh! This method displays the return (calculation) time, but, as you can see, this is a complex calculation. We could understand it more simply if we used method extraction (Method Extract method of refactoring) several times to break it, but then we risk clogging our Quote class with more code only necessary for a nice calculation of time. Also, please do not look at the fact that the model implements the presentation logic - this is just an example of a bulky code.
So, now the first step of this refactoring, which Feather (the author of “Working Effectively With the Legacy Code”) calls the “Break Out Method Object”. You can open your Working Effectively With Legacy Code on page 330 and read more. If you do not have it, buy it :). Actually, I digress. Here is a plan of action:
1. Create a new calculation class.
2. Define in it a method for work in a new way.
3. Copy the body of the old method and replace the references to pointers with objects.
4. Create a constructor for it, which takes arguments to assign the variables used in step 3.
5. Delegate the old method to the new class and method.
I slightly changed the original Ruby template because we cannot rely on the compiler (Lean On The Compiler) and a few steps Feather does just that. In any case, let's try on this code. Step one:
Copy Source | Copy HTML
- class Quote < ActiveRecord :: Base
- def pretty_turnaround
- #snip
- end
- class TurnaroundCalculator
- end
- end
Second:
Copy Source | Copy HTML
- class TurnaroundCalculator
- def calculate
- end
- end
Third:
Copy Source | Copy HTML
- class TurnaroundCalculator
- def calculate
- return "" if @turnaround. nil ?
- if @purchased_at
- offset = @purchased_at
- days_from_today = (( Time .now - purchased_at.to_time) / 60/60/24) .floor + 1
- else
- offset = Time .now
- days_from_today = @turnaround + 1
- end
- time = offset + (@turnaround * 60 * 60 * 24 )
- if (time.strftime ( "% a" ) == "Sat" )
- time + = 2 * 60 * 60 * 24
- elsif (time.strftime ( "% a" ) == "Sun" )
- time + = 1 * 60 * 60 * 24
- end
- "# {time.strftime (" % A% d% B ")} (# {days_from_today} business days from today)"
- end
- end
I like to give the usual names at the beginning, and then change them at step 5 after it is clear what exactly he does. Most likely, our code itself will tell us a good name.
Fourth:
Copy Source | Copy HTML
- class TurnaroundCalculator
- def initialize (purchased_at, turnaround)
- @purchased_at = purchased_at
- @turnaround = turnaround
- end
- def calculate
- #snip
- end
- end
Fifth:
Copy Source | Copy HTML
- class Quote < ActiveRecord :: Base
- def pretty_turnaround
- TurnaroundCalculator. new (purchased_at, turnaround) .calculate
- end
- end
Done! We have to run the tests and see how they pass. Even if “running tests” means checking manually ...
So what's the advantage? Well, now we can begin the process of refactoring, but we are in our own little clean room. We can add methods to our TurnaroundCalcuator class without clogging the Quote class, we can write quick tests for Calculator only and break the idea of ​​calculations in one place where it can be easily changed later. Here is our class after several refactorings:
Copy Source | Copy HTML
- class TurnaroundCalculator
- def calculate
- return "" if @turnaround. nil ?
- "# {arrival_date} (# {days_from_today} business days from today)"
- end
- protected
- def arrival_date
- real_turnaround_time .strftime ( "% A% d% B" )
- end
- def real_turnaround_time
- adjust_time_for_weekends ( start_time + turnaround_in_seconds )
- end
- def adjust_time_for_weekends (time)
- if saturday ? (time)
- time + 2 * 60 * 60 * 24
- elsif sunday ? (time)
- time + 1 * 60 * 60 * 24
- else
- time
- end
- end
- def saturday ? (time)
- time.strftime ( "% a" ) == "Sat"
- end
- def sunday ? (time)
- time.strftime ( "% a" ) == "Sun"
- end
- def turnaround_in_seconds
- @turnaround * 60 * 60 * 24
- end
- def start_time
- @purchased_at or Time .now
- end
- def days_from_today
- if @purchased_at
- (( Time .now - @ purchased_at.to_time) / 60/60/24) .floor + 1
- else
- @turnaround + 1
- end
- end
- end
Wow! This code that I wrote 3 years ago is not perfect, but it can almost be understood. And each piece makes sense. This is after 2 or 3 waves of refactoring, which I will probably reveal in a separate post, because what has now turned out is somewhat more illustrative than I thought. In any case, you get the idea. This is what I mean when I say that it is aimed at five-line methods in Ruby; if your code is clear, you can easily edit it.
The idea of ​​extracting clean Ruby objects is also valid in Rails. Look at this route:
Copy Source | Copy HTML
- root: to => 'dashboard # index' ,: constraints => LoggedInConstraint
BUT? LoggedInConstraint?
Copy Source | Copy HTML
- class LoggedInConstraint
- def self .matches? (request)
- current_user
- end
- end
Oh yeah. The object that describes the routing policy. Wonderful. Also examples of validation shamelessly stolen from
omgbloglol :
Copy Source | Copy HTML
- def SomeClass < ActiveRecord :: Base
- validate: category_id,: proper_category => true
- end
- class ProperCategoryValidator <ActiveModel :: EachValidator
- def validate_each (record, attribute, value)
- unless record.user.category_ids. include ? (value)
- record errors .add attribute, 'has bad category.'
- end
- end
- end
This is not a pure Ruby class, but you get the idea.
Now you probably think: “Steve, this is not only for Rails, you lied!” Well, yes, actually, you caught me: this is not a secret for object-oriented Rails, this is more a general object-oriented approach. But there is something specific to Rails that seems to be enticing you to never break classes. Perhaps lib / seems such a repository of garbage. It is possible that the 15-minute examples only include ActiveRecord models. Perhaps more closed Rails applications, (Attention: just my own opinion) than open non-Rails, so there are not many good examples to rely on. (I have such a hunch, because Rails is often used to develop websites for companies. Gemes? Exactly? My web application? Not much. Nevertheless, I do not have statistics to confirm.)
In general: retrieving domain objects is good. They make your tests quick, the code short, and make it easier to make changes later. I still have something to say about this, especially about the “quick tests”, but I have already exhausted the possible length of the post. Until next time!