Quantcast
Channel: Hacker News 50
Viewing all articles
Browse latest Browse all 9433

Making some Discourse code a little better

$
0
0

Comments:"Making some Discourse code a little better"

URL:http://grantammons.me/2013/02/09/making-some-discourse-code-a-little-better/


I’m sure by this point everyone has seen Discourse, which is a new stab at forum and communication software. Indeed, it certainly seems well-polished and will certainly make a splash.

I’ve noticed that some of the code in the codebase is less than steller. I’m going to blog about refactoring a particularly nasty piece of their codebase, and dictate what I am learning along the way.

So first, the offender:

class TopicsController
 def show
 @topic_view = TopicView.new(params[:id] || params[:topic_id],
 current_user,
 username_filters: params[:username_filters],
 best_of: params[:best_of],
 page: params[:page])
 # Consider the user for a promotion if they're new
 if current_user.present?
 Promotion.new(current_user).review if current_user.trust_level == TrustLevel.Levels[:new]
 end
 @topic_view = TopicView.new(params[:id] || params[:topic_id],
 current_user,
 username_filters: params[:username_filters],
 best_of: params[:best_of],
 page: params[:page])
 anonymous_etag(@topic_view.topic) do
 # force the correct slug
 if params[:slug] && @topic_view.topic.slug != params[:slug]
 fullpath = request.fullpath
 split = fullpath.split('/')
 split[2] = @topic_view.topic.slug
 redirect_to split.join('/'), status: 301
 return
 end
 # Figure out what we're filter on
 if params[:post_number].present?
 # Get posts near a post @topic_view.filter_posts_near(params[:post_number].to_i)
 elsif params[:posts_before].present?
 @topic_view.filter_posts_before(params[:posts_before].to_i) elsif params[:posts_after].present?
 @topic_view.filter_posts_after(params[:posts_after].to_i)
 else
 # No filter? Consider it a paged view, default to page 0 which is the first segment
 @topic_view.filter_posts_paged(params[:page].to_i)
 end
 View.create_for(@topic_view.topic, request.remote_ip, current_user)
 @topic_view.draft_key = @topic_view.topic.draft_key
 @topic_view.draft_sequence = DraftSequence.current(current_user, @topic_view.draft_key)
 if (!request.xhr? || params[:track_visit]) && current_user
 TopicUser.track_visit! @topic_view.topic, current_user
 @topic_view.draft = Draft.get(current_user, @topic_view.draft_key, @topic_view.draft_sequence)
 end
 topic_view_serializer = TopicViewSerializer.new(@topic_view, scope: guardian, root: false)
 respond_to do |format|
 format.html do
 @canonical = "#{request.protocol}#{request.host_with_port}" + @topic_view.topic.relative_url
 if params[:post_number]
 @post = @topic_view.posts.select{|p| p.post_number == params[:post_number].to_i}.first
 page = ((params[:post_number].to_i - 1) / SiteSetting.posts_per_page) + 1
 @canonical << "?page=#{page}" if page > 1
 else
 @canonical << "?page=#{params[:page]}" if params[:page] && params[:page].to_i > 1
 end
 last_post = @topic_view.posts[-1]
 if last_post.present? and (@topic_view.topic.highest_post_number > last_post.post_number)
 @next_page = (@topic_view.posts[0].post_number / SiteSetting.posts_per_page) + 2
 end
 store_preloaded("topic_#{@topic_view.topic.id}", MultiJson.dump(topic_view_serializer))
 end
 format.json do
 render_json_dump(topic_view_serializer)
 end
 end
 end
 end
end

Whoa momma! That is a hairy function. Indeed, the TopicsController is the worst offender in terms of complexity in the entire codebase! Let’s try to make the world a nicer place here.

One of the nice things about the Discourse codebase is the even though there are a few hairy parts of the app, the app itself is very well tested, and indeed the code coverage on TopicsController#show is nearly 100%.

Let’s get started.

Beginning to refactor

The first item of the show function is some logic that considers whether the user should be considered for a promotion.

# Consider the user for a promotion if they're new
if current_user.present?
 Promotion.new(current_user).review if current_user.trust_level == TrustLevel.Levels[:new]
end

This code can move into a before_filter, since it doesn’t really have anything to do with showing a post.

before_filter :consider_user_for_promotion, only: :show
def consider_user_for_promotion
 return unless current_user.present?
 Promotion.new(current_user).review if current_user.trust_level == TrustLevel.Levels[:new]
end

But then it occured to me that we’re actually doing some pretty in-depth logic in order to run the promotion code. This piece of code

if current_user.trust_level == TrustLevel.Levels[:new]

…is probably too much knowledge for the TopicsController. Indeed it seems that the Promotion class seems to be in flux, and that particular piece of logic is not necessary at the moment. By removing that logic we get to the final form for that method:

def consider_user_for_promotion
 Promotion.new(current_user).review if current_user.present?
end

Ah, much nicer.

Breaking down the components of the function

The rest of the function can be broken down in to 5 pieces:

Initializing a TopicView, which is in charge of figuring out which topics to display, Doing some logic to enforce that a topic’s slug matches, Doing tons of if gateways to determine which functions in TopicView to run, Tracking the visit of a particular user, and finally Running the logic to present the html or json to the client.

Slug logic

The next logical piece I sought to remove was the interesting logic around ensuring that the requested slug matches the topic id.

# force the correct slug
if params[:slug] && @topic_view.topic.slug != params[:slug]
 fullpath = request.fullpath
 split = fullpath.split('/')
 split[2] = @topic_view.topic.slug
 redirect_to split.join('/'), status: 301
 return
end

For the moment, I decided just to make them functions in the controller, with verbose names. Perhaps later we’ll move them out of the controller, but for now simply moving this logic out of the method is good enough.

def slugs_do_not_match
 params[:slug] && @topic_view.topic.slug != params[:slug]
end
def redirect_to_correct_topic
 fullpath = request.fullpath
 split = fullpath.split('/')
 split[2] = @topic_view.topic.slug
 redirect_to split.join('/'), status: 301
end

…and in the controller, we end up with just a single line:

redirect_to_correct_topic and return if slugs_do_not_match

…which reads much more easily.

Crazy gateways

The next piece of code is a series of if gateways that expose how tightly coupled the TopicsController and TopicView classes are. Essentially we are doing a lot of work to determine which logic in TopicView to run.

# Figure out what we're filter on
if params[:post_number].present?
 # Get posts near a post
 @topic_view.filter_posts_near(params[:post_number].to_i)
elsif params[:posts_before].present?
 @topic_view.filter_posts_before(params[:posts_before].to_i)
elsif params[:posts_after].present?
 @topic_view.filter_posts_after(params[:posts_after].to_i)
else
 # No filter? Consider it a paged view, default to page 0 which is the first segment
 @topic_view.filter_posts_paged(params[:page].to_i)
end

There is a lot of feature envy going on in the above example.

We are probably directing the logic of TopicView a little too much here. Figuring out which filter to run is not really the job of our controller. The TopicView itself, since it knows how to filter, should understand how to filter itself.

So I started with writing just the interface I would like to see:

@topic_view.filter_posts(post_number: params[:post_number],
 posts_before: params[:posts_before],
 posts_after: params[:posts_after])

Then I implemented that in lib/topic_view.rb, by simply removing that logic from the controller and pasting it into the TopicView class as a new method:

def filter_posts(opts = {})
 if opts[:post_number].present?
 # Get posts near a post
 filter_posts_near(opts[:post_number].to_i)
 elsif opts[:posts_before].present?
 filter_posts_before(opts[:posts_before].to_i)
 elsif opts[:posts_after].present?
 filter_posts_after(opts[:posts_after].to_i)
 else
 # No filter? Consider it a paged view, default to page 0 which is the first segment
 filter_posts_paged(opts[:page].to_i)
 end
end

Granted, that function is still a little crazy. But right now I am only concentrating on moving logic into better positions for readability and maintainability.

Here is a gist of the TopicsController as it stands currently.

There is even more we can put into the TopicView. We are initializing TopicView with a ton of paramters, and then calling filter_posts later down the line. Why not have TopicView take care of all of that for me?

@topic_view = TopicView.new(params[:id] || params[:topic_id],
 current_user,
 username_filters: params[:username_filters],
 best_of: params[:best_of],
 page: params[:page],
 post_number: params[:post_number],
 posts_before: params[:posts_before],
 posts_after: params[:posts_after])

This initialize method of TopicView is starting to get out of hand. Because there is a direct correlation between the names and values of the options, I thought it might be okay if we just pass in params directly:

@topic_view = TopicView.new(params[:id] || params[:topic_id], current_user, params)

..which is now a one-liner.

The next 2 lines that seem kind of suspect are the following in TopicsController:

@topic_view.draft_key = @topic_view.topic.draft_key
@topic_view.draft_sequence = DraftSequence.current(current_user, @topic_view.draft_key)

@topic_view.topic.draft_key is a clear Demeter violation, and there is just no sense in having the controller handle this internal logic. It is another example of the controller dictating the internal state of a totally separate class. So those lines go into TopicView’s initialize function:

class TopicView
 def initialize(topic_id, user=nil, options={})
 ...
 @draft_key = @topic.draft_key
 @draft_sequence = DraftSequence.current(user, @draft_key)
 end
end

Finally the last piece here is to deal with some logic that appears to be tracking the user, when the user clicks on a topic:

if (!request.xhr? || params[:track_visit]) && current_user
 TopicUser.track_visit! @topic_view.topic, current_user
 @topic_view.draft = Draft.get(current_user, @topic_view.draft_key, @topic_view.draft_sequence)
end

For now, I just extracted this stuff into new functions that live in the controller, and made the tracking a one-liner:

track_visit_to_topic

See where we are at with the TopicsController refactor

Handling the responding logic

The html response seems to be creating a bunch of variables that will be used in the app/views/topics/show.html.erb template.

The first thing that jumped out at me is that there were a bunch of if gateways to determine how to set the @canonical variable. As it turns out, however, nearly all of the variables needed for the view can be satisfied by a simple refactoring of the @topic_view instance, which is what I went ahead doing.

This means we can remove the entire section about setting the @canonical variable.

I added this in lib/topic_view.rb:

def canonical_path
 path = @topic.relative_url
 path << if @post_number
 page = ((@post_number.to_i - 1) / SiteSetting.posts_per_page) + 1
 (page > 1) ? "?page=#{page}" : ""
 else
 (@page && @page.to_i > 1) ? "?page=#{@page}" : ""
 end
 path
end

..and refactored the view to use @topic_view:

<%- content_for :canonical do %><%= "#{request.protocol}#{request.host_with_port}#{@topic_view.get_canonical_path} %><%- end %>

(Granted there is probably a better way to do the request.protocol etc.)

The last bits of logic in the controller determine the value of @next_page, which will provide a link to the user, if there is another page of posts:

last_post = @topic_view.posts[-1]
if last_post.present? and (@topic_view.topic.highest_post_number > last_post.post_number)
 @next_page = (@topic_view.posts[0].post_number / SiteSetting.posts_per_page) + 2
end

The above is yet another example of feature envy, and is rife with demeter violations, so the correct place for this code is in TopicView:

class TopicView
 def next_page
 last_post = @posts[-1]
 if last_post.present? and (@topic.highest_post_number > last_post.post_number)
 (@posts[0].post_number / SiteSetting.posts_per_page) + 2
 end
 end
 def next_page_path
 "#{@topic.relative_url}?page=#{next_page}"
 end
end

At the end of the day, the final TopicsController#show method looks something like this:

def show
 @topic_view = TopicView.new(params[:id] || params[:topic_id], current_user, params)
 anonymous_etag(@topic_view.topic) do
 redirect_to_correct_topic and return if slugs_do_not_match
 View.create_for(@topic_view.topic, request.remote_ip, current_user)
 track_visit_to_topic
 perform_show_response
 end
end

..which is much easier to read and maintain.

Conclusion

I really love how discourse is a solid contender to compete against the old guard LAMP stacks of PhpBB, invasion powerboard, bbpress, etc. The technology stack is much better, and it seems just like a totally fresh start. I’m excited to contribute more and make Discourse a shining example of well-architected code.

Keep track of the pull request here!

Tweet Please enable JavaScript to view the comments powered by Disqus.blog comments powered by

Viewing all articles
Browse latest Browse all 9433

Trending Articles