這篇文章發表於 8 年前,文章內容可能已經過時,請謹慎參考。
上週五在處理網址續費的時候,發現幫老爸公司管理的網址已經多到一個程度。所以就決定把手邊可以轉移的服務都往 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
- Domain Manager
- 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 封裝過,所以可以透過類似物件的方式存取屬性
- 因為主要是使用的是台幣,但是也希望儲存不同幣種的價格所以使用了
MoneyGem 的功能
至於 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 倒是大大改善不少在這部分所浪費掉的時間。
雖然沒有如預期的完成到最基本可以購買域名,但是整體上來說倒是累積了不少經驗。