All talk but no code...

makes lulalala a bluff boy

Posts match “ Rails ” tag:

Rails Config 私人偏方

| Comments

在一個 Rails 專案,常需要使用一些設定值。最常見的就是各種 API 使用的 key。通常我們會把這些設定獨立起來放在一個 yaml 檔案裡面,避免紀錄在 git 的版本管理系統裡面。個人使用了 Settingslogic ,覺得十分合用,不過在一些細節上我想要推廣一些撇步:

讓設定檔成為能獨立運作的 class

Rails 的啟動是十分花時間的,在 Ruby 1.9 下面通常要等個幾十秒。有時候有些可以獨立運作的套件為了使用一個 Rails 下面的設定值,就得花時間啟動整個 Rails 才能取得設定。要避免這種情形,Settingslogic 我是這樣寫的:

# config/app_config.rb 檔案
require 'settingslogic'
class AppConfig < Settingslogic
  source File.dirname(__FILE__) << "/app_config.yml"
end

首先是手動 require Settingslogic,然後其 yaml 檔案則是使用相對路徑作參照(與 app_config.rb 同一個目錄)。這樣子在其他環境就能簡單 require "./path/to/app_config" 讀取設定了。

我是把我的 app_config 放在 config 資料夾下面,然後在 config/initializers/ 下開個 app_config.rb 檔案 require 我的 app_config

我的 config/app_config.yml 長這樣:

exception_notification:
  recipients:
    - lulz@example.com
    - lols@example.com
sendgrid:
  user_name: 'foobar'
  password: '2000'
rollbar:
  access_token: dghgkalt4k5439fkgio43pb2

當然這個檔案不會放進版本控制。會放進版本控制的是 config/app_config.yml.example 檔案,裡面只有放 key 沒有值,讓大家知道需要設定什麼。我也沒有用 namespace 之類的東西,反正一個地方就一個設定,依照 exmaple 照樣畫葫蘆即可。

把網域資訊存進設定檔中

一個網站的網域資訊是很多設定不可或缺的一部分。比如說我使用 capistrano_nginx 就常常需要像是 .example.com 的設定。網域設定又會因為環境的不同而改變。 production 跟 staging 各會有不同的設定。所以我覺得把網域寫在 config 中是最恰當的。

# app_config.rb 中
  # returns domain string
  # pass in false for segment key to hide that segment, e.g. `protocol:false`
  def self.domain(params = {})
    params = {protocol:'http'}.merge(params)
    params = self.get('domain_setting').to_hash.merge(params)

    url = ''
    url << params[:protocol].clone << '://' if params[:protocol]
    url << params[:subdomain].clone << '.' if params[:subdomain]
    url << params[:domain]
    url << ':' << params[:port].to_s if params[:port]
    url
  end
# app_config.yml 中
# 注意我的 key 是 symbol 喔
domain_setting:
  :domain: 'lvh.me'
  :port: 3000

這樣子,我在就可以呼叫下列指令來取得不同的網域資訊:

# irb 中
AppConfig.domain #=> "http://lvh.me:3000"
AppConfig.domain(protocol:false) #=> "lvh.me:3000"
AppConfig.domain(protocol:false, subdomain:'admin') #=> "admin.lvh.me:3000"

這裡使用的參數跟 url_for 類似。

我在 production.rb 跟 staging.rb 當然也就這樣改了:

config.asset_host = AppConfig.domain(subdomain:'assets')
config.action_mailer.default_url_options = { :host => AppConfig.domain(protocol:false) }

像是 staging/development 這種地方,把可能變動的網域給寫死在 rb 檔然後存進去 git 中本來就怪怪的,所以我都用設定檔來作設定。另一個好處是,想要修改網域就只要修改一個地方,徹底貫徹DRY懶人精神!

最後,記得也要準備一份只有 key 沒有 value 的 app_config.yml.example ,放入版本管理中,給以後的人作參考喔。

事後 eager load associations

| Comments

Eager loading 是 Rails 解決 N+1 問題的方法,用 includes 方法就能在讀取資料庫資料時順便把 association 也讀進來。

不過 includes 是得在讀取之前就先下的指令,而我卻有時候卻只有一堆已經讀進來的 Active Record 物件,想要只用一次 sql query 把每個物件自己的 association 都讀進來,是該怎樣作呢?其實對一個陣列的 ActiveRecord 我們可以這樣作:

posts = [a, b, c] # some AR Post objects
ActiveRecord::Associations::Preloader.new(posts, :comments).run()

Rails 4.1 upgrade and FactoryGirl.create with association

| Comments

I was trying to upgrade from Rails 4.0 to Rails 4.1. Some specs broke, and I found a small behaviour change.

So my user has_one profile.

user.association(:profile).loaded? # false
FactoryGirl.create(:profile, user: user)
user.association(:profile).loaded? # false

Prior to my upgrade, the profile association will not be loaded before/after FactoryGirl.create call.

However after Rails 4.1

user.association(:profile).loaded? # false
FactoryGirl.create(:profile, user: user)
user.association(:profile).loaded? # true

This means I have to be careful to not have stale profile in tests, calling reload more often.

P.S. my factory_girl gem has remain unchanged in version 4.4.0 and factory_girl_rails gem in 4.4.1. So this is solely due to Rails behavior change.

Apply SEO-friendly url only to show path

| Comments

Rails developers often use to_param to add more information to the url. Taking this very page as an example:

http://logdown.com/account/posts/290439-seo-and-to-param

We can clear get an idea of what this page is about. Therefore search engines favours this kind of urls.

However, if we just override to_param, we would also see other urls getting changed. One most notable example would be edit path:

http://logdown.com/account/posts/290439-seo-and-to-param/edit

I think this can cause issues as the seo-friend param can potentially get very long. Then it would be difficult to notice the edit in the url. Also it may not be that useful to do SEO on action urls (rather than the content urls). We probably will never want edit page to appear on search engines.

I think instead, we should conditionally do SEO on urls that needs it. One way to do this is to have our own version of the url helpers. For example, we can define post_url and post_path like this:

# Override default generated Rails route helpers.
module CustomUrlHelper
  def post_path(target, options = nil)
    target = "#{post.id}-#{post.title[0..60]}" if target.respond_to?(:title)
    super(target, options)
  end
end
Rails.application.routes.url_helpers.send(:include, CustomUrlHelper)

This means only show/delete/update paths will be SEO friendly. Other paths (especially custom actions) will be unaffected.

Always use respond_to

| Comments

We have a controller action which has a error handling state.

  if error
    flash[:error] = render_to_string(partial: "foo_error").html_safe
    render :error # renders error.js.erb
  end

However eventhough the js is rendered, the response content-type is still "text/html". (The request Accept is set to
*/*;q=0.5, text/javascript, application/javascript, application/ecmascript, application/x-ecmascript
) This is very puzzling to us, because this always worked for us.

Later we found out that the root cause was because we call render_to_string. If we remove that call, Rails will again be able to guess the content-type as text/javascript. Somewhere in the Rails internal must have set the type when render_to_string is called, eventhough it is not directly used by the response rendering.

Using respond_to would also solve this issue, forcing Rails to return text/javascript as the content type.

  if error
    flash[:error] = render_to_string(partial: "foo_error").html_safe
    respond_to do |format|
      format.js { render :error }
    end
  end

I guess we should always specify respond_to, so the content-type can be deterministic.

AdequateErrors - Overcoming limitation of Rails model errors API

| Comments

Over the years I encountered many issues related to ActiveModel::Errors API. After looking at the Rails source, I realized the original design was the root cause. errors was originally just a hash of array of String, which worked for simple requirements, but not for more complex ones.

In April I started collecting use cases, and study Rails source. Last month I finally put my hands on implementing a solution: a gem to apply object-oriented design principles to make each error an object, and provide new set of APIs to access these objects. I call it AdequateErrors.

AdequateErrors can be accessed by calling model.errors.adequate. It co-exists with existng Rails API, so nothing will break. But what issues does it solve? Let me list them one by one:

Query on errors using where

Imagine we need to access the empty error on any attributes:

model.errors.details.each {|attribute, errors|
  errors.find {|h|
    h[:error] == :empty
  }
}

AdequateErrors provides a where method. Now we can stop using loops, and write complex queries:

model.errors.adequate.where(type: :empty)
model.errors.adequate.where(attribute: :title, type: :empty)
model.errors.adequate.where(attribute: :title, type: :empty, count: 3)

This returns an array of Error objects. Simple.

Access both the message and details of one particular error

If one attribute has two foo_error and one bar_error, e.g.:

# model.errors.details

{:name=>[{error: :foo_error, count: 1}, {error: :bar_error}, {error: :foo_error, count: 3}]}

How do you access the message on the third particular error? With the current implementation, we have to resort to using array indexes:

model.errors.messages[:name][2]

Or we can call generate_message to recreate a message from the details, but that's also tedious.

With AdequateErrors, we won't have this problem. Error is represented as an object, message and details are its attributes, so accessing those are straightforward:

e = model.errors.adequate.where(attribute: :title, type: :foo_error).first
e.message # full message

e.options # similar to details, where meta informations such as `:count` is stored.

Lazily evaluating message for internationalization

@morgoth mentioned this issue that when you're adding error, it's translated right away.

# actual:

I18n.with_locale(:pl) { user.error.full_messages } # => outputs EN errors always


# expecting:

I18n.with_locale(:pl) { user.error.full_messages } # => outputs PL errors

I18n.with_locale(:pt) { user.error.full_messages } # => outputs PT errors

Taking this into consideration, AdequateErrors lazily evaluates messages only when message is called.

Error message attribute prefix

Not all error messages start with the attribute name, but Rails forces you to do this. People have developed hacks to bypass this. Others simply assigned errors to :base instead of the actual attribute. This is ugly.

Here is AdequateErrors' solution. It has its own namespace in the locale file, and instead of the global default format "%{attribute} %{message}", the prefix is moved into each individual entries:

en:

  adequate_errors:

    messages:

      invalid: "%{attribute} is invalid"

      inclusion: "%{attribute} is not included in the list"

      exclusion: "%{attribute} is reserved"

All built-in error types have been converted into this. If one wishes to have prefix-less error, simply have its entry in locale file without the %{attribute}.

Just less error prune code

I remember when I first learned about Object-Oriented design principle in uni, there was this example of payroll system. In the system, one array stores account name and another array stores account number. Whenever we need to delete an account, we need to manipulate both arrays. Further more, if we need to add a new attribute, we need to add a third array. It is very clear that objectifying this system can make it simpler and less error-prone.

This is what the current Rails errors implementation looks like:

def copy!(other) # :nodoc:
  @messages = other.messages.dup
  @details  = other.details.dup
end

def clear
  messages.clear
  details.clear
end

def delete(key)
  attribute = key.to_sym
  details.delete(attribute)
  messages.delete(attribute)
end

This being similar to the case I mentioned above, really can benefit from an object-oriented approach.

Conclusion

If you are a long-time Rails developer, chances are you have met similar issue before, please try this gem. If you have other usecases that you wish to improve on, I would like to know and see if it can be added into the gem. Happy hacking!