0

Các vấn đề cần chú ý khi lập trình meta trong Ruby

Tài liệu: Things to Consider when Metaprogramming in Ruby

Lập trình meta trong Ruby là một chủ đề phân cực. Mục đích chính của lập trình meta trong ruby là việc viết code sinh ra code ở thời điểm đang chạy. Lập trình meta làm cho code ngắn gọn và linh hoạt hơn. Tuy nhiên, nó không phải là không có chi phí. Đối với hầu hết mọi thứ, không có gía trị là free, thậm chí là metaprogramming.

Chắc chắn rằng sẽ có cơ hội và chỗ đứng thích hợp cho metaprogramming, nhưng nhận thấy sự nhượng bộ là cần thiết để hỗ trợ cho metaprogramming solutions là điều quan trọng.

Khả Năng Đọc và tìm hiểu Code Một vấn đề với giải pháp lập trình meta đó là trở ngại trong việc tìm hiểu code. Khi bước vào một dự án mới hay đơn giản chỉ là làm quen với các thứ tồn tại, tìm sự thực thi code trong text editor có thể hoàn toàn khó khăn nếu phương thức được định nghĩa không tồn tại.

Cho ví dụ, Chúng ta có thể đảm bảo rằng một lớp User tồn tại tập các phương thức với lập trình meta:

    class User
      [
        :password,
        :email,
        :first_name,
        :last_name
      ].each do |attribute|
        define_method(:"has_#{attribute}?") do
          self.send(attribute).nil?
        end
      end
    end

Mặc dù có một ít sự sắp xếp trước, code này là một danh sách các phương thức đơn giản trên lớp User. Giải pháp này dễ dàng được mở rộng để include thêm các thuộc tính mà chưa định nghĩa phương thức đầy đủ trên mỗi thuộc tính.

Tuy nhiên, các phương thức đó không thể tìm kiếm sử dụng grep, silver searcher, hay các công cụ "find all" khác. Do đó phương thức has_password? không bao giờ được định nghĩa tường minh trong code. Nó không thể tìm thấy được.

Một vấn đề nữa là một số developer chọn viết ghi chú(comment) các phương thức được định nghĩa trên khối metaprogramming. Giải pháp đơn giản này là tuyệt vời để giúp cho code dễ đọc hơn:

    class User
      # has_password?, has_email?, has_first_name?,
      #  has_last_name? method definitions
      [
        :password,
        :email,
        :first_name,
        :last_name
      ].each do |attribute|
        define_method(:"has_#{attribute}?") do
          self.send(attribute).nil?
        end
      end
    end

Hiệu suất (Performance) Phụ thuộc vào tổng số lần một phần code được thực thi, sự xem xét đến hiệu suất là vô cùng quan trọng. "Hot Code" là một thuật ngữ để mô tả code mà được gọi thường xuyên trong suốt chu trình request ứng dụng. Do đó không phải tất cả code được tạo bằng nhau, hiểu về hiệu suất kéo theo các tiếp cận metaprogramming khác nhau là cần thiết khi viết hay chỉnh sửa hot code.

The Setup: Một ứng dụng ví dụ cần xử lý dữ liệu đầu vào mở rộng. Trong lúc tiếp nhận dữ liệu nó tạo ra một truy cập tới môt phần của ứng dụng. Có vô số tùy chọn tồn tại để giải quyết vấn đề naỳ, nhưng chúng ta có thể đảm bảo rằng chỉ cố một vài cái là có thể thực hiện được cho Ruby codebase.

Dự liệu đầu vào như sau:

    {
      "user": {
        "name": "Some User",
        "phones": [
          "818-555-5555",
          "415-555-5555"
        ],
        "email": "email@whatever.com",
        "birthday": "12-12-1900"
      }
    }

Chú ý: Dữ liệu này sẽ liên quan tới các vi dụ tiếp theo gọi nó là incoming_data và chúng ta đã chuyển đổi nó từ JSON thành Ruby hash.

1. All Methods: Một cách để chấp nhận và tích hợp dữ liệu đầu vào này là tạo ra một lớp để gắn tất cả các thuộc tính dưới key 'user' tới các phương thức.

    class UserMetaMethods
      def initialize(hash)
        hash.each_pair do |key, value|
          self.class.send(:attr_accessor, key)
          self.send(:"#{key}=", value)
        end
      end
    end

    user = UserMetaMethods.new(incoming_data['user'])
    user.email
    # => email@whatever.com

Giải pháp này làm ra các truy cập tới dữ liệu đầu vào rất tốt. Tất cả các thuộc tính xuất hiện giống như là các phương thức được trả về hoàn toàn với respond_to? và có biến đối tượng tương thích mỗi thuộc tính.

2. method_missing Một nhóm các giải pháp lập trình meta có thể không được thực hiện mà không sử dụng method_missing. Với method_missing, một lời gọi phương thức non-existent có thể bị chặn trên đối tượng và được ước lượng thêm dữ liệu không biết tới hàm gọi gốc.

    class UserMethodMissing
      def initialize(hash)
        @hash = hash
      end

      def method_missing(method_name, *arguments, &block)
        key = method_name.to_s
        if @hash.key?(key)
          @hash[key]
        else
          super
        end
      end

      def respond_to_missing?(method_name, include_private = false)
        @hash.key?(method_name.to_s) || super
      end
    end

    user = UserMethodMissing.new(incoming_data['user'])
    user.email
    # => email@whatever.com

Phương thức respond_to_missing? cũng được định nghĩa để cho phép respond_to?method gọi thực thi thành công. Tìm hiểu thêm về respond_to_missing?đây.

Chú ý: Các Pattern tương tự điều này được sử dụng trong một số thư viện phổ biến như là OpenStructHashie để lấy ra các kết quả tương tự.

3. “Regular” Object Với tư cách là một control, một đối tượng regular Ruby được tạo với các thuộc tính cụ thể được định nghĩa:

    class UserRegular
      attr_reader :name,
                  :phones,
                  :email,
                  :birthday

      def initialize(hash)
        @name = hash['name']
        @phones = hash['phones']
        @email = hash['email']
        @birthday = hash['birthday']
      end
    end

    user = UserRegular.new(incoming_data['user'])
    user.email
    # => email@whatever.com

Nhược điểm của cách tiếp cận này đó là: Nếu sự ràng buộc của dịch vụ bên ngoài thay đổi đối tượng này có thể không được khởi tạo với tất cả dữ liệu thích hợp.

4. A Hash Không thêm code được yêu cầu cho tiếp cận này, một consumer có thể đơn giản sử dụng kết quả Ruby Hash sau khi tiếp nhận JSON đã được phân tích.

    incoming_data['user']['email']
    # => email@whatever.com

Không phải là một giải pháp lập trình meta, nhưng vẫn là một cách hợp lệ để xử lý việc truyền dữ liệu. Sử dụng một Hash đơn giản không tạo ra sự linh động cho các giải pháp khác nhưng có thể là một trường hợp cơ bản tuyệt vời cho việc kiểm tra hiệu suất.

Làm thế nào để so sánh Cuối cùng, phần thú vị nhất là: performance benchmarks

Thư viện chúng ta sẽ sử dụng để test các giải pháp là: benchmark/ips.

Thư viện này đơn giản chỉ khai báo các phần cài đặt khác nhau và sau đó so sánh chúng.

    require 'benchmark/ips'

    Benchmark.ips do |x|
      x.report('UserMetaMethods') do
        1000.times do
          u = UserMetaMethods.new(incoming_data['user'])
          u.email
        end
      end

      x.report('UserMethodMissing') do
        1000.times do
          u = UserMethodMissing.new(incoming_data['user'])
          u.email
        end
      end

      x.report('UserRegular') do
        1000.times do
          u = UserRegular.new(incoming_data['user'])
          u.email
        end
      end

      x.report('Hash') do
        1000.times do
          u = Hash(incoming_data['user'])
          u['email']
        end
      end

      x.compare!
    end

Mỗi report tương ứng một giải pháp được mô tả ở trên. MộtHash report không cần khởi tạo một đối tượng mới, nhưng vì mục đích nhất quán, một Hash mới được khởi tạo từ tất cả mọi thứ dưới key 'user' của Hash gốc.

Kết quả chạy code này:

    Calculating -------------------------------------
       UserMetaMethods     79.294  (± 2.5%) i/s -    399.000
     UserMethodMissing      1.531k (± 1.2%) i/s -      7.791k
           UserRegular    913.295  (± 1.4%) i/s -      4.628k
                  Hash      3.141k (± 1.0%) i/s -     15.860k

    Comparison:
                  Hash:     3141.2 i/s
     UserMethodMissing:     1530.5 i/s - 2.05x slower
           UserRegular:      913.3 i/s - 3.44x slower
       UserMetaMethods:       79.3 i/s - 39.61x slower

Wow! chúng ta thấy ở trên sử dụng Hash, method_missing là nhanh nhất và tất cả mọi người sẽ đi thay đổi tất cả code sử dụng method_missing !không, dừng lại không nên làm điều này.

Trong khi nó có thể nhanh hơn UserRegular, nó không phải là không có nhược điểm của nó. Một giải pháp method_missing tất nhiên có giá trị khác nhau trong mỗi trường hợp khác nhau nhưng với một cái benchmark đơn giản như vậy không đủ thuyết phục bất cứ ai chuyển code dựa trên tốc độ cả.

Còn về các thư viện tồn tại mà có các hành vi tương tự với method_missing(ví dụ HashieOpenStruct) thì sao?

Để thêm chúng tới benchmark tồn tại, các lớp tương thích được tạo như sau:

    require 'ostruct'

    class UserOpenStruct < OpenStruct
    end

    require 'hashie'

    class UserMash < Hashie::Mash
    end

Sau đó hai hàm gọi report mới có thể thêm chúng tới benchmark tồn tại

    Benchmark.ips do |x|
      # ...

      x.report('OpenStruct') do
        1000.times do
          u = UserOpenStruct.new(incoming_data['user'])
          u.email
        end
      end

      x.report('UserMash') do
        1000.times do
          u = UserMash.new(incoming_data['user'])
          u.email
        end
      end

      # ...
    end

Kết quả của hai cái thêm vào có một ít ngạc nhiên:

    Calculating -------------------------------------
       UserMetaMethods     79.050  (± 2.5%) i/s -    399.000
     UserMethodMissing      1.537k (± 1.3%) i/s -      7.752k
           UserRegular    914.824  (± 1.4%) i/s -      4.576k
            OpenStruct     49.954  (± 6.0%) i/s -    250.000
              UserMash    194.411  (± 1.5%) i/s -    988.000
                  Hash      3.140k (± 0.9%) i/s -     15.759k

    Comparison:
                  Hash:     3140.1 i/s
     UserMethodMissing:     1536.9 i/s - 2.04x slower
           UserRegular:      914.8 i/s - 3.43x slower
              UserMash:      194.4 i/s - 16.15x slower
       UserMetaMethods:       79.0 i/s - 39.72x slower
            OpenStruct:       50.0 i/s - 62.86x slower

Mặc dù OpenStructHashie dường như rất giống với giải pháp method_missing, cả hai đều có kết quả xấu. Tuy nhiên, giống với các giải pháp lập trình meta khác, cả OpenStructHashiebù lại cho sự thiếu sót về mặt tốc độ này với sự linh hoạt.

Nếu đây là một vấn đề thực trong ứng dụng production,OpenStructHashie tất nhiên cả hai có thể là một giải pháp có thể tồn tại. Ngoài ra code path để sử dụng các thư viện là rất hot, vấn đề hiệu suất của chúng không phải là một nhân tố.

Tại sao tốc độ chậm? Giải pháp lập trình meta chậm là do một phần được làm với Ruby inline method cache. Trong Ruby, inline method cache chịu trách nhiệm lưu trữ các phương thức mà nó đã thực hiện để tránh phải tìm kiếm và thực hiện lại mỗi lần. Sự trở ngại của lập trình meta với cache xây dựng sẵn này bởi cache key không hợp lệ của nó.

Mỗi lần một lớp được mở lại hay một phương thức được định nghĩa trên một lớp, một phần của inline method cache key thay đổi, kết quả không được tìm thấy trong cache và nó phải tìm kiếm method. Metaprogrammed code (đặc biệt là code mà thực thi ở mỗi Object.new giống như UserMetaMethod) không có lợi đối với inline method caching nó giống như các cách mà code thông thường làm. Để tìm hiểu chi tiết về Ruby inline method caching xem ở đây.

Sử dụng Tool thích hợp cho Job

Khi lặp qua danh sách các lựa chọn, không có một data point nào đủ để xác định một lựa chọn là tốt nhất so với các lựa chọn khác. Benchmarks giống như một data point và mang độ sâu để so sánh, không có quy luật cho nó. Sau tất cả, ai quan tâm một phần của code chậm là bao lâu nếu nó không bao giờ chạy?

Metaprogramming là một tool mạnh mẽ trong ngôn ngữ ruby mà nên sử dụng. Giống như bất cứ thứ gì, sử dụng metaprogramming quá nhiều có thể làm cho code khó maintain. Sự sắp xếp code có thể tuyệt với cho job security, nhưng có thể hiệu năng thấp, khó đọc, và khó maintain bởi những người khác.

Khi được sử dụng chính xác và trong trường hợp thích hợp, metaprogramming có thể là tuyệt vời. Các trick được biết khi sử dụng nó và khi ngăn cản nó. Đôi lúc chỉ cần sử dụng mỗi Hash đã là một giải pháp tốt nhất.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí