Rails 建構 RESTful API 專案


接續上一篇文章「 Rails 在 Mac 設定開發環境 」,現在你的開發環境已經準備好了,讓我們開始構建 API 專案,我將描述建立一個僅 Rails API 的應用程式,以及使用 Rails Generator 使得開發變得容易和快速,是改進工作流程的重要工具,讓開發人員可以專注於做內部業務邏輯。

建立一個新的 Rails 專案

使用 rails new 快速產生第一個 Rails 專案,為了產生以 API 為中心的框架,使用 --API flag 可以排除在其他情況下不會使用或不必要的功能。

  • 裝載較少的 Middleware
  • ApplicationController 繼承自 ActionController::API 而不是 ActionController::Base
  • 跳過生成 views, helpers, and assets
rails new restful-api --api --skip-active-storage --skip-action-mailer --skip-action-mailbox -T

使用命令 -T 將跳過 Minitest::Unit 文件和文件夾的生成,我們之後將使用 RSpec 作為測試而不是 MiniTest。

啟用 CORS(跨域資源共享)

CORS 是一個瀏覽器做跨網域連線的安全機制,透過 HTTP header 的設定,可以規範瀏覽器在進行跨網域連線時可以存取的資料權限與範圍,包括哪些來源可以存取,或是哪些 HTTP verb, header 的 request 可以存取。

為了防止對您的 API 進行不必要的訪問,Rails 會自動禁用 CORS。

開啟 Gemfile 檔案取消 rack-cors 註解:

gem 'rack-cors'

並在 terminal 中,運行 bundle install 更新專案套件:

bundle install

為簡單起見,將允許所有來源可以存取。

更新 config/initializers/cors.rb 以允許所有來源 (*) 發出請求。

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins '*'

    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

通過 Rails generators 命令建立資源

Rails 提供了很多 Generators 指令快速建立:model、controller、test 和 route 文件,然後復製到專案目錄中,多虧了 rails generate 讓開發者不必考慮架構配置的負擔,立即開始實現專案功能。

在這篇文章中,我們將使用以下需求:

  • 用戶、 訂單
  • 一個用戶可以有很多訂單

我們使用 rails generate resource 建立,它將為我們產生 model、controller、migrate。

# 產生用戶相關資源
rails generate resource User

# 產生訂單相關資源
rails generate resource Order

執行後將看到該命令一舉建立了以下文件:

定義資料表結構

當構建專案時,我們需要對數據庫進行更改可能是增加資料表、增加欄位、修改欄位類型、增加索引等 ⋯⋯ Rails 中的 Migration 允許我們撰寫 Ruby 程式來對數據庫進行這些更改,與撰寫 SQL 語法更改相比,使用 Rails Migration 撰寫 DDL 有幾個優點:

  • 與數據庫無關,可以在 SQLite3、PostgreSQL 和 MySQL 上面執行。
  • 修改數據庫模式以版本控制的方式更好團隊協作開發。

接著可以在目錄 db/migrate 找到資料表定義用的檔案,打開檔案並增加以下代碼:

# db/migrate/20220328114839_create_users.rb
class CreateUsers < ActiveRecord::Migration[7.0]
  def change
    create_table :users do |t|
      t.string :email
      t.string :name
      t.timestamps
    end
  end
end

# db/migrate/20220405101628_create_orders.rb
class CreateOrders < ActiveRecord::Migration[7.0]
  def change
    create_table :orders do |t|
      t.references :user, index: true
      t.integer :price
      t.timestamps
    end
  end
end

在 Terminal 中運行以下命令:

rails db:migrate

你會得到類似這樣的輸出:

設定 Models 關係類型與資料驗證

在 Rails 中使用 ActiveRecord 形式的 ORM (Object Relational Mapping) 作為 Model 層,Active Record 提供的許多方法,讓開發者通過這些方法操作著資料庫裡的資料而不需要撰寫 SQL 語句,同時也支援了很多種資料庫系統,例如從 SQLite3 切換到 MySQL 而無需在調整語法就可以輕鬆更換。

在目錄 app/models 找到我們剛剛使用 rails generate 命令建立的 Model 檔案:

# app/models/user.rb
class User < ApplicationRecord
end

# app/models/order.rb
class Order < ApplicationRecord
end

首先了解 Rails Model 如何對應資料表

Model 名稱遵循著 Ruby 的命名規則,Rails 會將 class 名稱轉成複數來找到對應的資料表,如果是需要不同於 Active Record 所提供的命名慣例,可以覆寫命名方法

class User < ApplicationRecord
    self.table_name = "users"
end

但是如果有許多資料表在它們的名稱中只有相同的前綴,就要在每個 Model 中命名資料表名,為了改善程式,我們可以使用 table_name_prefix 方式。

所以首先我們可以創建一個 module Thirdparty 並設置一個 table_name_prefix 以 thirdparty_在使用該 module 時增加到資料表名稱之前:

# app/models/thirdparty.rb
module Thirdparty
  def self.table_name_prefix
    'thirdparty_'
  end
end

並使用 namespace Thirdparty 設定 Model Order

# app/models/tw/city.rb
module Thirdparty
  class Order < ApplicationRecord
  end
end

對於與表名中具有相同前綴的 Model,可以很方便的通過這個方法,幫助我們在程式中不重複。

宣告 Model 之間的關聯

在專案設計時數據資料表通常相互關聯,而將這些關聯在Rails 的世界裡連結起來,就可以使用 ActiveRecord 提供的方法簡化了常見的操作,省去大量與資料庫互動的部分。

在這篇文章需求中我們目標讓每個用戶可以擁有很多訂單,而每筆訂單只屬於一個用戶,因此在 User Model 使用 has_many 來宣告一對多的關係,而在 Order Model 使用 belongs_to 聲明屬於一個 User Model,完整程式的部分:

has_many - 告訴 Rails 每個用戶會有多筆訂單:

# app/models/user.rb
class User < ApplicationRecord
    has_many :orders, dependent: :destroy
end

belongs_to - 告訴 Rails 每個訂單都屬於一個用戶:

# app/models/order.rb
class Order < ApplicationRecord
  belongs_to :user
end

在 User Model 中另外設定 dependent: :destroy 此選項,這可以幫助我們再刪除用戶時連同關聯的訂單資料也一併刪除。

定義儲存資料的驗證

在將資料儲存到資料庫之前,使用 Active Record Validations 來驗證數據屬性,如果這些驗證產生任何錯誤 Rails 將不會儲存,為 Model 提供了額外的安全層,確保寫入的資料是符合規定的。

假設需求是要求用戶填寫的電子郵件是有效,此外如果沒有填寫名字就無法建立用戶,現在我們在 Model 裡加上這些需求的驗證規則,可以這樣寫:

# app/models/user.rb
class User < ApplicationRecord
    VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i.freeze

    validates :email, presence: true, length: { minimum: 10, maximum: 255 },format: { with: VALID_EMAIL_REGEX },uniqueness: { case_sensitive: false }
    validates :name, presence: true, length: { minimum: 3, maximum: 25 }

    has_many :orders, dependent: :destroy
end

驗證最常用的選項 presence: true 用來檢查此欄位是否為空值,這裏的空值是 nil 或者空字串都會被限制,以及使用 length 限制資料的長度,並且利用 format 正則表達式模式驗證電子郵件格式判斷是否有效,最後加上 uniqueness: (case_sensitive: false) 避免在驗證電子郵件時區分大小寫。

# app/models/order.rb
class Order < ApplicationRecord
  belongs_to :user

    validates :user, presence: true
end

如果在關聯性之間更謹慎一點,可以在 belongs_to 的那個欄位後面,再寫一次 presence: true,如此一來不只檢查 foreign_key 是不是空值,還會檢查這筆引用的對像是否真的存在。

處理 REST Client 請求和響應

REST API 透過遵循 HTTP Method (GET、PUT、POST 和 DELETE) 來識別提供不同功能,指的是讀取、更新、建立和刪除有關資源的操作。

定義提供的服務

首先,我們擬定提供的 API 將具有以下端點:

GET /users/{id} 將接受 GET 請求並通過 id 返回指定的用戶
POST /users 將接受 POST 請求建立一筆新的用戶記錄
POST /users/{user_id}/orders 將接受 POST 請求並通過 user_id 建立一個指定用戶的訂單

編寫 Controller

在目錄中 app/http/controllers 找到名為 users_controller.rb 的檔案,編寫查詢指定用戶的邏輯,讓我們來實現它:

# app/http/controllers/users_controller.rb
class UsersController < ApplicationController
  # GET /users/{id}
  def show
    if user
      render json: user
    else
      render json: user.errors
    end
  end

  private

  def user
    @user ||= User.find(params[:id])
  end
end

在上面的程式碼中,建立了一個 private user 方法,在這個方法中使用 ActiveRecord 的 find 與 API 端點中提供的 id 查詢相匹配的用戶,並將其用戶給實例變數(Instance Variable) @user,在 show action 中檢查 user 方法是否有傳回用戶並將以 JSON 格式響應結果,若如果不存在,則發送錯誤。

private user 方法中使用了 ||= 邏輯運算符號將 if 邏輯簡化,方法相當於:

def user
    if @user
     return @user
  else
     @user = User.find(params[:id])
     return @user
  end
end

接下來,建立新用戶的邏輯,與查詢指定用戶一樣,將依賴 ActiveRecord 來驗證和儲存提供的用戶資料,再次更新 users_controller.rb

# app/http/controllers/users_controller.rb

class UsersController < ApplicationController
  # GET /users/{id}
  def show
    if user
      render json: user
    else
      render json: user.errors
    end
  end

  # POST /users
  def create
    user = User.create!(user_params)
    if user
      render json: user
    else
      render json: user.errors
    end
  end

  private

  def user_params
    params.permit(:name, :email)
  end

  def user
    @user ||= User.find(params[:id])
  end
end

create action 中,使用 ActiveRecord 的 create 建立一個新用戶,並通過使用 Rails 提供的 Strong Parameters 的特性來防止被惡意傳入的可能性,這樣除非有指定的參數才會被傳入,否則將會過濾掉。

我們建立了一個 private user_params 方法,只允許傳回 name, email 參數資料,將這個方法作為 create 的參數,以防止錯誤或惡意內容進入數據庫。

在執行 ActiveRecord create 的時候也將會觸發 Active Record Validations 定義的條件規則,在執行驗證後,如果提供的資料與任何一個規則不匹配時,將會引發錯誤與通過 errors 實例方法訪問傳回的錯誤集合。

最後,找到名為 orders_controller.rb 的檔案,編寫建立用戶訂單的邏輯:

# app/http/controllers/orders_controller.rb

class OrdersController < ApplicationController
  before_action :current_user

  # POST /orders
  def create
    order = @user.orders.create!(order_params)
    if order
      render json: order
    else
      render json: order.errors
    end
  end

  private

  def order_params
    params.permit(:price)
  end

  def current_user
    @user ||= User.find(params[:user_id])

    unless @user
      render json: user.errors
    end
  end
end

根據 REST 設計最佳實踐,我們使用 nested resource ,因此將 user_id 作為參數傳遞,並看到我們在 OrdersController 裏面聲明 before_action :current_user ,這意味著在以下場景中,方法current_user 將首先被執行,最後執行 controller 的 action,如此一來若有很多 action 就不會有重複的程式碼產生。

before_action 可以通過 render 隨時中斷執行,以 current_user 方法為例,如果沒有匹配 user_id 參數的用戶,那麼它將使用 render 方法傳回錯誤,因此 controller 的 action 將永遠不會執行,通過同樣的邏輯,我們可以確定如果 action 被執行,那麼會存在一個@user實例變量提供我們在 action 中使用。

建立 Routes

Rails 會根據 config/routes.rb 這個檔案的內容,從客戶端接收 HTTP 請求並將請求轉發到相應 controller 中定義的 action。

開啟 config/routes.rb,為我們的 API 指定 Route,如下:

# config/routes.rb

Rails.application.routes.draw do
  get '/users/:id', to: 'users#show'
  post '/users', to: 'users#create'
  post '/users/:user_id/orders ', to: 'orders#create'

  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

  # Defines the root path route ("/")
  # root "articles#index"
end

檢查列出現有 routes 列表,請在 terminal 中執行以下命令:

rails routes --expanded

測試我們的 API

現在,為了檢查結果我們將使用 Postman 工具進行測試,在測試之前,首先啟動開發 Rails server Rails 框架提供了一個內置的 server 工具,可以使用 rails server 命令運行,執行以下命令:

rails server -p 3000

設定 Postman 環境變數

通常,環境變數可以支援在讓開發者不同環境下使用不同的參數來進行測試全部的測試,畢竟很多時候開發者會先使用 Postman 測試 API 是否正常,但是若要在掌握不同環境(Testing、Staging ⋯⋯)各個階段環境測試,又會是大幅度的更動,因此使用環境變數是可以在請求中使用的一組變量,簡化測試的調整幅度,也可以幫助團隊成員對共享數據的訪問。

  1. 開啟 Postman 點選左側選單 Environments
  2. 建立新環境變數,點選 +
  3. 定義一組 server 變數為 API 的端點 http://localhost:3000
  4. 將設定的環境變數資料儲存,點選畫面中的 Save

測試 API 結果

  1. 回到 Collections 建立 request 並選擇介面中 (1) 的位置,宣告當前的環境 develop
  2. 設定發出 POST 請求以及帶入環境變數 {{server}} 呼叫建立用戶的位置並傳遞建立資料
  3. 點選 Send 測試建立用戶的 API

在上面的示例中,提出了一個請求 POST /users 將建立一筆新的用戶記錄,如畫面中 Response 的部分可以看到建立的用戶紀錄,就代表已成功完成新增。

到目前為止,Postman 仍然是我個人對在 API 上運行手動測試的偏好,它簡化了構建 API 的每個步驟,以及輕鬆生成文件檔案,讓開發者除了在開發 API 及編寫規範之外,手動測試以讓我們確定 API 是否符合預期。

測試主要的目的是為了確保產出的品質,在測試過程中有很多細節或重複性的測試,以及有良好的測試涵蓋搭配 git push 前進行全部的測試,就會需要撰寫自動化的測試程式,也就是寫程式去測試程式,在之後的文章中再來描述使用 RSpec 來進行自動化測試。

結論

我們已經完成了一個使用 Rails 作為 API 數據處理的程式端,當然這只是基礎的建置,Rails 還提供許多的功能以及許多實用的工具,喜歡這框架的開發者可以再深入了解發掘更多。

不同語言的框架讓我從中學習到不同的知識與應用,依據專案需求,可以更有彈性選擇適合的程式語言,最後附上本篇使用的程式碼,可以在 GitHub 上找到。

Rails 在 Mac 設定開發環境


Ruby 是 Shopify 的主要語言,在 Shopify Plus 商家上可以使用 Ruby 建構 Shopify Scripts 幫助商家更多靈活的進行各項開發,為了更瞭解 Shopify 上的各種串接開發,所以踏上 Ruby on Rails (RoR) Rails 之旅。

要開始用 Ruby on Rails (RoR) 來開發之前,我們先來介紹及設置開發環境和相關工具。

Mac 開發環境設定

安裝 RVM

對於前端開發 RVM 就如同 NVM 的功能是用來管理 Ruby 版本的套件,在 RVM install page 網站上包含有關在 macOS 上安裝的 RVM 的詳細說明,在這我將提供我操作使用的步驟:

# 安裝 RVM stable
\curl -sSL https://get.rvm.io | bash -s stable --ruby

# 重新加載 shell 配置
source ~/.rvm/scripts/rvm

# 列出目前有哪些可以安裝的列表
rvm list known

# 安裝指定版本
rvm install 2.7.2

# 確認目前設定版本
rvm -v

RubyGems 介紹與管理

RubyGems 是 Ruby 社群套件包及框架的管理工具,當我們利用 RVM 在安裝 Ruby 的時候就會內建 Gem,RVM 也提供了 rubygems CLI 命令允許更改指定版本,也可以安裝當前的最新版本。

#rvm rubygems [x.y.z|latest-x.y|latest|current|master|head]

# 確認校驗和錯誤嘗試
rvm rubygems current --verify-downloads 1

# 確認安裝版本
gem -v

安裝 Bundler

Bundler 是一個 Ruby gem,用於管理套件及處理套件相容性問題,執行 bundle install 時,會根據專案目錄中 Gemfile 的設定,下載安裝指定的 Gem 與相依套件。

# 安裝 Bundler
gem install bundler

安裝 Rails

Rails 是使用 Ruby 所寫的開發框架,基於 MVC 架構模式並且設計出許多讓開發者更有效率的工具,讓開發者不必從頭開始編寫所有代碼,可以專注在核心業務,大幅縮短開發時間。

# 安裝 Rails
gem install rails

# 確認安裝版本
rails --version

建立 Rails 專案

在 Rails 的開發過程中,許多作業都可以在 Terminal 執行 Rails 命令來完成,接下來就用 rails new 來產生 Rails 專案:

rails new sample

執行完後我們切換到產生的專案目錄並啟動 Rails 內建的 web server:

# 進入專案目錄
cd sample

# 啟動 web server
rails server

使用瀏覽器訪問 http://localhost:3000 你應該可以看到這個畫面:

以上,基本的 Ruby on Rails 的環境佈署設定與專案建置已經完成。

JavaScript 使用 Web Worker 處理複雜任務


隨著數據變得越來越大,若應用程式保持在單線程中運行 UI 性能將受到越多的影響,處理效能瓶頸時的解決方式,透過 Browser Web Worker 讓 JavaScript 可以有 multi thread 而不會影響到原來的 main thread,讓網頁使用者體驗上,穩定畫面的流暢度。

單執行緒阻塞

首先,先提供範例來演示長時間影響 Web 應用程式的狀況:

See the Pen
demo
by Eric Wang (@wweitinio)
on CodePen.0

在上面的範例中,使用遊戲中人物的動畫效果來展示,點擊按鈕將執行下面這段程式碼來模擬資源密集型計算,將觀察到阻塞了 main thread 造成動畫凍結了幾秒鐘。

function sleep(milliseconds) {
    const currentTime = new Date().getTime();

    let i = 0;
    while (currentTime + milliseconds >= new Date().getTime()) {
        i++;
        if(i % 1000000 === 0){
            console.log('rows processed : ' + i);
        }
    }
}

我們來了解凍結動畫的原因,開啟 Chrome 開發者工具(F12 或 Ctrl + Shift + I),切換到 Performance 分頁,點選左上角的灰色圓圈 Record (Ctrl + E) 以啟動 JavaScript 分析,然後點擊範例應用程式中的按鈕執行頁面互動,最後按下 Stop 按鈕結束分析,此時就會跑出一張效能表:

上圖中可以看出在觸發按鈕事件時佔了 CPU 的多數時間, 這樣的時間花費會讓使用者感受到頁面嚴重的停頓,甚至使用者執行其他所觸發的事件瀏覽器也會沒辦法即時處理,如果網頁在停頓更長的時間沒有任何響應,瀏覽器還可能會提示用戶是否要停止關閉頁面。

可以想像它就是瀑布式的執行每一個動作,雖然可以利用 event callback 來完成 "非同步" 的工作,但終究是在同一個瀑布上面流動。

在開發案例中也常會遇到密集的計算,例如報表資料,從 server 端取回資料之後,在前端進行排序,或是從 API 中接收巨大的數據集並調用 JSON.parse(),JavaScript 的 main thread 就會阻塞,畫面就像當掉了一樣。

不可預測的性能問題

fps (Frame per second) 表示的是每秒畫面更新次,越高的 fps 代表每秒更新次數越多,人眼會感覺越來越流暢,對於使用者反應回饋也會更快,在開發更複雜邏輯時,除了測試資料邏輯處理運算之間經過的時間外,還需要確保運行設備的渲染刷新率。

在開發過程中我們可以在自己的設備上,專注處理可能會導致過度的運算,對整體性能產生負面影響,然而一段 JavaScript 需要多長時間才能完成,也取決於在設備上運行的速度。

過往可能的做法我們可以在定義開發介於廣泛的設備上,將過於複雜的工作分解為更小部分的任務,保持應用程式流暢且穩定以最小的延遲響應的使用者界面。

拆分同步邏輯,也並不是所有適用,可能遇到的問題:

  • 不是所有邏輯都可拆分

    數組排序、迴圈搜尋、圖像處理 ⋯⋯ 等,還有執行中需要維護當前狀態,也無法輕易地拆分爲子任務

  • 拆分的粒度難以掌控

    在其他低功率的設備上開發可能再拆分到更小,才能控制在 16ms 內讓瀏覽器完成每畫面的渲染

  • 拆分的邏輯難以維護

    對同步邏輯進行再拆分,每次改動業務邏輯,都需要去 review 子邏輯

使用 Worker 的改善

為了確保範例中的動畫效果不會受到執行計算的影響,我們將計算的邏輯移動到不同的線程,我們就可以長時間運行也不會影響 UI 線程 (main thread)

首先,要創建計算的線程,我們使用 Worker() 通過指定文件路徑來構建,讓文件在 main thread 中加載和運行:

const worker = new Worker("./worker.js");

如果使用的只是一段程式碼時則可以使用 Blob 搭配URL.createObjectURL() 產生的臨時網址來構建:

const code = `(function () {

})();`;

const createBlobObjectURL = (code: string) => {
  const blob = new Blob([`${code}`], { type: "text/javascript" });        
  const url = URL.createObjectURL(blob); 
  return url;
};

const worker = new Worker(createBlobObjectURL(code));

接著在 main thread 中,再增加一個按鈕並發送給 worker

$('#button2').click(function () {
    worker.postMessage('long task started');
});

worker script 裡,我們將原本計算的 sleep 函式移過來,使用 onmessage 事件偵聽接收 main thread 所發送過來的資料進行資料運算,在調用 postMessage 將計算結果發送回去:

const code = `(function () {
    function sleep(milliseconds) {
        const currentTime = new Date().getTime();

        let i = 0;
        while (currentTime + milliseconds >= new Date().getTime()) {
            i++;
            if(i % 1000000 === 0){
                console.log('rows processed : ' + i);
            }
        }
    }

    self.onmessage = function (event){
        console.log(event.data);

        sleep(10000);

        self.postMessage('long task completed');
                self.close();
    }
})();`;

最後要在 main thread 中接收 worker 計算的結果,我們也要在 main thread 使用 onmessage 事件偵聽接收 worker 發送過來的資料:

worker.onmessage = function (event) {
    console.log(event.data)
}

整理使用 Worker 改善的範例演示:

See the Pen
demo 2
by Eric Wang (@wweitinio)
on CodePen.0

在上面的範例中,點擊 “worker測試” 按鈕執行模擬運算,可以觀察到計算的結果仍在記錄到 Chrome Devtools Console,但不會影響頁面上人物的動畫效果。

再次使用 Chrome Performance 分析,確定 Web Worker 的性能影響,應該會看到類似於下圖的結果:

從上圖中,明顯的觀察是在 main thread 下方出現一個 worker thread,而模擬資源密集計算都在 worker thread 中計算不再發生在 main thread 上,因此改善了動畫效果的性能讓使用者體驗上穩定畫面的流暢度。

Worker 在開發時需要注意及思考的

終止 Web Worker

建立 Web Worker 會在使用者的設備上建構真正的線程,這也是會消耗系統資源。因此 worker 執行完成時終止釋放 worker thread:

// close main script
worker.terminate();

// close worker script
self.close();

Web Worker 的局限性

  • 沒有 DOM 訪問權限

    無法讀取 main thread 網頁的 DOM 元素,也不能取得 document、window 等對象,但是可以獲取 navigator、location(只允許讀取)、XMLHttpRequest、setTimeout ⋯⋯ 等瀏覽器 API。

  • 沒有共享狀態

    worker script 無法訪問 main script 的數據,對任何一個線程的雙方都使用 postMessage() 方法發送各自的資料,使用 onmessage() 事件接收處理資料。

Transferable Objects

在 worker 相互發送數據這個過程中,數據並不是被共享而是被複製,如果要傳遞一個 50MB 的大文件(File、Blob、ArrayBuffer、JSON),那麼會產生明顯的資源開銷。

為了解決這個問題,postMessage()方法也支持傳輸 Transferable 數據類型 (類似 C/C++ Pass by reference),使用 Transferable 傳輸時,會直接把數據從一個執行環境 (worker thread 或 main thread) 傳輸到另一個執行環境,這樣不會額外增加一份資源消耗,並且傳輸速度極快因為不需要數據拷貝。

JavaScript 使用 Web Audio 和 Canvas 設計音樂韻律動畫

在開發過程遇到了一個對我來說特別有趣的應用,藉由這次的開發來介紹相關的功能應用,這個項目是利用擷取音樂波形數據來設計與音樂播放的同時並轉換視覺化動態效果,以往在 Flash 應用時期常常會利用音訊及動畫製作許多開發,如今 HTML5 對於裝置硬體訪問以及動畫的設計也能輕鬆的訪問及制作複雜的動畫。

接下來開始描述這個項目建構,並一同了解每個相關的工作原理,在最後提供完整的示例。

需要的相關工具

  • Web Audio API
  • Canvas API
  • jQuery library
  • CreateJS library

在這個項目中使用是 HTML5 提供 Web Audio API 以及 Canvas API 作為我們的主要應用,在使用 jQuery 使某些操作更容易完成,以及藉由 CreateJS 函式庫來建構精彩的動畫。

基本設定

建立一個基本的 HTML 頁面,它加載了這次項目會使用的 2 個函式庫,以及我們在項目中需要的 CSS 規則的樣式表。

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title></title>
    <style>
        body{
            background-color: #000;
        }
        #upload {
            position: fixed;
            top: 10px;
            left: 10px;
            z-index: 100;
        }
        #canvas {
            position: fixed;
            left: 0;
            top: 0;
            width: 100%;
            height: 100%;
        }
        audio {
            position: fixed;
            left: 10px;
            bottom: 10px;
            width: calc(100% - 20px);
        }
    </style>
</head>
<body>
<input type="file" id="upload" accept="audio/*"/>
<canvas id="canvas"></canvas>
<audio id="audio" controls></audio>
<script src="//ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="//code.createjs.com/1.0.0/createjs.min.js"></script>
<script>
    (function ($, createjs) {
        ....
    })(jQuery, createjs);
</script>
</body>
</html>

聲音數據處理

首先使用 jQuery 註冊 input[type="file"]  上傳事件來取得使用者上傳的音檔並播放。

let audioElement = $('#audio')[0];

$(document).ready(function () {
    $('#upload').change(function () {
        const input = this;

        if (input.files && input.files[0]) {
            audioElement.src = URL.createObjectURL(input.files[0]);
            audioElement.load();
            audioElement.play();
        }
    });
});

現在我們已經可獲得所需要的音檔文件源,可以開始連接分析聲音的數據。

連接音訊的處理容器

所先需要建立一個 AudioContext 容器來幫助我們管理所有聲音,單個 AudioContext 可以支持多個聲音輸入,因此我們在單個應用中只需要初始化一次,就可以對多個不同的聲音來源同時使用。

try {
    window.AudioContext = window.AudioContext||window.webkitAudioContext;

    let audioCtx = new AudioContext();
} catch(e) {
    alert('Web Audio API is not supported in this browser');
}

如果使用 Safari,則需要使用 webkitAudioContext 而不是 AudioContext。

接下來,建立一個 AnalyserNode,它會為我們提供用於實現視覺效果的頻率原始數據。

let analyser = audioCtx.createAnalyser();

設置 AnalyserNode fftSize 屬性值,這將告訴 AnalyserNode 傳回給我們的訊號數據數組應該有多大,另外要注意的是必須給它一個 2 的根值。

analyser.fftSize = 128;

最後再把我們 HTML 所用的 audio element 利用 AudioContext 提供的 createMediaElementSource 方法將它建立為 MediaElementAudioSourceNode 。

const source = audioCtx.createMediaElementSource(audioElement);

現在我們需要將所有 Node 連接在一起,以便它們可以讀取彼此的數據。

const source = audioCtx.createMediaElementSource(audioElement);

source.connect(analyser);
source.connect(audioCtx.destination);

循環讀取音訊數據

建立並連接所有 Node 後,我們建立一個長度為 frequencyBinCount (frequencyBinCount 是 fftSize 數量的一半) 空的 Uint8 陣列,作為未來讀取數據的容器。

let freqData = new Uint8Array(analyser.frequencyBinCount);

現在一切都準備就緒,可以開始我們的循環讀取所需要的數據。

首先,為我們開發項目構建自定義事件與監聽器,作為數據更新時可方便管理將數據發送給所有我們項目內所需要這些數據的程式。

let listeners = [];

function registerAudioListener(listener) {
    listeners.push(listener);
}

function audioListener(data) {
    listeners.forEach(l => l(data));
}

利用 requestAnimationFrame,它做的事情很簡單,就是在大約 1/60 秒後呼叫傳給它的 Callback function,並且用當時的「高精度」timestamp 當做參數傳給這個 Callback。

requestAnimationFrame(tick);

Callback function 我們需要利用 analyserNode 取得當下的音頻訊號來填充我們的剛剛所建立數據數組,為了循環我們必須在 function 內部裡面再次調用 requestAnimationFrame

function tick() {
    analyser.getByteFrequencyData(freqData);
    audioListener(freqData);
    requestAnimationFrame(tick);
}

可視化數據的呈現

現在我們可以進入有趣的部分,既然我們有了數據,終於可以用這些數據進行繪製,我們將使用 JavaScript Canvas API 與 CreateJS 將圖形繪製到 HTML 元素中。

初始化 Canvas

首先,我們建立一個初始化的 function 在畫面載入時使用,當我們還未取得任何音訊源時,我們先將音訊的數據填充數據,另外這裡填充的數據是 128,是為了讓視覺效果是對等的呈現,實際示例的項目擷取的音訊數據還是為 64,在稍後會在說明數據的處理。

let transitionAudioData = [];

function init() {
    $("#canvas").attr({
        width: $(document).width(),
        height: $(document).height()
    });

    for(let i = 0; i < 128; i++) {
        transitionAudioData.push(0);
    }

        ....
}

建立 Canvas 的場景創建了一個新的 Stage 對象,為了優化性能我們將啟用 snapToPixel,這樣做會導致運動(每像素)的平滑度有所下降,但會提高 FPS 並降低 CPU 負載。

stage = new createjs.Stage("canvas");

stage.snapToPixel = true;
stage.snapToPixelEnabled = true;

使用 createjs.Ticker 事件來更新場景,通過 addEventListener 方法中 tick 監聽事件來定期執行更新我們在音訊所得到的數據並繪製出來。

預設情況下,tick 事件每秒發生 24 次,但可以使用 setFPS 方法指定更改此頻率,每秒將被調用 60 次(大約),但如如果當您達到 CPU 的限制時,FPS 則 會下降。

createjs.Ticker.setFPS(60);
createjs.Ticker.addEventListener("tick", _.draw);

將數據繪製成線條

先定義兩個會使用的 color interpolation function,用來達到我們項目中每個線條顏色漸變的效果,我們將指定兩個 RGB 的顏色並在顏色中間已給定的數量來產生一系列的漸變顏色,再利用 getLineColors 方法生成這項目中所需要的 128 個線條顏色。

function getLineColors(){
    const colorgroup = [
        ['rgb(211, 212, 214)','rgb(170, 209, 225)'],
        ['rgb(51, 110, 168)','rgb(120, 67, 188)'],
        ['rgb(145, 58, 158)', 'rgb(204, 53, 131)'],
        ['rgb(215, 109, 79)','rgb(201, 143, 89)']
    ];

    let colors = [];

    for (let i = 0; i < colorgroup.length; i++){
        let _c = interpolateColors(colorgroup[i][0], colorgroup[i][1], transitionAudioData.length / colorgroup.length);

        for (let v = 0; v < _c.length; v++){
            colors.push(_c[v].join(","));
        }
    }

    return colors;
}

function interpolateColor(color1, color2, factor) {
    if (arguments.length < 3) {
        factor = 0.5;
    }
    let result = color1.slice();
    for (let i = 0; i < 3; i++) {
        result[i] = Math.round(result[i] + factor * (color2[i] - color1[i]));
    }
    return result;
}

function interpolateColors(color1, color2, steps) {
    let stepFactor = 1 / (steps - 1),
        interpolatedColorArray = [];

    color1 = color1.match(/\d+/g).map(Number);
    color2 = color2.match(/\d+/g).map(Number);

    for(var i = 0; i < steps; i++) {
        interpolatedColorArray.push(interpolateColor(color1, color2, stepFactor * i));
    }

    return interpolatedColorArray;
}

接下來建立 draw 的方法將所有的數據排列繪製呈現。

一開始調用 stage.removeAllChildren 當每次更新場景時就清除場景上所有的物件,並再以全新的數據組重新繪製線條,並依照所需要的設計效果定義變量來控制線條的寬度、高度、間距和顏色。

function draw() {
    stage.removeAllChildren();

    const spacing = 10;
    const lineWidth = 5;
    const lineHeightMultiplier = .5;

    const totalWidth = transitionAudioData.length * spacing - spacing;
    const offsetX = (stage.canvas.width - totalWidth) / 2;
    const offsetY = stage.canvas.height / 2;
    const colors = getLineColors();

    for (let x = 0; x < transitionAudioData.length; x++) {
        const audioValue = transitionAudioData[x];
        const lineHeight = audioValue * lineHeightMultiplier;
        const line = new createjs.Shape();

        line.graphics.setStrokeStyle(lineWidth, "round")
        line.graphics.beginStroke("rgb("+colors[x]+")");

        line.graphics.moveTo(x * spacing + offsetX, -lineHeight + offsetY);
        line.graphics.lineTo(x * spacing + offsetX, lineHeight + offsetY);

        stage.addChild(line);
    }

    stage.update();
}

註冊音訊數據的監聽

最後回到 init 方法中,使用 registerAudioListener 自定義事件來取得音訊數據更新至 Canvas 所存取的數組中,這邊會分兩段更新數據組是因為保留原始數據並為效果所需要的數據再次轉換。

在下面 Callback 的方法中我們事先使用 correctWithPinkNoiseResults 方法進行轉換一次,再將數據組複製一份進行翻轉,再將兩組數據進行合併,讓呈現的視覺效果上達到對稱的動態效果。

registerAudioListener(function(data) {
    const newAudioData = getPinkNoiseResults(data);
    const reverse_ay = newAudioData.slice();
    const _newAudioData = reverse_ay.reverse().concat(newAudioData);

    if (transitionAudioData.length === _newAudioData.length) {
        createjs.Tween.get(transitionAudioData, {
            override: true
        }).to(_newAudioData, 50);
    } else {
        transitionAudioData = _newAudioData;
    }
});

聲音頻率校正

Web Audio API 擷取得每個數據音量總是從 0255 的值,當我們稍微將每個數值做調整,視覺效果所呈現的方式又會更加完美,所以才會將原始數據組分開來並建立 getPinkNoiseResults 方法來依照所需要的效果再次校正。

function getPinkNoiseResults(data) {
    let data2 = [];
    let pinkNoise = [0.7060367470305, 0.85207379418243, 0.68842437227852, 0.63767902570829, 0.5452348949654, 0.50723325864167, 0.4677726234682, 0.44204182748767, 0.41956517802157, 0.41517375040002, 0.41312118577934, 0.40618363960446, 0.39913707474975, 0.38207008614508, 0.38329789106488, 0.37472136606245, 0.36586428412968, 0.37603017335105, 0.39762590761573, 0.39391828858591, 0.37930603769622, 0.39433365764563, 0.38511504613859, 0.39082579241834, 0.3811852720504, 0.40231453727161, 0.40244151133175, 0.39965366884521, 0.39761103827545, 0.51136400422212, 0.66151212038954, 0.66312205226679, 0.7416276690995, 0.74614971301133, 0.84797007577483, 0.8573583910469, 0.96382997811663, 0.99819377577185, 1.0628692615814, 1.1059083969751, 1.1819808497335, 1.257092297208, 1.3226521464753, 1.3735992532905, 1.4953223705889, 1.5310064942373, 1.6193923584808, 1.7094805527135, 1.7706604552218, 1.8491987941428, 1.9238418849406, 2.0141596921333, 2.0786429508827, 2.1575522518646, 2.2196355526005, 2.2660112509705, 2.320762171749, 2.3574848254513, 2.3986127976537, 2.4043566176474, 2.4280476777842, 2.3917477397336, 2.4032522546622, 2.3614180150678,];

    for (var i = 0; i < 64; i++) {
        data2[i] = data[i] / pinkNoise[i];
        data2[i] = data[i] / pinkNoise[i];
    }
    return data2;
}

整體設計的視化音樂:

2022-01-09 22.44.22.gif

完整的範例:


See the Pen Web Audio by Eric Wang (@wweitinio) on CodePen
1

AWS Athena 分析查詢 AWS 服務日誌

在做大數據分析往往會遇到許多效能性的問題,造成開發者會另外在評估快取機制或是程式端優化的步驟,當引入 AWS Athena 這項服務時讓開發者大大簡化所有複雜的需求,AWS Athena 也是無服務器可以自動擴展,即使在處理更複雜查詢和大型數據集時,也可以依靠它並行執行查詢并快速生成結果。

關於 AWS Athena

將數據作為資源存放在 Amazon S3 中,在 AWS Athena 的部分只需指向 Amazon S3 中存放的位置並定義結構描述,開發者使用標準 SQL 就能解析查詢大規模的資料集。

數據格式

查詢服務使用幾種不同的數據格式,其中包括 ORC、JSON、CSV 和 Parquet,Amazon 建議使用Apache Parquet 將數據轉換為列式存儲格式,因為交互式查詢服務的核心功能遵循計算和存儲的分離,使用壓縮和列格式可以降低查詢和存儲成本,同時進一步提高性能。

Amazon 還建議對數據進行分區,以減少查詢需要掃描的數據量,從而提高查詢性能。

這可以提高性能並降低查詢成本,還可以配對 Amazon EMR 或 Glue 來轉換數據格式,以提高文件結構和格式的效率。

實作資料分析處理

接下來使用 Application Load Balancer 和 AWS S3 的訪問日誌來了解如何使用 AWS Athena 分析存在於 Amazon S3 文件夾中的整個數據檔案。

首先開啟 Athena 服務,在查詢編輯器中執行以下命令來建立一個數據庫:

CREATE DATABASE logs_db

設定與查詢 AWS ALB 服務訪問日誌

要了解如何啟用 Application Load Balancer 訪問日誌,您可以閱讀 這篇文章

在該數據庫中創建資料表結構:

CREATE EXTERNAL TABLE IF NOT EXISTS alb_logs (
    type string,
    time string,
    elb string,
    client_ip string,
    client_port int,
    target_ip string,
    target_port int,
    request_processing_time double,
    target_processing_time double,
    response_processing_time double,
    elb_status_code string,
    target_status_code string,
    received_bytes bigint,
    sent_bytes bigint,
    request_verb string,
    request_url string,
    request_proto string,
    user_agent string,
    ssl_cipher string,
    ssl_protocol string,
    target_group_arn string,
    trace_id string,
    domain_name string,
    chosen_cert_arn string,
    matched_rule_priority string,
    request_creation_time string,
    actions_executed string,
    redirect_url string,
    lambda_error_reason string,
    new_field string
    )
ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.RegexSerDe'
WITH SERDEPROPERTIES (
    'serialization.format' = '1',
    'input.regex' = 
    '([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*):([0-9]*) ([^ ]*)[:-]([0-9]*) ([-.0-9]*) ([-.0-9]*) ([-.0-9]*) (|[-0-9]*) (-|[-0-9]*) ([-0-9]*) ([-0-9]*) \"([^ ]*) ([^ ]*) (- |[^ ]*)\" \"([^\"]*)\" ([A-Z0-9-]+) ([A-Za-z0-9.-]*) ([^ ]*) \"([^\"]*)\" \"([^\"]*)\" \"([^\"]*)\" ([-.0-9]*) ([^ ]*) \"([^\"]*)\" \"([^\"]*)\"($| \"[^ ]*\")(.*)'LOCATION 's3://your-alb-logs-directory/AWSLogs/<ACCOUNT-ID>/elasticloadbalancing/<REGION>/';

上述的語法中需要將 LOCATION 中的值調整為要查詢的 AWS ALB 日誌位置。

示例使用的情境

查詢 ALB 的所有客戶端 IP 地址,並列出訪問次數:

SELECT distinct client_ip, count() as count 
FROM "logs_db"."alb_logs" 
GROUP BY client_ip ORDER BY count() DESC;

查詢 ALB 有關 HTTP 503 錯誤進行故障排除:

SELECT * FROM "logs_db"."alb_logs" 
WHERE elb_status_code = '503' LIMIT 10;

設定與查詢 AWS S3 訪問日誌

要了解如何啟用 S3 訪問日誌,您可以閱讀 這篇文章

在該數據庫中創建資料表結構:

CREATE EXTERNAL TABLE IF NOT EXISTS s3_logs(
  `bucketowner` STRING,
  `bucket_name` STRING,
  `requestdatetime` STRING,
  `remoteip` STRING,
  `requester` STRING,
  `requestid` STRING,
  `operation` STRING,
  `key` STRING,
  `request_uri` STRING,
  `httpstatus` STRING,
  `errorcode` STRING,
  `bytessent` BIGINT,
  `objectsize` BIGINT,
  `totaltime` STRING,
  `turnaroundtime` STRING,
  `referrer` STRING,
  `useragent` STRING,
  `versionid` STRING,
  `hostid` STRING,
  `sigv` STRING,
  `ciphersuite` STRING,
  `authtype` STRING,
  `endpoint` STRING,
  `tlsversion` STRING)
ROW FORMAT SERDE
  'org.apache.hadoop.hive.serde2.RegexSerDe'
WITH SERDEPROPERTIES (
  'input.regex'='([^ ]*) ([^ ]*) \\[(.*?)\\] ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) (\"[^\"]*\"|-) (-|[0-9]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) (\"[^\"]*\"|-) ([^ ]*)(?: ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*))?.*$')
STORED AS INPUTFORMAT
  'org.apache.hadoop.mapred.TextInputFormat'
OUTPUTFORMAT
  'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
LOCATION
  's3://your-s3-logs-directory/prefix/'

上述的語法中需要將 LOCATION 中的值調整為要查詢的 AWS S3 日誌位置。

示例使用的情境

查詢在特定時間段內已刪除對象的日誌:

SELECT requestdatetime, remoteip, requester, key 
FROM "logs_db"."s3_logs" 
WHERE operation like '%DELETE%' 
AND parse_datetime(requestdatetime,'dd/MMM/yyyy:HH:mm:ss Z')
BETWEEN 
parse_datetime('2021-10-18:07:00:00','yyyy-MM-dd:HH:mm:ss')
AND
parse_datetime('2021-12-06:08:00:00','yyyy-MM-dd:HH:mm:ss');

要顯示特定 IP 地址在特定時間段內傳輸的數據量:

SELECT 
SUM(bytessent) as uploadtotal,
SUM(objectsize) as downloadtotal, 
SUM(bytessent + objectsize) AS total 
FROM "apeiro"."s3_access_logs" 
WHERE remoteIP = '3.1.86.30' 
AND parse_datetime(requestdatetime,'dd/MMM/yyyy:HH:mm:ss Z')
BETWEEN 
parse_datetime('2021-10-18:07:00:00','yyyy-MM-dd:HH:mm:ss')
AND 
parse_datetime('2021-12-06:08:00:00','yyyy-MM-dd:HH:mm:ss');

優化 AWS Athena 成本

用戶只需為運行的查詢掃描的數據量付費,此外存儲在 S3 中的結果可能會產生存儲費用。

  • 每 TB 掃描數據的定價為 5 美元。這意味著您只需為您運行的查詢付費,無需額外費用。
  • 查詢四捨五入到最接近的 MB,最小為 10 MB。
  • 用戶按常規 S3 費率為存儲的數據付費。

建議用戶使用壓縮數據文件,以列格式保存數據,並定期刪除舊的結果集以保持較低的費用。在 Apache Parquet 中格式化數據可以加快查詢速度並減少查詢費用。

結論

Athena 可協助分析在 Amazon S3 中存放的非結構化、半結構化和結構化資料,當需要管理很多複雜的 AWS 環境時,這種方法就可以簡化很多排查步驟。

或是有相關開發上的功能需求也可以透過程式端連接 AWS Athena 做資料搜集應用,並可以再開發的產品系統上顯示當前伺服器的狀態,對於以往開發要抓取伺服器狀態的工作也簡化了許多。

AWS S3 Bucket 設定 Logging & Lifecycle

當建立完成 S3 Bucket 默認情況下日誌記錄是處於禁用狀態,必須再進行設定啟用日誌記錄來增強安全性,通過日誌記錄我們可以有助於安全審核找出可疑活動,並且可以從用戶訪問對象的位置以及訪問最多的對象了解更多信息。

了解與啟用日誌記錄

有兩種方法可以記錄對 S3 存儲資源的訪問資訊:

伺服器存取記錄

類似於 HTTP Server 日誌記錄,它包含有關請求者、資源響應以及有關請求者的其他詳細信息。

設定啟用存取記錄

  • 開啟 AWS S3 管理介面
  • 點擊 Bucket 名稱並選擇屬性
  • 在伺服器存取記錄部分選擇編輯
  • 在編輯界面中選擇啟用並選擇儲存的目標位置

日誌格式

存取記錄文件命名方式以以下格式寫入目標儲存貯體:

s3://bucket/prefix/YYYY-mm-DD-HH-MM-SS-UniqueString

內容使用以下以空格分隔的格式寫入,它看起來像這樣:

79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be awsexamplebucket1 [06/Feb/2019:00:00:38 +0000] 192.0.2.3 79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be 3E57427F3EXAMPLE REST.GET.VERSIONING - "GET /awsexamplebucket1?versioning HTTP/1.1" 200 - 113 - 7 - "-" "S3Console/0.4" - s9lzHYrFp76ZVxRcpX9+5cjAnEH2ROuNkd2BHfIa6UkFVdtjf5mKR3/eTPFvsiP/XV/VLi31234= SigV2 ECDHE-RSA-AES128-GCM-SHA256 AuthHeader awsexamplebucket1.s3.us-west-1.amazonaws.com TLSV1.1

內容包含 HTTP / REST 的操作(例如 GET、PUT、POST、OPTIONS.. ),每個紀錄有 24 個字串段,您可以 在此處 閱讀有關更多詳細的數據字段。

AWS CloudTrail 資料事件

AWS CloudTrail 是一項可以審查您的 AWS 內所有活動的服務。Object-level logging 與 AWS CloudTrail 集成可以在 CloudTrail 中查看和分析日誌,這些日誌事件以 JSON 格式保存在 CloudTrail 中,進入 CloudTrail 後詳細事件將存儲在 S3 Bucket 中,並可輕鬆與其他服務集成,例如 CloudWatch(監控/警報)、SNS(通知)、SQS(用於其他處理的隊列)和 lambda 函數(無服務器處理)。

設定啟用存取記錄

  • 開啟 AWS CloudTrail 管理介面
  • 點擊 Create trail 進入 Step 1
  • 選擇 Use existing S3 bucket 以及設定依需求要開啟的服務,進入 Step 2
  • Events 的步驟只點選開啟 Data events 收集 S3的數據

日誌格式

所有的活動紀錄都會紀錄在 CloudTrail Event history 裡面記載了有關操作的完整詳細信息,另外這些訪問紀錄也會以 JSON 數據結構保留在 S3 bucket。

了解兩者紀錄資料之間的差異

伺服器存取記錄

類似於 Web 服務器訪問日誌,以簡單的格式記錄信息,並存儲在 S3 bucket 中,它不包括訪問操作的對象完整的詳細資料,在事件發生時比較難有詳細的指標進行問題追蹤。

但在使用 3rd-party 日誌搜尋工具或者開發自己的解析/通知系統,伺服器存取記錄相關的信息如對象/請求的大小和響應時間,這對容量和性能規劃以及熱門文件... 分析就已經很有用。

在成本上此功能是免費提供的,唯一的成本是日誌的存儲成本。

AWS CloudTrail 資料事件

可靠的 API 調用日誌記錄,例如操作中的 ACL 定義... 等提供了數據的完整度,在安全性和 IT 操作都有較多的詳細資訊可以追蹤。

在與 AWS 其他服務 CloudWatch, SNS , SQS 也可以很輕鬆地進行串接,來達到全面的監控策略保護 S3 bucket 。

自動刪除舊文件

在完成啟用存取記錄後我們收集了大量的數據在一段時間後就不是那麼必要,佔用的空間越來越大在成本上會耗費很多,所以必須定期刪除不必要的數據,在 AWS 平台上我們可以應用生命週期規則,從 S3 bucket 中自動刪除數據。

設定生命週期規則

  • 開啟 AWS S3 管理介面
  • 點擊 Bucket 名稱並選擇管理在點擊建立生命週期規則
  • 替生命週期規則設定一項名稱

  • 設定篩選條件前綴,這個目錄及子目錄下的所有檔案都會被涵括在內

  • 最後設定執行的規則希望文件在 30 天後被刪除,選擇"讓目前版本的物件過期"並輸入天數

在 S3 Bucket上我們使用的是未啟用版本控制針對生命週期所有的物件都是目前的版本,可以透過上述的規則進行檔案在30天後將被標將被標記成過期物件,而被標記過期的物件我們需要再重複剛剛的動作另外建立一組新的規則在標記1天後永久刪除。

當每組文件需要有不同的保留時間,規則也可以設定很多組來自動執行,這樣就可以幫助避免不必要的費用自動刪除舊日誌。

結論

雲服務本身並不能完全的提供安全保護,隨著攻擊者不斷發現配置中的漏洞,當管理者本身也必須實時監控日誌當存在影響網站的基礎服務或安全相關問題時,我們就可以輕鬆識別這些變化的紀錄並深入解決問題,確保開發出來的應用程式具有更高的穩定性和可用性。

AWS Elastic Load Balancing 建立訪問日誌


Amazon ELB (Elastic Load Balancing) 提供訪問日誌,捕獲有關發送到負載平衡器的詳細信息,每個紀錄會包含如接收請求的時間、客戶端的 IP 地址、延遲、請求路徑和服務器響應等信息,這些訪問日誌可用於分析流量或是解決安全和操作問題。

一開始建立完成 Amazon ELB 默認情況下是禁用的,必須要再手動啟用訪問日誌記錄以及指定到 Amazon S3 保留下訪問數據。

設定 Amazon S3

準備在 Amazon S3 建立 Buckets 儲存 ELB 訪問日誌記錄,在管理多個環境時,將日誌存儲在單獨的 S3 存儲桶中,以便輕鬆查找特定環境的日誌。

  • 打開 Amazon S3 控制台建立 Buckets,必須與 ELB 在相同區域

  • 修改 bucket policy 授予 ELB 寫入訪問日誌的權限,以下使用策略:

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Principal": {
            "AWS": "arn:aws:iam::elb-account-id:root"
          },
          "Action": "s3:PutObject",
          "Resource": "arn:aws:s3:::bucket-name/AWSLogs/your-aws-account-id/*"
        },
        {
          "Effect": "Allow",
          "Principal": {
            "Service": "delivery.logs.amazonaws.com"
          },
          "Action": "s3:PutObject",
          "Resource": "arn:aws:s3:::bucket-name/AWSLogs/your-aws-account-id/*",
          "Condition": {
            "StringEquals": {
              "s3:x-amz-acl": "bucket-owner-full-control"
            }
          }
        },
        {
          "Effect": "Allow",
          "Principal": {
            "Service": "delivery.logs.amazonaws.com"
          },
          "Action": "s3:GetBucketAcl",
          "Resource": "arn:aws:s3:::bucket-name"
        }
      ]
    }

    使用策略中所需要調整的屬性描述:

  • bucket-name:填入建立的 bucket 名稱

  • your-aws-account-id:填入 AWS 帳戶 ID,可以在帳戶設定中找到

  • elb-account-id:填入 ELB 所在區域的 ID

Region Region name Elastic Load Balancing account ID
us-east-1 US East (N. Virginia) 127311923021
us-east-2 US East (Ohio) 033677994240
us-west-1 US West (N. California) 027434742980
us-west-2 US West (Oregon) 797873946194
af-south-1 Africa (Cape Town) 098369216593
ca-central-1 Canada (Central) 985666609251
eu-central-1 Europe (Frankfurt) 054676820928
eu-west-1 Europe (Ireland) 156460612806
eu-west-2 Europe (London) 652711504416
eu-south-1 Europe (Milan) 635631232127
eu-west-3 Europe (Paris) 009996457667
eu-north-1 Europe (Stockholm) 897822967062
ap-east-1 Asia Pacific (Hong Kong) 754344448648
ap-northeast-1 Asia Pacific (Tokyo) 582318560864
ap-northeast-2 Asia Pacific (Seoul) 600734575887
ap-northeast-3 Asia Pacific (Osaka) 383597477331
ap-southeast-1 Asia Pacific (Singapore) 114774131450
ap-southeast-2 Asia Pacific (Sydney) 783225319266
ap-south-1 Asia Pacific (Mumbai) 718504428378
me-south-1 Middle East (Bahrain) 076674570225
sa-east-1 South America (São Paulo) 507241528517
us-gov-west-1* AWS GovCloud (US-West) 048591011584
us-gov-east-1* AWS GovCloud (US-East) 190560391635
cn-north-1* China (Beijing) 638102146993
cn-northwest-1* China (Ningxia) 037604701340

啟用 Amazon ELB 訪問日誌記錄

  • 打開 Amazon ELB 控制台選擇已經建立的 ELB 在 description 裡點選 Edit attributes

  • 點選啟用並填入 S3 Bucket 名稱

這樣就可以完成日誌的相關設定,在我們 Amazon ELB 運行了一段時間之後,所設定的 S3 Bucket 路徑中就可以找到收集的日誌資訊。

深入了解 ELB 日誌

Amazon ELB 會依照設定時間在每個時間段內生成一次日誌文件,以下是輸出檔案的文件格式:

bucket[/prefix]/AWSLogs/aws-account-id/elasticloadbalancing/region/yyyy/mm/dd/aws-account-idelasticloadbalancingregion_load-balancer-id_end-time_ip-address_random-string.log.gz

我們可以從日誌中收集所有使用者連線的資訊、主機檔案存取的紀錄,以下是日誌的內容:

h2 2018-07-02T22:23:00.186641Z app/my-loadbalancer/50dc6c495c0c9188 
10.0.1.252:48160 10.0.0.66:9000 0.000 0.002 0.000 200 200 5 257 
"GET https://10.0.2.105:773/ HTTP/2.0" "curl/7.46.0" ECDHE-RSA-AES128-GCM-SHA256 TLSv1.2
arn:aws:elasticloadbalancing:us-east-2:123456789012:targetgroup/my-targets/73e2d6bc24d8a067
"Root=1-58337327-72bd00b0343d75b906739c42" "-" "-"
1 2018-07-02T22:22:48.364000Z "redirect" "https://example.com:80/" "-" 10.0.0.66:9000 200 "-" "-"

Amazon ELB 日誌包含非結構化數據,我們可以使用正則表達式將這些數據轉換 JSON 格式或者其他格式以便開發或是串接第三方平台,有關日誌每個字段的完整描述請參考 AWS 文檔

充分利用 ELB 日誌

了解日誌的格式後我們就可以專注於這些服務生成的日誌及其結構,並處理使用這些日誌的價值。

以下是些常用的案例:

  • 結合第三方工具如 PrometheusCoralogix 這些強大的軟體做數據視覺化分析
  • 使用 Amazon CloudWatch 分析數據中異常的警報
  • SaaS 服務商統計業務流量

Shopify Webhooks 進行即時資料訂閱

共享數據 webhooks,這是一種將數據再發生特定事件後即時從一個應用程式發送到另一個應用程式的單向通信,當我們需要從應用服務將資料保持同步至其他系統上或是擴展其他現有功能需求時,webhooks 則能很好的解決在開發上的需求。

Webhooks的工作原理

Webhook 通信是通過從應用服務提供的程式向目標發送 HTTP 請求來實現的資料傳遞。當應用服務中發生事件時,將會觸發該事件相關數據至所設定的 HTTP 請求端點。

例如在 Shopify 上為商家註冊一個 orders/create 事件並提供一個 HTTPS URL。每當商家上的訂單有被建立時,Shopify 就會向註冊的 URL 以 JSON 或 XML 格式發送該訂單相關數據。

設置和配置 Webhook

使用 REST API 管理配置 webhook

當我們是一個 Public App 就會需要通過 REST API 的方式來管理配置 webhook,以下是 Shopify 提供 webhook 相關的 Endpoints:

使用 Shopify 商家平台配置 webhook

再 Shopify 商家平台配置 webhook 就比較容易,我們可以從管理平台中點擊 設定 → 通知 → 滑動到最下方就可以找到 webhook 設定的介面。

Webhook 在開發時需要注意及思考的

安全證書

在設定 Shopify webhook 時接收端的 Server 必須是為 SSL 的環境

當丟失數據

在設計系統時考慮可能系統故障或是中斷時,我們該如何處理丟失的數據,在官方文件上也有提到不能僅僅依賴於從 Shopify webhooks 接收數據,所以避免這樣的情境發生,需要也考慮設計一個定期查詢 Shopify API 數據的相關程式作為應對。

數據的重複性

多次接收相同的 webhook 是可能發生的,所以我們在自己的系統上也需要記錄每次傳輸的數據,並使用 Webhook 的唯一 ID X-Shopify-Webhook-Id 來驗證 Webhook 是否已被處理完成。

快速回應狀態碼

如果在 Shopify 發送的五秒內接收端未收到 HTTP 狀態,則會認為它超時視為錯誤響應並重新發送 POST 請求,若持續發生將在 48 小時內總共發送 19 個請求。

在設計系統時,需要思考區分哪些任務是耗時的,再來用 queue 作業方式來將複雜的任務放入背景處理。

接收端的驗證

Shopify webhook 發送資料到接收端時會包含以下 HTTP Header:

  • X-Shopify-Topic — 該值會帶入是由哪個觸發事件的名稱
  • X-Shopify-API-Version — 該值會帶入目前使用的版本
  • X-Shopify-Webhook-Id — 該值會帶入 webhook 的唯一 ID
  • X-Shopify-Shop-Domain — 該值會帶入關聯的商家域名 shop.myshopify.com
  • X-Shopify-Hmac-Sha256 — 該值會帶入 base64 編碼的字串,用於驗證 webhook 的請求

我們可以通過 HTTP Header 將 X-Shopify-Hmac-Sha256 的值與計算出的 HMAC 來進行比較驗證,如果 X-Shopify-Hmac-Sha256 與計算的結果匹配,那麼就可以確定該通知是從 Shopify 發送的。

PHP code example

define('SHOPIFY_APP_SECRET', '');

$hmacHeader = $_SERVER['HTTP_X_SHOPIFY_HMAC_SHA256'];
$data = file_get_contents('php://input');

$calculatedHmac = base64_encode(hash_hmac('sha256', $data, SHOPIFY_APP_SECRET, true));

if ($calculatedHmac === $hmacHeader)
{
    echo 'hmac verified';
}

Shopify App 使用 OAuth 2 進行商家認證和授權

在現在的程式開發語言或框架,可能能夠找到現有的函式庫來快速打造出需要的功能,避免自己編寫過多的程式,但在遭遇問題處理時可能往往會不知道背後的運作流程,所以寫了這篇文章來記錄了解 Shopify 的 OAuth 身份驗證流程的工作原理。

首先建立一個應用程式,獲得 API key 與 API secret key,並在 App 設定中提供 App URL 與允許的重新導向網址。

簡敘什麼是 OAuth2?

根據官方 OAuth 描述:OAuth 2.0 是用於授權的行業標準協議。OAuth 2.0 專注於客戶端開發人員的簡單性,同時為 Web 應用程序、桌面應用程序、移動電話和客廳設備提供特定的授權流程。

它是一種身份驗證和授權訪問權限的方式,網路上的用戶可以在訪問他們在其他網站上的資料,而不需要提供他們的帳戶資料(用戶名/密碼)。

Shopify OAuth 流程

OAuth 流程中處理的步驟:

  1. 商家發送安裝應用程式的請求
  2. 將權限請求導向回 shopify
  3. 商家確認授權頁面
  4. shopify 向應用程式返回確認結果
  5. 將結果再次發送回 Shopify 獲取永久 access token

1.商家發送安裝應用程式的請求

當商家訪問我們的應用程式時,Shopify 會導向並傳遞幾個額外的參數到我們設定的 App URL。

https://app.domain.dev/?hmac=93cf7b063bae0790b74bb0a34e8e5b78e997de8ae771202f3bcd71f504d6d68e&shop=store.myshopify.com&timestamp=1634909927

收到請求時首先進行 hmac 驗證來確保是來自 shopify 的請求。取得 hmac 參數以外的參數組成字串,使用 sha256 加密算法取得 hexdigest 並驗證是否與 hmac 參數一致。

PHP code example

$ary = [];
$hmac = $_GET['hmac'];
unset($_GET['hmac']);

foreach($_GET as $key => $value) {
    $ary[] = $key."=".$value;
}

$str = join('&', $ary);
$ver_hmac =  hash_hmac('sha256', $str, "APP-SECRET-KEY", false);

if($ver_hmac == $hmac)
{
    echo 'hmac verified';
}

2.將權限請求導向回 shopify

現在已經完成請求的真實性,再將該請求的參數信息結合起來構建一個 ****URL 向商家取得授權。

https://store.myshopify.com/admin/oauth/authorize?client_id=080cceb67cdeebfd96390a4d59c66fc1&redirect_uri=https%3A%2F%2Fapp.domain.dev%2Fauth%2Fshopify%2Fcallback&scope=read_products%2Cwrite_products&state=aaf4d88ef34fdc39c951d1864b627fdb65b9e719
  • client_id:應用程式的 API key
  • redirect_uri:在商家授權應用程式的權限請求後,shopify 會將頁面導向該網址。此 URL 需要與App 設定中提供的重新導向網址一致
  • scope:應用程式被授予的權限, shopify 會提示商家查看權限授權範圍
  • state:每個權限 URL 需要創建的隨機值,再 shopify 返回確認結果時必須檢查此值是否一致

3.商家確認授權頁面

當應用程式把所需要的授權訊息導向回去時, shopify 會提示應用程式所需要授予的權限。

4.shopify 向應用程式返回確認結果

商家點擊 "安裝應用程序" 這時候 shopify 會將頁面在重新導向至我們再第二個步驟所帶入的 redirect_uri ,並且傳遞幾個額外的參數讓我們再次驗證。

https://app.domain.dev/auth/shopify/callback?code=04353d50220ae434b065224421480b90&hmac=cfdcc94094bd645d012281c4071dd27b504690732751bdc3187a622e78d08303&host=dGVzdC1lcmljeHh4Lm15c2hvcGlmeS5jb20vYWRtaW4&shop=store.myshopify.com&state=aaf4d88ef34fdc39c951d1864b627fdb65b9e719&timestamp=1634977849
  • code:可以使用它來交換商家權限的永久 API access code
  • hmac:與第一個步驟一樣通過計算 HMAC SHA256 digest,來驗證請求的真實性
  • state:確認在第二個步驟所提供的隨機值是否與返回的請求一致
  • shop:商家資料

5.將結果再次發送回 Shopify 獲取永久 access token

透過 Shopify API 來交換我們最終所需要的永久 access token。

PHP code example

$query = [
  "client_id" => $api_key, 
  "client_secret" => $shared_secret, 
  "code" => $params['code'] 
];

$access_token_url = "https://" . $params['shop'] . "/admin/oauth/access_token";

$ch = curl_init();
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_URL, $access_token_url);
curl_setopt($ch, CURLOPT_POST, count($query));
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($query));
$result = curl_exec($ch);
curl_close($ch);

$result = json_decode($result, true);
$access_token = $result['access_token'];

此 access token 則具有我們在第 2 步驟中所請求的權限範圍。我們現在可以使用它代表商店所有者向 Shopify API 發出請求進行查閱或異動資料。

Shopify App Development

Shopify App 允許開發者為在線商店客戶提供增強的服務,擴充既有 Shopify 功能或是與第三方應用程式進行資料串接,定制以滿足商店客戶的特定需求。

Shopify App 可以分為三種模式,來進行增強商家需求

Public App

  • 允許與多個商店合作
  • 需經 Shopify 審核,才能讓商店客戶安裝
  • 可以列於 Shopify App Store

比較 Shopify App 類型時還有技術差異,Public App 需要使用 OAuth 進行身份驗證並獲得用戶的許可,才能訪問 REST API 中的任何資源。

Custom App

  • 限制單一商家使用
  • 不需要經 Shopify 審核
  • 部分 API 有限制

Custom App 也可以嵌入到 Shopify 管理面板中,提供單一商店客戶做使用,但在 Shopify API 的訪問上就有些限制,無法使用更多複雜功能的 API。

Private App

Private App 不能嵌入到 Shopify 管理面板,使用情境會像是在處理商店數據與第三方數據進行處理,將資料導入或導出,在 Shopify API 的訪問權限上也有限制。

存取 Shopify API 的身份驗證是通過商店建立好的 API key 與 Password 來進行簡單的身份驗證。