一段騷操作 我又搞出了啥

Remember發表於2020-05-26

又一週沒寫文章了,本著我一天一週兩週一更新一貫頻繁懶散的風格,開始了今天的文章。

需求的誕生

上週末在覆盤的時候,因為我的一些筆記都是記在印象筆記裡的,包括日常工作,學習安排計劃、讀書計劃以及代辦事項。有些時候,沒有特別留意的話,容易忘記一些瑣事。蘋果備忘錄?不行,它不會訊息提醒你。蘋果還有一個提醒事項,截個圖感受下:

不行不行不行,這個太麻煩了,寫完還要選時間。我就不能一句話寫上明天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 再好不過了。

寫在最後

最後,我突然發現我過段時間還有一件很重要的事要進行提醒。剛好這個功能做完了。那麼:relieved::smirk:…………(此處省略一萬個心理活動),我果斷拿出手機,點開 iphone 提醒應用,趕緊記錄下來。


真香警告⚠️。

微信現在有專題文章了,挺好的。到時候可以分享自己看過的書單。這段時間在看 《go併發程式設計》和《黑天鵝》。希望之後每看一本書能做個專題的總結,方便以後檢視。這篇文章原文發在一段騷操作 我又搞出了啥

本作品採用《CC 協議》,轉載必須註明作者和本文連結

吳親庫裡

相關文章