Metaprogramming In Ruby
Table of contents
Bài viết cuối năm 2020, mình muốn giới thiệu đến với các bạn kỹ thuật metaprogramming thông qua 1 ví dụ thực tiễn - viết Rest API nhé. Viết code ruby đã fun, có metaprogramming lại còn fun hơn!
What is metaprogramming?
Một cách ngắn gọn, metaprogramming là kỹ thuật để làm sourcecode - bản thân nó tự định nghĩa ra thêm code mới trong lúc runtime. Tưởng tượng như bạn đang code để lập trình ra thêm những đoạn code khác vậy. Nói như vậy nhưng không phải áp dụng nó để chúng ta tạo ra thứ gì bất biến, khó kiểm soát đâu! Sứ mệnh của nó là giúp chúng ta gom lại tất cả các xử lý tương tự nhau về làm một, viết code tối thiểu nhất có thể! Kỹ thuật này là 1 cái gì đó rất trick/hacking nên khuyến cáo trước là làm rồi dễ ghiền lắm nhé! :))
Không phải tất cả các ngôn ngữ đều hỗ trợ metaprogramming, trong số những ngôn ngữ hỗ trợ thì cũng chỉ 1 số ít là được sử dụng rộng rãi trong thực tế. Mình biết đến kỹ thuật này nhờ vào Ruby. Bởi vì nó khá là phổ biến ở đó, chúng ta có thể bắt gặp metaprogramming ở hầu hết các ruby repository trên Github!
See more at wikipedia > https://en.wikipedia.org/wiki/Metaprogramming
What Ruby has for metaprogramming?
Ngoài việc với thiết kế Metaclass - mọi thứ kể cả Class cũng thực chất là instance của 1 class cấp trên nào đó, theo mình yếu tố chính đưa metaprogramming trở lên phổ biến trong Ruby là nhờ vào các helper methods dưới đây.
- instance_variable_set/instance_variable_get
cho phép set/get biến instance dynamically
instance_variable_set("@foo#{'bar'}", 123)
# give us same result as
@foobar = 123
puts instance_variable_get("@foo#{'bar'}")
# give us same result as
puts @foobar
- send
cho phép gọi method với tên bất kỳ
obj.send("foo#{'bar'}")
# give us same result as
obj.foobar
- define_method
cho phép định nghĩa method dynamically
define_method "foo#{'bar'}" do
# do something
end
# give us same result as
def foobar do
# do something
end
Use metaprogramming when implement Rest APIs
Before metaprogramming
Giả sử bạn cần viết Rest API cho 2 resource là Car và Bike. Làm như bình thường thì sẽ viết ra CarsController và BikesController kiểu như sau.
class CarsController < Api::BaseController
before_action :set_car, only: %i[show update destroy]
def create
...
end
def show
...
end
def update
...
end
def destroy
...
end
private
def set_car
@car = Car.find(params[:id])
end
end
class BikesController < Api::BaseController
before_action :set_bike, only: %i[show update destroy]
def create
...
end
def show
...
end
def update
...
end
def destroy
...
end
private
def set_bike
@bike = Bike.find(params[:id])
end
end
Mình không trích dẫn ra tất cả logic, nhưng ít nhất bạn cũng đã nhận ra phần logic tương tự bị lặp lại ở chỗ set_car/set_bike rồi chứ? Tất cả chỉ là vì muốn tạo thêm 1 Rest API mới, nhưng mà class model cần tham chiếu đến thay đổi tùy từng API, và tên biến instance cũng cần thay đổi theo từng resource mà ta không thể làm class rồi kế thừa hay dùng kỹ thuật mixin để xử lý chung ở 1 chỗ được.
Trong công việc lập trình hàng ngày của chúng ta, có rất nhiều khi như vậy - chúng ta copy code từ class này qua class khác, rồi sau đó đổi tên biến, đổi tiên class vân vân đúng không?
After metaprogramming
Giờ mình sẽ demo metaprogramming để gom tất cả những đoạn code có shared behavior đó lại.
Đầu tiên, trong mỗi controller của 1 Rest API, ta cần suy diễn ra tên resource đang cần xử lý là gì. Ví dụ cars_controller.rb thì resource sẽ là car, và bikes_controller.rb thì là bike => có nghĩa là có thể lấy tên controller để suy ra tên resource!
# Ex: cars_controller.rb will return "car" string
def resource_name
@resource_name ||= controller_name.singularize
end
# Ex: cars_controller.rb will return "Car" class
def resource_class
@resource_class ||= resource_name.classify.constantize
end
Có 2 methods trên rồi, tiếp theo phần truy cập database lấy ra dữ liệu của record tương ứng với id rồi set vào biến instance sẽ rất đơn giản. Mình sẽ sử dụng instance_variable_set/instance_variable_get ở đây.
before_action :auto_set_resource, only: %i[show update destroy]
# Ex: cars_controller.rb will set up "@car" instance variable newly
def auto_set_resource(resource = nil)
resource ||= resource_class.find_by!(id: params[:id])
instance_variable_set("@#{resource_name}", resource)
end
# Ex: cars_controller.rb will return "@car" instance variable
def resource_object
instance_variable_get("@#{resource_name}")
end
Như vậy chúng ta chỉ cần tạo controller, công việc còn lại như gọi đến model class, rồi set biến instance vân vân thì metaprograming sẽ dynamically thực hiện cho! Tất cả được tự điều chỉnh dựa theo tên của controller! Toẹt vời phải không.
Logic cơ bản cho 4 actions CRUD cũng có thể nhóm lại bằng metaprogramming theo cách tương tự. Các bạn xem chi tiết implementation ở đây nhé. https://gist.github.com/chienkira/b6cb119912b2a90abbe34e8c4240d691
Thành quả cuối cùng là code controller của Rest API sẽ rút gọn được đến mức “cực tiểu” như dưới đây. Mỗi khi cần thay đổi common behavior của toàn bộ Rest API, công việc cũng trở lên dễ dàng vì chỉ cần sửa duy nhất module RestfulResponsable.
module Api
module V1
class CarsController < Api::V1::BaseController
include ::RestfulResponsable
# CRUD (create/show/update/destroy) logics are implemented inside RestfulResponable module
private
def car_params
params.permit(:code, :maker, :number)
end
end
end
end
Metaprogramming mang lại nhiều lợi ích như rút gọn code cho chúng ta, đóng băng các common behavior đảm bảo logic là duy nhất và thống nhất. Tuy nhiên đánh đổi lại, code readability sẽ bị giảm xuống chẳng hạn. Mình nghĩ áp dụng nó quá nhiều chưa chắc đã là ý tưởng hay, nên là chúng ta cũng nên chú ý đừng làm lố quá, kẻo bị thằng khác trong team nó chửi cho nha. 😃
Happy metaprogramming!