又一週沒寫文章了,本著我一天一週兩週一更新一貫頻繁懶散的風格,開始了今天的文章。
需求的誕生
上週末在覆盤的時候,因為我的一些筆記都是記在印象筆記裡的,包括日常工作,學習安排計劃、讀書計劃以及代辦事項。有些時候,沒有特別留意的話,容易忘記一些瑣事。蘋果備忘錄?不行,它不會訊息提醒你。蘋果還有一個提醒事項,截個圖感受下:
不行不行不行,這個太麻煩了,寫完還要選時間。我就不能一句話寫上明天9點通知我大樂透開獎,你就給我安排上嗎?本著一個不會製造需求的程式設計師不是一個好產品思想觀念,我決定一波操作完成這個需求。
開始落地
好了,現在要實現使用者在公眾號 庫裡的深夜食堂 傳送一段帶有時間的文字,根據上面的時間,在特定的時間內進行通知,提醒對方有未完成的代辦事項。本來想著用微信公眾號的訂閱訊息實現,但是奈何自己手上只有一個未認證的個人訂閱號,沒有訂閱訊息的許可權。只能被動的接收和回覆使用者傳送的訊息。那就只能你了,兄弟。
沒有了訂閱訊息,也就意味著我需要接入外部的通知手段來實現。無非就在於簡訊和郵箱。還有心靈感應?這個我只有一顆心,忙不過來。郵箱通知,如果微信沒開啟的話,也收不到,那就只剩下簡訊了。好了,現在你在微信公眾號傳送一段需要通知的內容和時間,然後我在對應的時間點傳送給你簡訊,就像這樣:
然後第二天六點30分你會準時準點收到一條簡訊。
開始有了一絲絲的沙雕樣子,嗯,這正是我想要的效果。根據傳送公眾號傳送內容,提取出使用者交代的時間以及聯絡方式。時間方面,只要把正常人表達的時間規則提取出來就完事了。比如說明天上午9點、2020-05-20 05:20、後天上午9點20分或者明天19:70,這四種表達應該包含了大部分表示式了,也正是現在程式所支援的。
你總不能來個:
下星期一的前一天提醒我告訴小姐姐,先別急著嫁人,再等我一期雙色球。
你要是這麼寫,那我也只能返回給你:
開始實現
其實核心是由Go標準庫程式碼包的 time 實現的。我們把使用者每條需要傳送的通知存入到資料庫中。初始化專案的時候,專門拉出一個 Goroutine 實現一個三小時觸發一次的斷續器,檢視資料庫中接下來三小時內需要傳送通知的訊息。
func Scheduler() {
//3小時醒過來一次檢視下一個三小時內要傳送的簡訊
for {
if isFirst == false {
timer := time.NewTicker(3 * time.Hour)
<-timer.C
}
isFirst = false
now := GetLocalTimeNow()
hh, _ := time.ParseDuration("1h")
threeTime := now.Add(hh * 3)
//查詢最近三小時內有沒有要傳送的簡訊
rows, err := models.Db.Query(
"select * from todos where notice_time>? and notice_time<? and status=?",
SetFormatTime(now), SetFormatTime(threeTime), 2)
if err != nil {
log.Println(err.Error())
}
for rows.Next() {
var todo = models.Todo{}
var email = &Email{}
var phone = &Phone{}
if err = rows.Scan(&todo.Id, &todo.Content, &todo.CreatedAt,
&todo.NoticeTime, &todo.Status, &todo.Phone, &todo.Email); err != nil {
log.Println(err.Error())
}
email.Body = todo.Content
phone.Phone = todo.Phone
phone.Id = todo.Id
go func(todo2 models.Todo, email2 *Email, phone2 *Phone) {
SendEmailOrPhone(todo2, email2, phone2)
}(todo, email, phone)
todos = append(todos, todo)
}
}
}
每一條開啟一個 Goroutine 呼叫 SendEmailOrPhone(),裡面再計算出具體距離通知的時間,分別啟動一個時間間隔的定時器。
func SendEmailOrPhone(todo models.Todo, email *Email, phone *Phone) {
//檢視這條通知離現在的時間 定時器安排一下傳送
now := GetLocalTimeNow()
noticeTime, _ := time.ParseInLocation("2006-01-02 15:04:05",
todo.NoticeTime.Format("2006-01-02 15:04:05"), time.Local)
diff := noticeTime.Sub(now)
timer = time.NewTimer(diff)
<-timer.C
//暫時關閉郵箱提醒
//email.SendNotice()
phone.SendNotice(todo.Id)
log.Println("此次傳送的手機號是:%d", phone)
log.Println("主鍵id是:", todo.Id)
}
有人會說,如果這時候在這三小時內,有新的通知進來,而且需要通知時間還是當前斷續器內需要傳送的,那麼豈不是就通知不到了?因為下一次的斷續器肯定不會查詢到這條通知。對應這樣的通知,除了插入到表的操作,另外也單獨給它啟動一個定時器,傳送成功的通知得標記一下,防止二次傳送。核心部分:
//通知時間小於現在的3小時,直接搞個定時器
func isCreateTimerForSendNotice(lastId int64, sendTime string, createdTime time.Time, phone string) time.Duration {
log.Println(sendTime)
cstSh, _ := time.LoadLocation("Asia/Shanghai")
noticeTime, err := time.ParseInLocation("2006-01-02 15:04:05", sendTime+":00", cstSh)
if err != nil {
fmt.Println(err.Error())
}
diff := noticeTime.Sub(createdTime)
//直接給他一個定時器 執行 即使下一個斷續器啟動 檢索資訊的時候這條通知已經標註已通知了
if diff.Hours() < 3 && diff.Hours() > 0 {
var noticePhone = &Phone{}
noticePhone.Phone = phone
noticePhone.Id = lastId
go func() {
//到點執行
timer := time.NewTimer(diff)
<-timer.C
noticePhone.SendNotice(lastId)
}()
}
return diff
}
如果傳送成功的話,再標註一下對應訊息已通知。現在還有另一個問題,如果此時由於網路和一些奇怪的問題,導致傳送失敗,那這時候小姐姐接收不到簡訊還不得傷心死,不行,我決定不允許這種事情發生。
所以還需要加入重試機制。這個重試,我們可以使用 channel 來完成。在 Go 的哲學中:
Don’t communicate by sharing memory; share memory by communicating. (不要通過共享記憶體來通訊,而應該通過通訊來共享記憶體。)
接受者負責接收資料,然後重發簡訊,可以設定重發間隔值,傳送失敗可能短時間也會失敗,再設定一個最大允許重試的次數。我們可以去使用 channel 高階一點的用法,通過傳參去約束函式只能進行收或者發。
var ErrNoticeChannel = make(chan Phone, 10)
//錯誤的通知集合
var CountErr = make(map[int64]int, 10)
//接收
func getHandleErrNotice() <-chan Phone {
return ErrNoticeChannel
}
//傳送
func SendHandleChannel(ch chan<- Phone, phone2 Phone) {
ch <- phone2
}
//處理髮送失敗通知
func HandlerErrNotice() {
handles := getHandleErrNotice()
for sendPhone := range handles {
timer := time.NewTimer(time.Second * 30)
<-timer.C
if CountErr[sendPhone.Id] >= 3 {
log.Printf("id為%d的通知重試超過三次了", sendPhone.Id)
lock.RLock()
delete(CountErr, sendPhone.Id)
lock.RUnlock()
continue
}
log.Println("重試中")
lock.RLock()
CountErr[sendPhone.Id] += 1
lock.RUnlock()
sendPhone.SendNotice(sendPhone.Id)
}
}
為什麼要這樣約束呢?一般在實際開發場景中,面向介面而不是實現。我們可能會這樣定義一個介面:
type Notice interface {
SendInt(ch chan<- Phone)
}
那麼一個型別如果想成為一個介面型別的實現型別,必然要實現它的所有方法。如果我們在某個介面定義了單向的通道,等同於定義了此介面的實現類通道型別。
現在還可以看到,這裡對 map 型別變數 CountErr 操作的時候加了鎖,因為 map 並不是併發安全的。但是,其實這裡,我並沒有在程式別處操作這個變數,僅僅在這個接收通道有對 map 進行操作,那麼就可以不必使用互斥鎖。這麼快就忘記 Go 的哲學了?然後我們只要在傳送失敗的時候把資料傳入通道即可。
// ......
// ......
// ......
// 通過 client 物件呼叫想要訪問的介面,需要傳入請求物件
response, err := client.SendSms(request)
// 處理異常
if _, ok := err.(*errors.TencentCloudSDKError); ok {
SendHandleChannel(ErrNoticeChannel, *phone)
fmt.Printf("An API error has returned: %s", err)
return
}
// ......
// ......
// ......
寫到這裡,這個簡單的功能也就實現了(當然也忽略了微信介面對接,擼文件就完事了)。文字框輸入實現我不說你也知道,正則匹配。沒有啥分詞系統,沒有啥自然語言處理。而且支援的不夠完善。目前能支援這些時間格式:明天上午9點、2020-05-20 05:20、後天上午9點20分或者明天19:70。
原始碼地址:https://github.com/wuqinqiang/remind-go。如果覺得可以的話,順手點個 star 再好不過了。
寫在最後
最後,我突然發現我過段時間還有一件很重要的事要進行提醒。剛好這個功能做完了。那麼…………(此處省略一萬個心理活動),我果斷拿出手機,點開 iphone 提醒應用,趕緊記錄下來。
真香警告⚠️。
微信現在有專題文章了,挺好的。到時候可以分享自己看過的書單。這段時間在看 《go併發程式設計》和《黑天鵝》。希望之後每看一本書能做個專題的總結,方便以後檢視。這篇文章原文發在一段騷操作 我又搞出了啥
本作品採用《CC 協議》,轉載必須註明作者和本文連結