当开发者出于验证或者通知的目的想要为应用程序添加 短消息服务 组件时,通常会使用像 Twilio [1] 提供的 RESTful API,但是 API 之下到底发生了什么呢?

在这篇文章,您将了解 通用计算机协议(UCP) [2] 是什么以及如何使用 Go 语言通过这个协议直接与  短消息服务中心(SMSC) [3] 通信来发送和接收  SMS [4] .

术语

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 之间序列号(在 协议规范 [5] 中称为“传输引用号”)的 UCP 请求,SMSC 会同步的返回一个 UCP 响应信息。SMSC 也可以发送 UCP 请求,比如 “ delivery notification operation ” 和 “ delivery short message operation ”。我们也需要定期的向 SMSC 发送 ping,以便它不会认为该连接过期而将其断开。

我们以 Client 类型开始,这个类型包含了向 SMSC 发送的登录信息。登录信息通常是由运营商提供的,但出于测试目的,我们可以使用  SMSC 模拟器 [6]


// 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 [7]


// 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) [8] 分隔的。对应的字节码是  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

totalParts int

refNum int

sender string

receiver string

message string

msgID string

dcs string

}

为了处理 超长 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 [9] 格式,对发送者名字进行加密。

我们使用 select 语句从从 SMSC 获得响数据包。 它会处于阻塞状态,直到通道  submitSmRespCh 变成可读或者发生了超时

Send 返回一个消息标识符的列表,表明 SMSC 成功接收到了  submit short message operation 请求。数据是同步返回的。例如,如果我们发送了一个包含 5 个消息片段的 超长 MO 消息, Send 就会返回一个包含 5 个字符串的列表

[09191234567:130817221851, 09191234567:130817221852, 09191234567:130817221853, 09191234567:130817221854, 09191234567:130817221855]

每个标识符有如下的格式 recipient:timestamptimestamp 部分可以使用  020106150405 这样的格式,用  time.Parse [10] 来解析。如果你更熟悉  strftime [11] ,  timestamp 也可以使用  %d%m%y%H%M%S 这样的格式。

示例

我写了一个简单的项目 CLI [12] 来演示这个库,我们使用  SMSC simulator [13] 当做短消息中心,通过  Wireshark [14] 查看 UCP 数据包

首先,通过 go get 获取 CLI 和 SMSC 模拟器,并且确保  redis [15] 运行在地址  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 [16] 包,它包含额外的一些功能,比如速率限制和收费管理。欢迎提出您的宝贵建议。

谢谢

via: https://blog.gopheracademy.com/advent-2018/how-to-send-and-receive-sms/

作者: Jorick Caberio [17] 译者: jettyhan [18] 校对: polaris1119 [19]

本文由 GCTT [20] 原创编译, Go 中文网 [21] 荣誉推出

References

[1] Twilio:  https://www.twilio.com/docs/sms/api

[2] 通用计算机协议(UCP):  https://wiki.wireshark.org/UCP

[3] 短消息服务中心(SMSC):  https://en.wikipedia.org/wiki/Short_Message_service_center

[4] SMS:  https://en.wikipedia.org/wiki/SMS

[5] 协议规范:  http://documents.swisscom.com/product/1000174-Internet/Documents/Landingpage-Mobile-Mehrwertdienste/UCP_R4.7.pdf

[6] SMSC 模拟器:  https://github.com/jcaberio/ucp-smsc-sim

[7] ring:  https://golang.org/pkg/container/ring/

[8] 文件结束符分隔(ETX):  https://en.wikipedia.org/wiki/End-of-Text_character

[9] UCS2:  https://en.wikipedia.org/wiki/Universal_Coded_Character_Set

[10] time.Parse:  https://golang.org/pkg/time/#Parse

[11] strftime:  http://strftime.org/

[12] CLI:  https://github.com/go-gsm/ucp-cli

[13] SMSC simulator:  https://github.com/jcaberio/ucp-smsc-sim

[14] Wireshark:  https://www.wireshark.org/

[15] redis:  https://redis.io/

[16] ucp:  https://github.com/go-gsm/ucp

[17] Jorick Caberio:  https://blog.gopheracademy.com/advent-2018/how-to-send-and-receive-sms/

[18] jettyhan:  https://github.com/jettyhan

[19] polaris1119:  https://github.com/polaris1119

[20] GCTT:  https://github.com/studygolang/GCTT

[21] Go 中文网:  https://studygolang.com/

相关文章