Github 連結

Dcard lab 看到徵實習的消息,我當然選了我最擅長的後端,有趣的是得完成一項作業才能應徵,雖耳聞已久,這倒是我第一次「做作業」。看了一下題目,挺好玩的,不太滿足於僅僅做完要求,又堆疊了一些額外的東西,每項都是第一次碰,多學了不少東西,故來發文記錄。

題目

Dcard 每天午夜都有大量使用者湧入抽卡,為了不讓伺服器過載,請設計一個 middleware:

  • 限制每小時來自同一個 IP 的請求數量不得超過 1000
  • 在 response headers 中加入剩餘的請求數量 (X-RateLimit-Remaining) 以及 rate limit 歸零的時間 (X-RateLimit-Reset)
  • 如果超過限制的話就回傳 429 (Too Many Requests)
  • 可以使用各種資料庫達成

想法

在該求職頁面中的申請條件,有一條是熟悉go或node.js的框架,剛好我現在主力語言是 go,雖然以前寫Web都是用 python-django,卻也嚮往用 go 來寫 web,所以在語言上毫不猶豫的就選擇了 go。

資料庫本來想用 PostgreSQL 或 MySQL,因為以前自己寫 Django 或在 Cool 的日子接觸過,算是挺熟悉的了;開寫沒多久,突然想到一直在各種場合遇到 redis,卻從來沒實際操作過,心癢之下就改成 redis。這倒不是一時腦衝的決定,而是題目的情境的確非常適合使用 redis(這可能是出題者的巧思,考驗應徵者對各種資料庫的熟悉程度),因為 redis 是 key-value,資料之間並沒有關聯性,而且 redis 有天然的 TTL 可以用,在題目的高造訪量的情境下,使用 NoSQL 是最佳解。

更多的堆疊

雖然遇到一些 bug,畢竟是挺簡單的 middleware,一兩天就寫完了,後來多寫了幾條自己想做的 TODO,也一一的完成了,包含測試(學到了如何 mock a database)、CI(使用沒用過的 travis CI)、log(把內建的 log 套件換成第三方開發的 sirupsen/logrus,再套一些函數而已)、framework(使用 gin,但因為只是 hello-world,感受不到它的強大 XD)

細談程式碼

這個 project 挺值得拿出來談的是我將介面(interface)和實作分離,先在 database.go 定義好這個專案中 db 該有的 method 和回傳值,如下:

type Database interface {
	// Connect to the database server with defined maxIP and timeout
	Init(int, int) error
	// Check if the IP is in database and whether it's forbidden or not
	Find(string) (bool, bool)
	// return X-RateLimit-Remaining and X-RateLimit-Reset
	GetKey(string) (string, string, error)
	// If IP is not found in database, then create one
	SetKey(string) error
	// Increment the visit counter of the IP
	IncrementVisitByIP(string) error
}

這樣,我就可以在 middleware 裡面直接使用這個 db 的 method,即使我還沒有寫好實作。

func limitVisit(db Database) gin.HandlerFunc {
	return func(c *gin.Context) {
		ip := c.ClientIP()
		exist, tooMany := db.Find(ip)
		if !exist {
			err := db.SetKey(ip)
			if err != nil {
				log.Fatal("Set redis key", err)
			}
		} else {
			err := db.IncrementVisitByIP(ip)
			if err != nil {
				log.Fatal("Increment redis key", err)
			}
		}
		remaining, ttl, err := db.GetKey(ip)
        // 以下省略

也就是說,我可以留到最後再來考慮實作 db 的細節,例如我可以猶豫這個 db 的系統要用 PostgreSQL 還是 redis,但不管用哪一個,只要符合 interface 所定義的內容,想用哪個就用哪個。

Go 的 implement 是隱式的,換句話說,我不用宣告 redisServer 這個 struct 實作 Database,只要我寫好 interface 定義的 method 即可。

func (db *redisServer) Init(maxIP int, timeout int) error
func (db *redisServer) Find(ipaddr string) (existed bool, toomuch bool) 
func (db *redisServer) GetKey(ipaddr string) (string, string, error)
func (db *redisServer) SetKey(ipaddr string) error
func (db *redisServer) IncrementVisitByIP(ipaddr string) error

除了 implement 介面定義的內容,當然也可以加一些新的 method 以方便使用,我就多宣告了 reset

func (db *redisServer) Reset() 

至於怎麼把 redisServer 傳到 middleware?

func setupRouter(db Database) *gin.Engine {
	r := gin.Default()
	r.GET("/", limitVisit(db), hello)
	return r
}
func main() {
	var db redisServer
	if err := db.Init(1000, 3600); err != nil {
		log.Fatal("redis server", err)
	}
	db.Reset()
	r := setupRouter(&db)
	r.Run()
}

如果之後有一天,自己或其他人發現這個專案有其他新功能要添加等等因素,而想要把 redis 換成 MySQL,也只要實作介面,再把 main 的 redisServer 換成別的 struct 就好了,是不是簡單又方便呢?