2021年1月30日

敏捷開發:你我心中,都有一個屬於自己的 MVP

agile-scrum
由於自己沒有真的很理解過敏捷開發或 Scrum 到底是怎麼一回事,但公司的新專案又開始想要嘗試用這種方式來做 MVP,基於凡事問網友的心態,在 ALPHACamp 社群以及 Twitter 上得到了很多有價值的回饋,很感謝大家分享意見讓我知道。
我的問題大概是這樣的:
「想要請問大家所謂的敏捷開發?
團隊認為敏捷開發就是要快,不用管太多細節,先推出去收到市場回饋再來修改。但很多工程面的東西像是 DB 的設計,API 的規格的改動,添加幾個欄位還好,但若是動到比較大的架構改起來都很痛。
是真的想要了解大家在碰到這種情況是怎麼處理?因為我沒有深入理解過敏捷開發,不清楚在實際執行上的細節,只知道每次都說就是要快,先不考慮其他情況。
是不是我們沒有掌握到敏捷開發的核心?還是具體來說有那些東西是可以很「敏捷」?哪些東西不適合嗎?
先謝謝有經驗到大家」
下面則整理大家的想法和意見。
:::caution
本人並沒有任何實際了解過敏捷或 scrum 的學習經驗,因此若有任何錯誤或想法,都歡迎提出並一起討論。
:::

敏捷的重點在於快速應對市場需求變化的能力

首先,敏捷的重點在於快速應對市場需求變化的能力,同時也驗證自己對於產品能否達到市場需求的概念(Proof of Concept, POC),減少過多的時間或人力成本在開發某個專案,做到非常完整,但推出去之後才被市場打臉。
這裡直接 quote Jack Sung 所說的:
Jack Sung:「敏捷開發並不是短期的速度快,畢竟開發還是需要跑完基本的流程,而是面對市場變化的反應速度比起瀑布流還快,就像 POC(Proof of Concept),敏捷開發是更務實的去驗證想法,而非一個美好的想像去花費很高的時間跟成本去開發一個未經驗證的產品,等到真的推上線才被市場賞兩巴掌。」
:::info
至於什麼叫「完整」或「非常完整」真的是非常藝術的問題,有的人認為東西可以 work 就可以推出,有的人認爲至少要通過自己這一關後才算可以,而設計、工程、一直到商業端每個人的想法也都不同。也許業務認為有東西可以賣就好,設計認為的會是這個產品拿出去可以見人嗎?工程思考的可能是產品夠不夠穩定,有沒有漏洞,資料庫後續如何修改等等。
:::

敏捷的目的是要提早發現問題:對象是市場而非開發團隊

之所以要敏捷是因為市場的變化太快速,因此當有產品沒有收到預期的回饋時,適時且立即的停止、停損或轉換方向是敏捷的重點與核心。
然而,這並不表示要壓縮開發時程或開發品質,如同 Yan 所說的,整個開發流程應該還是嚴謹可靠的,而不是隨便快就好的。
Yan:「敏捷開發不單單是針對開發團隊而是整個軟體工程,從訪談>需求>規格>設計>開發>測試>發布,整個流程應該還是嚴謹的,只是它縮小整個目標進行對目標進行快速迭代,中途有錯應該立即修改或終止,這是 scrum 所謂的「快速試錯」,而已經成型產品遇到要整個砍掉重來,長遠來說如果是必要,我也認同需要去做,根據我的經驗以開發來說敏捷並不會減少「開發時間」。」

敏捷開發不會比較快速:敏捷 != 快

如同上面所述,敏捷的重點是為了因應市場的快速變化,避免到後來才發現一場空。但這個敏捷並不是快的意思,該有的開發流程還是不能省,Rafeni 分享的這張圖我覺得很到位:
Minumum Viable Product
這裡直接引用 ALPHACamp 助教 Yan 所說:
Yan:「目標是造車,縮小目標往往會被誤認為先造輪子,但其實應該是「可用的」滑板車,因為只造輪子根本收不到市場/客戶回饋,根本無法知道產品方向對不對;壓縮開發時間=壓縮開發品質,各種開發模式都是透過流程改善來減少時間成本,而非去壓榨單位去達到節省目的。」
每次開發出來的「可用的產品」,到底是要驗證什麼概念,是否能實際收到市場的回饋都是要考慮的問題。至於給人使用的「產品」和有功能就好的「東西」之間的界線要如何定義,又是另一門藝術了。

盡可能在開發時做到解耦、模組化

據 Jack Sung 所說,一般來說走敏捷開發對工程團隊來講是痛苦的,太多東西是無法預期或事先規劃的,因此打掉重練是常有的事,能做的是盡可能在開發時做到解耦、模組化,以減少這個痛。
agile-scrum
畢竟以上圖來說,腳踏車和滑板車已經是完全不同的概念了,要做到擴充性和預留彈性幾乎就是不可能的事,所以要認知到打掉重練可能是必經的過程

打掉重練是常有的事,但⋯再想想⋯

雖然說打掉重練可能是常有的事,但如果總是在經歷「整個」打掉重練,而不是部分功能或模組打掉,或是整個系統架構的調整,這時候就需要再多想想了。
如同前幾個段落中的金字塔圖(MVP)所示,在敏捷開發中並非省略掉開發流程來達到敏捷。Rafeni 提到,不應該打著敏捷開發就跳過了規劃、跳過了去思考產品是設計給誰用的、是要幫誰解決問題。 Jacob 則提到,PM 和 RD 溝通不良,或當問題都沒有先想清楚就馬上進到開發,產品設計沒有做好使用者研究時,常常才是導致架構需要不斷大改。
Rafeni:「因為起點對了,過程與終點才可能對。否則錯誤的流程也是會導致錯誤的結果的。」
也就是說,如果你覺得常常是整個系統在進行大架構的改動,甚至是經常感受到隕石擊落般的痛快時,那麼不要乖乖的抱著「打掉重練是正常的想法」,而是需要好好的再想想

參考資料

2020年12月30日

[掘竅] Golang - 使用 slice of pointers 或 slice of structs

TL;DR;

如果這個 slice 或 slice 中的元素有可能會被操作修改,那麼就用 slice of pointers;如果這個 slice 單純只是拿來讀取,那麼就用 slice of structs;或者也可以無腦的使用 slice of pointers 以避免後續不小心而產生的 bug。

為什麼會看到 slice of pointers 的用法

在 Golang 中常會看到 slice 中存放的是 slice of pointers,使用 slice of pointers 或 slice of structs 有幾個很重要的差別。先來看看下面的程式碼。
假設我們先定義一個名為 Vertex 的 struct:
type Vertex struct {
	X int
	Y int
}
接者在建立 slice of struct Vertex:
  • 當我們把「slice 中的 struct」取出來的時候,實際上是把 slice[1] 複製一份到 point1 裡面,因此即使修改了 point1 內的 X 和 Y,在輸出 slice 時並不會看到改變
func main() {
	var slice []Vertex
	for i := 0; i < 4; i++ {
		slice = append(slice, Vertex{i * 10, i * 100})
	}

  // before change: [{X:0 Y:0} {X:10 Y:100} {X:20 Y:200} {X:30 Y:300}]
	fmt.Printf("before change: %+v \n", slice)


	point1 := slice[1] // 取出的是 slice of struct
	point1.X += 1
	point1.Y += 1

  // slice[1] 並沒有被修改: [{X:0 Y:0} {X:10 Y:100} {X:20 Y:200} {X:30 Y:300}]
	fmt.Printf("after change: %+v \n", slice)
}
  • 但若我們取出來的是「slice of pointer」,這時候才會參照回原本 slice 中的 struct,因此當我們修改 point1 內的 X 和 Y 時,會看到輸出的 slice 產生對應的改變
func main() {
	var slice []Vertex
	for i := 0; i < 4; i++ {
		slice = append(slice, Vertex{i * 10, i * 100})
	}

  // before change: [{X:0 Y:0} {X:10 Y:100} {X:20 Y:200} {X:30 Y:300}]
	fmt.Printf("before change: %+v \n", slice)

	point1 := &slice[1] // 差別在這:取出的是 slice of pointer
	point1.X += 1
	point1.Y += 1

  // slice[1] 有被修改:  [{X:0 Y:0} {X:11 Y:101} {X:20 Y:200} {X:30 Y:300}]
	fmt.Printf("after change: %+v \n", slice)
}
  • 除此之外,當 slice 因為長度改變時,會產生「記憶體重新配置(memory relocate)」,這時候的 slice 和原本一開始的 slice 已經是指稱到底層不同的 array,進而導致修改 point2 內的 X, Y 後,在最終輸出的 slice 並無法看到改變
func main() {
	var slice []Vertex
	for i := 0; i < 4; i++ {
		slice = append(slice, Vertex{i * 10, i * 100})
	}

	// before change: [{X:0 Y:0} {X:10 Y:100} {X:20 Y:200} {X:30 Y:300}]
	fmt.Printf("before change: %+v \n", slice)

	point1 := &slice[1]
	point2 := &slice[2]
	point1.X += 1
	point1.Y += 1

	// slice[1] 有被修改:[{X:0 Y:0} {X:11 Y:101} {X:20 Y:200} {X:30 Y:300}]
	fmt.Printf("after change: %+v \n", slice)

	// 改變 slice 的 len,使其超過原本的 cap,將導致 reallocate,
	// 這時候的 slice 和原本的 slice 已經指稱到不同的記憶體位置
	slice = append(slice, Vertex{4, 40})

	// 這時候對 point2 修改無法修改到原本 slice 中的 {X:2, Y:20}
	point2.X += 1
	point2.Y += 1

	// slice[2] 並沒有被修改: [{X:0 Y:0} {X:11 Y:101} {X:20 Y:200} {X:30 Y:300} {X:4 Y:40}]
	fmt.Printf("after reallocate: %+v \n", slice)
}

解決參照到不同 slice 的作法

要解決這樣的問題,有幾種不同的做法:

方法一:避免 slice 超過其 cap 導致重新 relocate 外

可以的話,在一開始定義 slice 時,就把 slice 的 length 設好,且避免 relocate:
func main() {
	// 方法一:先把 slice 的 len 設好,並避免 relocate
	slice := make([]Vertex, 8)
	for i := 0; i < 4; i++ {
		slice[i] = Vertex{i * 10, i * 100}
	}

  // ...
}

方法二:使用 slice of pointers

// 使用 2. slice of pointers
func main() {
	var slice []*Vertex  // 這裡使用 slice of pointers
	for i := 0; i < 4; i++ {
		slice = append(slice, &Vertex{i * 10, i * 100})
	}

	printAll("before change", slice) // [{X:0 Y:0} {X:10 Y:100} {X:20 Y:200} {X:30 Y:300}]
	point1 := slice[1]
	point2 := slice[2]
	point1.X += 1
	point1.Y += 1

	printAll("after change", slice) // [{X:0 Y:0} {X:11 Y:101} {X:20 Y:200} {X:30 Y:300}]

	// 改變 slice 的 len,使其超過原本的 cap,將導致 reallocate,
	// 這時候的 slice 和原本的 slice 已經指稱到不同的記憶體位置
	slice = append(slice, &Vertex{4, 40})

	point2.X += 1
	point2.Y += 1

  // 透過 slice 可以看到修改後的結果
	printAll("after reallocate", slice) // [{X:0 Y:0} {X:11 Y:101} {X:21 Y:201} {X:30 Y:300} {X:4 Y:40}]
}

func printAll(description string, slice []*Vertex) {
	sliceStruct := make([]Vertex, len(slice))
	for i, s := range slice {
		sliceStruct[i] = *s
	}
	fmt.Printf("%s: %+v \n", description, sliceStruct)
}

方法三:使用 index 而非使用 Pointer

func main() {
	var slice []Vertex
	for i := 0; i < 4; i++ {
		slice = append(slice, Vertex{i * 10, i * 100})
	}

	// before change: [{X:0 Y:0} {X:10 Y:100} {X:20 Y:200} {X:30 Y:300}]
	fmt.Printf("before change: %+v \n", slice)

	slice[1].X += 1
	slice[1].Y += 1

	// slice[1] 有被修改:[{X:0 Y:0} {X:11 Y:101} {X:20 Y:200} {X:30 Y:300}]
	fmt.Printf("after change: %+v \n", slice)

	// 改變 slice 的 len,使其超過原本的 cap,將導致 reallocate,
	// 這時候的 slice 和原本的 slice 已經指稱到不同的記憶體位置
	slice = append(slice, Vertex{4, 40})

	// 透過 index 的方式修改 slice
	slice[2].X += 1
	slice[2].Y += 1

	// slice[2] 有被修改:[{X:0 Y:0} {X:11 Y:101} {X:21 Y:201} {X:30 Y:300} {X:4 Y:40}]
	fmt.Printf("after reallocate: %+v \n", slice)
}

不一定要用 slice of pointers?

從上面的例子中可以看到,如果會需要修改到 slice 中的元素,要避免參照到錯誤的 slice 或 slice 中的元素,使用 slice of pointers 可以算是最簡單的方式,但這麼做對效能和記憶體並不見得是最好的!一般來說使用 slice of structs 的效能比 slice of pointers 好(參考:Potential dangers of storing structs rather than pointers)。
然而除了效能之外,可維護性也很重要,未來如果有需要修改元素內的資料時,是否有人會忘記把取出來的 slice 加上 & ,或者因為添加了 slice 中的元素而導致 slice relocate 而導致不必要的錯誤呢?
以我個人來說,如果這個 slice 或 slice 中的元素有可能會被操作修改,那麼就用 slice of pointers;如果這個 slice 單純只是拿來讀取,那麼就用 slice of structs。

參考

2020年12月20日

[Mobile] Android Samsung Note 10 vs. Apple iPhone mini 12 使用心得

我是一個對智慧型手機莫名有興趣的人,曾經在 hTC 打工時寫過幾篇開箱文、去年也寫過 Samsung S10+ 和 Note 10 的簡單比較,從智慧型手機出到現在,曾經用過 Micrsoft Lumia 620、LG G 系列、小米 Mix2S、小米 Mi 9、Google Pixel 3XL、Samsung S 系列,一直到現在用的 Samsung Note 10。
Samsung Note 10
雖然換過許多不同的裝置,手邊用 Mac 和 iPad,耳機使用的是 AirPods Pro,但就是從來都沒有實際用過 iPhone。從去年的 iPhone 11 到今年年初推出的 iPhone SE 2020 都吸引到我一波,然後都忍住了 XD,到了年底的 iPhone 12 mini 是壓倒駱駝的最後一根稻草,是時候來感受看看所謂蘋果生態系的時候了!
這篇文章就來分享一下當前使用 Note 10 和 iPhone 12 mini 的體驗心得。

前言

我覺得現在聊手機品牌,特別是聊到安卓(Android)和蘋果(Apple)的比較並不是一個令人太愉快的經驗,因為和他人討論這東西時,已經變成很像在聊「政治」,大家有自己很明確的立場,只要立場對了什麼都好,很難真的去考量功能本身的好壞,所以漸漸也覺得自己用得開心就好,不太想和別人實際去討論這些「功能」上的優劣,因為很多人常常也不願意理解你在說什麼,他只選擇相信自己想相信的。我想任何事物,只要到了 O 粉的程度,就已經是信仰層次,盲目是必然的現象,不論是政治或手機。

系統使用體驗

日常使用流暢性

以系統體驗來說,iPhone 的流暢性真的是非常優秀,過場動畫非常順暢,操作起來就是很開心。iPhone 總是能把每天需要用到的細節體驗優化到很好,以每天會做上 n 次的解鎖這個動作來說,整個動作一般來說就是行雲流水一般的順暢。用過 iPad 的 Touch ID,和 iPhone 的 Face ID,以我個人來說,即使在經常需要戴口罩的今天,Face ID 還是我認為非常優異的解鎖方式,雖然真的會稍不方便,但如果 Touch ID 和 FaceID 只能選一個的話,我會選 Face ID,對我來說能少一個動作實在是太重要了。
反觀 Samsung Note 10 在這一塊就顯得相當不足,雖然同時提供了指紋解鎖和臉部解鎖,但指紋解鎖的感受沒辦法達到過去實體指紋感測器那種「秒解」的爽度;臉部解鎖雖然還算堪用,但有時會發生已經臉部解鎖完成,我還傻傻在使用指紋解鎖的情形,因為臉部解鎖後,指紋的圖示並沒有消失。另外,一開始買的時候,進到解鎖畫面前,畫面會有稍微的卡頓的情況,這個部分在後續的更新已經好非常多,這些體驗上的細節雖然不影響使用,但總會覺得沒那麼流暢。

大眾運輸通勤最強 - Samsung Pay 悠遊卡

但除了解鎖之外,搭乘大眾運輸對我來說也是每天的例行事務,這時候 Samsung Note 10 的 Samsung Pay 悠遊卡發揮它極大的優勢,手機本身就是悠遊卡這點真的太強大了,甚至手機沒電時仍然可以使用,你不再需要每次到了捷運站去包包裡掏卡片。iPhone 雖然在國外也有整合交通卡的功能,但在台灣還沒辦法使用。這點也絕對不單單是 MegSafe 可以補足的。

App 使用流暢性差不多

以 App 使用流暢性來說,我覺得在 App 使用時,兩者使用的感覺並沒有差異非常大,所以如果擔心使用 App 時會有卡頓的情況,在我使用這兩隻時都不太有這樣的情況,不論是日常的 Facebook、Intagram、Mobile01 等等,兩隻都非常順暢。

單手操作

單手操作我覺得是 iPhone 的弱勢,用了 iPhone 基本上很難完全單手操作,即使我用的是 iPhone mini,偶爾還是會需要把右手大拇指橫跨過整個螢幕去點擊左上角按鍵鍵的情形。另外,雖然 iPhone 也有單手模式,但基本上就是把最上面的東西往下掉一半,方便你去點擊,點完一次後整個畫面又會彈回到全螢幕。突然理解了,為什麼 iPhone 的使用者似乎沒有很在意是否有單手操作的功能,因為不論是 iPhone 12 mini、iPhone 12、iPhone 12 Pro Max,經常性的都會需要雙手操作。
相對地,Note 10 或 Samsung 的手機雖然螢幕大了一圈,但 6.3" 的螢幕重量僅僅只有 168g,並再搭配 One Hand Operation+ 後,透過手勢就可以達到返回上一頁、鎖定螢幕、螢幕截圖、進入多工畫面等等,如果不夠用的話,和 iPhone 一樣可以透過手勢進入單手模式。Samsung 的單手模式是以等比例的方式縮小螢幕,可以自己調整縮放比例,而且不會點一次就整個彈回全螢幕,再加上 Google 鍵盤本身也有支援單手模式,因此當我在使用 Note 10 時,雖然螢幕很大,卻有更長時間是可以單手操作手機的。
Andorid 單手模式

拍照攝影

我平常蠻在意手機拍照的功能,因為我沒有相機,也不想出去玩時總是要背一台相機跑來跑去,因此對我來說,手機的拍照是重要的。這次雖然買的是 iPhone 12 mini,少了 2 倍鏡頭和 LiDAR,但光就一般拍照來說,應該還是可以簡單比較的。
我認為這兩隻手機在光線充足的情況下都能夠拍出非常優異的照片,但比較明顯的差異會發生在光線不足的情況,這個光線不足並不是指夜拍,而是一般常見的陰天、室內等環境。當光線不足的時候兩隻手機的成像則會有蠻明顯的差異。
在下面的照片中左側放的都是 Note 10(關閉 AI 辨識)、右側放的都是 iPhone 12 mini,並且都使用自動模式,沒去刻意調什麼參數。至於哪張比較「真實」、「自然」,哪張比較好看,每個人心中都有自己的評分標準,就不多說明了。

有一種真實,是 iPhone 覺得的真實

不論是 iPhone 的用戶或 Youtuber 最常自詡的就是 iPhone 的照片接近肉眼所近,最真實也最自然;而 Andorid 的手機就都是美顏、修很大、雖然好看但不自然。但真的是這樣嗎?
在夜間拍攝的時候,iPhone 預設的亮度變得非常高,第一眼會覺得非常亮眼,單若看廣告招牌上的文字就有些過曝。
iphone 12 mini vs Andriod Samsung Note 10
在光線比較不充足時,iPhone 12 mini 拍出來的照片較容易黃黃黑黑,對比度偏強的感覺:
iphone 12 mini vs Andriod Samsung Note 10
iphone 12 mini vs Andriod Samsung Note 10
iphone 12 mini vs Andriod Samsung Note 10
相對地,Samsung Note 10 則會比較偏白粉與柔和(可以留意照片中白色的部分):
iphone 12 mini vs Andriod Samsung Note 10

Samsung Note 10 光線不足時對比度不足、色彩不夠明確

這個情況有好有壞, Samsung Note 10 在光線不足(陰天或室內)則較容易因為對比不夠,而顯得顏色不那麼明確(偏白)。這時當場景本身就是暖色調時,iPhone 的成像就好很多:
iphone 12 mini vs Andriod Samsung Note 10
iphone 12 mini vs Andriod Samsung Note 10
iphone 12 mini vs Andriod Samsung Note 10
iphone 12 mini vs Andriod Samsung Note 10

iPhone 12 場景辨識功能

至於食物的部分,iPhone 12 mini 不知道是不是因為在相機的設定中多了場景辨識的緣故,我個人覺得這食物的顏色很強烈...,相較之下 Samsung Note 10 雖然第一眼看上去沒這麼討喜,但後續如果需要調整色彩應該會也比較容易:
iphone 12 mini vs Andriod Samsung Note 10
iphone 12 mini vs Andriod Samsung Note 10
除了食物之外,自然環境也同樣碰到類似的情況,iPhone 12 mini 的色彩雖然相當鮮豔,第一時間也覺得非常好看,但顏色還是相對較為飽和,對比度也較高,天空的效果也顯得更劇烈:
iphone 12 mini vs Andriod Samsung Note 10

結論

我認為這兩隻手機都是非常棒的手機,多數客觀的使用者也都承認 Apple 絕對有很多優異的功能,而三星的手機在拍照上也能有自然真實而好看的表現;往往令人感到難以理解或不舒服的是那種意識形態般的盲目擁護或盲目貶低。
以使用情境來說,在一般通勤的時候,我會想要使用 Samsung Note 10,不論是搭乘大眾運輸或在大眾運輸上滑手機,我覺得 Note 10 的方便性(悠遊卡、單手模式)和螢幕大小都更適合我。但到了公司若是中午外出去個便利商店、買個飲料等等這種會使用到行動支付的場景,iPhone 系統順暢的體驗會讓我覺得操作起來很舒適。
就我個人來說,iPhone 優異的流暢性適合給想要好好使用手機的使用者,它可能不是什麼地方都最好最強,但多數時候可以讓你在體驗上有 90 分以上的體驗,被照顧的舒舒服服;而三星或高階安卓的使用者則更適合喜歡「玩手機」的人,除了拍照的表現也相當優秀外,單手模式的客製化、主題的調整、Widget 的功能等等,有時還會有雖然兩個人都拿同一型號手機,都卻第一時間不知道怎麼使用對方手機的情況呢!
最後,附上兩隻手機拍的一些比較照片,在 Google 相簿 每張相片右上角有個 i,點擊之後即可檢視不同照片是用哪隻手機進行拍攝:

2020年11月16日

[go-pkg] context package

此篇為各筆記之整理,非原創內容,資料來源可見下方連結與文後參考資料:
context package 最重要的就是處理多個 goroutine 的情況,特別是用來送出取消或結束的 signal。
我們可以在一個 goroutine 中建立 Context 物件後,傳入另一個 goroutine;另一個 goroutine 即可以透過 Done() 來從該 context 中取得 signal,一旦這個 Done channel 關閉之後,這個 goroutine 即會關閉並 return。
Context 也可以是受時間控制,它也可以在特定時間後關閉該 signal channel,我們可以定義一個 deadline 或 timeout 的時間,時間到了之後,Context 物件就會關閉該 signal channel。
更好的是,一旦父層的 Context 關閉其 Done channel 之後,子層的 Done channel 則會自動關閉。

重要概念

  • 不要把 Context 保存在 struct 中,而是直接當作第一個參數傳入 function 或 goroutine 中,通常會命名為 ctx
  • server 在處理傳進來的請求時應該要建立一個 Context,而使用該 server 的方法則應該要接收 Context 作為參數
  • 雖然函式可以允許傳入 nil Context,但千萬不要這麼做,如果你不確定要用哪個 Context,可以使用 context.TODO
  • 只在 request-scoped data 這種要交換處理資料或 API 的範疇下使用 context Values,不要傳入 optional parameters 到函式中。
  • 相同的 Context 可以傳入多個不同的 goroutine 中使用,在多個 goroutines 中同時使用 Context 是安全的(safe)
func DoSomething(ctx context.Context, arg Arg) error {
	// ... use ctx ...
}

context.Background()

context.Background() 會回傳一個不是 nilempty Context這個 Context 絕不會被取消(canceled)、不會有值、也不會有 deadline。這通常會用在 main function、初始化(initialization)或測試中使用,可以作為處理請求時最高層的 Context(top-level Context)。

context.TODO()

context.TODO() 會回傳一個不是 nil 的 empty Context。它通常會使用在還不清楚要使用哪個 Context 時,或還無法取得 Context 的情況下使用。

context.WithCancel()

context.WithCancel() 函式會回傳 Context 物件和 CancelFunction。這個 Context 的 Done channel 會在 cancel function 被呼叫到時關閉,或是父層的 Done channel 關閉時亦會關閉。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
  • 重複呼叫 cancel() 不會有任何效果
  • context 建議當成函式或 goroutine 的參數傳入,並且命名為 ctx,並不建議把它保存在 struct 中
  • Context 可以有父子層的關係,也就是一個 Context 可以產生另一個 Context,但一旦父層 Context 取消/關閉時,所有根據這個 Context 所產生的 Context 也會一併關閉
// https://medium.com/rungo/understanding-the-context-package-b2e407a9cdae
func square(ctx context.Context, c chan int) {
	i := 0
	for {
		select {
		case <-ctx.Done(): // STEP 2:監聽 context Done
			return // kill goroutine
		case c <- i * i:
			i++
		}
	}
}

func main() {
	c := make(chan int)

	// STEP 1:建立可以被 cancel 的 context
	ctx, cancel := context.WithCancel(context.Background())

	go square(ctx, c)

	for i := 0; i < 5; i++ {
		fmt.Println("Next square is", <-c)
	}

	// STEP 3:當所有訊息都從 channel 取出後,使用 cancel 把 square 這個 goroutine 關閉
	cancel()

	time.Sleep(3 * time.Second)

	fmt.Println("Number of active goroutines", runtime.NumGoroutine())
}
範例:
// code modified from appleboy
// https://blog.wu-boy.com/2020/08/three-ways-to-manage-concurrency-in-go/
func startProcessA(ctx context.Context, name string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println(name, "Exit")
			return
		case <-time.After(1 * time.Second):
			fmt.Println(name, "keep doing something")
		}
	}
}

func main() {
	// 使用 context.WithCancel 取得 ctx 和 cancel
	ctx, cancel := context.WithCancel(context.Background())
	go startProcessA(ctx, "Process A") // 執行 goroutine 並把 context 傳入
	time.Sleep(5 * time.Second)
	fmt.Println("client release connection, need to notify Process A and exit")
	cancel() // 呼叫 cancel 方法
	fmt.Println("Process finish")
}

context.WithDeadline()

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
  • context.WithDeadline() 中可以指定一個時間(time.Time)當作 deadline,一旦時間到時,就會自動觸發 cancel
  • context.WithDeadline() 同樣會回傳 cancel,因此也可以主動呼叫 cancel
  • 如果父層的 context 被 cancel 的話,子層的 context 也會一併被 cancel
var startTime = time.Now()

func worker(ctx context.Context, durationSecs int) {
	select {
	// STEP 3:deadline 時間到時或主動呼叫 cancel 時,都會進入 ctx.Done()
	case <-ctx.Done():
		fmt.Printf("%0.2fs - worker(%ds) killed!\n", time.Since(startTime).Seconds(), durationSecs)
		return // kills goroutine

	// 模擬做事所需花費的時間
	case <-time.After(time.Duration(durationSecs) * time.Second):
		fmt.Printf("%0.2fs - worker(%ds) completed the job.\n", time.Since(startTime).Seconds(), durationSecs)
	}
}

func main() {
	// STEP 1:建立 deadline
	deadline := time.Now().Add(3 * time.Second)

	// STEP 2:將 deadline 傳入並取得 cancel
	ctx, cancel := context.WithDeadline(context.Background(), deadline)

	// STEP 4:如果 main 比其他 goroutine 提早結束時,呼叫 cancel 讓其他 goroutine 結束
	defer cancel()

	go worker(ctx, 2)
	go worker(ctx, 3)
	go worker(ctx, 4)
	go worker(ctx, 6)
	fmt.Println("Number of active goroutines", runtime.NumGoroutine())

	time.Sleep(5 * time.Second)

	fmt.Println("Number of active goroutines", runtime.NumGoroutine())
}

context.WithTimeout()

超過一定的時間後就會停止該 function
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
  • context.WithTimeout() 的用法和 context.WithDeadline() 幾乎相同,差別只在於 WithTimeout() 帶入的參數是時間區間(time.Duration
  • 實際上,WithTimeout() 的底層仍然是呼叫 WithDeadline(),只是它會幫忙做掉 time.Add() 的動作
func printFeature(client pb.RouteGuideClient, point *pb.Point) {
  // 透過 context.WithTimeout 取得 ctx 和 cancel
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

  // 把可能會花許多時間的方法帶入 ctx
	feature, err := client.GetFeature(ctx, point)

	if err != nil {
		log.Fatalf("%v.GetFeature(_) = _, %v: ", client, err)
	}
	log.Println(feature)
}

context.WithValue()

func (oAuth *OAuth) GetClient(certPath, keyPath string) (*http.Client, error) {
	sslcli, err := addTLSCertificate(certPath, keyPath)
	if err != nil {
		return nil, fmt.Errorf("add tls certificate %v", err)
	}

	ctx := context.TODO()
	ctx = context.WithValue(ctx, oauth2.HTTPClient, sslcli)
	client := oAuth.Config.Client(ctx)

	return client, nil
}

參考文章

[go-pkg] time/rate package

golang

重要概念

在 Golang 中使用 Limiter 來控制在特定頻率內,某個事件是否允許被執行。這個 Limiter 是實作 Token Bucket 的方式來達到限流的目的,也就是會先設定:
  • event rate(r)將 token 放入桶子的頻率,例如每秒將放入 n 個 token 到桶子(bucket)中。
  • burst size(b)一個桶子(bucket)中能夠容納的 token 數量
一開始桶子會是滿的,只要桶子中有剩餘的 Token 就可以取用,若沒有剩餘的 Token 則需要等待後才能取用。

建立 Limiter:NewLimiter

keywords: NewLimiter
使用 NewLimiter 來建立一個 non-zero Limiter:
func NewLimiter(r Limit, b int) *Limiter
Limiter 包含兩個主要的屬性:
  • r:rate,型別是 LimitLimit 的型別是 float64), 是用來定義**「每秒」內某事件可以發生的次數**,zero 的話表示不允許任何事件發生。可以透過 Every(interval time.Duration) Limit 這個方法來取得 Limit。
  • b:burst size,表示桶子的大小,也就是桶子中可以放入多少 Token
// r:rate,每秒會放入 10 個 token
// b:burst size,桶子的大小只能容納 1 個 token
limiter := rate.NewLimiter(10, 1)

fmt.Println(limiter.Limit(), limiter.Burst()) // 10, 1
也可使用 Every() 來產生 Limit
// func Every(interval time.Duration) Limit
//
// r:每 100 毫秒會放入 1 個 token(同樣也是每秒會有 10 個 token)
// b:桶子的大小只能容納 1 個 token
limit := rate.Every(100 * time.Millisecond)
limiter := rate.NewLimiter(limit, 1)

fmt.Println(limiter.Limit(), limiter.Burst()) // 10, 1

使用 Limiter

keywords: Allow, Reserve, Wait, AllowN, ReserveN, WaitN
Limiter 主要有三種方法,分別是 Allow, ReserveWait一般來說最常使用到的是 Wait。這三種方法都需要消耗「一個」 token,差別在於當 token 不足的時候所採取的行為
當 Token 不足時:
  • Allow:會回傳 false
  • Reserve:會回傳 Reservation,表示預約未來的 Token 並告知要等多久後才能再次使用
  • Wait:會等待那裡(阻塞),直到有足夠的 Token 或該 context 被取消。
如果需要一次消耗多個 Token,則使用 AllowN, ReserveNWaitN

Wait/WaitN

func (lim *Limiter) Wait(ctx context.Context) (err error)  // 等同於 WaitN(ctx, 1)
func (lim *Limiter) WaitN(ctx context.Context, n int) (err error)
  • WaitN 會阻塞住,每次執行需要消耗 n 個 token,也就是直到有足夠(n)的 token 時才會繼續往後執行
  • 在下述情況發生時會回傳錯誤
    • 如果需要消耗的 token 數目( n) 超過 Limiter 水桶的數量(burst size)時
    • Context 被取消(canceled)
    • Context 的等待時間超過 Deadline 時
// 範例程式碼:https://www.jianshu.com/p/1ecb513f7632
func main() {
  counter := 0
  ctx := context.Background()

  // 每 200 毫秒會放一次 token 到桶子(每秒會放 5 個 token 到桶子),bucket 最多容納 1 個 token
  limit := rate.Every(time.Millisecond * 200)
  limiter := rate.NewLimiter(limit, 1)
  fmt.Println(limiter.Limit(), limiter.Burst()) // 5,1

  for {
    counter++
    limiter.Wait(ctx)
    fmt.Printf("counter: %v, %v \n", counter, time.Now().Format(time.RFC3339))
  }
}

Allow/AllowN

func (lim *Limiter) Allow() bool      // 等同於 AllowN(time.Now(), 1)
func (lim *Limiter) AllowN(now time.Time, n int) bool
  • AllowN 表示在某個的時間點時,每次需要消耗 n 個 token,若桶子中的 token 數目是否滿足 n,則會回傳 true 並消耗掉桶子中的 token,否則回傳 false
  • 只有在你想要 drop / skip 超過 rate limit 的事件時使用,否則使用 ReserveWait
// 範例程式碼:https://www.jianshu.com/p/1ecb513f7632
func main() {
  counter := 0

  // event rate:每 200 毫秒會放一次 token 到桶子(每秒會放 5 個 token 桶子)
  // burst size:bucket 最多容納 4 個 token
  limit := rate.Every(time.Millisecond * 200)
  limiter := rate.NewLimiter(limit, 4)
  fmt.Println(limiter.Limit(), limiter.Burst()) // 5,4

  for {
    counter++

    // 每次需要 3 個 token
    if isAllowed := limiter.AllowN(time.Now(), 3); isAllowed {
      fmt.Printf("counter: %v, %v \n", counter, time.Now().Format(time.RFC3339))
    } else {
      fmt.Printf("counter: %v, not allow \n", counter)
      time.Sleep(100 * time.Millisecond)
    }
  }
}

Reserve/ReserveN

func (lim *Limiter) Reserve() *Reservation   // 等同於 ReserveN(time.Now(), 1)
func (lim *Limiter) ReserveN(now time.Time, n int) *Reservation
  • ReserveN 會回傳 Reservation,用來指稱還需要等多久才能有足夠的 token 讓事件發生;後續的 Limiter 會把 Reservation 納入考量
  • 當 n 超過桶子能夠容納的 token 數量時(即,Limiters 的 burst size),Reservation 的 OK 方法將會回傳 false
func main() {
  counter := 0

  // event rate:每 200 毫秒會放一次 token 到桶子(每秒會放 5 個 token 桶子)
  // burst size:bucket 最多容納 3 個 token
  limit := rate.Every(time.Millisecond * 200)
  limiter := rate.NewLimiter(limit, 3)
  fmt.Println(limiter.Limit(), limiter.Burst()) // 5,3

  for {
    counter++
    // 每次執行需要 2 個 token
    tokensNeed := 2
    reserve := limiter.ReserveN(time.Now(), tokensNeed)

    // r.OK() 是 false 表示 n 的數量大於桶子能容納的數量(lim.burst)
    if !reserve.OK() {
      fmt.Printf("一次所需的 token 數(%v)大於桶子能容納 token 的數(%v)\n", tokensNeed, limiter.Burst())
      return
    }

    // reserve.Delay() 可以取得需要等待的時間
    time.Sleep(reserve.Delay())

    // 等待完後做事...
    fmt.Printf("counter: %v, %v \n", counter, time.Now().Format(time.RFC3339))
  }
}
  • r.Delay():可以得到需要等待的時間,0 則表示不用等待

調整 Limiter

keywords: SetBurst, SetLimit, SetBurstAt, SetLimitAt
如果需要動態調整 Limiter 的數率和桶子的大小,則可以使用 SetBurstSetLimit 的方法。

整合 GIN 限制向 client 發送 Request 的次數

限制特定 usecase / API 中的 limiter

usecase (API)

  • PostUsecase 的 struct 中定義 Limiter 的型別
  • router/post.go 中使用 NewLimiter() 來建立 Limiter
  • GetPost 中透過 Limiter.Wait() 來限制發送請求的頻率
// usecase/post.go

// STEP 1:在 struct 中定義 limiter,並在 router/post.go 中建立 Limiter
type PostUsecase struct {
  Limiter *rate.Limiter
}

func (p *PostUsecase) GetPost(ctx *gin.Context) {
  id := ctx.Param("id")

  // STEP 3:使用 Limiter.Wait,每次會消耗桶子中的一個 token
  p.Limiter.Wait(ctx)

  // STEP 4:實際發送請求
  post := getPost(id)

  ctx.JSON(http.StatusOK, post)
}

router

  • 使用 NewLimiter() 來建立 Limiter
    • rate.Every(200 * time.Millisecond):每 200 毫秒會放入一個 token 到桶子(bucket)中
    • rate.NewLimiter(limit, 1):桶子的容量(burst size)為 1 個 token
// router/post.go

func registerPosts(router *gin.Engine) {

  // STEP 2:使用 NewLimiter() 來建立 Limiter
  limit := rate.Every(1000 * time.Millisecond)
  limiter := rate.NewLimiter(limit, 1)
  postHandler := &usecase.PostUsecase{
    Limiter: limiter,
  }

  router.GET("/posts/:id", postHandler.GetPost)
}

限制多支 usecase / API 的 limiter

撰寫 limiter package

如果是很多不同支 API 都需要限制流量的話,則可以建立一個獨立的 package:
// ./pkg/limiter/limiter.go
package limiter

// STEP 1:建立 limiter
// rate:每秒會放 1 個 token 到 bucket 中
// burst size:桶子最多可以容納 1 個 bucket
var RateLimiter = rate.Every(time.Millisecond * 1000)
var RequestLimiter = rate.NewLimiter(RateLimiter, 1)

在 API 中使用 limiter

並在需要限流的 limiter 中使用它:
// ./usecase/post.go
package usecase

import "sandbox/gin-sandbox/pkg/limiter"

func (p *PostUsecase) GetPost(ctx *gin.Context) {

  // STEP 3:使用建立好的 limiter
  // 每次需要消耗桶子中的 1 個 bucket
  limiter.RequestLimiter.Wait(ctx)

  post := getPost(id)

  ctx.JSON(http.StatusOK, post)
}
在另一支需要限流的 API 中使用寫好的 limiter:
// ./usecase/healthcheck.go

import "sandbox/gin-sandbox/pkg/limiter"

package usecase

func (h *HealthCheckUsecase) Pong(ctx *gin.Context) {

  limiter.RequestLimiter.Wait(ctx)

  ctx.JSON(http.StatusOK, gin.H{
    "message":    "pong",
    "threadNum,": threadNum,
    "counter":    counter,
  })
}

使用 JMeter 測試結果

若我們的 Limiter 限制每秒給一個 token 到 bucket 中,且 bucket 的 burst size(能夠容納的 token 數量)為 1 時,表示每秒只能處理一個請求。
若以 JMeter 進行測試,可以看到 Throughput(流量)的欄位即為 1.0/sec
Screen Shot 2020-11-16 at 4.31.57 PM

範例程式碼

參考