Skip to Content

Rails Design Pattern - Adapter Pattern

Posted on

Mở đầu

Nếu bạn là một web developer với khoảng 2 năm kinh nghiệm, chắc hẳn các bạn đã không ít lần đọc qua về các Design patterns hay cách áp dụng chúng để làm cho code trở nên hướng đối tượng hơn, dễ đọc, dễ hiểu, dễ maintain, dễ mở rộng, … Các design patterns được áp dụng khá nhiều trong các Rails projects, mà phổ biến nhất là Service Object, Decorators, Form Object, Query Objects, Policies, Null Objects, … Những design patterns này có lẽ đã khá quen thuộc với các bạn nên hôm nay mình sẽ giới thiệu 1 design pattern khác (không mới nhưng vẫn rất hiệu quả) đó là Adapter Pattern

Adapter Pattern

Adapters là các objects được sinh ra với mục đích đóng gói các công việc gọi API của bên thứ 3.

Adapters có thể coi là 1 abstraction layer bên trên mỗi khi chúng ta gọi API ra bên ngoài. Tất cả các công việc như tạo input, call api, xử lý dữ liệu trả về, … sẽ được nhóm chung vào 1 chỗ để dễ quản lý. Đồng thời nếu trong tương lai, chúng ta muốn thay đổi gem đã dùng để gọi API, chúng ta có thể dễ dàng chuyển đổi hơn, chỉ cần tìm và thay thế 1 chỗ chứ k phải sửa trong toàn project.

Note: Adapter objects được đặt ở /app/adapters/folder

Problem

Chúng ta có 1 bài toán đơn giản như sau: Gọi API của Instagram với 1 access token có sẵn, sau đó lấy về feed của tài khoản đó.

Khá đơn giản phải không =))

1. Bad Solution

Bạn có thể làm việc này rất đơn giản, chỉ cần viết API call trong controller hoặc trong Service Object: Tạo Instagram client trong controller bằng token có sẵn, sau đó gọi API và lấy kết quả trả về. Ez =))

class PhotosController < ApplicationController
  Rails.application.secrets.instagram_access_token

  def index
    instagram_client = Instagram.client(access_token: INSTAGRAM_ACCESS_TOKEN)
    feed = instagram_client.user_recent_media

    render json: feed
  end
end

Có 1 nhược điểm mà bạn có thể nhận thấy, đó là những đoạn code của chúng ta có thể bị trùng lặp, và nằm tản mác ở nhiều nơi. Nếu sau này muốn thay thế gem khác (hoặc có thể code của gem thay đổi) thì sẽ gặp khó khăn trong việc search in project && sửa lại code.

2. Better Solution

Chúng ta sẽ tách phần gọi API của Instagram ra 1 chỗ, InstagramAdapter. Các dữ liệu cần thiết để khởi tạo client cũng sẽ được move vào trong Adapter class. Bằng cách này, chúng ta sẽ dễ swap gem với 1 gem khác. Bên cạnh đó, ta cũng đảm bảo được Single Responsibility Prrinciple, do InstagramAdapter class chỉ có nhiệm vụ là handle việc gọi API tới Instagram.

class PhotosController < ApplicationController
  def index
    render json: instagram_client.recent_media_with_location
  end

  def instagram_client
    InstagramAdapter.new
  end
end
# app/adapters/instagram/instagram_adapter.rb
class InstagramAdapter
  INSTAGRAM_ACCESS_TOKEN = "9999"

  def initialize
    @client = Instagram.client(access_token: INSTAGRAM_ACCESS_TOKEN)
  end

  def recent_media
    @recent_media ||= @client.user_recent_media(count: 50)
  end

  def recent_media_with_location
    recent_media.reject { |item| item.location.nil? }
  end
end

Như vậy, việc gọi API tới Instagram đã được tách hẳn ra bên ngoài. Nếu như gọi tới nhiều API của 1 bên thứ 3 khác chúng ta cũng có thể tạo nhiều Adapter. Ví dụ chúng ta cần gọi dữ liệu liên quan tới Commit và Repo trên Github, ta có thể chia thành 2 adapters: app/adapters/github/commits_adapter.rbapp/adapters/github/repos_adapter.rb thừa kế từ app/adapters/github/base_adapter.rb. Khá dễ dàng để quản lý và mở rộng đúng không :D

Tuy nhiên, tới đây chúng ta lại gặp phải 1 vấn đề: Nếu như chúng ta muốn xây dựng 1 wrapper đầy đủ để tương tác với API của bên thứ 3 (bao gôm việc handle requests/response khi gọi API đó) thì việc chỉ sử dụng adapters là không đủ.

Ví dụ như khi gọi API, dữ liệu trả về ở dạng XML nhưng chúng ta lại muốn sử dụng dạng JSON chẳng hạn. Nếu như gem chúng ta dùng không đáp ứng đầy đủ được, chúng ta sẽ phải tìm 1 giải pháp hợp lý khác. (Thay vì việc viết thêm hàm vào trong Adapter để xử lý response trả về ^^)

Để giải quyết vấn đề này, chúng ta sẽ xây dựng Serializers và Deserializers Objects. Trong đó: Serializers sử dụng để xử lý đầu vào trước khi nó được gửi lên server của bên thứ 3. Deserializers sử dụng để parse responses trả về từ API.

Deserializers Objects

Trong thực tế, chúng ta rất dễ gặp những trường hợp như format của response trả về từ API không phù hợp với việc chúng ta đang cần làm.

Deserializers được lưu ở /app/adapters/{api_service}/deserializers/. Trong đó api_service có thể là facebook, github, …

Example:

module Instagram
  module Deserializers
    class RecentMedia
      attr_reader :response_body

      def initialize(response_body)
        @response_body = response_body
      end

      def success?
        stripped_response_body[:response_code] == "0"
      end

      def failed?
        !success?
      end

      def status_code
        stripped_response_body[:response_code]
      end

      def status_message
        stripped_response_body[:response_code_description]
      end

      private

      def stripped_response_body
        @stripped_response_body ||=
          @response_body[:hosted_page_authorize_response][:hosted_page_authorize_result]
      end
    end
  end
end

Khi đã có Deserializer này rồi, chúng ta sẽ dễ dàng xử lý response trả về trong Adapter:

module Adapters
  class InstagramAdapter
    INSTAGRAM_ACCESS_TOKEN = "9999"

    def initialize
      @client = Instagram.client(access_token: INSTAGRAM_ACCESS_TOKEN)
    end

    def recent_media
      Instagram::Deserializer::RecentMedia.new(raw_recent_media)
    end

    def recent_media_with_location
      recent_media.reject { |item| item.location.nil? }
    end

    private

    def raw_recent_media
      client.user_recent_media(count: 50)
    end
  end
end

Ngoài cách làm trên, bạn cũng có thể deserialize 1 collections items

module Instagram
  class RecentMedia

    def initialize(instagram_user_id)
      @instagram_user_id = instagram_user_id
    end

    def instagram_recent_media
      Instagram.user_recent_media(instagram_user_id, count: 50)
    end

    def feed
      @feed ||= instagram_recent_media.map { item| Deserializer::MediaItem.new(item) }
    end

    def items_with_location_present
      feed.reject { |item| item.restaurant_name.nil? }
    end

    def items_with_location_present_in_json
      items_with_location_present.map(&:to_json)
    end
  end
end

class InstagramAdapter
  INSTAGRAM_ACCESS_TOKEN = "9999"

  def initialize
    @client = Instagram.client(access_token: INSTAGRAM_ACCESS_TOKEN)
  end

  def user_recent_media
    itemize(@client.user_recent_media)
  end

  private

  def itemize(user_recent_items)
    user_recent_items.map { |item| MediaItem.new(item) }
  end
end
module Instagram
  class MediaItem
    attr_reader :item

    def initialize(item)
      @item = item
    end

    def restaurant_name
      item.location.name if item.location?
    end

    def image_url
      item.images.standard_resolution.url
    end
  end
end

4. Serializer Objects

Serializers Objects được dùng để chuẩn bị dữ liệu trước khi gửi nó lên trên server. Nó đặc biệt hữu dụng khi chúng ta dùng để tạo xml request.

module Dolcela
  class RecipeSerializer
    def initialize(id)
      @id = id
    end

    def method
      "post"
    end

    def request_body
      @xml_request_body ||= "<?xml version='1.0'?>" + Gyoku.xml(
        method_call: {
          method_name: "recipes.get",
          params: {
            param: {
              value: {
                struct: {
                  member: [
                    {
                      name: "content_id",
                      value: { int: @id }
                    },
                    {
                      name: "omit_author_data",
                      value: { int: 1 }
                    },
                    {
                      name: "get_basic_data",
                      value: { boolean: 1 }
                    },
                    {
                      name: "include_preparation_steps",
                      value: { boolean: 1 }
                    },
                    {
                      name: "get_action_shots",
                      value: { boolean: "1" }
                    },
                    {
                      name: "include_ingredients",
                      value: { boolean: 1 }
                    }
                  ]
                }
              }
            }
          }
        }
      )
    end
  end
end
module Coolinarika
  module Adapter
    class RecipeAdapter < BaseAdapter
      def recipe(id)
        @id = id
        RecipeDeserializer.new(fetch_recipe)
      end

      private

      def fetch_recipe
        execute_request RecipeSerializer.new(@id)
      end
    end
  end
end

trong BaseAdapter, chúng ta sẽ viết 1 hàm excute_request chung cho tất cả các adapters để các adapter có thể gọi tới API được.

module Coolinarika
  module Adapter
    class BaseAdapter

      API_BASE_URL = "http://www.coolinarika.com/api/"

      def self.execute_request(request)
        RestClient::Request.execute(request.method,
                                    url: API_BASE_URL,
                                    payload: request.body,
                                    headers: { content_type: "application/xml" }
                                   )
      end
    end
  end
end

Kết luận

Như vậy, chúng ta có thể thấy, lợi ích của việc sử dụng Adapter Objects là:

  • Giúp tạo ra 1 absstraction layer xung quanh việc sử dụng API bên ngoài. Bằng cách này, chúng ta sẽ tách sự phụ thuộc vào lib ra 1 chỗ riêng, sẽ dễ để maintain/ scale sau này.
  • Tiện dụng cho việc testing (do nó đã được tách thành các object riêng biệt, có đầu vào - trong serializer và đầu ra - trong deserializer).
  • Code hướng đối tượng hơn, tuân thủ các quy tắc của OOD

Hy vọng thông qua bài viết, các bạn có thể nắm được nguyên lý cơ bản của Adapter Pattern để áp dụng vào projects sau này.

Tham khảo:

Rails handbook: Adapter Pattern

Arkency Ruby on Rails Adapters

Adapter Design Pattern usage in Rails App

comments powered by Disqus