12/08/2018, 16:21

Sử dụng state_machine và các event.

Thông thường thì các đoạn code chúng ta viết ra để thưc hiện giải quyết vấn đề nào đó chẳng hạn như Raise lên một Exception thì không tránh khỏi việc phải sử dụng đến những câu điều kiện và làm cho đoạn code của chúng ta trở nên khá rối và khó đọc. Tôi tự hỏi có cách nào để loại bỏ những điều kiện ...

Thông thường thì các đoạn code chúng ta viết ra để thưc hiện giải quyết vấn đề nào đó chẳng hạn như Raise lên một Exception thì không tránh khỏi việc phải sử dụng đến những câu điều kiện và làm cho đoạn code của chúng ta trở nên khá rối và khó đọc. Tôi tự hỏi có cách nào để loại bỏ những điều kiện đó không, hay nói cách khác là nó sẽ thực hiện điều gì đó khi có sự thay đổi của 1 cái gì đó của 1 Object chẳng hạn. Điều đó là cho chúng ta có thế hình dung mã code của chúng ta rõ ràng hơn. Và state_machine đã cho tôi là được điều đó.

Để giải thích rõ hơn về nó chúng ta hãy băt đầu với đoạn code sau( tất nhiên là không phải tôi tạo ra nó, mà chỉ là lượm nhặt trên NET thôi. =)) ) :

class Order
    include AggregateRoot
    NotAllowed = Class.new(StandardError)
    Invalid    = Class.new(StandardError)

    def initialize(number:)
      @number         = number
      @state          = :draft
      @items          = []
    end

    def add_item(sku:, quantity:, net_price:, vat_rate:)
      raise NotAllowed unless state == :draft
      raise ArgumentError unless sku.to_s.present?
      raise ArgumentError unless quantity > 0
      raise ArgumentError unless net_price > 0
      raise ArgumentError if vat_rate < 0 || vat_rate >= 100

      # make changes and apply new state
    end

    def submit(customer_id:)
      raise NotAllowed unless state == :draft
      raise Invalid    if items.empty?

      # make changes and apply new state
    end

    def cancel
      raise NotAllowed unless [:draft, :submitted].include?(state)
      apply(OrderCancelled.strict(data: {
        order_number: number}))
    end

    def expire
      return if [:expired, :shipped].include?(state)
      apply(OrderExpired.strict(data: {
        order_number: number}))
    end

    def ship
      raise NotAllowed unless state == :submitted
      apply(OrderShipped.strict(data: {
        order_number: number,
        customer_id: customer_id,
      }))
    end

    private

    attr_reader :number, :state, :items, :fee_calculator, :customer_id

    def apply_strategy
      ->(_me, event) {
        {
          Orders::OrderItemAdded => method(:apply_item_added),
          Orders::OrderSubmitted => method(:apply_submitted),
          Orders::OrderCancelled => method(:apply_cancelled),
          Orders::OrderExpired   => method(:apply_expired),
          Orders::OrderShipped   => method(:apply_shipped),
        }.fetch(event.class).call(event)
      }
    end

    def apply_item_added(ev)
      # ...
    end

    def apply_submitted(ev)
      @state = :submitted
      @customer_id = ev.data[:customer_id]
    end

    def apply_cancelled(ev)
      @state = :cancelled
    end

    def apply_expired(ev)
      @state = :expired
    end

    def apply_shipped(ev)
      @state = :shipped
    end
  end

Như các bạn có thế thấy các method thưởng bắt đầu bằng việc kiểm tra state. Kiểm tra 1 state thì như thế này:

def ship
    raise NotAllowed unless state == :submitted
    # ...
  end

Kiểm tra với 2 state thì như thế này:

def cancel
    raise NotAllowed unless [:draft, :submitted].include?(state)
    # ...
  end

Thỉnh thoảng thì chúng ta lại muốn xử lý hay không làm gì thay vì phải raise error như thế này:

def expire
    return if [:expired, :shipped].include?(state)
    # ...
  end

Vì thế khi tôi sử dụng state_machine thì tôi ko cần phải tạo ra nhiều rules giống nhau. mà chỉ quan tâm nó như thế nào mà thôi. Điều đó làm cho mã code rõ ràng và tường minh hơn.

Khi tôi apply state_machine vào thì nó sẽ như thế này:

class Order
  include AggregateRoot
  NotAllowed = Class.new(StandardError)
  Invalid    = Class.new(StandardError)

  def initialize(number:)
    @number  = number
    @state   = 'draft'
    @items   = []
  end

  state_machine :state do
    state 'draft' do
      def add_item(sku:, quantity:, net_price:, vat_rate:)
        raise ArgumentError unless sku.to_s.present?
        raise ArgumentError unless quantity > 0
        raise ArgumentError unless net_price > 0
        raise ArgumentError if vat_rate < 0 || vat_rate >= 100

        # ...
      end

      def submit(customer_id:)
        raise Invalid if items.empty?

        # ...
      end
    end

    state 'submitted' do
      def ship
        apply(OrderShipped.strict(data: {
          order_number: number,
          customer_id: customer_id,
        }))
      end
    end

    state 'expired' do
      def expire; end
    end

    state 'cancelled' do
      def cancel; end
    end

    state all - %w(expired shipped) do
      def expire
        apply(OrderExpired.strict(data: {
          order_number: number
        }))
      end
    end

    state *%w(draft submitted) do
      def cancel
        apply(OrderCancelled.strict(data: {
          order_number: number
        }))
      end
    end
  end

Trông khá tuyệt đúng ko. clean hơn hẵn. Nếu một method có thể chỉ được gọi trong một state duy nhất thì bạn có thể định nghĩa như sau:

state 'submitted' do
    def ship
      # ...
    end
  end

Nếu bạn thử gọi method đó ở 1 state khác thì nó sẽ get một NoMethodError. Nó còn có thể định nghĩa một method được gọi trong chỉ 2 state:

state *%w(draft submitted) do
    def cancel
      # ...
    end
  end

với đoạn mà này thì method cancel sẽ thực hiện khi state mà 1 trong 2 state draftsubmitted

Hơn thế nữa chúng ta còn có thể thực hiện một method với các states khác trừ một số states chúng ta chỉ định:

state all - %w(expired shipped) do
    def expire
      # ...
    end
  end

Với đoạn mã trên nó method expire sẽ được gọi khi state được thay đổi và không phải là 2 states là expiredshipped

Tôi đã tìm thấy ở đây event transition và transition callback. Nó có thể giúp tôi chuyển đổi từ state này sáng state khác khi thực hiện 1 event nào đó do tôi định nghĩa.

 event :expire do
    transition all - %w(expired shipped) => :expired
  end

với đoạn mã trên tôi sẽ chuyển tất cả các state trừ expiredshipped thành state expired

Ngoài ra chúng ta còn có thể sử dụng các callback của nó như là before_transitionafter_transition

Chúng ta có thể sử dụng state_machine cho việc xử lý các state và sự thay đổi của chúng để thực hiện những việc chúng ta muốn thay vì cứ phải định nghĩa các rules để xử lý. Điều đó làm cho code trở nên rõ ràng và tưởng minh hơn.

0