Archive for เมษายน 2009
ArgumentError in Community#search ใน RailsSpace
สงครามยังไม่จบอย่าพึ่งนับศพทหาร…แก้บั๊กที่แล้วไปแป๊ปเดียว เจออันใหม่อีกแล้ว
หลังจากเราแก้ไขให้ will_paginate ทำงานได้ ปรากฏว่าพอทำตาม Listing 11.9 หน้า 336 ก็เจอปัญหาใหม่ ประมาณว่าพอเราค้นหาคำปุ๊ปจะเจอ error ว่า
ArgumentError in Community#search
Showing app/views/community/_user_table.html.erb where line #16 raised:
The @community variable appears to be empty. Did you forget to pass the collection
object for will_paginate?
จริงๆแล้วโค้ดตรงนี้ก็ไม่ใช่ปัญหาเท่าไร ปัญหาจริงๆอยู่ที่ว่าเราต้องใช้โค้ดใน Listing 11.10 เพื่อเพิ่ม method ให้ทำงานได้ แต่ว่า Listing 11.10 มันเข้ากันไม่ได้กับ will_paginate และ Paginator ก็ไม่มีใน Rails 2.3 แล้วด้วย เพราะงั้นเราต้องเขียนโค้ดทดแทนที่ทำงานกับ will_paginate ได้แทน
คุ้ยๆโค้ด will_paginate ดู ได้โค้ดทดแทนก็ประมาณนี้ (โค้ดค่อนข้างห่วย แต่เวิร์คไปก่อน + โค้ดนี้เป็นโค้ดต่อท้ายใน method search ของ community_controller.rb เพราะยังไม่รู้จะทำให้เป็น generic ยังไง)
def search
@title = "Search RailsSpace"
if params[:q]
...
# Now combine into one list of distinct users sorted by last name.
...
# Manual pagination.
hit_specs = @users.collect { |user| user.spec }
page = params[:page] || 1
@specs = WillPaginate::Collection.create(page, 5, hit_specs.count) do |pager|
pager.replace(hit_specs)
end
@users = WillPaginate::Collection.create(page, 5, @users.count) do |pager|
pager.replace(@users)
end
@specs = @specs.paginate(:page => page, :per_page => 5)
@users = @users.paginate(:page => page, :per_page => 5)
end
end
เท่านี้ก็เรียบร้อย กล้อมแกล้มให้ทำงานได้ไปก่อน ค่อยหาวิธีทำให้โค้ดสวยขึ้นเมื่อความรู้เพิ่มขึ้น
อธิบายโค้ดคร่าวๆ:
- สร้าง @specs ขึ้นมาเพราะ _user_table partial ต้องใช้ส่งให้ will_paginate และเซ็ตค่าให้เป็น specs เฉพาะในหน้าที่ระบุเท่านั้น (เพราะ first_item และ last_item ใน _result_summary partial ต้องใช้)
- สร้าง @users และเซ็ตค่าให้เป็นเฉพาะ users ที่อยู่ในหน้าที่ระบุเท่านั้นเพราะ _user_table partial ต้องใช้ คล้ายๆกับ @specs แหละ
- method create ของ WillPaginate::Collection จะทำการสร้าง collection ใหม่ที่จะครอบ @specs/@users โดยสนับสนุนการแบ่งหน้าในตัว (method paginate ก็เป็น method ที่ define ใน will_paginate gem)
ผจญภัยใน will_paginate เพื่อเพิ่ม first_item ใน RailsSpace (ภาค 3)
ต่อจากตอนที่แล้ว…ว่า Rails เหมือนกับจะไม่โหลด class WillPaginate::Collection จาก gem ขึ้นมาเพื่อ extend definition ที่เราสร้างไว้ใน lib/will_paginate/collection.rb เลยทดลองเปลี่ยนโค้ดโดยใช้ class_eval แทน
WillPaginate::Collection.class_eval do def first_item offset + 1 end end
เพราะคิดว่าการเรียกใช้ class จะทำให้ Rails โหลด gem ให้ แต่วิธีนี้มีสองปัญหา คือหนึ่ง Rails ไม่ได้โหลด gem ให้และสอง การที่ไม่ได้ define class แบบ class Collection ทำให้ Rails ยิง error ว่า
Expected /Users...rails_space/lib/will_paginate/collection.rb to define WillPaginate::Collection /opt/.../activesupport-2.3.2/lib/active_support/dependencies.rb:426:in `load_missing_constant'
จาก stack trace เราสรุปได้ว่า Rails รับผิดชอบในการโหลด dependencies ต่างๆ ไม่ใช่ Ruby เพราะฉะนั้น Rails สามารถที่เลือกโหลดหรือไม่โหลดไฟล์ได้ ข้อสังเกตุที่สองคือวิธีการโหลด Collection class นั้นต่างกับวิธีการโหลด class อื่นๆที่มาจาก gem ของ Collection class เป็นแบบนี้
/...activesupport-2.3.2/lib/active_support/dependencies.rb:426:in `load_missing_constant' /...activesupport-2.3.2/lib/active_support/dependencies.rb:80:in `const_missing' /...rails_space/lib/will_paginate/collection.rb:1 /...activesupport-2.3.2/lib/active_support/dependencies.rb:380:in `load_without_new_constant_marking' /...activesupport-2.3.2/lib/active_support/dependencies.rb:380:in `load_file' /...activesupport-2.3.2/lib/active_support/dependencies.rb:521:in `new_constants_in' /...activesupport-2.3.2/lib/active_support/dependencies.rb:379:in `load_file' /...activesupport-2.3.2/lib/active_support/dependencies.rb:259:in `require_or_load' /...activesupport-2.3.2/lib/active_support/dependencies.rb:425:in `load_missing_constant' /...activesupport-2.3.2/lib/active_support/dependencies.rb:80:in `const_missing' /...rails_space/lib/will_paginate/collection.rb:1 /...rubygems/custom_require.rb:31:in `gem_original_require' /...rubygems/custom_require.rb:31:in `require' /...activesupport-2.3.2/lib/active_support/dependencies.rb:156:in `require' /...activesupport-2.3.2/lib/active_support/dependencies.rb:521:in `new_constants_in' /...activesupport-2.3.2/lib/active_support/dependencies.rb:156:in `require'
ส่วนของ class อื่นๆจะประมาณนี้
/...gems/mislav-will_paginate-2.3.8/lib/will_paginate/array.rb:1 /...rubygems/custom_require.rb:31:in `gem_original_require' /...rubygems/custom_require.rb:31:in `require' /...activesupport-2.3.2/lib/active_support/dependencies.rb:156:in `require' /...activesupport-2.3.2/lib/active_support/dependencies.rb:521:in `new_constants_in' /...activesupport-2.3.2/lib/active_support/dependencies.rb:156:in `require'
จะเห็นได้ว่า แทนที่จะโหลด Collection class จาก gem จริงๆแล้ว Rails พยายามโหลด Collection มาจาก lib/will_paginate/collection.rb ของเราต่างหาก
หลังจากอุตส่าห์บ้าลองนู่นนี่อยู่นาน เพิ่งนึกได้ว่ามีหนังสือ The Rails Way อยู่ เลยลองเปิดดูบทแรก ปรากฏว่านี่มันเป็นพฤติกรรมที่มีมาตั้งแต่ Rails 1.2 แล้วนี่หว่า – -”
คือ Rails จะโหลด class หรือ module ที่ยังไม่ถูกโหลดขึ้นมาโดยอัตโนมัติ โดยพยายามหาจาก load_paths ที่ define ตอน Rails บู๊ตขึ้นมา สำหรับกรณีนี้ สรุปว่าปัญหาน่าจะเกิดจากการที่ /lib อยู่ใน load_paths ก่อน gem path นั่นเอง ลองดูได้จาก
$ script/console >> $:
(คลื่นแทรก: ลอง google ดู rails auto-loading ปรากฏว่ามีคนเจอปัญหาแบบเดียวกันเดี๊ยเลย ที่นี่)
เอาล่ะ หลังจากบ้าๆบอๆมานาน ก็ถึงวิธีแก้ เพราะไม่อยากมีภาคต่อแล้ว มันยาวเกิน
วิธีแก้วิธีแบบแรกก็คือใช้โค้ดแบบในลิงก์คนที่เจอปัญหาแบบเดียวกันนี่แหละ เพิ่มโค้ดนี้ต่อท้ายสุดใน environment.rb
module WpCollectionExtensions
def first_item
offset + 1
end
end
WillPaginate::Collection.instance_eval { include WpCollectionExtensions }
แบบทีสองคือใช้ after_initialize ของ config ใน environment.rb ประมาณว่าเพิ่มโค้ดนี้ก่อนจบ block ใน environment.rb วิธีนี้คือการเพิ่ม hook ให้ Rails รันโค้ดใน block ที่ส่งไปหลัง initialize ทุกอย่างเสร็จเรียบร้อย
config.after_initialize do class WillPaginate::Collection def first_item offset + 1 end end end
จริงๆแล้วสองวิธีนี้มันก็ไม่ต่างกันเท่าไรหรอก เพราะว่ามันรอจน Rails บู๊ตจบกระบวนความแล้วก็เอาโค้ดมาใช้
วิธีที่สามคือสร้างไฟล์ไว้ใน config/initializers แล้วเอาโค้ดไปใส่ไว้ในนี้แทน เพราะ Rails โหลดไฟล์ที่มีนามสกุล .rb ใน initializers นี้ด้วย เช่น สร้างไฟล์ชื่อ config/initializers/extend_will_paginate.rb แล้วใส่โค้ดเดียวกันก็ได้
class WillPaginate::Collection def first_item offset + 1 end end
เป็นอันจบซีรี่ส์โคตรยาวซะที จริงๆคงมีวิธีอื่นอีก แต่แค่นี้ก็เวิร์คแล้ว ไม่รู้จะหามันเพิ่มไปทำไมอีก
ร้อยแปดพันเก้า ผจญภัยใน will_paginate เพื่อเพิ่ม first_item ใน RailsSpace (ต่อ)
ในโพสก่อนหน้า เนื่องจากง่วงเลยหยุดไว้ตรงการสร้าง class WillPaginate::Collection ไว้ใน lib/will_paginate/collection.rb ไว้ประมาณนี้:
class WillPaginate::Collection def first_item offset + 1 end end
ซึ่งการวิเคราะห์ตอนง่วงนอนก็เหมือนจะใกล้เคียงอยู่ครึ่งนึง แต่ตอนนั้นยังไม่เข้าใจว่าทำไมเราถึงไม่สามารถ extend class ที่อยู่ในปลั๊กอินที่เป็น gem ได้ เลยลองนั่งไล่โค้ดใน Rails 2.3 เพิ่มเติม ได้มาซึ่งความน่าจะเป็นใหม่ แต่ก่อนอื่นต้องลองลำดับความเข้าใจก่อน
ใน Ruby เราสามารถ define class หรือ method ทับ class/method ที่มีอยู่แล้วได้ เช่น ถ้าเราเขียนโค้ดแบบนี้
module My class Test def oops puts "oops" end end end obj = My::Test.new obj.oops class My::Test def oops puts "oops, i did it again" end end obj.oops # prints "oops, i did it again"
ผลลัพธ์ที่ได้คือ “oops, i did it again” เพราะว่า Ruby อนุญาติให้เรา override method ได้
ปัญหาคือ ถ้า Ruby อนุญาติให้เรา override class definition ได้ ทำไมเราถึงไม่สามารถทำได้ใน Rails ล่ะ? เพราะสิ่งที่เราทำก็แค่พยายาม redefine WillPaginate::Collection ที่มาจาก gem และเพิ่ม method ใหม่เข้าไปนี่?
คำถามแรกคือ ตอนที่ Rails process ไฟล์ collection.rb ที่เราสร้างขึ้นมานั้น will_paginate gem ได้ถูกโหลดขึ้นมาใช้หรือยัง? เพราะถ้ามันถูกโหลดขึ้นมาใช้งานแล้ว ในทางทฤษฏีเราน่าจะสามารถ override class definition ได้ เพราะฉะนั้นถ้าเราคอมเม้นต์โค้ดที่มีอยู่ทั้งหมดในไฟล์ collection.rb และเพิ่มบรรทัดนี้เข้าไป:
puts defined? WillPaginate::Collection
เราก็จะพบคำตอบว่า…will_paginate ยังไม่ถูกโหลดขึ้นมาใช้งาน ตอน Ruby process ไฟล์นี้ (ลองดูใน log ของ script/server หรือ script/console มันจะพิมพ์บรรทัด nil ซึ่งเป็นผลลัพธ์จากโค้ดบรรทัดนี้นั่นเอง)
นี่หมายความว่าถ้าเรา define WillPaginate::Collection ไว้ใน lib/will_paginate/collection.rb เราก็จะสร้าง class ใหม่ขึ้นมา โดย class นี้จะมีแค่ method first_item ที่เรา define เอาไว้ ทำให้เกิด NoMethodError ที่เกิดขึ้นในโพสก่อนหน้านี่เอง
สมมุติว่าเราสันนิฐฐานว่า Rails ไม่โหลด will_paginate ก่อน process ไฟล์ collection.rb มาลองอะไรเล่นๆกันอีกหน่อย เรามาลอง require ‘will_paginate’ กันใน collection.rb ก่อนจะ redefine class ดูว่าจะเกิดอะไรขึ้น
require 'will_paginate' puts defined? Spec.paginate class WillPaginate::Collection def first_item offset + 1 end end
หลังแก้โค้ดเมื่อเรารัน script/server ก็จะมี output เหมือนเดิมคือ nil และถ้าเปิดเบราเซอร์ไปที่หน้า community และเลือกซักตัวอักษรนึงก็จะเจอ error แบบเดียวกันในโพสที่แล้วเลย คือ
NoMethodError in CommunityController#index undefined method `create' for WillPaginate::Collection:Class
ดูเหมือนว่าการ require will_paginate ก็ไม่โหลด definition ของ Collection จาก gem แต่ปัญหาคือเราสามารถเรียกใช้งาน Spec.paginate ได้ตามปกติใน community_controller.rb ซึ่งหมายความว่า gem ต้องถูกโหลดขึ้นมาแล้วแน่นอน
สรุปข้อมูลที่พบคร่าวๆก่อน:
- เกิดการ process ไฟล์ lib/will_paginate/collection.rb ขึ้นตอน Rails initialize
- will_paginate ยังไม่ถูกโหลดขึ้นมาตอน Rails process ไฟล์ lib/will_paginate/collection.rb
- การ require ‘will_paginate’ ใน collection.rb ก็ไม่โหลด gem ขึ้นมา
- gem ถูกโหลดขึ้นมาใช้งานแล้วเมื่อเราเปิดหน้า community page ในเบราเซอร์ (เพราะมีการเรียก paginate method จาก Spec model ได้จริงๆ จึงเกิด error)
- gem ที่ถูกโหลดขึ้นมาไม่รู้จัก Collection class ที่ define อยู่ในตัว gem เอง แต่กลับมาใช้ Collection class ที่ define ใน lib/will_paginate/collection.rb
จุดสังเกตุที่ 4 และ 5 น่าสนใจมาก ตอนนี้สันนิฐฐานว่า Rails ไม่ได้โหลด definition จาก gem ถ้า class ถูก define ไว้แล้วในที่อื่นๆ (ในกรณีนี้คือใน lib/will_paginate/collection.rb) อาจจะเป็นเพราะ Rails 2.3 เพิ่มฟีเจอร์การ lazy load/autoload เพื่อประสิทธิภาพการทำงาน
ข้อสันนิฐฐานนี้มีความเป็นไปได้ตรงที่ class อื่นๆของ gem เช่น WillPaginate และ finder extension ก็ถูกโหลดขึ้นมาใช้งาน จะเว้นก็แค่ Collection class จาก gem ที่ไม่ถูกโหลดขึ้นมาเลยทำให้เกิด NoMethodError
สิ่งที่เหลือต้องยืนยันคือว่า จริงหรือเปล่าที่ autoload ใน Rails 2.3 จะไม่โหลด class ที่ถูก define ไว้แล้วซ้ำสองครั้ง ถึงแม้ว่าการโหลดครั้งที่สองจะโหลด class ที่มี method เพิ่มเติมด้วย?
ติดตามตอนต่อไป…
ผจญภัยใน will_paginate เพื่อเพิ่ม first_item ใน RailsSpace
ในโพสก่อนหน้าเกี่ยวกับการแบ่งหน้าใน RailsSpace ได้เขียนอัพเดตต่อท้ายว่าไปเจอตัวอย่างของบล๊อก RailsSpace ที่เจ๋งกว่าเพราะว่าเอาโค้ด first_item, last_item, ฯลฯ ไปไว้ใน class WillPaginate เลย ทำให้ไม่ต้องสร้างตัวแปรพิเศษใน view
ปัญหามันอยู่ที่ว่าไอ้โค้ดตัวอย่างในไฟล์ lib/will_paginate_ext.rb มันเหมือนจะไม่ถูกโหลดขึ้นมาใช้งานใน Rails 2.3 นี่สิ ไม่รู้ว่าคนอื่นเจอปัญหาเดียวกันหรือเปล่าแต่ว่าถ้าเอาโค้ดไปอยู่ในไฟล์นี้แล้วเรียกใช้ first_item จะเจอปัญหาว่า:
NoMethodError: undefined method `first_item' for #<WillPaginate::Collection:0x291ec20>
เลยลองแก้วิธีแรกด้วยการเปลี่ยนชื่อไฟล์เป็น will_paginate.rb แต่เจอปัญหาใหม่ว่า
Expected /Users/Tik/temp/rails_space/lib/will_paginate.rb to define WillPaginate
อันนี้ก็พอเข้าใจได้ เพราะตาม convention แล้ว Rails คงคาดหวังให้เรามี class ชื่อ WillPaginate ในไฟล์ will_paginate.rb เลยลองใหม่ คราวนี้เปลี่ยนชื่อไฟล์เป็น collection.rb แต่พอเปลี่ยนชื่อแล้วก็กลับไปเจอปัญหาแรกอีก นั่นคือ NoMethodError
จากนั้นก็ลองย้ายไฟล์อีกที เพราะคิดว่า Rails อาจจะอยากให้มีไดเร็คทอรี่สำหรับ will_paginate ก็ได้ เลยย้ายไฟล์ไปที่ lib/will_paginate/collection.rb แทน หลังแก้ครั้งนี้ปรากฏว่าเจอปัญหาใหม่แทน:
NoMethodError: undefined method `create' for WillPaginate::Collection:Class from /opt.../mislav-will_paginate-2.3.8.../finder.rb:76:in `paginate'
ฮืมมม … จากอาการคร่าวๆ ดูแล้วน่าจะเกิดจากการที่เรา define class นี้ใน collection.rb ทำให้ Ruby ใช้ class นี้แทนสิ่งที่อยู่ใน will_paginate gem (เสมือนว่าเราสร้าง class ชื่อเดียวกันขึ้นมาเองอีกหนึ่ง class และ class ที่เราสร้างไม่มี method ต่างๆที่ will_paginate ต้องการ)
ปัญหาโลกแตกที่รอคอยการแก้ไขต่อไป…เนื่องจากดึกแล้วนั่นเอง