Một vài sai lầm khi code rails

1. Mở đầu

Sau đây mình xin giới thiệu 1 vài sai lầm mình và 1 vài người bạn của mình đã gặp khi code rails. (Hi vọng các bạn cũng đã từng gặp :v)

2. Nhầm lẫn time zone

a. get current time mà ko có time-zone Sai

Time.now
Date.today

Đúng

Time.zone.now
Time.current
Date.current

Nếu ko gọi theo time-zone, thời gian trả về là thời gian của server (Đặt tại việt nam là +7) thay vì là thời gian được config trong rails app (Tokyo +9).

b. chệnh lệch time-zone của app, so với time lưu trong mysql Trong rails, thuộc tính updated_at khi được gọi ra đã kèm theo time-zone

User.first.updated_at
  User Load (0.8ms)  SELECT  `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1
=> Wed, 21 Jun 2017 11:39:28 JST +09:00

Nhưng thực chất trong Database MySQL, thuộc tính này được lưu ở múi giờ (+0 UTC) dù cho trong rails-app có config time-zone ở đâu chăng nữa.

config.time_zone = "Tokyo"

Giá trị lấy từ mysql 2017-06-21 02:39:28

Tuy nhiên chúng ta có thể hiện tâm khi đọc, ghi thuộc tính kiểu dữ liệu Time, DateTime rails đã tự động convert cho chúng ta.

Time.zone.now
Tue, 27 Jun 2017 13:07:44 JST +09:00

User.where(updated_at: Time.zone.now..1.day.since)
User Load (0.6ms)  SELECT `users`.* FROM `users` WHERE (`users`.`updated_at` BETWEEN '2017-06-27 04:08:11' AND '2017-06-28 04:08:11')

Cho đến đây thì mọi thứ vẫn ổn.

Nhưng vấn đề phát sinh:

Date.current
Tue, 27 Jun 2017

User.where("DATE(users.updated_at) = ?",  Date.current)
SELECT `users`.* FROM `users` WHERE (DATE(users.updated_at) = '2017-06-27')

Hàm DATE của MySql sẽ lấy ra date của updated_at (Date ở múi giờ 0), đem so sánh với date '2017-06-27' ở múi giờ +9 Vậy là sẽ có sự sai lệnh về kết quả. Query trên lấy ra các User có updated_at trong khoảng 2017-06-27 00:00:00 - 2017-06-27 23:59:59 trong MySQL. Tương đương với updated_at trong khoảng 2017-06-27 09:00:00 - 2017-06-28 08:59:59 trong rails App

Như vậy query đúng phải là

User.where(updated_at: Date.current.beginning_of_day..Date.current.end_of_day)
SELECT `users`.* FROM `users` WHERE (`users`.`updated_at` BETWEEN '2017-06-26 15:00:00' AND '2017-06-27 14:59:59')

Qua đây ta cũng biết thêm 1 điều là, nếu APP rails lưu 1 trường với kiểu dữ liệu Date (thay vì mặc định là DateTime) thì sẽ gặp phải vấn đề tương tự nhưng sẽ rất ít người để ý.

3. Nhầm lẫn ở assets-pipeline

a. Sai lầm khi không sử dụng helper(image-url, asset-url, font-url...)

style.css

  .faq-search .search .btn-search {
    background: url("btn-search.png") ;
  }

Mặc dù trên Development Environment chúng ta vẫn thấy được ảnh background "btn-search.png" Nhưng khi đưa lên staging, production. Sau khi được áp dụng assets-pipeline, CDN assets Chúng ta sẽ gặp lỗi Failed to load resource "YOUR_WEB_HOST/btn-search.png" Nguyên nhân là ảnh đã được upload lên assets-host ( Amazon S3) /AMAZONES3/assets/btn-search.png nên cần phải sử dụng helper của rails, đổi tên file

style.css.scss

  .faq-search .search .btn-search {
    background: image-url("btn-search.png") ;
  }

b. Sai lầm khi sử dụng logic động trong assets javascript app/assets/javascripts/example.js.erb

console.log("<%= Date.current %>")

Thoạt nhìn có vẻ hợp lý, nhưng thực chất đây là assets nên sẽ được compile lại, nên Date.current sẽ luôn luôn có giá trị không đổi, (Đó là ngày compile assets), không phải là ngày gửi request lên controller. Để giải quyết vấn đề này cần tìm cách implement khác (Đọc giá trị này từ DOM HTML) app/assets/javascripts/example.js.erb

var date_current =  S(''#date-current").val()
console.log(date_current)

4. Nhầm lẫn khi sử dụng transaction, worker

a. Transaction không rollback

class User < ApplicationRecord
    validates :name, presence: true #Validate tên user ko được trống
end

User.first.name
"Kratos"
    ActiveRecord::Base.transaction do
      user = User.first
      user.update name: "ABC"
      user.update name: "" 
      true
    end

Theo lý thuyết về transaction thì mong muốn ở đây tên user vẫn được giữ nguyên là "Kratos". Nhưng kết quả là transaction đã được commit và tên user đang là "ABC" Cách sửa:

    ActiveRecord::Base.transaction do
      user = User.first
      user.update! name: "ABC"
      user.update! name: "" 
      true
    end

Nguyên nhân là transaction chỉ được rollback nếu như có exception được raise ra. update return false nếu như không pass validate, trong khi đó update!sẽ raise exception nếu update không thành công.

b. Sidekiq Worker bị retry nhiều lần

Tượng tự worker sẽ bị retry (chạy lại lần nữa) nếu như trong perform raise exception

class ExampleWorker
  include Sidekiq::Worker

  def perform id
    user = User.first
    UserMailer.send user
    user.update! name: ""
   
  end
end

Như vậy trong trường hợp này user sẽ liên tục nhận email spam vì worker này được retry nhiều lần. Vậy nên cần chú ý sửa lại 1 chút

class ExampleWorker
  include Sidekiq::Worker

  def perform id
    user = User.first
   
    if user.update! name: "ABC"
        UserMailer.send user
    end
  end
end

Sau khi sửa lại worker bị retry nhiều lần, nhưng user chỉ nhận được email 1 lần duy nhất khi worker chạy thành công

5. Vấn đề khi sử dụng assign_attributes

Khi sử assign_attributes ta luôn nghĩ chỉ gán giá trị cho object thì database sẽ không bị thay đổi. Nhưng....

class Firm
  has_many :clients
end
class Client
  belongs_to :firm
end
Firm.last.assign_attributes clients: []

Nhưng thực chất clients vẫn bị xóa.

SELECT `clients`.* FROM `clients` WHERE `clients`.`firm_id` = 32

DELETE FROM `clients` WHERE `clients`.`id` = 34
DELETE FROM `clients` WHERE `clients`.`id` = 35
DELETE FROM `clients` WHERE `clients`.`id` = 36

Vấn đề này mình gặp khá nhiều lần, không hiểu đây là lỗi của rails hay có lý do gì khiến họ cố tình làm vậy. :v Nhưng nói chung hãy lưu ý rằng assign_attributes vẫn có thể thay đổi vào database

6. Vấn đề khi sử dụng scope

Mặc dù 2 querry này nhìn cảm thấy như ko khác gì nhau

where.not message_converted: true
với
where.message_converted: [nil, false]

Nhưng....

SELECT * FROM `offers` WHERE (`offers`.`message_converted` != 1)SELECT * FROM `offers` WHERE (`offers`.`message_converted` = 0 OR `offers`.`message_converted` IS NULL)

Query số 1 lấy ra những thằng khác true => tức là false Query số 2 lấy ra những thằng nil hoặc false.

Như vậy rất dễ bị nhầm lẫn, hầu hết những người sử dụng scope 1 sẽ bị sai.

7 Schedule & Timezone

Lập lịch để chạy 1 việc vào lúc 04:30.

every :day, :at => '4:30 am' do
  rake "do_something"
end

Tuy nhiên vấn đề không đơn giản như thế nếu timezone của server lệch với timezone của rails_app (Ví dụ server đặt ở Nhật bản +9 ) nhưng timezone của APP là việt nam (+7). Lúc này crontab sẽ nhận 4:30 của giờ hệ thống (+9) trong khi mong muốn phải là 4:30 của timezone (+7).

30 4 * * * /bin/bash -l -c 'cd /home/o-o/Work/testapp && RAILS_ENV=production bundle exec rake do_something --silent'

Để giải quyết vấn đề này nên set thời gian hệ thống bằng thời gian của rails_app (+7). Vì đáng tiếc là crontab ubuntu ko hỗ trợ timezone 😦 hoặc tìm một service schedule khác có hỗ trợ timezone.

8 Duplicate Schedule

Vẫn là schedule này, tuy nhiên chạy trên hệ thống phân tán.

every :day, :at => '4:30 am' do
  rake "do_something"
end

Nếu hệ thống của bạn có 2 instances trở lên, bạn sẽ gặp vấn đề là các schedule này đều được chạy trên tất cả các instances. Có 2 cách khắc phục: a) Chỉ chạy schedule trên con leader /config/deploy.rb

set :whenever_environment, ->{fetch(:stage)}
set :whenever_identifier, ->{"#{fetch(:application)}_#{fetch(:stage)}"}
set :whenever_roles, :whenever

config/deploy/production.rb

if ENV["LOCAL_DEPLOY"]
  server "localhost", user: "deploy", roles: %w(app db whenever)
else
  require_relative "elb"
  servers = get_ec2_targets
  servers.each do |sv|
    roles = ["app"]
    if sv[:name] == ENV["AWS_LOCAL_DEPLOY_EC2_NAME"]
      roles << "db"
      roles << "whenever"
    end
    server sv[:private_ip], user: "deploy", roles: roles
  end
end

b) Chỉ định rõ role cho từng schedule

every :day, :at => '4:30 am', roles: [:whenever_leader] do
  rake "do_something"
end
every :day, :at => '5:00 am', roles: [:whenever_instances] do
  rake "do_something_on_instances"
end

config/deploy/production.rb

if ENV["LOCAL_DEPLOY"]
  roles = %w(app db whenever_leader)
  if ENV["RUN_ALL_SCHEDULE"]
    roles << "whenever_instances"
  end
  
  server "localhost", user: "deploy", roles: roles
else
  require_relative "elb"
  servers = get_ec2_targets
  servers.each do |sv|
    roles = ["app"]
    if sv[:name] == ENV["AWS_LOCAL_DEPLOY_EC2_NAME"]
      roles << "db"
      roles << "whenever_leader"
    else
      roles << "whenever_instances"
    end
    
    server sv[:private_ip], user: "deploy", roles: roles
  end
end

9 Kết luận

Trên đây là 1 vài điều thú vị mình đã từng gặp. Hi vọng bài viết này hữu ích. ❤️