Redis cluster on CentOs

這次來記錄一下架設  Redis Cluster 的經過。因為有公司GCP的帳號,所以這次環境是放在 GCP VM 上。想說 Cloud Launcher 不是點一點就幫你把 Cluster 架好了嗎…. 但是並沒有那麼簡單的事。首先我信心滿滿的 Launch 了這個:

但是後來RD跟我說他們想要UAT上的 Redis 是 Cluster,這樣才比較接近 Production 的設定。但這個 Redis HA 是幫你 Launch  Master/Slave 的 Replication Set。

首先上網研究了一下 Redis Cluster 到底長什麼樣子,理解下來大概是這樣:

由成雙的 Master/Slave 組成的 Cluster,每一個 Node 可以透過Node 設定好的 Port 前綴加1 互相溝通 (i.e. 6379 -> 16379 )

官網建議最少 Cluster 是 6 輛

Note that the minimal cluster that works as expected requires to contain at least three master nodes.


由於時間緊迫,不得已只好自己開VM來架 Cluster。選擇了比較熟悉的 CentOS7,先將 Redis 裝起來

$ sudo yum -y update
$ sudo yum install redis -y
$ sudo vim /etc/redis.config

這邊需要設定 Redis 要作為 Cluster 的組態。因為 Redis Cluster 設定時會讓你選擇誰是 Master 誰是 Slave, 所以 `redis.conf` 裡不需要設定 `slaveof`

大致上的設定是這樣的:

port 6379
bind 0.0.0.0
cluster-enabled yes
cluster-config-file nodes_6379.conf
cluster-node-timeout 15000
appendonly yes

六台都一樣,然後要啟動了

$ sudo systemctl start redis.service

啟動後看看狀況

$ sudo systemctl status redis.service
● redis.service - Redis persistent key-value database
Loaded: loaded (/usr/lib/systemd/system/redis.service; disabled; vendor preset: disabled)
Drop-In: /etc/systemd/system/redis.service.d
└─limit.conf
Active: active (running) since Fri 2018-04-27 06:12:30 UTC; 4s ago
Main PID: 12254 (redis-server)
CGroup: /system.slice/redis.service
└─12254 /usr/bin/redis-server 127.0.0.1:6379

都啟動後就可以來準備架設 Cluster了

首先你需要 Ruby。Redis 的 Github source 有付可以幫你設定 Cluster 的 Script `./redis-trib.rb`;是用 Ruby 寫的。(需要注意的是,我在 GCP 上 create 的這個 CentOS7 Ruby 版本是 2.0.0, 但要執行 `./redis-trib.rb` 需要 2.2 以上的 Ruby。)

所以我需要先 Upgrade Ruby。

接下來就是到 Github Redis Repository去把 Source Clone 下來 (官網本來是說, `gem install redis` 會放在 Utils 資料夾,但找來找去找不到,可能後來的版本就不附了)。

找到 `./redis-trib.rb` 後,確認一下六台主機的 ip (因為我bind 0.0.0.0) 是否正確並且 Redis 有啟動後。用下列指令來設定 Cluster

$ ./redis-trib.rb create --replicas 1 10.140.0.10:6379 10.140.0.11:6379 10.140.0.12:6379 10.140.0.13:6379 10.140.0.14:6379 10.140.0.15:6379
>>> Creating cluster
>>> Performing hash slots allocation on 6 nodes...
Using 3 masters:
10.140.0.10:6379
10.140.0.11:6379
10.140.0.12:6379
Adding replica 10.140.0.13:6379 to 10.140.0.12:6379
Adding replica 10.140.0.14:6379 to 10.140.0.10:6379
Adding replica 10.140.0.15:6379 to 10.140.0.11:6379</code>

M: ac14f4b6c395cfd7e715f901a4c726ffa0198273 10.140.0.10:6379
slots:0-5460 (5461 slots) master
M: 9bba60c982771ed8945caae0a4469f10615ddce4 10.140.0.11:6379
slots:5461-10922 (5462 slots) master
M: 32bf455e14e41ef3eb94ca27d0180b0164334cea 10.140.0.12:6379
slots:10923-16383 (5461 slots) master
S: b9eb6df267af707365282f641f66d36dcb1a25bc 10.140.0.13:6379
replicates 32bf455e14e41ef3eb94ca27d0180b0164334cea
S: e263026d609aa02c7dc845c287fc26ca7f4650d1 10.140.0.14:6379
replicates ac14f4b6c395cfd7e715f901a4c726ffa0198273
S: aeaacfed42bf7754f545abde642cf01380604ca4 10.140.0.15:6379
replicates 9bba60c982771ed8945caae0a4469f10615ddce4
Can I set the above configuration? (type 'yes' to accept):

設定 Master/Slave 配對的結構會如上顯示給確認。如果沒什麼問題的話,輸入 `yes`

>>> Nodes configuration updated
>>> Assign a different config epoch to each node
>>> Sending CLUSTER MEET messages to join the cluster
Waiting for the cluster to join..
>>> Performing Cluster Check (using node 10.140.0.10:6379)
........
......

[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.

醬就差不多設定好了。

其實好像沒那麼麻煩。麻煩的是準備六輛 VM…. 但對 GCP 其實也沒很熟,應該有更快生 VM 出來的方式。但這次就先處理到這裡好了。

Windows Server 安裝 RabbitMQ

今天準備做 Message Broker 的 POC。我想做的是,在兩台開發環境 VM 裝 RabbitMQ 來做基本的 cluster。正準備在內部開發環境上安裝 RabbitMQ ,但因為內網開發環境的資源已經將要用完。IT幫我查了一下,許多之前開的VM幾乎沒什麼使用率,遂表示不建議再生VM。建議我找之前開的VM來重複利用。

因為原來的VM都是 Windows Server 2012,所以下載了 RabbitMQ 的 Windows Distribution 與 OTP (Erlang) 準備安裝。

問題

安裝完以後遇上了小問題。我要使用 rabbitmqctl status 來查閱安裝結果的時候出現

Status of node rabbit@DEV03 ...
Error: unable to perform an operation on node 'rabbit@DEV03'. Please see diagnostics information and suggestions below.

Most common reasons for this are:

 * Target node is unreachable (e.g. due to hostname resolution, TCP connection o r firewall issues)
 * CLI tool fails to authenticate with the server (e.g. due to CLI tool's Erlang cookie not matching that of the server)
 * Target node is not running

In addition to the diagnostics info below:

 * See the CLI, clustering and networking guides on http://rabbitmq.com/documentation.html to learn more
 * Consult server logs on node rabbit@DEV03

接著 Rabbit 的診斷結果一看就知道有可能是 Magic Cookie 的問題

DIAGNOSTICS
===========

attempted to contact: [rabbit@DEV03]

rabbit@DEV03:
  * connected to epmd (port 4369) on DEV03
  * epmd reports node 'rabbit' uses port 25672 for inter-node and CLI tool traffic
  * TCP connection succeeded but Erlang distribution failed
  * Authentication failed (rejected by the remote node), please check the Erlang cookie

Current node details:
 * node name: rabbitmqcli40@DEV03
 * effective user's home directory: C:Usersalex
 * Erlang cookie hash: gK4zBWcnFKbA++Vp1Jl/jQ==

翻找了一下文件後,發現 RabbitMQ 下載頁面交代說安裝 OTP 時要用 Admin 權限。應該是因為在安裝 Erlang 的時候會建立一個 Erlang Magic Cookie 。這個 .erlang.cookie 檔案會被寫入 C:\Windows 資料夾內(%HOMEDRIVE%)。

RabbitMQ 官網有提到同步 Cookie 的問題,這邊的預設立場是,Erlang 安裝完以後,有建立起 .erlang.cookie 檔案。

Synchronise Erlang Cookies (when running a manually installed Windows Service)

Erlang Security Cookies used by the service account and the user running rabbitmqctl.bat must be synchronised for rabbitmqctl.bat to function.

To ensure Erlang cookie files contain the same string, copy the <span class="code ">.erlang.cookie</span> file from the Windows directory (normally <span class="code ">C:\WINDOWS.erlang.cookie</span>) to replace the user <span class="code ">.erlang.cookie</span>. The user cookie will be in the user's home directory (<span class="envvar">%HOMEDRIVE%</span><span class="envvar">%HOMEPATH%</span>), e.g. <span class="code ">C:\Documents and Settings<span class="envvar">%USERNAME%</span>.erlang.cookie</span>or <span class="code ">C:\Users<span class="envvar">%USERNAME%</span>.erlang.cookie</span> (Windows Vista and later).

但我在VM上的 C:\Windows 卻找不到 .erlang.cookie 檔案!只找到 C:\Users\%USERNAME%\.erlang.cookie 我想可能是因為我登入VM的時候用的是AD帳號,安裝時權限不足以在 C:\Windows 下寫入檔案。厄..只好去翻了翻 Erlang 的官網看看怎麼辦。解法找不到,倒是有看到關於魔術餅乾(Erlang Magic Cookie)的用途:


Authentication determines which nodes are allowed to communicate with each other. In a network of different Erlang nodes, it is built into the system at the lowest possible level. Each node has its own magic cookie, which is an Erlang atom.

然後有關於 Cookie 的產生:


At start-up, a node has a random atom assigned as its magic cookie and the cookie of other nodes is assumed to be nocookie. The first action of the Erlang network authentication server (auth) is then to read a file named $HOME/.erlang.cookie. If the file does not exist, it is created. The UNIX permissions mode of the file is set to octal 400 (read-only by user) and its contents are a random string. An atom Cookie is created from the contents of the file and the cookie of the local node is set to this usingerlang:set_cookie(node(), Cookie). This also makes the local node assume that all other nodes have the same cookie Cookie.

解法

琢磨了一下,我就直接把 C:\Users\%USERNAME%\.erlang.cookie 複製到 %HOMEDRIVE%。但問題仍然還是一樣

* Authentication failed (rejected by the remote node), please check the Erlang cookie

然後試了一下是不是 RabbitMQ 的 erlang node 吃不到 cookie,於是就用 erl shell 設定 runtime 的 rabbit (-sname rabbit) 的 cookie ,設的跟 %HOMEDRIVE% 下的一樣,然後連線就正常了!

C:\Windows\system32>"C:\Program Fileserl9.2\binerl.exe" -sname rabbit -setcookie SBAGGAVFWIPXEEDTLWA
Eshell V9.2 (abort with ^G)
(rabbit@DEV03)1> erlang:get_cookie().
'SBAGGAVFWIPXEEDTLWA'

想了一下,原來還是我沒想到是登入的帳號問題。我是用 AD 帳號登入安裝並的,RabbitMQ 的確是幫我在我建了一個 .erlang.cookie 檔案在我的 user 資料夾。但服務啟動的時候執行者是 local system account

rabbit-mq-windows-service-screen

我試了一下把執行服務的帳號改成我的AD帳號,重啟 RabbitMQ Service 再執行 rabbitmqctl status 就可以正常連線了!

看來,還是得乖乖建一個系統帳號給他使用才是。

另外,也可以透過設定 RabbitMQ 來給予 Erlang 付加參數

As an alternative, you can add the option “-setcookie value" in the RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS environment variable value:

RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS="-setcookie cookie-value"

但官方建議這個是比較不安全的做法。

智付通金流整合

今年初用 rails 做了個系統與智付通的金流整合。整合過程其實蠻單純的,稍微將實做整合的過程記錄在這邊。

當初整合智付通的流程是,使用者在我站挑選要購買的商品後,到結帳頁面輸入送貨地址,是否要開索取發票,是否使用折價卷或點數扣抵等等資訊。後。將使用者與確認完整的購買資訊 POST 到智付通的付款頁面。待使用者付款完成後,智付通會將使用者再導回我站,並且傳回付款結果等資訊告知我站是否已成功付款;同時背景也會呼叫我站的 Notify URL。

spgateway-integration

大概整合內容如上,很簡單。

幾個整合的重點:

  • 跳轉到智付通的頁面要用 Form POST 把玩家導過去,要整頁導過去。智付通文件有說明放 iframe 裡導頁會有問題。
  • 智付通回饋付款結果有兩個
    • ReturnURL (前景) 讓使用者在付款完後(不管成功失敗) 導回 ReturnURL
    • NotifyURL (背景) 會在背景把支付完後的資訊打給 NotifyURL
  • 加解密的方式

這邊遇到一點問題的就是加解密,所以說明一下。其他頁面跳轉大部分的金流好像都差不多。

加解密的方式

整個加密重點在這段,智付通的 API 文件上提供 mcrypt_encrypt(CRYPT_RIJNDAEL_128, key, CRYPT_MODE_CBC, iv) (PHP) 與 RijndaelManaged() (.NET) 的 AES 加密範例可參考。

用 ruby 有遇到一點麻煩,因為先參考了mcrypt 的範例,一開始就直接用 OpenSSL 來加密,但是 encrypt 出的字串與智富通的不合。參考這篇 Stack Overflow 的答案  可知,在 ruby 裡用 OpenSSL::Cipher 與 mcrypt 執行起來的行為是不一樣的。將 256 bit 的 key 傳入 mcrypt( ) 他會自己改為 rijndael-256 的編碼,而 OpenSSL::Cipher 只會拿前 128 bits 。

而智付通給的key 是 256 bits 的

權衡之下,還是用了 php-mcrypt ….讓 mcrypt 自己去調整編碼方式

# 這個 hash 轉成 QUERY STRING 涵式是這邊抄來的: 
# https://justanothercoder.wordpress.com/2009/04/24/converting-a-hash-to-a-query-string-in-ruby/
payload_str = Common::hash_to_querystring(@payload)

# 加密 payload
crypto = Mcrypt.new(:rijndael_128, :cbc, key, iv, :pkcs)
ciphertext = crypto.encrypt(payload_str)

ciphertext.unpack("H*").join()

最後,組出單向的核對字串:

def trade_sha(tradeInfo)
    key = Settings.payment.spgateway.key
    iv = Settings.payment.spgateway.iv

    Digest::SHA256.hexdigest("HashKey=#{key}&#{tradeInfo}&HashIV=#{iv}").upcase
end

完整的加密 Gist

在智付通把使用者導回 ReturnURL 的時後同時也會送來一串交易結果的 payload,這個 payload 的解密方式如下

def decrypt
    key = Settings.payment.spgateway.key
    iv = Settings.payment.spgateway.iv

    crypto = Mcrypt.new(:rijndael_128, :cbc, key, iv)
    plaintext = crypto.decrypt([@payload].pack('H*'))

    # 移除解密後多出來的 padding
    # 因為知道加密的內容是 JSON 所以可以用這樣的懶惰方式移除 padding...
    plaintext[0, plaintext.rindex('}') + 1]
  end

記得好像就醬,目前好像開立電子發票,信用卡請款等等都可用上述的方式加解密。整合來說並不算太複雜。

複雜的反而是開電子發票後如果客人要退貨,依照相關法規要退發票重新申報這些。如果可以直接進智付通後台操作那就還好…不然靠 API 要弄電子發票真的很繁雜….

初試 Dapper

Dapper 做為一個 .Net 的 ORM Library 用簡單來形容他真是太適合不過了。

幾年前在寫 .Net 的程式時習慣用 Entity Framework & Unit of Work 的方式來包裝資料層,剛開始改用 Dapper 腦筋還有些轉不過來。但試了一下後發現,如果把效能當作第一優先考量的話(這也是我現在手上在規劃的專案的優先考量點),Dapper真的是有無法抗拒的魅力。

Dapper 所帶來的優點這邊就不贅述了,這邊要談的是,使用 Dapper 做資料底層的時候,我想到的一些事:

  • Unit Of Work (UOW) 怎麼做呢?
  • 要怎麼做可單元測試的資料底層?
  • 使用時所思考過的事

Unit of Work?

原本使用 Entity Framework 的習慣是建立一個 MyDbContext 物件去繼承 DbContext 物件,然後在裡面定義

public virtual IDbSet { get; set; }

等等屬性代表 DB 裡的資料(表)。

用 Dapper 後,因為直接使用 SqlConnection 連線,連線管理相對變得單純,在網路上拜讀了 Joe Sauve 的一篇文章 Async Dapper with async SQL connection management。發現他所提供的作法非常的乾淨簡潔,照著他的建議實做了自己的程式:

這邊做了一個抽象的 Base Repository `RepositoryBase` 做出包含處理連線資料庫的 Method。

public abstract class RepositoryBase
{
    protected DbConnectionFactory DbConnectionFactory;

    protected RepoBase(DbConnectionFactory connectionFactory)
    {
        DbConnectionFactory = connectionFactory;
    }

    ///
    /// 這 Method 就是 Joe 所建議的 Async 模式,詳情可以參考 Joe 的 Blog
    /// [Async Dapper with async SQL connection management](http://www.joesauve.com/async-dapper-and-async-sql-connection-management/)
    ///
    protected async Task WithConnectionAsync(Func dbOperation)
    {
        try {
            using (var connection = DbConnectionFactory.GetSqlDbConnection()) {
                await connection.OpenAsync();
                // Asynchronously open a connection to the database
                return await dbOperation(connection); 

                // Asynchronously execute getData, which has been passed in as a Func
            }
        } catch (TimeoutException ex) {
            throw new Exception(String.Format( "{0}.WithConnectionAsync() experienced a SQL timeout", GetType().FullName), ex);
        } catch (SqlException ex) {
            throw new Exception(String.Format( "{0}.WithConnectionAsync() experienced a SQL exception (not a timeout)", GetType().FullName), ex);
        }
    }
}

Joe 的這個做法,漂亮的地方在於;這樣的做法簡單的把 開啟連線,Try/Catch 都集中在一個地方處理。實做這個 abstract class 時只需要把需要執行的 .Execute() .Query() 包在 `async connection => { } ` 裡就好。

更棒的是這個方式同樣照顧到需要寫非同步處理的需要!

下面是一個繼承實做 RepositoryBase 的 class `CustomerRepository`。

public class CustomerRepository : RepositoryBase, ICustomerRepository
{
    public CustomerRepository(DbConnectionFactory conn) : base(conn)
    {
    }

    // .... 與 Customer 有關的 Method 可放在這裡,
    // 比如說 CreateCustomer(), CreateAddress()
    // 這些新增可能都會跨好幾個表格

    // 像這個新增 Customer
    async Task ICustomerRepository.CreateCustomerAsync(Customer customer)
    {
        var customerId = Guid.NewGuid();

        return await WithConnectionAsync(async c => {
            using(var t = t.BeginTransaction()){

                // 建立 Customer 資料
                c.Execute(@"INSERT INTO Customers (Id, Name) VALUES(@Id, @CustomerName)",
                        new {
                            Id = customerId
                            AddressText = customer.name
                        }, transaction: t);

                // 住址。這邊假設 Customer 關連到多筆住址
                c.Execute(@"INSERT INTO Addresses (Id, Name) VALUES(@AddressId, @AddressText)",
                        new {
                            AddressId = Guid.NewGuid(),
                            AddressText = customer.addressText,
                            ....
                            CustomerId = customerId
                        }, transaction: t);

                // 送 DB 執行
                t.Commit();
                return customerId;
            }
        });

    }
}

單元測試

單元測試是在用了 Dapper 的時候覺得很不方便的事情。當 TSQL 都需要寫在程式或 Stored Procedure 裡,單元測試就不得不做一些變更了。

用 EF 可以輕鬆的 Mock 測試資料塞到 Service Class 裡來驗證商務邏輯,但 Dapper 只能真的準備一個資料庫(In Memory 或 localdb 之類的)來做類似整合測試來涵蓋底層資料存取的邏輯。

比起直接建一個 ICollection 當作測試資料當然是慢了點。而且並不能完整符合的單元測試的定義。但就要看使用 Dapper 要達成的主要目的的價值是否是我們要的。

使用時所思考過的事

目前的資料庫是與 DBA 討論而一起建立起來而不像之前由 EF Code First 建起來的。我在看 Repository 就比較像是 Aggregate Root 的樣子,把一些相關的底層表格整理成一個 Repository。

簡單的來說,我在做底層的時候,比較需要在意的事情應該是 Domain Model 的設計。既然 Repository 被當成 Aggregate Root。以我現在正在想辦法實現的架構 (CQRS):

domain

Command 可能較為單純一些,把要對資料異動的幾個動作一起進去即可,但 Query 的 Method 可能就會包含整合不同 Aggregate Root 的一些 Projection。

但目前仍然在想說,是不是有需要把 Query 跟 Command 的 Method 拆開來寫在不同的 Repository。

設計決定有時候真的很難 …


稍微結論一下,本篇討論了一下使用 Dapper 來做底層存取時我自己遇到與想到的一些問題:單元測試、非同步方法的實做等等。

手上在設計的系統會有很大流量。之前在做 CQRS 使用的是 EF 來打底,效能上沒有遇到太多 Application 造成的效能瓶頸,但 Dapper 應該還可以再讓系統跑快一點。

References

單元測試

本章介紹程式設計師應都該知道的,有關於單元測試的基本概念。當然我們常常會說:「這功能我測過了」。但專案功能要求的「測過了」跟我們說的「測過了」怎麼還是會有出入呢?

– 程式設計師最原始的測試經驗
– 單元測試 (Unit test) 的定義
– 評估你的(單元)測試
– 比較整合測試與單元測試
– 撰寫測試時的建議

程式設計師最原始的測試經驗

筆者在尚未接觸單元測試以前,也會非常細心的測試自己的程式。以開發一個網站來舉例:

我的網站有一個簡單的表單頁面讓使用者註冊

ch4_001-1

假設我想測試的是伺服器端有沒有在沒輸入姓名的情況下擋下使用者送出表單的行為。那麼,我打開註冊頁面後,就故意不打姓名然後點註冊按鈕來測試看看

ch4_002.png

然後看到警告視窗我就會滿意的說我的程式有阻擋未完成的表單,並且也測試過了(?)

ch4_003.png

送給測試工程師(QA Engineer)測試的結果令筆者為之氣結:『表單沒有驗證』。原來筆者沒有測試到輸入一個空格在姓名欄位就送出表單,只檢查姓名欄位長度而已:

public ActionResult Register(string name, string email){
	if(name.length > 0){
		// 處理表單
	}
}

如果我們一貫用上述的方式測試我們的程式,不但效率不好,更糟糕的是測試後通常仍然過不了測試工程師(QA Engineer)的刁鑽古怪測試案例。

撰寫程式的工程師,也沒有多餘的時間把測試工程師準備好的測試案例都測過一次(這不應該是程式設計師花大部分時間在做的事情啊)。

那麼,身為程式設計師,我們能做什麼來讓我們有自信的保證我們做出成品的品質呢?

單元測試 (Unit test) 的定義

為什麼是「單元」呢?寫過專案的人應該都了解,一個完整的專案是由許多小的細節環構而成的。就像車子的引擎有無數的小零件組合而成。為了確認每一個程式裡的功能都能各司其職的運作如常,我們在測試時,會將我們的程式,以「單元」來劃分。一個「單元」(Unit),就是系統裡一個最小的「工作單元」,它有可能只是一個函式(Method),類別(Class)等可執行的程式單位。 Unit Test – Definition

「單元測試」即是指可以被自動執行的測試程式,他將會透過公開的介面來執行你撰寫的程式,並依照你預期的執行結果或行為驗證。

理想的情況下,當程式在持續的開發時,對應的單元測試的「質」與「量」也會相對的增加。不斷的累積下,工程師將會更依賴每次執行測試的結果來判斷是否無誤。當測試程式反映出錯誤的時候程式設計師就有此依據來判斷是程式的撰寫問題,或者有可能是,當初撰寫的測試程式已經不符合預期的結果而需要修改。

為什麼單元測試非寫不可

舉一個簡單的案例,相信身為程式設計師的各位都有過類似的經驗:

你被指派接手一個已開發完的系統,你的工作是維護它,修改已知和未知的問題並且增加新功能。但當你研究了幾天它的原始碼發現裡面博大精深難以理解的時候,能幫助你的有誰呢?

  • 原作者
  • 文件

原作者可能可以提供你相當的協助,但相信不會花太多時間來解釋細節給你聽。而文件或許從專案經理組了專案團隊開始做專案以後就沒人去更新過,這時後最有幫助的,應該是該系統的「單元測試」(當然,我是說如果有的話)。

單元測試的功用不只是測試系統執行的正確性而已,而是被測試的「單元」程式所包含的程式邏輯的正確性。換句話說,一個單元測試其實包含了正確的邏輯。

訂單系統的例子

假設我們撰寫的訂單系統裡有一個結帳的功能,在系統使用者選購網頁上的產品後,結帳時幫使用者計算需支付的金額:

public decimal Checkout(List<Order> orders){
	var total = orders.Select(x => x.Price).Sum();
	return total;
}

現在的功能的需求是:

把訂單裡每個項目的價格加總後回傳

相對應的測試程式應該準備好一個訂單物件(`List`)並傳入這個 `Checkout()` 的函式裡去執行,然後取得運算結果後與預期的計算結果比對。

假設我們的程式需求變更了,改成:

把訂單裡每個項目的價錢折扣後,加總回傳

public decimal Checkout(List<Order> orders){
	var total = orders.Select(x => x.Price - (x.Price * x.Discount)).Sum();
	return total;
}

修改後的程式同樣的拿我們之前撰寫的測試程式去測試,出錯的測試結果就會告訴我們:我們在單元測試裡的預期的結果跟我們撰寫的程式是有出入的。

這時後我們檢視單元測試會發現並沒有打折扣這樣的商業邏輯。我們就可以針對這點,來就程式的需求調整系統程式或者是單元測試。以求兩者皆符合我們目前專案的結帳功能的需求。

評估你的(單元)測試

「單元測試」有一些獨特的特徵,如果測試程式不符合以下任一點的話,那麼我們應該將他歸類為「整合測試」:

  • 可快速被執行
  • 可完全自動化
  • 可完全掌控被測試的程式,利用注入偽造(Mocking)與模擬(Sutbbing)的方式來做出假的資料與行為,讓被測試的程式在沒有任何相依模組的載入下一樣能被測試程式執行
  • 不需要依照定義好的順序執行,換句話說,各個單元測試都應該有自己的獨立性
  • 直接在記憶體裡執行,也就是不依賴任何資料庫或是檔案存取
  • 使用一致的數值與資料來執行測試,使用亂數來產生資料是整合測試時才做的事
  • 每個單元測試都專注在單一的邏輯概念即可
  • 易讀性
  • 易維護性。同上,測試程式將會被許多合作的工程師閱讀與維護,寫出看的懂的測試程式很重要
  • 可靠。理想的狀況是,你可以只看測試程式與測試結果就大概知道,有通過測試的程式如何「正確」,與沒通過測試的程式是如何「不正確」

以上幾點不只可以驗證測試程式,相對的也可以拿來評估要被測試的程式。

撰寫測試時,可以由上列出的特徵來判斷你的程式是不是可以寫出「單元測試」。如果程式無法寫出「單元測試」的話,那麼表示程式的結構可能沒有做好模組分離或是抽離相依性等等的基礎建設。如果系統持續如此開發下去的話,系統的延展性與維護性都將會面臨較大的困難。考慮到以後的日子,越早重構程式,往後的日子越省事。

比較整合測試與單元測試

上一章節提到的「整合測試」與「單元測試」,與用汽車引擎來類比「單元測試」就像引擎上的小零件。那麼「整合測試」我們也可以類比成測試整個引擎的運轉狀況。這樣的測試要的是每一個引擎上的小零件湊在一起運轉時的功能是不是能如我們所預期。

簡單的概念是,如果「整合測試」失敗的話,引擎理的任何一個零件,或是任何的零件之間互動的因素都有可能造成測試失敗。反觀「單元測試」,測試失敗時我們可以由測試案例得知是哪一個零件有問題,進而修復。

「整合測試」的特徵是:

  • 一次測試兩個以上互相依賴的單元(模組)
  • 預期功能性的測試結果
  • 測試需要依賴網路,資料庫或實體檔案存取
  • 需要設定執行環境

與「單元測試」比較起來,整合測試一樣應該是要可以自動化&可以重複被執行,但差別在於整合測試通常需要設定好程式執行的環境,然後讓程式在執行時,透過程式提供的介面來執行需要被測試的程式。也是因為這樣的關係,「整合測試」通常在執行上需要比「單元測試」花更多時間值執行,也相對的需要更多時間準備測試資料。

同時,整合測試出錯時,有時候並不一定表示程式是有問題的,錯誤的測試環境設定也一樣可能導致整合測試失敗。我們可以透過整合測試的過程來確保程式在各個環境下都可以正常執行,並且同時驗證環境設定是否正確。

撰寫測試時的建議

撰寫單元測試當然是程式設計師的工作,自己寫的程式還是得自己才懂得怎麼才能測出問題。「單元測試」的特點就是易於撰寫與執行。因為「單元」應該要是一小段程式,所以幾行的測試程式應該就要可以寫完單元測試。

佈署 Rails App 的一些想法

基礎的架構想法是這樣的:

SSL 憑證掛在處理流量的 Nginx 上順便讓他做基本的 LoadBalancer。後面就是N個 Rails App 的 VM 來處理LB後的流量。Rails 的 Application 還是用習慣的 unicorn 來執行成 unix socket 讓同台機器上的自己的 Nginx 來把非 static 的 Request 導向 Rails App。

rails-topology.png

Nginx 上的設定

upstream store {
    server 10.x.x.1;
    server 10.x.x.2;
}

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    listen 443 ssl default_server;

    .....
    location / {
        proxy_pass http://store_milife;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

想得到要小心的是

  • 如果要用 SSL 架站的話,Nginx 上要把 HTTP 的 Request 轉向 HTTPS 再導到 內網的 Rails App,類似這樣的方式:
server {
    ...
    if ($scheme = http) {
        return 301 https://$server_name$request_uri;
    }
}
  • Rails erb 裡的 link_to 會因為在內網只用 http 架站 enforce_ssl 沒有打開,所以會導向 http://。如果 Web Server 有處理掉這段的話就可以不用在 Rails 裡面處理
  • enforce_ssl 不要打開,不然會發生無限導向的狀況

 

佈署 Rails App 到 Ubuntu 16

專案

現在到了一個沒有 .NET 的工作環境,臨時趕一專案需要趕快做一個 EC 平台串金流。

基於對 Rails 的愛,選了 Rails 來開發這個專案。時間雖然很趕,但 Rails 的框架很多東西都做好了,開發起來就很輕鬆。

設施&佈局

佈署目標是 Google Computing Cloud, 本來打算用 Container Engine 但因為 SSL 已經設定好在一個 Computing Cloud VM 上。找了一下暫時找不到怎麼把 Computing Enging 的 vm 跟 Container engine 內網串起來。所以還是建了 Ubuntu VM 來佈署。

>> 這次佈署的架構在這篇

在已經掛了 SSL 的 Nginx 上做簡單的 Reverse Proxy:

upstream store_milife {
    server 10.x.x.x;
    server 10.x.x.x;
    server 10.x.x.x;
}

server {
    location / {
        proxy_pass http://store-sites;
    }
}

透過內網把 Request 轉到我們部署了 Rails 的VM。這裡我們裝了另一個 Nginx 讓它先處理 Static asserts 再處理其他的 Request,我們把這些其他的 Request 轉到 unicorn 建立的 unix socket

upstream app {
    server unix:/opt/store/shared/sockets/unicorn.sock fail_timeout=0;
}

server {

    root /opt/store/public;

    try_files $uri/index.html $uri @app;

    location @app {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_redirect off;
        proxy_pass http://app;
    }
    ...
}</pre>
<h2>Session</h2>
這麼一來因為我們有了 Load Balance 的機制,Rails App 就得把 Session 往共用的儲存空間放,不能再放本機記憶體,<a href="https://github.com/rails/activerecord-session_store" target="_blank" rel="noopener">這個 GEM</a> 可讓你很輕鬆的設定資料庫為 Session store,在原本的資料庫多一個表格存放 Session
gem 'activerecord-session_store'

送信功能

結帳後送信在 Rails 也是很簡單的,Rails 把送信的機制做在 Active Job 裏。 Active Job 讓 Rails App 在需要執行一些背景工作時可以透過一致的介面溝通。你可以選用你喜歡的 Queue 來處理在 Application 裡送出的 Job 。

Rails 預設的 Async Queue 是跟著 application 跑在同一個執行緒的。在開發這個專案的時候我選用了 Resque 。Resque 是把 Queue 存在 Redis 裡。

Email 的設定:

config.active_job.queue_adapter = :resque
# 根據不同的環境來設定 Queue 可避免類似 QA 環境的 Job 被 Production 吃到的問題
config.active_job.queue_name_prefix = "store_#{Rails.env}"
# 把要寄出的信件開在開發機上的瀏覽器預覽方便開發調整
config.action_mailer.delivery_method = :smtp
# 正式環境下用 :smtp 沒問題,但是測試環境下我用了 `letter_opener`
# 測試環境:
config.action_mailer.delivery_method = :letter_opener

接下來我們可用

$ rails g mailer StoreMailer

來建立幫我們寄件的郵差,在 app/mailers/store_mailer.rb 裏我們可以設定要寄出 Email 的內容。

寄信對 Rails 來說,就像在 Render HTML 頁面一樣,在 app/views/store_mailer 裡我們將設定對應的 view 作為 Email 的內容。比較不同的是,Mailer 要使用 heleper 的話需要自己指定 helper 不會自已載入。

class StoreMailer < ApplicationMailer     helper :application # Load app/helpers/application_helper.rb          ... end 

另外在 mailer class 裡我們需要設定內容,def order_notification 會對應到 app/views/store_mailer/order_notification.erb

 
def order_notification(user_session, order)     
    @header = {         
        :title => '訂購完成通知',
        :disclaimer => true,
    }

    @user = user_session
    @order = order
    mail(to: @user['email'], subject: @header[:title])
end

View 的寫法就與 HTML 頁面的 ERB 一樣

執行 Mailer Job

Resque config/initializers/resque.rb 可以載入 config/resque.yml 並藉此設定與 Redis 的連線

config/resque.yml

development: localhost:6379
test: localhost:6379
production: 10.x.x.x:6379

config/initializers/resque.rb

rails_root = ENV['RAILS_ROOT'] || File.dirname(__FILE__) + '/../..'
rails_env = ENV['RAILS_ENV'] || 'development'

resque_config = YAML.load_file(rails_root + '/config/resque.yml')
Resque.redis = resque_config[rails_env]

執行 Resque 的工作步驟很簡單,因為在此專案裡我有使用 foreman 管理不同的 process, 所以在 Procfile 裏我多加了下列工作:

mailer: rake environment resque:work QUEUE=store_${RAILS_ENV}_mailers

執行時,只需要如下指令即可啟動郵差的執行緒

foreman start mailer

Active Job 背景執行的工作

系統長大的時候常常會遇到一些效能的瓶頸,比如說,我們要把資料庫裡的資料匯出成 Excel 讓人下載,資料庫那邊的效能瓶頸以外,就是處理效能的瓶頸了。Active Job 讓我可以把要做的事情丟到 Queue 裡讓另一個 process 來處理。 Rails Application 就不需要在 Request Timeout 前短短回應時間內把它做完。

這邊有佈署 Active Job 的方式