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 的方式

Active Job 的佈署實例

當我們在 Web 處理 Request 的執行緒做一些消耗性能但是又不緊急的工作,流量大時不免會變成系統效能的瓶頸。

Async => 非同步的設計在這時候可以幫我們降低同一個執行緒要處理的工作消耗,可以用來處理類似發信,寫檔案,緩慢寫入讀取資料的事情。

在 Rails 要做到很簡單,Rails 有 Active Job 可以搭配許多 Queue framework,這邊我用的 Resque 就是其中之一。設定並不困難,安裝了 gem 然後在 #{env}.rb 設定

config.active_job.queue_adapter = :resque
config.active_job.queue_name_prefix  = "app_#{Rails.env}"

這邊要說的是…部署這些 Job 的方式。

之前有提到的專案應用上,我使用的系統是 Ubuntu 16。Rails 的 Application 裡用的是 foreman 來管理啟動 unicorn server。

web: bundle exec unicorn -c ./config/unicorn.rb

Worker 也可以這樣使用。在我們佈署到上線的環境時,我們會需要把這些 Worker 的執行程式包裝成服務來啟動。醬可以藉由設定讓他在 Server 重啟之後自己繼續執行。

Woker 的 Procfile 會像這樣:

mailer: rake environment resque:work QUEUE=store_${RAILS_ENV}_mailers
logger: rake environment resque:work QUEUE=store_${RAILS_ENV}_logger
filegen: rake environment resque:work QUEUE=store_${RAILS_ENV}_file_generation

Foreman 可以幫我們做到這點。它提供了許多匯出成系統服務的指令。我使用的指令如下:

$ sudo foreman export systemd --env .production-env --app storeworkers --user store /etc/systemd/system

這個指令會依照當下執行目錄的 Procfile 加上 --env 指定的環境變數來產生服務設定檔,放在指定的 /etc/systemd/system 裡。

產生的 /etc/systemd/system/storeworkers.target 會包含 Procfile 指定的所有工作,而每一個 Procfile 指定的工作內容將會被定義匯出在 /etc/systemd/system 裡。

匯出後我們可以透過 systemctl 來設定。

$ sudo systemctl [start|restart|enable] storeworkers.target

設定為 enable 後將可以在系統啟動後一併執行服務。

status 則可以查看該 worker 的最近幾筆紀錄

sudo systemctl status storeworkers.target

● storeworker.target
   Loaded: loaded (/etc/systemd/system/storeworkers.target; enabled; vendor preset: enabled)
   Active: active since Thu 2017-06-29 02:59:04 UTC; 20min ago

Jun 29 02:59:04 storeworkers systemd[1]: Stopped target storeworkers.target.
Jun 29 02:59:04 storeworkers systemd[1]: Stopping storeworkers.target.
Jun 29 02:59:04 storeworkers systemd[1]: Reached target storeworkers.target.

體驗 Elasticsearch 的魅力 – Part 1 :: Docker

目的

目前手上專案系統的 Log 仍然存在 MS SQL Server 裡。原因是因為系統後台一開始就建置了 Log 查詢的頁面功能。讓管理人員可以查詢系統登入、運作狀況以及 API 交換的每一筆訊息紀錄。這功能讓技術克服人員與開發人員很方便的進來系統撈取 Debug 錯誤時所需的有用資訊。

在初期快速開發的階段這些功能都是用 Entity Framework 建造以求開發迅速。但當系統上線後每日運轉下來 Log 的成長量也已大大超乎預期。雖然已經因為資料庫負荷沉重把 Log 搬離主要的交易資料庫放在較為次級的 SQL Server。但每日都有相當成長量讓我們不得不面對找尋完善的 Log 管理機制。

這個測試,我們需要在不改變系統功能的前提下評估:

  1. Elasticsearch 的 REST API 是否足夠讓我們建置客製化的 API 來包裝讓並使用現有系統後台 Log 查詢機制來查詢 (不再用 Linq + Entity Framework 查 SQL Server)。
  2. Elasticsearch 完全替代 MS SQL 來存 Log:
    1. 備份與資料維護
    2. Clustering
    3. Failover 機制
    4. SSRS 報表的替代方案

相關應用

  • Docker
  • Elasticsearch
  • Kibana
  • Logstash

動手

首先我們需要把 Elasticsearch 與 Kibana 設定好,然後倒一些 Log 進 Elasticsearch 並且試試 Kibana 上的查詢功能。

這篇先寫第一部分,關於 Docker 的使用與設定。

設定 Docker

用 Docker 來測試 ELK。可以避免在日常開發機器上裝太多不常用的系統服務。

Docker Repository 上已經有許多 ELK 的 Image, 我選了 sebp/elk,並有提供詳細的 文件說明

這個 docker image 上有所有 ELK Stack,啟動時需要指定開啟各服務所需要的連接埠讓本機可與 Container 溝通。執行方式如下:

docker run -p 5601:5601 -p 9200:9200 -p 5044:5044 -it --name elk sebp/elk

指定三個連接埠 (-p) 分別代表:

  • 9200: Elasticsearch
  • 5601: Kibana
  • 5044: Logstash beats

Dockerfile

這個測試主要的目的是學習如何使用 ELK 達成目的。在不修改任何程式的前提下,第一階段要取得Log 資料須由 MS SQL 匯出到 Logstash。

因為有些 Container 相關的設定,所以我用了 Dockerfile 來把設定檔塞到 Container 裡:

FROM sebp/elk
# 在Container 建立一個工作資料夾,我們在本機編輯好的設定檔與其他必要檔案都可透過這個資料夾與Container交換
RUN mkdir /workspace
WORKDIR /workspace
ADD . /workspace

#另外我在本機有編輯了 logstash 的設定檔需要一開始就塞進 Container 裡。這邊可以將檔案加到指定的 Container 上的路徑
ADD logstash-mssql.conf /etc/logstash/conf.d/logstash-mssql.conf

#這個測試會用到 logstash 的 JDBC plugin。用以下這個步驟安裝
RUN gosu logstash /opt/logstash/bin/logstash-plugin install logstash-input-jdbc

logstash-input-jdbc 並沒有包含不同資料庫所需要的驅動程式。我們所使用的資料庫是 SQL Server。我們需要自己 下載 JDBC 驅動程式。

下載後解開壓縮檔把 sqljdbc42.jar 跟 Dockerfile 放在同一個資料夾。

docker-compose

目前只有一個 image/container,應該是用不到 docker-compose 來管理。但用 docker-compose 可以幫助我們記得要指定的連接埠。省的啟動 container 得打一大串指令。

version: '2'
services:
elk:
build: . # 要 docker-compose 執行時建置同目錄下的 Dockerfile
container_name: elk # 幫 Container 命名,之後比較好打指令
volumes:
- .:/workspace # 將本地資料夾掛在Container的 /workspace 資料夾下 [host:container]
ports:
- "9200:9200" # 對應本地與 Container 的連接埠
- "5601:5601"
- "5044:5044"

同樣跟 Dockerfile 放一起。在該資料夾下執行

docker-compose up

就可以啟動 container

設定 Logstash

sebp/elk 裡已經設定好一些 Logstash 設定檔。可以抓 beats, 系統記錄檔等等,並會把結果輸出在 localhost:9200 的 Elasticsearh 。在 container 裡的 /etc/logstash/conf.d/ 可以找到這些設定檔。之前提到需要撈 SQL Server 的設定檔也需要放在 /etc/logstash/conf.d/ Logstash 啟動時方可一併載入。

Docerfile 裡的 ADD logstash-mssql.conf /etc/logstash/conf.d/logstash-mssql.conf 這行就是要幫我們在 container 建立時把同資料夾裡的 logstash-mssql.conf 塞到 Container 的 /etc/logstash/conf.d/ 資料夾。

以下是我們設定 Logstash 撈取 SQL Server 資料庫的設定檔:

 
input{
  jdbc {
    jdbc_driver_library => "/workplace/sqljdbc42.jar"
    jdbc_driver_class => "com.microsoft.sqlserver.jdbc.SQLServerDriver"
    jdbc_connection_string => "jdbc:sqlserver://10.0.75.1:1433;databaseName=LogDatabase"
    jdbc_user => "sa"
    jdbc_password => "sa"
    jdbc_validate_connection => true
    statement => "SELECT * FROM Logs"
  }
}

output {
  elasticsearch {
    hosts => "localhost:9200"
    index => "apilogs"
    document_id => "%{id}"
    document_type => "ApiLog"
  }
}

jdbc 區段可設定資料抓取方式。以及 SQL Server 伺服器的連線資訊。這邊要注意 SQL Server 是在本機 (Windows) 上執行的 SQL Server Express。從 Container 裡面用 JDBC 連線到 SQL Server 需要透過 HTTP 通訊協定,但 SQL SERVER 預設是禁止 HTTP 連線存取的。所以我們還需要修改一下 SQL SERVER 的設定:

Sql Server Configuration Manager > SQL Server Network Configuration > Protocols for MSSQLSERVER > TCP/IP 設為 Enabled 後重新啟動資料庫服務即可。

另外,我的 docker container 與本機 連線的方式 用的是 Bridge Driver。可以在本機使用 docker network inspect bridge 指令來查看網路介面的設定狀況。

回到 Logstash config 檔:

jdbc_connection_string => "jdbc:sqlserver://10.0.75.1:1433;databaseName=LogDatabase"
在這裡我直接將連線資訊指向 Docker 在 Host 機器上安裝的網路介面。

statement => "SELECT * FROM Logs"
撈資料時使用的 SQL Statement

設定檔好之後,可以使用 Logstash 的指令來跑跑看︰

首先我們要取得 Container 的 Shell 指令介面,在Windows 的指令模式下輸入:

docker exec -it elk /bin/bash

首先確保 Dockerfile 所執行複製進 Container 是正確的 Logstash .conf 檔,可用 vim 等純文字編輯程式開來看看:

vim /etc/logstash/conf.d/logstash-mssql.conf

無誤後 :q! 退出 vim 就可跑跑 Logstash 看看效果如何了

/opt/logstash/bin/logstash -f /etc/logstash/conf.d/logstash-mssql.conf

型爸日記 :: Days 75 Daddy’s Birthday

小龍貓來臨之後許多事都得放在一邊,其中當然包括爸媽的生日(媽媽的生日還得提早去吃大餐跳過產檢的日期XD)。今年初上任型爸的第一個生日禮物,當然就是可愛的小龍貓!媽媽特別幫他換上新衣服綁上緞帶,回家看到小龍貓這副樣子都快笑翻了。不過在笑翻了的背後有另外一種溫馨的感覺。不是當了爸爸我想很難體會的出矣。

生活雖然沒有過的很爽,尤其這幾個月,工作也頗忙碌,加上有了小龍貓後基本上把我們夫妻倆人禁足了好幾個月。要出門總是先考慮到想去的地方是不是適合扛著小龍貓一起去。即使如此,仍然甘之如飴的每天看著她漸漸長大(又有點不太想要她這麼快長大,矛盾矣)。

型爸的生日有一個小小的蛋糕!一家三人窩在客廳吃起來特別好吃(夫妻一人一半 ˋ ˊ 小龍貓在旁邊哼哼哼踢踢踢踏踏踏)至於願望就不用說~任何新上任的爸爸願望應該都大同小異吧。總之,32歲的今年,這個女兒出生讓我又多承諾了許多事。往後可要努力兌現啊!

在沙發上不安份的扭來扭去

在沙發上不安份的扭來扭去