Python从零实现SGIP短信网关协议:编解码、连接管理与避坑指南
发布时间:2026/9/3 22:18:24 作者:尧图编辑部 阅读量:1,286

简介SGIP是面向国内短信网关互通的TCP/IP协议常用于短信中心间交互。这份资源以Python实现SGIP协议适合开发者在无电信环境下模拟或测试短信网关掌握报文构造、解析与MT/MO消息流程。压缩包共22个文件大小27KB以7个py源码为核心覆盖协议常量、消息处理库、服务端及注册端等模块另含SVN版本管理遗留文件与工程配置便于查看历史版本和工程结构。目前已有293人学习。通过阅读这些代码可理解SGIP固定头部、可变头部与数据体的组织方式学习基于socket和struct完成TCP通信、二进制打包解包、双向消息处理及基础容错设计为后续扩展短信网关功能或参与相关项目打下基础。 做短信网关接入的对SGIP这三个字母应该不陌生。它是中国移动定义的短消息网关接口协议SP服务商要连移动的短信网关发短信、收状态报告基本绕不开这个协议。现实里很多团队用Java写网关接入但用Python做SGIP协议栈的项目其实也不少——脚本快、好调、能快速验证业务逻辑尤其适合中小体量的短信推送场景。这篇就把我在项目里用Python从零实现SGIP协议的完整过程掰开揉碎讲一遍从协议格式、消息编解码到连接管理、消息收发再到那些文档里根本不会写的坑。如果你正准备对接移动短信网关或者想搞明白SGIP协议内部是怎么工作的这篇文章值得你花十分钟看完。1. SGIP协议到底在做什么1.1 SGIP解决了什么问题SGIP全称Short Message Gateway Interface Protocol直译就是短消息网关接口协议。它在整个短信链路里的位置很清晰SP的短信业务系统通过SGIP协议接入移动的短信网关SMG然后网关再把短信路由到目标用户的手机上。用户回复短信或者系统产生状态报告时也通过同一个连接回传给SP。协议本身是基于TCP/IP的走的是长连接方式。这意味着你连上之后不是发一条短信就断开而是要维持一条持久的TCP连接通过心跳包保活同时在这条连接上并发地收发消息。这个设计和HTTP那种请求-响应完就断的模式完全不同初次接触的人很容易在连接管理上栽跟头。SGIP最核心的价值在于它定义了一套完整的消息格式和交互流程怎么登录鉴权Bind怎么提交短信Submit怎么接收状态报告Report怎么处理用户上行短信Deliver。这套流程把SP和网关之间的交互规则固化下来两边只要按协议实现就能建立起可靠的消息通道。1.2 为什么选择Python来实现说实话短信网关这块的老系统用Java、C写居多毕竟电信级服务对性能和稳定性要求高。但Python在这个场景里并不是没有位置。我这边选择Python主要是三个原因。第一业务侧本身是Python技术栈网关模块要嵌入现有的业务系统里用Python可以减少跨语言调用的成本。第二短信推送的业务逻辑天然适合Python这种开发效率高的语言尤其是发送策略、频控、模板管理这些Python写起来比Java快一倍不止。第三Python的struct、socket、asyncio这些库对二进制协议的支持相当顺手实现SGIP编解码并不复杂。当然Python也有短板最典型的就是性能。但SGIP这种网关协议单连接的消息吞吐量主要受限于网络延迟和网关侧的处理能力Python的GIL在I/O密集场景下并不会成为瓶颈。实测下来单进程处理每秒几百条短信提交完全没问题对于大多数SP业务来说绰绰有余。2. 协议格式拆解与核心概念2.1 消息头结构SGIP的消息结构很规整所有消息都分为消息头和消息体两部分。消息头固定20个字节这是整个协议的地基必须精确解析。消息头的三个字段用C语言的结构体表达就是这样的typedef struct { unsigned int message_length; // 消息总长度含消息头 unsigned int request_id; // 消息类型 char sequence_id[12]; // 序列号12字节 } SGIP_HEADER;对照表如下字段长度说明message_length4字节整个消息的长度包含这20字节头request_id4字节区分消息类型比如1是Bind、3是Submitsequence_id12字节由时间戳序列号组成用于消息追踪注意这里有个特别关键的细节SGIP的消息头三个字段全部采用网络字节序也就是大端序Big-Endian。这是电信协议里最常见的字节序规定但也是最容易被忽略的地方。如果你在解析的时候用了小端序解析出来的消息长度会是一个天文数字直接导致后续所有逻辑崩溃。2.2 核心消息类型SGIP协议定义了十几类消息实际开发中真正高频用到的是下面这几个消息类型request_id方向用途Bind0x00000001SP → SMG登录请求Bind_Resp0x00000002SMG → SP登录响应Submit0x00000003SP → SMG提交短信Submit_Resp0x00000004SMG → SP提交响应Deliver0x00000005SMG → SP用户上行短信Deliver_Resp0x00000006SP → SMG上行响应Report0x00000007SMG → SP状态报告Report_Resp0x00000008SP → SMG状态报告响应Bind和Bind_Resp对应登录鉴权流程Submit/Submit_Resp对应短信提交Deliver是用户上行短信比如用户回复的短信Report是短信下发的状态报告比如用户是否收到。搞清楚这几个SGIP的核心功能就覆盖了八成。2.3 序列号的设计逻辑SGIP的序列号sequence_id是12字节这12字节分成两段前4字节是时间戳的秒数后8字节是递增序号。这个设计意味着只要在同一秒内发送的消息不超过一定量序列号就是全局唯一的。序列号的作用很直接它是请求和响应之间的关联凭证。你发一条Submit消息网关返回的Submit_Resp里会携带相同的时间戳格式序列号你用它来匹配是哪条Submit的响应。同时网关下发的Deliver和Report序列号由网关侧生成你回复Resp时需要原样返回。我在实现时踩过一个坑如果多个连接复用同一套序列号生成逻辑就可能出现序列号冲突。正确的做法是每个连接维护独立的序列号生成器或者在序列号里带上连接标识保证全链路唯一。3. Python实现核心环节3.1 消息编解码struct模块的正确姿势Python的struct模块是处理SGIP二进制消息的核心工具。用pack和unpack配合格式字符串就能完成字节流的组装和拆解。先看消息头的编解码。因为SGIP的字段都是大端序格式字符串里要用!前缀显式声明import struct import time # 消息头消息总长 请求ID 12字节序列号 HEADER_FORMAT !II12s def build_sequence_id(): 生成12字节序列号4字节时间戳 8字节递增序号 ts int(time.time()) seq get_next_seq() return struct.pack(!I, ts) struct.pack(!Q, seq) def pack_message(request_id, body: bytes) - bytes: 组装一条完整的SGIP消息 sequence_id build_sequence_id() header struct.pack( HEADER_FORMAT, 20 len(body), # 消息总长度 request_id, sequence_id ) return header body def unpack_header(data: bytes) - tuple: 解析消息头 msg_length, request_id, sequence_id struct.unpack(HEADER_FORMAT, data[:20]) return msg_length, request_id, sequence_id这里有几个容易出错的地方。第一!II12s中的12s是12字节的字节串不是字符串用struct打包时传入的必须是bytes类型。第二!前缀代表网络字节序大端序不写默认是本机字节序在x86机器上是小端序结果就全反了。第三message_length是整个消息的总长度不是消息体的长度这个头尾关系千万别搞反。3.2 Bind登录建立可信连接的第一步Bind是SP连上网关后的第一条消息。它的消息体固定长度为27字节包含Login Name16字节、Login Password16字节、Login Type1字节。虽然固定长度加起来是33字节但协议规定Login Type占1字节整体看是一个20字节头27字节体的结构。实际上SGIP的Bind消息体是这么定义的Login Name 16字节Login Password 16字节Login Type 1字节总计33字节的体长度。消息头里的message_length就是203353。import socket def build_bind(username: str, password: str, login_type1) - bytes: 构建Bind登录请求 body ( username.encode(utf-8).ljust(16, b\x00) password.encode(utf-8).ljust(16, b\x00) struct.pack(!B, login_type) ) return pack_message(0x00000001, body) def parse_bind_resp(data: bytes) - int: 解析Bind_Resp返回结果状态 _, request_id, _ unpack_header(data) assert request_id 0x00000002, f不是Bind_Resp实际是{request_id} # 消息体就1个字节状态码0为成功 result struct.unpack(!B, data[20:21])[0] return result连接时有个细节值得注意很多SP在联调时会漏掉Login Type字段的取值。Login Type为1代表SP主动连接这是最常见的场景。如果配成了其他值网关会直接拒绝登录。返回值0代表成功非0都是失败常见的失败码有1IP校验错误、2用户名或密码错误、3版本不匹配。3.3 Submit消息核心的短信提交流程Bind成功之后就能正式提交短信了。Submit的消息体结构相对复杂包含SP编号、业务类型、计费类型、用户号码、消息内容等字段。def build_submit( sp_id: str, # SP的企业代码 user_number: str, # 接收号码 content: str, # 短信内容 msg_fmt15 # 15表示UTF-8编码 ) - bytes: 构建Submit短消息 body struct.pack(!B, 0x02) # Submit类型标识 body sp_id.encode(utf-8).ljust(10, b\x00) body struct.pack(!B, 0x01) # 业务类型点播 body struct.pack(!B, 0x01) # 计费类型按条 body b\x00 # 费用值1字节 body b\x00 * 3 # 目标计费用户号码3字节 body struct.pack(!B, 0x00) # 用户计费号码类型 body b\x00 * 6 # 目标计费用户号码6字节 body struct.pack(!B, 0x01) # 信息类型短消息 body struct.pack(!B, msg_fmt) body user_number.encode(utf-8).ljust(21, b\x00) body struct.pack(!B, 0x00) # 承载类型 body b\x00 * 8 # 保留字段 body struct.pack(!I, 0x00) # 优先级 body b\x00 * 16 # 业务代码 body b\x00 * 8 # 备用字段 # 消息内容长度加内容 content_bytes content.encode(utf-8) body struct.pack(!I, len(content_bytes)) body content_bytes return pack_message(0x00000003, body)实际开发中Submit消息体字段很多上面只列了关键字段。最需要注意的是几个长度的设定SP编号固定10字节不足右补\x00用户号码固定21字节不足右补\x00消息内容长度msg_length是4字节大端整数不包含在固定字段里Submit发出后网关会回Submit_Resp响应体前4字节是Submit消息的序列号第5字节是结果状态码。0代表成功接收非0则说明消息被网关拒绝。3.4 接收Deliver和Report短信提交成功后网关会异步下发两类消息Deliver用户上行短信和Report状态报告。这两个都需要SP主动回复对应的Resp响应否则网关会认为SP处理超时。def handle_message(data: bytes): 处理网关下发的完整消息 msg_length, request_id, sequence_id unpack_header(data) if request_id 0x00000005: # Deliver # 解析上行内容 content_len struct.unpack(!I, data[20 8:20 12])[0] content data[20 12:20 12 content_len].decode(utf-8, errorsignore) # 原样返回Deliver_Resp resp pack_with_sequence(0x00000006, b\x00, sequence_id) return resp, content elif request_id 0x00000007: # Report # 处理状态报告更新发送状态 resp pack_with_sequence(0x00000008, b\x00, sequence_id) return resp, None elif request_id 0x00000002: # Bind_Resp result struct.unpack(!B, data[20:21])[0] print(fBind结果: {result}) return None, None这里有个必须注意的规则回复Resp时序列号必须和收到的消息一致。也就是响应消息里的sequence_id要原样返回不能重新生成。如果换了新序列号网关就没法把Resp和原消息对应上会一直等你的响应直到超时。我在初版实现里就吃了这个亏后来才把发Resp必须带原序列号这条规则写死在代码注释里。3.5 心跳保活与会话管理SGIP没有专门的心跳消息类型它是利用TCP连接的空闲检测机制实现的。网关侧一般会配置一个空闲超时时间如果超过这个时间没有收到任何数据网关就会主动断开连接。所以SP侧需要周期性发送一条消息来保活。实践中常用的保活方式有两种一是周期性地发一条不需要业务处理的Submit或Report二是依赖TCP的KeepAlive选项。但TCP KeepAlive的默认探测周期太长Linux下默认2小时不太可靠。更稳妥的做法是开一个定时任务每隔30到60秒往连接里写一条心跳数据。我没有采用伪造业务消息的方式而是直接在应用层维护了一个最后收发时间的检查。每次收发消息都更新这个时间戳同时起一个后台线程定期检查如果距离上次收发超过30秒就发送一条空心跳可以是一条Report_Resp序列号用一个保留值确保连接不空闲。4. 常见问题与排查技巧实录4.1 TCP连接建立后被立刻断开这个现象我遇到太多次了。Bind消息发过去网关直接断连。第一反应是查用户名密码但很多时候根本不是账号问题。排查思路是这样的先抓包看Bind消息的字节内容比对协议文档逐字节检查。最常见的坑是消息长度字段算错了把message_length误填成了消息体的长度网关解析时认为消息不完整直接丢弃并断开连接。还有一种是Login Type字段传了ASCII字符而不是二进制值比如传了b10x31而不是b\x01网关鉴权失败。另一个容易忽略的问题是SP的IP地址白名单。网关侧配置了只允许特定IP段接入如果服务器出口IP不在白名单里网关会在Bind阶段返回状态码1IP校验错误然后断开连接。4.2 中文短信乱码或超长SGIP的消息内容编码由msg_fmt字段决定常用的取值是15UTF-8和8GBK。如果SP用UTF-8编码内容但msg_fmt填了8网关按GBK解析就会乱码。反过来网关下发Deliver时也要先读msg_fmt再按对应编码解码。还有一个比较隐蔽的问题一条短信的内容长度限制。SGIP协议规定单条短信内容最大长度是140字节GBK编码下约70个汉字UTF-8编码下约46个汉字。超出这个长度要么拆分提交要么走长短信内容拼接协议。我处理的方式是提交前统一做内容长度检查超长就按70字GBK场景或46字UTF-8场景拆分并为每条拆分消息生成独立的Submit请求同时在业务上关联同一个批次号方便后续对账。4.3 序列号重复导致消息丢失序列号的作用是关联请求和响应。如果多条Submit用了同一个序列号网关返回的Submit_Resp你就无法区分是哪条消息的响应极端情况下会导致漏报。这个问题在高并发场景下特别容易触发。我最初用int(time.time())加进程内自增计数生成序列号后来发现多线程并发时两个线程可能在同一秒内拿到同一个自增值。修复方案是在自增序列号里加入线程ID的后几位或者直接用itertools.count()加锁保证严格递增。更彻底的做法是把序列号生成器做成独立模块用Redis的INCR命令保证多进程环境下也唯一。4.4 消息粘包和半包问题TCP是流式协议没有消息边界。SGIP协议通过message_length字段来界定消息边界但网络传输中可能出现粘包多条消息一次收到和半包一条消息分多次收到。处理这个问题需要一个缓冲区收到数据先追加到缓冲区然后循环解析——只要缓冲区长度大于等于20字节就解析消息头拿到message_length再判断缓冲区是否已经包含完整消息。如果包含就取出这条消息处理然后继续循环如果不够就等待下一批数据。class SGIPConnection: def __init__(self, sock: socket.socket): self.sock sock self.buffer b def recv_message(self) - list: 从缓冲区提取完整消息列表 messages [] while True: if len(self.buffer) 20: break msg_length, _, _ unpack_header(self.buffer) if len(self.buffer) msg_length: break messages.append(self.buffer[:msg_length]) self.buffer self.buffer[msg_length:] return messages这个缓冲区逻辑是SGIP消息处理的核心几乎所有问题都和它有关。实测中最常犯的错是没用while循环处理完缓冲区里所有完整消息导致多条消息只处理了一条剩下的留在缓冲区里造成消息延迟。4.5 问题排查速查表现象可能原因排查方法Bind后立即断连IP白名单未配置、密码错误检查Bind_Resp状态码核对网关侧IP设置Submit后无响应序列号格式错误、消息长度不符抓包对比字节长度检查大端小端中文乱码msg_fmt与编码不匹配确认UTF-8对应15、GBK对应8大量消息超时缓冲区半包循环未跑完检查recv逻辑是否while循环取完所有完整消息连接空闲后断开心跳未处理增加定时心跳逻辑周期建议30秒以内排查协议问题时抓包工具是必须的。用Wireshark的Follow TCP Stream功能可以直观看到收发双方的字节流和协议文档逐字节对比很快就能定位问题。我每次联调必开抓包这比瞎猜效率高太多。5. 一些实战心得回到Python实现这件事本身我想说几个项目推进过程中的切身体会。第一个是关于测试环境的。移动短信网关有完整的联调测试环境但申请流程有时比较慢。可以先在本地用一个小工具模拟网关侧行为——收到Bind回Bind_Resp收到Submit回Submit_Resp这样能在等待联调环境的时候就完成协议栈的八成验证。我在项目前期就是这么干的等测试环境批下来代码基本已经稳定了。第二个是日志记录的重要性。SGIP是长连接协议问题出现时往往已经是几十条消息之后了。如果没有详细日志排查起来就像大海捞针。我这边每个关键节点都打了结构化日志消息类型、序列号、相关号码、结果码日志行里带上时间戳和消息唯一标识。这样出问题时直接按序列号检索日志很快就能还原整条消息的生命周期。第三个心得关乎性能。Python版本做短信网关接入在超高并发下确实比不上Java或者C的实现但大多数业务场景根本到不了那个量级。如果真要压性能可以用asyncio重写连接管理部分把I/O模型从多线程改成事件循环能省不少线程上下文切换的开销。我后来的版本就是这么干的同样的机器配置吞吐量提升了一倍多。最后再分享一个细节SGIP协议里所有字符串字段都是固定长度不足部分补\x00。但有些网关实现会用空格而不是\x00补位这会导致你收到的号码后面带着一串空格。解析时统一做一次rstrip(\x00 )同时去掉\x00和空格可以避免很多莫名其妙的对账问题。这个坑我印象太深了写在这里算是给大家提个醒。本文还有配套的精品资源点击获取