4 months ago

有將文章加入最愛連結的不影響,已經對新的網站做舊有連結導向。

已搬遷至 新站

 
5 months ago

前言

這兩支算是滿普遍熱門的 rails gem

  • 做 file upload 會用 Carrierwave
  • 紀錄 model operation log 會直接用 PaperTrail

但這兩支 Gem 有天生不合的地方,如果安裝時沒有稍微調整,等數據跑下去,發現時就會知道這是個巨坑

設定情況

  1. 一個常見情況 User table 有 image 欄位,資料格式是 string, 用來傳使用者頭像的數據
  2. 設定了 has_paper_trail, 只有在 update 時觸發的 callback 會記錄 object 前後變化
class User < ApplicationRecord
...
mount_uploader :image, HeadImageUploader
has_paper_trail on: [:update]
...
end

在另一張表 Order 裡面,我希望能夠拿到該訂單下訂時的 user 的 is_enterprise 狀態

class Order < ApplicationRecord
...
  def owner_is_enterprise_when_ordering?
    owner.versions.version_at(self.created_at).is_enterprise?
  end
...
end

因為 PaperTrail 再執行 version_at 時,會直接去下 WHERE 找出當時時間 range 的 Log 並把裡面的 object deserialized 出來成一個 instance,也就是執行了 reify

所以到這裡一切都很美好,只要 call owner_is_enterprise_when_ordering? 我可以知道當時下訂單時,該使用者的企業認證狀態囉, right?

踩雷的開始

但今天我就拿到一個 error

ActionView::Template::Error: undefined class/module HeadImageUploader::Uploader70109597847360

這時我就不明白怎麼會炸在 HeadImageUploader ?

直到我嘗試了更新 User 的 image 後,去執行 owner_is_enterprise_when_ordering? 發現確實炸了

/home/deploy/.rbenv/versions/2.4.4/lib/ruby/2.4.0/psych.rb:253 in load
/gems/paper_trail-8.0.1/lib/paper_trail/serializers/yaml.rb:17 in load
/gems/paper_trail-8.0.1/lib/paper_trail/version_concern.rb:184 in object_deserialized
/gems/paper_trail-8.0.1/lib/paper_trail/reifier.rb:17 in reify
/gems/paper_trail-8.0.1/lib/paper_trail/version_concern.rb:217 in reify
/gems/paper_trail-8.0.1/lib/paper_trail/record_trail.rb:412 in version_at
app/models/order.rb:391 in owner_is_enterprise_when_ordering?

嗯,Log 顯示一路炸上去

繼續追查下去,發現一個重點,通常 PaperTrail 預設的儲存格式是 YAML,但像是 Carrierwave 就是 JSON,也就是我們會遇到所謂「天生不合」的問題。

我們發現在每一次的 callback, paper trail versions 會將 image 欄位試圖存入整個 object, 然後在執行 reify 時在做 object deserialized 就炸了

接著開始找解決方案,大致有兩種作法

  1. skip 掉 image 的 paper trail
  2. 對 paper trail 做 monkey-patch

不過不管你挑哪一種作法,基本之前存的資料如果拉出來會爆就是廢了,畢竟他可是直接存 YAML 呢(竟然是預設值),然後你就會發現最好玩的是...

在 PaperTrail 的 issue 中,有人建議 A JSON serializer should be included by default

然後 Carrierwave 裡面的 wiki 也直接教你 How to: use with PaperTrail

結論

最後我是放棄 patching 資料了,就直接 rescue 找不到資料的提示了

提醒如果有這兩個 Gem 混用的時機,切記要注意

最好一開始就把 PaperTrail 預設改成 JSON 或是把 file uploader 的欄位 skip 掉

但在討論串中就有人認為既然都 Paper trail 了,理當是每個欄位都需要被紀錄,所以把欄位 skip 掉也不是很好的作法。

如果無藥可救,就放棄整個 PaperTrail 的 reify 功能吧,反正你也對爛掉的資料做不了 deserialize...

 
5 months ago

如果你遇到下列的條件,需要反思這樣的邏輯有必要拆成 service object 嗎?

  • 這個邏輯抽象出來,只有一個地方使用,沒有複用的可能
  • 單元測試並不好測試
  • 當他需要從 controller 傳 params 時(因為 controller 的 params 是層層關係做出來的,並非單純一個 hash 這麼簡單)
 
5 months ago

有個需求是,對一個集合算出所有的數據中,兩個欄位的時間相減,取全部平均花費時間。

狀況如下

# == Schema Information

#

# Table name: orders

# ...

#  notify_at       :datetime

#  released_at   :datetime

# ...

我們希望可以拿到訂單中,每一筆出貨時間與通知轉帳時間相減後的秒數,除以所有訂單

這樣一來,我就可以知道訂單平均在這個階段「付款 -> 出貨」所需花費時間的平均值

如果寫純 SQL query 當然不難

SELECT AVG(orders.released_at - orders.notify_at) FROM orders

但在 Rails 中,要如何對已有的大量數據做這樣的計算呢?

非正規化

  1. 對 orders 新增一個欄位 processing_time
  2. 在 order 的 after_commit callback 中計算並更新這個時間
  3. 寫 task 對以前的訂單進行 patching
  4. 最後你就可以用 Order.average(:processing_time)

會遇到幾個問題

  1. 每次訂單完成時,都會更新這個欄位,多一條 query
  2. 如果資料量龐大,task 會跑很久,也會在這個時間吃滿 db memory

寫純 SQL Query 在 model 內

也不是不行,但如果團隊都是 ORM 派的就很受不了了 XD

class Order < ApplicationRecord
  def self.avg_released_time
    sql = <<-SQL

    SELECT AVG(orders.released_at - orders.notify_at) FROM orders
    SQL

    
    find_by_sql(sql)
  end
end

# 拿到這個...也不能用

# [

#    [0] #<Order:0x00007fe0c7d16370> {

#        :id => nil

#    }

# ]

更好的作法

既然有 average (ActiveRecord::Calculations) 可以用,那只要組合一下就行了

一開始會想 Order.average(:xxx), 這樣到底要怎麼寫?

xxx 裡面到底要放什麼?

能夠跟 where 一樣帶參數之類的嗎?

看了一下 source code 後發現,他其實是去呼叫 calculate

# File activerecord/lib/active_record/relation/calculations.rb, line 55
    def average(column_name, options = {})
      # TODO: Remove options argument as soon we remove support to
      # activerecord-deprecated_finders.
      calculate(:average, column_name, options)
    end

於是我嘗試出了這樣的組合

Order.calculate(:average, "orders.notify_at - orders.created_at")
# (0.7ms)  SELECT AVG(orders.notify_at - orders.created_at) FROM "orders"
# 0.0

發現好像成功了,但不知道為什麼數據總是 0.0

後來發現這樣的 timestamp 相減出來的數值是時間格式,但 average 這個 API 是預計回傳 Numeric, 所以就會無論如何都回傳 0.0

那麼就要用 PostgreSQL 的方法,把兩筆時間相減後變成數字,這樣 Rails 應該就接的到了

找了一下方法

epoch 可以將時間轉換成為秒數

SELECT EXTRACT(EPOCH from TIMESTAMP '2001-02-16 20:38:40');
Result: 982352320

SELECT EXTRACT(EPOCH from INTERVAL '5 days 3 hours');
Result: 442800

那麼把這個方法用 Rails 呼叫看看

Order.calculate(:average, "extract(epoch from orders.notify_at - orders.created_at)")
#   (1.3ms)  SELECT AVG(extract(epoch from orders.notify_at - orders.created_at)) FROM "orders"
# 51.146269

完美,這樣一來不用新增欄位,也可以快速的拉出數據,兼顧效能及美觀

稍微整理一下就可以變成一個好用的方法

class Order < ApplicationRecord
  def self.avg_released_time
    calculate(:average, "extract(epoch from orders.released_at - orders.notify_at)") || 0
  end
end

# Usage

# Order.avg_released_time

參考來源:

 
5 months ago

複雜的東西其實可以搬去 service object 解決

但把資料翻譯成人能夠看的懂得東西,屬於表現層,表現層的抽象也可以用 decorator pattern

參考 https://github.com/drapergem/draper

 
5 months ago

基本要求

Rails version: 5.1 以上

基礎建設

建立帶 vue 的 rails 專案

rails new rails-vue-todolist --skip-turbolinks --skip-spring --webpack=vue

除了 Rails 建立以外,還會有 webpack 的安裝,過程需要等待一下

cd rails-vue-todolist

git add .
git commit -m "Initialize Rails App"

快速建立 scaffold 的 CRUD

rails g scaffold Todolist item:string

別忘記 migrate, 生成 table

rake db:migrate

config/routes.rb
# 指定一下首頁

root "todolists#index"

可以先嘗試一下 vue 的 hello world

加入 javascript pack tag

app/views/layout/application.html.erb
...
<%= javascript_pack_tag 'hello_vue' %>
...

然後在 todolists 的 index 設置進入點

app/views/todolists/index.html.erb
<div id="hello"></div>
...

刷新頁面後,應該能在首頁看到 Hello Vue! 的字樣

大概知道這樣的關係後,就可以將 vue 逐步的動手改造 todolists 啦

首先,我們先把 app/views/todolists/index.html.erb 中的內容清空

只留下

app/views/todolists/index.html.erb
<div id="todolists"></div>

這意味著 index 頁面,將不直接從 server side render, 直接用 vue 來做,而這是一個入口點的設置。

修改進入的主要檔案, 不用 demo file 了

app/views/layout/application.js
# 把原本的 <%= javascript_pack_tag 'hello_vue' %> 替換如下

<%= javascript_pack_tag 'application' %>

宣告抓取 element todolists

app/javascript/pack/application.js
import Vue from 'vue'
import App from '../todolists.vue'

document.addEventListener('DOMContentLoaded', () => {
  const el = "#todolists"
  const app = new Vue({
    el,
    render: h => h(App)
  })

  console.log(app)
})

並且生成 todolists.vue 在 app/javascript 之下

指令: touch app/javascript/todolists.vue

Index

接下來我們的 todolists/index, 就會直接從這個 vue 檔案渲染出來。

app/javascript/todolists.vue
<template>
  <div>
    <h1>Todo Lists</h1>
    <table>
      <thead>
        <tr>
          <th>#</th>
          <th>Item</th>
        </tr>
      </thead>

      <tbody>
        <tr v-for="todo in list" >
          <td>{{ todo.id }}</td>
          <td>{{ todo.item }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
export default {
  data: function () {
    return {
      list: [
        { id: 1, item: "Foo" },
        { id: 2, item: "Bar" }
      ]
    }
  }
}
</script>

這時候,刷新頁面,應該可以看到我們 default 的數據 foo & bar 了。

再來要確保資料的生成,可以進 rails console 建立幾筆資料

Todolist.create(item: "Test1")
Todolist.create(item: "Test2")

再來我們期望能夠在畫面正確輸出 Test1 & Test2, 就要開始動手寫 vue method 了

app/javascript/todolists.vue
<script>
export default {
  data: function () {
    return {
      list: [
        { id: 1, item: "Foo" },
        { id: 2, item: "Bar" }
      ]
    }
  },

  created: function() {
   this.fetchTodoLists();
  },

  methods: {
    fetchTodoLists: function() {
       const resource = this.$resource('/todolists.json/{ id }');
       resource.get().then(function(response){
         this.list = response.data
       });
    }
  }
}

這時候刷新頁面,你會發現什麼都沒改變,並且 console 出現

TypeError: this.$resource is not a function

因為這時我們還沒有引入這個 Vue 的 resource 套件,方便我們操作 http method

先安裝相關的套件,下指令

yarn add vue-resource

安裝完後修改 app/javascript/application.js 在頂部加上這兩行,把他import 進去

import VueResource from 'vue-resource'
Vue.use(VueResource);

刷新頁面,應該就會發現我們的 item 是從我們 DB 拉出來的吧?

完成 index lists 的部分了

Create & Delete

接下來我們做 Create 的部分

app/javascript/todolists.vue
<template>
    ...
    
    <input type="text" v-model="todo" class="form-control" autofocus="true">
    <button @click="addTodo()" class="btn btn-primary" :disabled="!todo.length">Add Todo</button>
    
    ...
</template>

<script>
export default {
  data: function () {
    return {
      todo: '',
      list: [
        { id: 1, item: "Foo" },
        { id: 2, item: "Bar" }
      ]
    }
  },
  ...
  ......
</script>

操作一下,是不是發現有輸入框和按鈕了?而且當輸入框沒任何字串時,按鈕的樣式會是 disable

再來就是綁定 addTodo 的事件了,一樣是寫在 method

app/javascript/todolists.vue
<script>
...
...
methods: {
...
    addTodo(){
      this.$http.post('todolists.json', { item: this.todo }, {})
      .then((res) => this.fetchTodoLists(), this.todo = '')
      .catch((error) => console.log('Got a problem' + error));
    },
</script>

迫不及待按下 add Todo 了吧? 這時候你會收到 422 的 response, 是因為 csrf token 的保護,導致你送出的表單被否決了。

簡單的解決方法如下

app/javascript/appliation.js
...
...
document.addEventListener('DOMContentLoaded', () => {
  Vue.http.headers.common['X-CSRF-Token'] = document.getElementsByName('csrf-token')[0].getAttribute('content')
...
...
})

這樣一來 Create 就做好了吧? Delete 也是一樣意思,只是從發 POST 變成發 Delete,這邊就只放 code 了

app/javascript/todolists.vue
<template>
  <div>
    <h1>Todo Lists</h1>

    <input type="text" v-model="todo" class="form-control" autofocus="true">
    <button @click="addTodo()" class="btn btn-primary" :disabled="!todo.length">Add Todo</button>

    <table>
      <thead>
        <tr>
          <th>#</th>
          <th>Item</th>
           <th>operate</th>
        </tr>
      </thead>

      <tbody>
        <tr v-for="todo in list" >
          <td>{{ todo.id }}</td>
          <td>{{ todo.item }}</td>
          <td>
            <button @click="deleteTodo(todo.id)" class="btn btn-primary">Delete Todo</button>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
export default {
  data: function () {
    return {
      todo: '',
      list: [
        { id: 1, item: "Foo" },
        { id: 2, item: "Bar" }
      ]
    }
  },

  created: function() {
   this.fetchTodoLists();
  },

  methods: {
    addTodo(){
      this.$http.post('todolists.json', { item: this.todo }, {})
      .then((res) => this.fetchTodoLists(), this.todo = '')
      .catch((error) => console.log('Got a problem' + error));
    },

    deleteTodo(todo_id){
      this.$http.delete('todolists/'+ todo_id +'.json')
      .then((res) => this.fetchTodoLists())
      .catch((error) => console.log('Got a problem' + error));
    },

    fetchTodoLists: function() {
       const resource = this.$resource('/todolists.json/{ id }');
       resource.get().then(function(response){
         this.list = response.data
       });
    }
  }
}
</script>

Edit & Update

實做方式有兩種

  • vue routes(換頁)
  • SPA(不換頁)

這邊我用 SPA 的方式去做,直接點擊文字可以出現一個 輸入框以及更新的按鈕,詳細實做就看我的 github 參考了

 
6 months ago

新建一個來兼看 crontab 是否順利執行的文件

touch /tmp/crontab_log.txt

設定定時任務

crontab -e

0/5 * * * * /bin/date "+%Y-%m-%d %H:%M:%S" >> /tmp/crontab_log.txt

:wq 存檔後退出,會看到

crontab: installing new crontab

接著我們來看每分鐘是否會將當前時間記錄到該文件裡

tail -f /tmp/crontab_log.txt

如果有出現就代表成功,可以在 mac 上執行定時任務。

如果沒有的話參考 在MAC OS X上如何启用crontab?

如果對於 cron 的時間設定不熟悉也可以參考 crontab.guru

 
11 months ago

這篇實作主要是練習如何使用 Swift 去實現

  1. Get JSON from URL
  2. Parse JSON

因為我平常有 Ruby 開發相關區塊鏈接 API 的經驗,所以這邊我挑了 CoinMarketCap 來串接,主要是因為他有直接兌換美元的結果,不需要再去計算,像是其他交易所如 Poloniex 只有 BTC 對 USDT ,畢竟 USDT 並不是真正的美元匯率(只是錨定接近而已)

一開始的 Outlet 跟簡單的 Interaction 就不多說了,就是拉一下 UI 畫面,分別設定好 Label 怎麼顯示、Button 會觸發什麼事件。

在這邊我意外發現一個專門講解 Swift 開發技巧的 Youtube 頻道,講的內容都很新,就以我現在 2017/12/02 來看,可以在影片看到上傳日期是 2017/07/01 ,講解的是最新 Swift 4 如何利用 Decodable 去 Parse JSON

Parsing JSON Just Became Super Easy in Swift 4 with Decodable

直接上程式碼

先宣告一個 Decodable 的 struct, 自訂你需要的型別

struct CryptoCurrency: Decodable {
    let name: String
    let price_usd: String
}
...

解析後並設定 label text 的 function

func updateBitcoinPrice() {
    self.cryptoCurrencyLabel.setText("Fetching...")
    
    // Coinmarketcap

    guard let url = URL(string: "https://api.coinmarketcap.com/v1/ticker/?limit=10") else { return }
    
    URLSession.shared.dataTask(with: url) { (data, response, error) in
        guard let data = data else { return }
        
        do {
            let crypto_currency = try JSONDecoder().decode([CryptoCurrency].self, from: data)
            
            let btc_price = crypto_currency.first?.price_usd ?? "0.00"
            
            self.cryptoCurrencyLabel.setText("\(btc_price) USD")
            
        } catch let jsonErr {
            print("Error serializing json:", jsonErr)
        }
    }.resume()

這邊我比較懶的部分是直接拉 Array 的第一筆資料去顯示比特幣價格,因為 CoinMarketPrice 的價格顯示是依照交易量排名,也因此我認為比特幣交易量應該都是在第一名,所以在部份比較偷雞XD,不過還要在學習改進的部分是如何針對一長串的 Array 去找到指定符合的數值(今天只學拿到 data 然後解析完輸出)。

小結

不過稍微研究了一下 Swift 4 新的 Decodable + JSONDecoder 用法除了可以達到更簡單的語法撰寫,同時也能夠支援自訂型別,在以往的解決辦法常常要寫很冗長的程式碼或是使用第三方套件到了 Swift 4 都可以通通丟掉了。

實際作品在我的 Github watchOS-BitcoinPrice

參考資料

 
11 months ago

三個點的符號 ..., 英文的正式名稱是 Ellipsis,中文譯是指省略的意思,不過一般在學習這類運算符,下 google 的關鍵字應該多半都會是 three dots 或是 dot-dot-dot

展開運算符號 (Spread Operator)

這種運算符是一種速寫的語法,一開始可能不好理解,不過用上癮後就會熟能生巧了

const boys = ["Nic", "Kurt", "Jimmy"]
const girls = ["Doris", "Flower"]

console.log([...boys]) // ["Nic", "Kurt", "Jimmy"]


//  兩個 Array 組合

console.log([...girls, ...boys]) // ["Doris", "Flower", "Nic", "Kurt", "Jimmy"]

原本需要用 concat 完成的組合 Array 動作只要用展開運算符拼起來就可以了,而且不受 concat 只能放在陣列後方

替代原本 Array 的 apply 方法

由於擴展運算符可以展開 array, 所以在 ES6 之後不需要用 apply 方法,將 array 轉為函數的參數

// ES5

function sum(x, y, z) {
  return x + y + z;
}

var args = [1, 2, 3]
sum.apply(undefined, args); // 6


// ES6

function sum(x, y, z) {
  return x + y + z;
}

const args = [1, 2, 3]
sum(...args) // 6

展開運算符還有一個特別的功能,會把可迭代(iterable)或與陣列相似(Array-like)的Object 轉變為 Array

在 JavaScript 中內建的可迭代(iterable) Object 有

  • String
  • Array
  • TypedArray
  • Map
  • Set
const myName = "Nic"
const chars = [...myName] // ["N", "i", "c"]

參考資源

 
11 months ago

垂直下拉關閉鍵盤

通常用到 text view 讓人可以鍵盤輸入資訊的時候,其實可以貼心的加入垂直下拉就可以關閉鍵盤的功能,因為很多 APP 打完字是沒辦法關鍵盤的,這其實滿惱人的。

而這個功能也不需要額外寫 function , text view 元件中只要在 Scroll View 裡面將 Bounce Vertically 打勾就可以了。

開啟自動彈出鍵盤

如果你的這個頁面是一載入時,就是需要用戶填寫資訊或筆記的地方,像是內建的備忘錄,其實可以自動彈出鍵盤,不需要等用戶點擊螢幕之後才跑出鍵盤,這是一個比較貼心的方式。

只要在 Text view 元件中的 keyboard 設定為 Dismiss interactively 就可以了