如何傳送和接收 SMS: 用 Go 語言實現 GSM 協議
當開發者出於驗證或者通知的目的想要為應用程式新增 短訊息服務 元件時,通常會使用像 Twilio 提供的 RESTful API,但是 API 之下到底發生了什麼呢?
在這篇文章,您將瞭解 通用計算機協議(UCP) 是什麼以及如何使用 Go 語言通過這個協議直接與 短訊息服務中心(SMSC) 通訊來發送和接收 SMS .
術語
MT 資訊
運營商傳送給使用者的簡訊息,例如天氣更新資訊
MO 資訊
使用者傳送給運營商的短訊息,例如向一個指定號碼傳送關鍵字來查詢餘額資訊
超長 MT 訊息和超長 MO 訊息
起過 160 個字的 SMS 被視為 超長 SMS. 傳送 超長 MT 訊息 時,需要把它拆分成多個 資訊片段。每個訊息片段包含本片段的編號,整個訊息的編號和一個引用編號。
超長 MO 訊息的每個訊息片段也包含本片段的編號,整個訊息的編號和一個引用編號。我們需要把這些訊息片段組合起來,以便解析使用者傳送的原始 超長 MO 短訊息
通用計算機協議
通用計算機協議(UCP)主要用來連線 短訊息服務中心(SMSC),傳送和接收 SMS
session management operation
允許我們向 SMSC 傳送登入資訊
alert operation
允許我們對 SMSC 傳送 Ping
submit short message operation
允許我們傳送 MT 訊息
delivery notification operation
由 SMSC 傳送給客戶端,做為訊息傳輸的狀態憑證,標識之前傳送的訊息是否傳送成功
delivery short message operation
由 SMSC 傳送給客戶端,是對使用者傳送的 MO 訊息 的響應
實現
我們可以把 UCP 看成一個傳統的 客戶端 - 伺服器 協議。建立 TCP 連線後,我們傳送包含 00 到 99 之間序列號(在 協議規範 中稱為“傳輸引用號”)的 UCP 請求,SMSC 會同步的返回一個 UCP 響應資訊。SMSC 也可以傳送 UCP 請求,比如 “ delivery notification operation ” 和 “ delivery short message operation ”。我們也需要定期的向 SMSC 傳送 ping,以便它不會認為該連線過期而將其斷開。
我們以 Client
型別開始,這個型別包含了向 SMSC 傳送的登入資訊。登入資訊通常是由運營商提供的,但出於測試目的,我們可以使用 SMSC 模擬器
// Client represents a UCP client connection. type Client struct { // IP:PORT address of the SMSC addr string // SMSC username user string // SMSC pasword password string // SMSC accesscode accessCode string }
傳輸引用號
為了生成範圍從 00 到 99 之間的合法傳輸引用號,我們可以使用標準庫中的 ring 包
// Client represents a UCP client connection. type Client struct { // skipped fields ... // ring counter for sequence numbers 00-99 ringCounter *ring.Ring } const maxRefNum = 100 // INItRefNum INItializes the ringCounter counter from 00 to 99 func (c *Client) INItRefNum() { ringCounter := ring.New(maxRefNum) for i := 0; i < maxRefNum; i++ { ringCounter.Value = []byte(fmt.Sprintf("%02d", i)) ringCounter = ringCounter.Next() } c.ringCounter = ringCounter } // nextRefNum returns the next transaction reference number func (c *Client) nextRefNum() []byte { refNum := (c.ringCounter.Value).([]byte) c.ringCounter = c.ringCounter.Next() return refNum }
建立 TCP 連線
我們可以使用 net 包與 SMSC 建立 TCP 連線。然後使用 bufio 包建立帶緩衝的讀寫器
建立 TCP 連線後,我們就可以向 SMSC 傳送一個 session management operation
請求。這個請求中包含傳送給 SMSC 的登入資訊。
type Client struct { // skipped fields .... conn net.Conn reader *bufio.Reader writer *bufio.Writer } const etx = 3 func (c *Client) Connect() error { // INItialize ring counter from 00-99 c.initRefNum() // establish TCP connection conn, _ := net.Dial("tcp", c.addr) c.conn = conn // create buffered reader and writer c.reader = bufio.NewReader(conn) c.writer = bufio.NewWriter(conn) // login to SMSC c.writer.Write(createLoginReq(c.nextRefNum(), c.user, c.password)) c.writer.Flush() resp, _ := c.reader.ReadString(etx) err = parseSessionResp(resp) // ....other processing.... return err }
函式 createLoginReq
建立了一個包含登入資訊的 session management operation
請求資料包。函式 parseSessionResp
解析 SMSC 對這個 session management operation
返回的響應資料包。如果我們傳送的登入資訊是正確的,此函式返回 nil ,否則返回 error.
通道和 Goroutines
我們可以為將不同的 UCP 操作視為單獨的 Gorutine 和 通道 .
type Client struct { // skipped fields .... // channel for handling submit short message responses from SMSC submitSmRespCh chan []string // channel for handling delivery notification requests from SMSC deliverNotifCh chan []string // channel for handling delivery message requests from SMSC deliverMsgCh chan []string // channel for handling incomplete delivery message from SMSC deliverMsgPartCh chan deliverMsgPart // channel for handling complete delivery message requests from SMSC deliverMsgCompleteCh chan deliverMsgPart // we close this channel to signal Goroutine termination closeChan chan struct{} // waitgroup for the running Goroutines wg *sync.WaitGroup // guard against closing closeChan multiple times once sync.Once } // Connect will establish a TCP connection with the SMSC // and send a login request. func (c *Client) Connect() error { // after login, spawn Goroutines sendAlert(/*....*/) readLoop(/*....*/) readDeliveryNotif(/*....*/) readDeliveryMsg(/*....*/) readPartialDeliveryMsg(/*....*/) readCompleteDeliveryMsg(/*....*/) return err } // Close will close the UCP connection. // It's safe to call Close multiple times. func (c *Client) Close() { // close closeChan to terminate the spawned Goroutines // we use a sync.Once to close closeChan only once. c.once.Do(func() { close(c.closeChan) }) // close the underlying TCP connection if c.conn != nil { c.conn.Close() } // wait for all Goroutines to terminate c.wg.Wait() }
讀取 UCP 資料包
我們通過 readLoop
從 UCP 連線讀取資料包。合法的 UCP 資料包是以 檔案結束符分隔(ETX) 分隔的。對應的位元組碼是 03
readLoop
會一直讀取直到發現 etx
,然後解析讀取到的資訊,並將其傳送到相應的通道。
// readLoop reads incoming messages from the SMSC // using the underlying bufio.Reader func readLoop(/*.....*/) { wg.Add(1) go func() { defer wg.Done() for { select { case <-closeChan: return default: readData, _ := reader.ReadString(etx) opType, fields, _ := parseResp(readData) switch opType { case opSubmitShortMessage: submitSmRespCh <- fields case opDeliveryNotification: deliverNotifCh <- fields case opDeliveryShortMessage: deliverMsgCh <- fields } } } }() }
傳送 Keepalive
sendAlert
會向 SMSC 定期傳送 ping,我們用 time.NewTicker 建立了一個定期觸發的定時器。 createAlertReq
建立了一個包含合法傳輸引用號的 alert operation
請求資料包
// sendAlert sends a keepalive packet periodically to the SMSC func sendAlert(/*....*/) { wg.Add(1) ticker := time.NewTicker(alertInterval) go func() { defer wg.Done() for { select { case <-closeChan: ticker.Stop() return case <-ticker.C: writer.Write(createAlertReq(transRefNum, user)) writer.Flush() } } }() }
讀取傳遞通知狀態
readDeliveryNotif
用來讀取 SMS 的傳遞通知狀態。每讀到一個 delivery notification operation
就會向 SMSC 傳送一個確認資料包。
// readDeliveryNotif reads delivery notifications from deliverNotifCh channel. func readDeliveryNotif(/*....*/) { wg.Add(1) go func() { defer wg.Done() for { select { case <-closeChan: return case dr := <-deliverNotifCh: refNum := dr[refNumIndex] // msg contains the complete delivery status report from the SMSC msg, _ := hex.DecodeString(dr[drMsgIndex]) // sender is the access code of the SMSC sender := dr[drSenderIndex] // recvr is the mobile number of the recipient subscriber recvr := dr[drRecvrIndex] // scts is the service center time stamp scts := dr[drSctsIndex] msgID := recvr + ":" + scts // send ack to SMSC writer.Write(createDeliveryNotifAck([]byte(refNum), msgID)) writer.Flush() } } }() }
讀取傳遞短訊息
readDeliveryMsg
用來讀取 MO 訊息。
// readDeliveryMsg reads all delivery short messages // (mobile-originating messages) from the deliverMsgCh channel. func readDeliveryMsg(/*....*/) { wg.Add(1) go func() { defer wg.Done() for { select { case <-closeChan: return case mo := <-deliverMsgCh: xser := mo[xserIndex] xserData := parseXser(xser) msg := mo[moMsgIndex] refNum := mo[refNumIndex] sender := mo[moSenderIndex] recvr := mo[moRecvrIndex] scts := mo[moSctsIndex] sysmsg := recvr + ":" + scts msgID := sender + ":" + scts // send ack to SMSC with the same reference number writer.Write(createDeliverySmAck([]byte(refNum), sysmsg)) writer.Flush() var incomingMsg deliverMsgPart incomingMsg.sender = sender incomingMsg.receiver = recvr incomingMsg.message = msg incomingMsg.msgID = msgID // further processing } } }() }
型別 deliverMsgPart
包含了用來連線和解碼收到的 超長 MO 訊息片段所需要的必要資訊。
// deliverMsgPart represents a deliver sm message part type deliverMsgPart struct { currentPart int totalPartsint refNumint senderstring receiverstring messagestring msgIDstring dcsstring }
為了處理 超長 MO 資訊,我們把 每個訊息片段 傳送到通道 deliverMsgPartCh
上,把 MO 訊息傳送到通道 deliverMsgCompleteCh
上。
// readDeliveryMsg reads all delivery short messages // (mobile-originating messages) from the deliverMsgCh channel. func readDeliveryMsg(/*....*/) { wg.Add(1) go func() { defer wg.Done() for { select { case <-closeChan: return case mo := <-deliverMsgCh: // INItial processing ...... if xserUdh, ok := xserData[udhXserKey]; ok { // handle multi-part mobile originating message // get the total message parts in the xser data msgPartsLen := xserUdh[len(xserUdh)-4 : len(xserUdh)-2] // get the current message part in the xser data msgPart := xserUdh[len(xserUdh)-2:] // get message part reference number msgRefNum := xserUdh[len(xserUdh)-6 : len(xserUdh)-4] // convert hexstring to integer msgRefNumInt, _ := strconv.ParseInt(msgRefNum, 16, 0) msgPartsLenInt, _ := strconv.ParseInt(msgPartsLen, 16, 64) msgPartInt, _ := strconv.ParseInt(msgPart, 16, 64) incomingMsg.currentPart = int(msgPartInt) incomingMsg.totalParts = int(msgPartsLenInt) incomingMsg.refNum = int(msgRefNumInt) // send to partial channel deliverMsgPartCh <- incomingMsg } else { // handle mobile originating message with only 1 part // send the incoming message to the complete channel deliverMsgCompleteCh <- incomingMsg } } } }() }
函式 readPartialDeliveryMsg
中啟動的 Goroutine 會從通道 deliverMsgPartCh
中讀取訊息,然後把訊息片段合併成完整的 超長 MO 訊息。函式 readCompleteDeliveryMsg
中啟動的 Goroutine 會從通道 deliverMsgCompleteCh
讀取 MO 訊息,並執行相應的回撥函式。
傳送 SMS
我們用 Send
來發 SMS.
// Send will send the message to the receiver with a sender mask. // It returns a list of message IDs from the SMSC. func (c *Client) Send(sender, receiver, message string) ([]string, error) { msgType := getMessageType(message) msgParts := getMessageParts(message) refNum := rand.Intn(maxRefNum) ids := make([]string, len(msgParts)) for i := 0; i < len(msgParts); i++ { sendPacket := encodeMessage(c.nextRefNum(), sender, receiver, msgParts[i], msgType, c.GetBillingID(), refNum, i+1, len(msgParts)) c.writer.Write(sendPacket) c.writer.Flush() select { case fields := <-c.submitSmRespCh: ack := fields[ackIndex] if ack == negativeAck { errMsg := fields[len(fields)-errMsgOffset] errCode := fields[len(fields)-errCodeOffset] return ids, &UcpError{errCode, errMsg} } id := fields[submitSmIdIndex] ids[i] = id case <-time.After(c.timeout): return ids, &UcpError{errCodeTimeout, "Network time-out"} } } return ids, nil }
getMessageType
確定訊息包含的是普通 GSM-7 格式的字元還是 Unicode 字元
getMessageParts
把 超長 SMS 拆分成多個訊息片段
encodeMessage
負責建立包含適當引用號的合法 submit short message orperation
資料包,把 unicode 格式的訊息轉化為 UCS2 格式,對傳送者名字進行加密。
我們使用 select
語句從從 SMSC 獲得響資料包。 它會處於阻塞狀態,直到通道 submitSmRespCh
變成可讀或者發生了超時
Send
返回一個訊息識別符號的列表,表明 SMSC 成功接收到了 submit short message operation
請求。資料是同步返回的。例如,如果我們傳送了一個包含 5 個訊息片段的 超長 MO 訊息, Send
就會返回一個包含 5 個字串的列表
[09191234567:130817221851, 09191234567:130817221852, 09191234567:130817221853, 09191234567:130817221854, 09191234567:130817221855]
每個識別符號有如下的格式 recipient:timestamp
。 timestamp
部分可以使用 020106150405
這樣的格式,用 time.Parse 來解析。如果你更熟悉 strftime , timestamp
也可以使用 %d%m%y%H%M%S
這樣的格式。
示例
我寫了一個簡單的專案 CLI 來演示這個庫,我們使用 SMSC simulator 當做短訊息中心,通過 Wireshark 檢視 UCP 資料包
首先,通過 go get
獲取 CLI 和 SMSC 模擬器,並且確保 redis 執行在地址 localhost:6379
上
$ go get GitHub.com/go-gsm/ucp-cli $ go get GitHub.com/jcaberio/ucp-smsc-sim
匯出以下環境變數
$ export SMSC_HOST=127.0.0.1 $ export SMSC_PORT=16004 $ export SMSC_USER=emi_client $ export SMSC_PASSWORD=password $ export SMSC_ACCESSCODE=2929
執行 SMSC 模擬器,在瀏覽器中訪問 localhost:16003
$ ucp-smsc-sim
執行 CLI
$ ucp-cli
我們用 Gopher
向 09191234567
傳送一條訊息 Hello, 世界
. 模擬器會返回包含 [09191234567:021218201629]
的響應。我們還可以從模擬器中看到傳遞通知資訊。
我們可以通過 Wireshark 檢視具體的 UCP 資料包
我們可以在瀏覽器中檢視 SMS
為了模仿使用者傳送的 MO 資訊,我們可以傳送以下 curl
請求
curl -H "Content-Type: application/json" -d '{"sender":"09191234567", "receiver":"2929", "message":"This is a mobile-originating message"}' http://localhost:16003/mo
我們模仿的是一個號碼為 09191234567
的使用者向 2929
傳送了以下的資訊 This is a mobile-originating message
我們可以看到 CLI 接收到了這各 MO 資訊,並且在 Wireshark 得到了驗證
總結
Go 語言中內建的一些特性,比如 Goroutine 和 通道 讓我們可以方便的實現 UCP 協議。我們用 Go 語言的訊息處理方式,以併發的方式處理不同型別的 UCP 訊息。我們用不同的 Goroutine 來代表不同的 UCP 操作,並通過通道與之通訊。在實現各種協議操作時我們也大量的使用的標準庫。如果你在電信領域工作,並且可以訪問到 SMSC,可以嘗試使用 ucp 包,它包含額外的一些功能,比如速率限制和收費管理。歡迎提出您的寶貴建議。
謝謝