简介:CMPP3.0协议在Java环境下的实现实例,面向短信平台开发者、通信协议学习者和需要接入中国移动短信网关的技术人员。源码基于CMPP3.0规范,覆盖SUBMIT提交、DELIVER接收、QUERY状态查询等核心命令,并涉及TCP长连接与心跳、GBK编解码、多线程并发收发、异常重试、状态跟踪和日志记录等关键机制。资源共17个文件,其中14个Java源文件构成主要实现,1个properties文件用于配置参数,2个TXT文档除基础说明外,还包含定时器逻辑参考,对心跳保活和超时重发有直接帮助;整个压缩包仅23KB,结构清晰便于阅读和移植。已有947人学习下载,适合想通过实际代码快速理解CMPP3.0通信流程的开发者。通过分析这部分代码,可以掌握短信网关对接的完整思路,减少协议摸索时间,也能为后续二次开发或性能优化提供基础参考。 做短信平台接入的同学,对CMPP这三个字母应该不陌生。CMPP是中国移动定义的短信网关协议,目前企业对接用得最多的就是3.0版本,也就是标题里这个cmpp3.0_java_实现对应的核心对象。这篇内容把我过去在企业短信平台项目里,用Java从零写CMPP3.0接入层的完整思路、关键代码和踩坑记录整理出来,覆盖协议结构、Netty编解码、登录认证、心跳重连、长短信拆分这些硬核环节,以及生产环境里最容易出问题的几个点。如果你正准备自研短信网关,或者要对接运营商短信通道,这篇文章应该能帮你省掉不少试错成本。我不打算贴一个能直接编译的大工程,而是把为什么这么设计、动手时要注意什么讲透,代码给关键片段,这样你换框架也照样能用。
1. 项目背景与整体设计思路
1.1 为什么要折腾CMPP3.0
先说你什么时候会碰到这个东西。正常情况下企业发短信有两条路:一条是买云厂商的短信API,一条是直接跟运营商或者SP代理对接。前者省事但单条成本高、批量发送受限;后者就需要自己实现运营商的接入协议。CMPP就是中国移动那头的接入协议,全称China Mobile Peer to Peer,目前企业侧主流版本是CMPP3.0。
CMPP3.0解决的问题说白了就是一套双向通信规则:企业短信系统(SP侧)怎么登录网关、怎么把短信内容提交给网关、怎么接收上行短信、怎么获取状态报告、怎么维持连接不中断。和联通SGIP、电信SMGP一样,它基于TCP/IP,走的是长连接模式。所以Java实现CMPP3.0的本质,就是基于TCP长连接,写一套符合协议规范的客户端程序。
那为什么不直接用现成的开源实现?很多年前确实有CMPP的开源jar包,但要么协议版本停留在2.0,要么停止维护跟不上生产要求。短信这种业务一旦出问题就是事故,消息头错了、状态报告解析错了、长短信拆分不规范,用户手机收不到或者收到乱码,后台还看不出原因,这种锅谁都背不起。自己拿Java实现一遍,好处是机制透明、可控性强,后续要扩展多通道、加对账、加限速也顺手。
1.2 技术选型:为什么我选了Netty
同样是Java实现TCP客户端,你可以用原生Socket、Apache Mina、Netty,三者我都试过。原生Socket写CMPP不是不行,但心跳、断线重连、半包粘包、线程模型全要自己造轮子,开发周期直接拉长。Mina比Socket好一些,但社区活跃度和资料量现在都不如Netty。
我最终选了Netty,核心原因是CMPP的报文结构跟Netty的LengthFieldBasedFrameDecoder太契合了。CMPP每条消息的开头固定4字节记录整条消息的总长度,这不就是标准的LengthFieldBasedFrameDecoder使用场景吗?把decoder的lengthFieldOffset设为0、lengthFieldLength设为4,Netty自动帮你从TCP字节流里切出完整报文,粘包半包问题直接消掉一大半。另外Netty自带的EventLoop线程模型,天然适合做消息派发,避免了多线程并发读写的麻烦。
技术选型这里还有个容易被忽略的点:CMPP客户端要和网关保持一个长连接,生产环境经常是单连接模式,因为网关侧按SP账号限连。这意味着你的客户端必须足够稳,不能动不动断线,断了要快速重连。Netty在这块做得相当成熟,链路断掉之后触发channelInactive,我只需要在这里写重连逻辑就行,不必像原生Socket那样自己监听异常再处理。
1.3 模块划分与消息流转
这套实现的整体架构,我按职责拆成四层,这样出了问题定位快:
- 连接管理层:负责登录、心跳、断线重连,维护一个可用的Channel状态。
- 协议编解码层:负责Java对象和CMPP二进制报文之间的相互转换,对接Netty的Pipeline。
- 业务处理层:接收业务系统下发的短信请求,组装CMPP_SUBMIT请求,处理异步状态报告。
- 消息状态管理层:管理每一条短信从待发送到送达失败的完整状态机,做重试和幂等。
业务侧传入一条短信,先落库生成消息ID,然后进入发送队列,由发送模块组装CMPP_SUBMIT报文写入Netty Channel,同时把消息状态置为待状态报告。网关处理后回CMPP_SUBMIT_RESP,我们更新状态;用户最终收到短信,网关还会回一条DELIVER状态报告,我们再更新为最终状态。这套流程看着不复杂,但每一步都有细节,下面逐个拆。
2. 协议核心细节与编码解码实现
2.1 12字节消息头,一个字节都不能错
CMPP3.0所有消息都共用一个12字节的消息头,这是整个实现的地基。结构非常简单:
- Total_Length:4字节,消息总长度,含头本身。
- Command_Id:4字节,命令字,标识消息类型。
- Sequence_Id:4字节,消息流水号,同一个会话内唯一。
这三个字段都是无符号整数,网络字节序(Big Endian)。写Java代码时最容易在这里翻车,因为Java的ByteBuffer默认是Big Endian,但如果谁不小心用了LITTLE_ENDIAN去读写,解析出来的长度和命令字全乱了。我见过有同事排查半天发现报文长度对不上,最后是Endianness写错,这种问题隐蔽且致命。
用Netty的话,头12个字节很好处理。但要注意Total_Length是整包长度,而LengthFieldBasedFrameDecoder如果直接配置成切出完整包再把12字节头全部剥离,后续handler读到的就是纯消息体。我习惯保留消息头,在业务handler里统一读,因为有些网关返回的消息体字段和头关联紧密,一起解析逻辑更清晰。做法就是lengthAdjustment设为0、initialBytesToStrip设为0,拿到的ByteBuf就是带有完整消息头的报文。
2.2 登录认证这一步到底校验了什么
CMPP连接建立后,客户端要立刻发CMPP_CONNECT登录,相当于告诉网关“我是哪个企业、密钥对不对”。这个请求里最核心的字段是AuthenticatorSource,一个16字节的MD5摘要,计算规则是:
String timestamp = "MMDDHHMMSS"; // 当前时间,精确到秒 byte[] sourceAddrBytes = sourceAddr.getBytes("GBK"); // 企业代码 byte[] sharedSecretBytes = sharedSecret.getBytes("GBK"); // 共享密钥 byte[] timestampBytes = timestamp.getBytes("GBK"); byte[] authSourceBytes = new byte[sourceAddrBytes.length + 9 + sharedSecretBytes.length + timestampBytes.length]; // 拼装顺序:Source_Addr + 9个0x00 + Shared_Secret + Timestamp // 计算MD5,得到16字节数组作为AuthenticatorSource MessageDigest md5 = MessageDigest.getInstance("MD5"); byte[] result = md5.digest(authSourceBytes);中间那段9个0x00的填充是协议写死的,很多第一次实现的人容易漏。漏了之后网关返回的Status不会告诉你具体原因,只给你一个非0的失败码,要自己对着报文逐字节核对才能发现。另外Timestamp的格式是月日时分秒,不是时间戳秒数,这里也容易惯性写错。
登录成功之后,网关会回CMPP_CONNECT_RESP,Status为0表示成功。我建议这个响应里记录一下网关侧消息ID和链路信息,后续排查方便。还要注意一点:有些网关对登录失败次数有限制,连续失败几次会临时封禁IP,所以重连逻辑里要对登录失败做退避,别用死循环狂打。
2.3 长短信拆分,手机收到乱码的头号原因
CMPP单条短信的Msg_Content最多支持70个汉字(140字节,按UCS2编码),超过就得拆分。拆分的方案是这里必须讲透的:把一条长消息拆成N条短消息,每条内容前面加一个6字节的UDHI头,同时把CMPP_SUBMIT里的TP_udhi标志位置1。
6字节头的格式是:
- 第1字节:0x05,表示UDHI头剩余长度。
- 第2字节:0x00,信息元标识,表示是拼接短信。
- 第3字节:0x03,信息元数据长度。
- 第4字节:参考值,标识同一批消息,官方建议是随机数或者递增计数。
- 第5字节:总条数。
- 第6字节:当前条序号。
例如一条短信拆成两条,第二个字段填2,第三条填1。要注意总条数和当前序号的取值范围是1到255,如果超过255条得重新设计分包策略,但现实场景里不会有人一条短信发255个分片。
实际开发时,拆分前先判断编码格式。中英文混合我建议直接用UCS2(Msg_Fmt=8)按字符数拆分,每个分片70个字符;纯ASCII短信可以用Msg_Fmt=0按160字节拆。如果拆分逻辑和编码长度算错了,网关不会报错,但手机上会显示乱码或者收不全。我曾经遇到过一个线上问题:短信内容里带了emoji,UCS2编码下emoji占两个字符,但代码还按一个字符计数,导致70字符的边界判断全错。后来统一改成按byte[]长度来计算拆分点才算根治。
3. 连接管理与高并发发送的实操实现
3.1 心跳保活和断线重连,链路不能“假死”
TCP连接虽然建立着,但中间路由器交换机随时可能把空闲连接清掉。CMPP协议规定上下行链路空闲时间超过一定阈值,网关会自动断开。所以必须定时发CMPP_ACTIVE_TEST心跳,我一般间隔30秒发一次,如果连续3次没收到ACTIVE_TEST_RESP,就判定链路不可用,主动触发重连。
心跳这块要注意的是不要只发不管。一个常见的实现方式是启动一个ScheduledExecutorService,每30秒往Channel写心跳包,同时维护一个lastResponseTime,在handler里收到任何消息都刷新这个时间。判断链路健康时,不是看发送成功与否,而是看lastResponseTime距当前时间有没有超过阈值。因为可能发送端看起来正常,但接收端早就断了。断线重连我用的是指数退避策略:第一次重连等3秒,第二次6秒,第三次12秒,最大不超过60秒。同时重连前必须确保旧连接完全关闭,否则会出现多个连接同时注册到网关,触发网关侧重复登录错误。
3.2 异步发送模型和Sequence_Id流水号管理
CMPP_SUBMIT的发送不能直接写在业务线程里同步等响应,否则一个网关响应慢,业务系统全卡住。我这边采用的生产者消费者模型:业务线程把消息扔进一个阻塞队列,专门的发送线程从队列取出并写入Netty Channel,网关返回SUBMIT_RESP和后续的DELIVER状态报告时,通过回调更新消息状态。
流水号Sequence_Id是全链路排查的重要依据。我建议用一个AtomicLong生成,初始值为当前时间戳的低32位或者其他自定义值,每次发送前自增,并循环使用0到0xFFFFFFFF的无符号范围。重点来了:因为协议里Sequence_Id是4字节无符号整数,Java里如果用int自增到Integer.MAX_VALUE后会溢出成负数,ByteBuf写入时再转成无符号就会出问题。所以要么直接用long从0开始自增并取模0x100000000L,要么在写入前做seq & 0xFFFFFFFFL处理。
发送模块还有一个必须做的工作:消息持久化。我这边发送前把消息状态先落库,状态为SENDING,等收到SUBMIT_RESP成功后再改成已提交网关,等最终状态报告再改成DELIVRD或失败。这样即使程序崩溃,重启后也能根据数据库状态做对账和补偿,不至于短信发了但用户收不到还查不出来。
3.3 生产环境的线程模型和性能验证
Netty的EventLoop线程数量默认是CPU核数的两倍。但如果我们的业务handler里做了数据库操作、调用外部接口这些阻塞操作,会直接把EventLoop线程卡死,导致整个Channel处理瘫痪。所以我会把业务处理逻辑扔到独立的业务线程池里,比如结合CMPP_DELIVER上行消息处理、状态报告入库这些操作,全都丢给业务线程池异步执行。
性能方面,单连接状态下,CMPP_SUBMIT的处理能力一般在每秒几十到几百条不等,具体取决于网关限制和消息长度。如果你们有大批量群发需求,建议做一层本地队列加超时重发机制。我曾经在一台4核8G的机器上压测,Netty客户端保持单连接,持续发70字以内的短信,稳定在每秒220条左右,CPU使用率只有30%,说明瓶颈完全在网关侧而不是客户端代码。
4. 常见问题与排查技巧实录
4.1 粘包、拆包和字节序问题
这类问题表现很典型:日志里报“报文长度异常”“解析失败”,或者连着报一堆非预期的Command_Id。排查第一步,把收到的原始字节用十六进制打出来,人工确认Total_Length和实际长度是否一致。这里有个很实用的工具:Netty的LoggingHandler,加在pipeline最前面就能看到原始报文,平时调试开着,线上关了就行。
字节序问题我之前也提过,最容易发生在两种场景:一种是自己手工从ByteBuf读字段时没有调用readInt()而是用了getInt(),导致指针不前进;另一种是用了writeInt()写入后,又用其他方式改了readerIndex。这些细节很难从报错信息里看出,只能靠打印原始字节对比协议规范逐字段排查。
4.2 提交失败,先对照状态码而不是乱猜
网关返回的CMPP_SUBMIT_RESP里带一个Result字段,不同取值含义完全不同。我把自己在生产中遇到过、最有参考价值的几个列出来:
| Status值 | 含义 | 常见处理方式 |
|---|---|---|
| 0 | 成功 | 等待最终状态报告 |
| 2 | 超时 | 需要重新发送,建议配合幂等控制 |
| 3 | 消息长度错 | 检查Msg_Length和Msg_Content字节数是否一致 |
| 4 | 资费代码错 | 检查FeeCode和FeeType |
| 5 | 超过最大信息条数 | 检查长短信拆分逻辑 |
| 8 | 源地址无效 | 检查Src_Id和企业代码配置 |
| 9 | 目的地址无效 | 对手机号做格式校验 |
看到Status非0,不要先怀疑网关,优先检查自己组装的消息体字段。我遇到过最多的就是Msg_Length算错:内容是UTF-8的字节数,但协议要求的是按GBK或UCS2计算的字节数,中文字符两边差一倍,提交必然失败。建议在组装报文时统一用ByteBuf里的字节数动态计算,不要用字符串的length()。
4.3 重复投递、乱序和状态报告丢失
CMPP这种长连接TCP协议,天然有重复风险。比如客户端发送SUBMIT后,因为网络抖动,网关处理了但响应没回来,客户端超时重发,网关就可能重复下发。解决思路很简单:利用数据库里的消息状态机做幂等。一条消息在SENDING状态下被重复调用时,先查库判断是否已在途,如果已发送且等待响应中,直接忽略本次重试。
状态报告乱序的问题在长短信场景更明显。比如一条长短信拆成3片,手机端需要全部到齐才能拼接完整。有用户反馈只收到其中2片,原因往往不是网关丢包,而是状态报告回来的时候我们还没把三片消息关联成一条完整记录。我的做法是发短信时保存一个关联批次号,状态报告回来后按批次号分组聚合,统一更新整条消息状态。
最后再分享一个经验:不管客户端实现得多好,一定要做每日对账。每天凌晨把数据库中状态为已提交但没终态的短信拉出来,和网关侧发送明细做比对,该补发补发,该标记失败标记失败。短信这个业务不怕慢,就怕不知道消息去哪了。这套CMPP3.0的Java实现上线后,我们的消息到达率从接手前的94%左右提升到接近99%,最主要靠的就是把这些细节都做扎实。
本文还有配套的精品资源,点击获取