如何发送和接收 SMS: 用 Go 语言实现 GSM 协议

jettyhan · 2019-01-26 20:50:13 · 1675 次点击 · 预计阅读时间 16 分钟 · 大约8小时之前 开始浏览    
这是一个创建于 2019-01-26 20:50:13 的文章,其中的信息可能已经有所发展或是发生改变。

当开发者出于验证或者通知的目的想要为应用程序添加 短消息服务 组件时,通常会使用像 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
    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 格式,对发送者名字进行加密。

我们使用 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 来解析。如果你更熟悉 strftimetimestamp 也可以使用 %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

我们用 Gopher09191234567 发送一条消息 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 包,它包含额外的一些功能,比如速率限制和收费管理。欢迎提出您的宝贵建议。

谢谢


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

作者:Jorick Caberio  译者:jettyhan  校对:polaris1119

本文由 GCTT 原创编译,Go语言中文网 荣誉推出


有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

1675 次点击  
加入收藏 微博
被以下专栏收入,发现更多相似内容
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传