蒼時弦也
蒼時弦也
資深軟體工程師
發表於

用 Redux 跟 GraphQL 玩 Rails 5.1

上週五在處理網址續費的時候,發現幫老爸公司管理的網址已經多到一個程度。所以就決定把手邊可以轉移的服務都往 Gandi 丟過去。畢竟粗略估算可以達到 Grid B 的費率(實際上只有九五折)不過考量到有 API 能夠管理,以及一些自動化的手段,雖然相對還是稍微貴了一點,但是省去後續不少麻煩確實是有利的。

也因為這樣,就打算以串 Gandi 的 API 來練手一下,原本是想做完管理 Domain 的部分,不過沒想到在實作一些技術面上的東西花了不少時間,只做完簡單的價格查詢。

根據我的習慣,我通常會在新專案使用新的技術,這次是使用 Rails 5.1 支援 Webpack 的功能搭配上 Redux 和 GraphQL 來應用。我學 React 的時間是在 Redux 出來之前,所以一直都使用手刻 Flux 架構的方式去寫。

而 GraphQL 之前因為沒有成熟的 Ruby Gem 也一直沒有去碰,最近除了有相容 Rails 之外,也出現了透過 ActiveRecord 的 Association Reflect 去解決 GraphQL 會出現 N+1 問題的 Gem 讓我總算是下定決心去嘗試看看。

這個專案我自己的規劃是這樣的:

  • Dashboard
    • Domain Manager
      • Auto Renew
      • DNS Zone
        • CloudFlare Intergate
      • DNSSEC
    • SSL Manager
      • Auto Renew
      • Auto Deploy
  • API
    • GraphQL (Front-end)
    • RESTFul API (3rd-party)

大致上定位是基於 Gandi 給的 API 做自動化的管理,以及在一些服務上的部署可以有效率的處理(會整合 DevOps 之類的)


不過既然要買網址,所以就需要先了解 Gandi 上的收費以及可以購買的網址。

1gem 'gandi'

運氣不錯,已經有人將原本的 XMLRPC API 封裝成一個 Gem 可以用很簡單的方式來操作。

如果像我一樣使用 Ruby 2.4 因為 XMLRPC 已經從 Core 移除,所要自行追加 gem 'xmlrpc' 來補齊功能。

這個 Gem 的使用方法大致上如下:

1api = Gandi::Session.new(ENV['GANDI_API_TOKEN'])
2api.domain.list # 顯示帳號下所有的 Domain

不過對於 Rails 來說其實不容易使用,既然是第三方的 API 就先封裝成一個 Service 物件比較容易處理。 至於建立 API Client 實例的動作也可以封裝一下方便使用。

 1# frozen_string_literal: true
 2
 3# :nodoc:
 4module Gandi
 5  def self.api
 6    options = {}
 7    options[:env] = :test unless Rails.env.production?
 8    @api ||= Gandi::Session.new(Settings.gandi.token, options)
 9  end
10end

我習慣會使用 SettingsLogic 這個 Gem 來管理設定,這邊也可以將 Settings.gandi.token 替換成 ENV['GANDI_API_TOKEN'] 之類的。

因為 Gandi 有提供測試環境,所以在非 Production 時一律採用測試環境。

接下來就是封裝 Gandi 的價格查詢(Catalog)成為一個 Service 供系統使用(可能是歐洲服務商的關係,API 相當慢,在本地端足存一份副本會好很多。)

 1# frozen_string_literal: true
 2
 3module Domain
 4  class PriceService
 5    def initialize(currency = :EUR, grid = :A)
 6        @currency = currency
 7        @grid = grid
 8    end
 9
10    def query(query)
11        query = {product: query.merge(type: :domain)}
12        Gandi.api.catalog.list(query, @currency, @grid)
13    end
14
15    def all
16        query({})
17    end
18  end
19end

基本上就是對原本的 Gandi 做簡單的封裝,現在透過 Domain::PriceService.new.all 就可以輕鬆存取到需要的價格資訊。 不過因為要匯入到本地的資料庫,回傳的資料結構並不是我所期望的狀況,所以就再做了一層封裝。

 1# frozen_string_literal: true
 2
 3module Domain
 4    class PriceService
 5        class Result < Array
 6            def to_domain
 7                map { |item| build_domain(item) }
 8            end
 9
10            private
11
12            def build_domain(item)
13                CatalogDomain.new(
14                    description: item.product.description,
15                    action: item.action.name.parameterize(separator: '_'),
16                    phase: item.action&.params&.tld_phase,
17                    price: convert_to_price(item),
18                    grid: item.unit_price.first.grid
19                )
20            end
21
22            def convert_to_price(item)
23                Money.from_amout(
24                    item.unit_price.first.price,
25                    item.unit_price.first.currency
26                )
27            end
28        end
29
30        # 略
31        def query(query)
32            # ...
33            Result.new(Gandi.api.catalog.list(query, @currency, @grid))
34        end
35    end
36end

如此一來,我就可以用 Domain::PriceService.new.all.to_domain 轉成對應的 Model 方便匯入的動作。

  • Gandi 這個 Gem 已經用 Hashie 封裝過,所以可以透過類似物件的方式存取屬性
  • 因為主要是使用的是台幣,但是也希望儲存不同幣種的價格所以使用了 Money Gem 的功能

至於 Model 的部分就不多論敘,不過因為域名的資料現在有約 4000 筆,所以需要借助 activerecord-import 這個 Gem 做一次性的匯入,即使透過 Rails 的 Batch 功能也沒有辦法高效率的做匯入。

不過,在 Gandi 回傳的資料會有以下情況。

  • action 的差異:新增、轉入、續約等等
  • phase 的差異:已上線、日升期等等(域名術語,日升期這類是給商標註冊者購買或者預先用高價保留域名用的)

所以如果再沒有對這些欄位增加限制的話,就會碰到匯入時重複的問題而發生錯誤。

所以在寫 Migration 的時候要補上下面的索引來增加限制。

1add_index [:description, :action, :phase, :currency, :grid],
2          name: :catalog_domain_constriant,
3          unique: true

要這樣做的理由,是因為 activerecord-import 支援 ON CONFLICT 的 SQL 語法,在 PostgreSQL 上可以在碰到重複的資料改為對特定欄位更新,而不是插入一筆資料。

於是就可以撰寫一個 Rake Task 來處理定期同步價格的任務。

 1# frozen_string_literal: true
 2
 3namespace :domain do
 4  desc 'Load domain prices from Gandi'
 5  task :refresh, [:currency, :grid] => [:environment] do |_, args|
 6    currency = args[:currency] || :EUR
 7    grid = args[:grid] || :A
 8    domains = Domain::PriceService.new(currency, grid).all.to_domain
 9    CatalogDomain.import domains, on_duplicate_key_update: {
10      conflict_target: [:description, :action, :phase, :grid, :currency],
11      columns: [:price]
12    }
13    puts "Total #{domains.size} rows in #{currency} loaded."
14  end
15end

透過 on_duplicate_key_update 的設定,就可以在碰到相同的資料時只更新價格,如此一來就可以利用 CronJob 來每天同步當日最新的價格資訊。

到此為止,就「域名資料」的部分就算是已經處理完畢了。

接下來對 GraphQL 設定,依照教學配置好之後,要先讓 GraphQL 可以支援顯示我們需要的資料。

 1# frozen_string_literal: true
 2
 3Types::QueryType = GraphQL::ObjectType.define do
 4  name 'Query'
 5
 6  field :domains do
 7    type types[Types::CatalogDomainType]
 8    argument :tld, types.String
 9    resolve ->(obj, args, ctx) {
10      if args[:tld]
11        CatalogDomain.where('description LIKE ?', "%#{args[:tld]}%")
12      else
13        CatalogDomain.all
14      end
15    }
16  end
17end

這邊先簡單的支援查詢域名的功能,先不討論透過幣種或者價格來查詢,先讓使用者可以查詢現有可以註冊的域名有哪些類型即可。

至於 CatalogDomainType 只是單純的設定欄位而已,這邊就跳過不多做討論。

另外似乎是因為資料蠻多的關係,連生成 JSON 都有點慢,所以這邊額外設定了 oj 這個 Gem 來加速(大約是十倍快)

1# frozen_string_literal: true
2
3Oj::Rails.set_encoder
4Oj::Rails.set_decoder
5Oj::Rails.optimize(Array, BigDecimal, Hash, Range, Regexp, Time)

到這邊,我們的 Backend 就全部完成處置,接下來就是要讓前端可以使用這些資料來呈現。

首先到原本的 Layout 上面追加 Webpack 的 JS 檔案。 (預設還是傳統的方式,所以要手動加上由 Webpack 生成的版本)

<!-- 略 --->
    <%= javascript_pack_tag    'application' %>
</head>
<!-- 略 --->

接下來開啟 Rails 的 Webpack 伺服器(./bin/webpack-dev-server)之前使用 beta1 的時候還有 Watcher 的選項,不過正式版似乎去掉了,不過用 dev-server 效果基本上是相同的。

要注意的是,強烈不建議 Webpack 跟原本的 Sprocket 混用,除了自己會搞混之外,原有的 ExecJS 也挺容易出錯的,統一寫在 app/javascript/packs 會是不錯的選擇。

接下來安裝一下需要的套件。

1yarn add react redux react-redux react-dom redux-observable rxjs prop-types immutable redux-thunk

關於 UI 互動上,我偏向 RxJS 的解法,所以採用的是 redux-observable 的方式,至於 redux-thunk 因為不熟,大多教學都會裝一下,所以這邊單純跟風。

首先,先來處理 Reducer 的部分,這邊直接利用 Immutable 的 fromJS 直接把資料轉換。不過筆數這麼多的情況下,其實是不建議這樣做的,不過暫時還沒有找到恰當的處理方式,所以就先這樣做。

 1import { List, fromJS } from 'immutable';
 2
 3import {
 4  QUERY_DOMAIN,
 5  RECEIVED_DOMAIN,
 6} from '../constriants';
 7
 8const initState = Map({
 9  fetching: true,
10  domains: List([]),
11});
12
13export const domainReducer = (state = initState, action) => {
14  switch (action.type) {
15    case START_REQUEST: {
16      return state.set('fetching', true);
17    }
18    case FINISHED_REQUEST: {
19      return state.set('fetching', false)
20                  .set('data', fromJS(action.response.data.domains));
21    }
22    default: {
23      return state;
24    }
25  }
26};
27
28export default domainReducer;

關於 constriants 就不多做說明,從以前的習慣就是會開一個目錄(或者檔案)把全部的 Action Type 統一塞在裡面管理,雖然說直接寫字串沒什麼問題,但是難免出錯,這種統一管理的方式倒是可以避免一些人為疏失。

接下來對 Action 做處理。

 1import { ajax } from 'rxjs/observable/dom/ajax';
 2import 'rxjs';
 3
 4import {
 5  QUERY_DOMAIN,
 6  RECEIVED_DOMAIN,
 7} from '../constriants';
 8
 9const ENDPOINT = '/graphql';
10
11export const queryDomain = query => (
12  {
13    type: QUERY_DOMAIN,
14    payload: JSON.stringify({ query }),
15  }
16);
17
18export const receivedDomain = response => (
19  {
20    type: RECEIVED_DOMAIN,
21    response,
22  }
23);
24
25export const queryDomainEpic = action$ => (
26  action$.ofType(QUERY_DOMAIN)
27         .debounceTime(1000)
28         .mergeMap(action =>
29           ajax({ url: ENDPOINT, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: action.payload })
30           .map(result => receivedDomain(result.response)),
31         )
32);

基本上跟一般的 Redux 沒有太大的差別,比較特別的是以 Epic 結尾的這個動作,這個就是 redux-observable 所提供的特殊 Action 行為,可以把它視為 Action 的管理者。

裡面的實作則是透過 RxJS 所實現的,簡單說就是碰到 QUERY_DOMAIN 類型的動作,先等待 1000ms 確認沒有其他操作後,用「最後一次」的操作繼續,並且合併另一個動作(Ajax 查詢)繼續進行。

此時的 QUERY_DOMAIN 被觸發後,會再等待被合併的 Ajax 查詢完成後才一起回傳。而這個 Ajax 查詢則是我們要做的 GraphQL 查詢。

接下來把焦點放到查詢畫面的元件上,這邊我們只討論做 Dispatch 的這個動作。

 1  componentDidMount() {
 2    this.props.dispatch(startRequest('{domains { description, price, currency }}'));
 3  }
 4
 5  onSearchChange() {
 6    const tld = this.text.input.value;
 7    this.setState({ search: tld });
 8    this.props.dispatch(
 9      startRequest(`{domains(tld: "${tld}") { description, price, currency }}`),
10    );
11  }

實際上也是很淺顯易懂的,在前面的 Action 中我們是採取直接將整個 GraphQL 傳入的方式,所以在觸發動作時也是直接將查詢寫到裡面。

還不熟悉 Redux 綁定輸入框的方式,因為有點晚了所以直接用 ref 的做法做綁定。

到這邊眼尖讀者可能會發現,我們並沒有去呼叫 queryDomainEpic 但是似乎卻自己運作起來了,這部分是 redux-observable 的特性,也就是說我們將呼叫實際動作的任務交給 Epic 來管理。

最後要統整一下所有的 Epic 整合到 Redux 裡面(跟 Reducers 類似)

const epics = combineEpics(queryDomainEpic);
const epicMiddleware = createEpicMiddleware(epics);

const store = createStore(
    applyMiddleware(epicMiddleware),
    reducers
);

接下來就會正常運作了!


其實這樣的進度大約花了快一天左右的時間,雖然中間有跑去設定 pry 跟打遊戲之類的,不過整體上來說要反覆的把 Redux 練熟之外,還要掌握 GraphQL 的應用,也是要花上不少時間在上面的。

不過學技術就是這樣,當原本的技術熟悉到一個程度後,做起來當然是非常熟練的。不過如果不願意花時間在新技術上,就會一直沒辦法便的熟練,雖然目前有遊戲跟很多坑的關係,其實也不太能練新技術。但是有機會的話,還是會想在各種專案上做一些嘗試,來看看自己到底能做到怎麼樣的效果。

這篇文章大多是省略了查文件就可以做到的部分,所以看起來挺簡單的。不過要查完文件後再踩雷之後做出來,倒也是一件不太容易的事情。不過 Rails 5.1 提供了 Webpack 環境以及一些好用的 Gem 倒是大大改善不少在這部分所浪費掉的時間。

雖然沒有如預期的完成到最基本可以購買域名,但是整體上來說倒是累積了不少經驗。