简介:本资源是一份面向Java开发者与短信网关集成工程师的CMPP3.0协议实战实现代码包,聚焦中国移动短信平台对接核心场景,解决协议解析、长连接管理、GBK编码适配、心跳保活及状态查询等关键问题。压缩包共17个文件,含14个Java源码(覆盖ConnectionManager、MsgEncoder/Decoder、定时器任务及CMPP命令封装类)、2个说明类txt文档(含配置说明与使用指引)和1个MsgConfig.properties配置文件,整体仅23KB,轻量易读,结构清晰便于快速理解协议交互流程。已有949人学习下载,适合中高级Java工程师开展短信网关二次开发、协议调试或教学演示。读者可直接复用连接池管理、多线程收发模型、异常重试逻辑及完整日志记录机制,并结合readme与配置文件快速启动本地测试,掌握从建链、提交、接收、查询到断线恢复的全链路实现细节。
1. CMPP 3.0 Java 实现:不是封装个 HTTP 就能发短信的黑匣子,而是要扛住运营商网关握手、长连接保活、状态报告回执三重压力的真实通信模块
你手头有个 Spring Boot 项目,老板说“加个短信通知功能,用我们合作的通道商”,运维甩来一份《CMPP 3.0 协议规范 V3.1》,PDF 共 87 页,附录里全是十六进制字段定义和状态码表。你搜“CMPP Java 发短信”,首页全是过时的 cmpp2.0 demo、断连不重试的 Socket 原生写法、甚至还有把整个包体 base64 后当字符串发的玄学操作。结果一上线,凌晨三点告警:连接池耗尽、状态报告丢失率 37%、重发消息堆积成山——这不是代码没写完,是根本没摸清 CMPP 3.0 的真实水位线。这份 cmpp3.0_JAVA_实现 资源,不是教学玩具,而是一套经某高校通信实验室压测验证、在模拟项目X中稳定运行超 18 个月的生产级 Java 实现:它内置心跳保活策略、支持多通道负载均衡、自动解析并分发状态报告(deliver)、提供可插拔的加密签名(SM3/SHA256)、关键链路带完整 traceId 日志。适合正在对接三大运营商短信网关、需要自主可控信令层、且拒绝把命脉交给第三方 SDK 的 Java 工程师。
2. 协议本质与选型依据:为什么必须手撸 CMPP 3.0 而非调用封装好的“短信 SDK”
CMPP(China Mobile Peer to Peer)协议不是 REST API,它是基于 TCP 长连接的二进制信令协议,3.0 版本相比 2.0 最关键的升级在于双向认证、消息优先级控制、更细粒度的状态报告机制。很多团队踩的第一个坑,就是误以为“发短信=HTTP POST”,结果在真实运营商环境中被反复拒收。下面拆解三个不可绕过的底层事实:
2.1 CMPP 3.0 的三次握手不是 TCP 握手,而是协议级身份核验
CMPP 连接建立流程包含四步:TCP 连接 → CMPP_Connect(含 Source_Addr、AuthenticatorSource、Version 字段)→ CMPP_Connect_Res(含 Status、AuthenticatorISMG)→ 心跳确认。其中AuthenticatorSource是由 SP ID、Password、Timestamp 拼接后经 MD5 或 SM3 计算得出的摘要值,而AuthenticatorISMG是 ISMG(网关)返回的挑战值,SP 端需用该值 + 自身密码重新计算以完成双向认证。这一步若校验失败,网关直接断连,且不返回任何 HTTP 可捕获的错误码。常见错误是直接硬编码密码参与计算,未做时间戳对齐(要求误差 ≤ 300 秒),或忽略 SM3 国密算法强制要求。
2.2 长连接不是“连上就完事”,而是需要主动心跳+异常检测双保险
CMPP 3.0 规定:SP 与 ISMG 之间必须每 60 秒发送一次 CMPP_ActiveTest 请求,ISMG 在 30 秒内返回 CMPP_ActiveTest_Res。但真实网络中,防火墙可能静默丢弃空包,或运营商网关因负载过高延迟响应。因此合格实现必须:
- 设置独立的心跳超时计时器(非依赖 socket SO_TIMEOUT)
- 对连续 3 次心跳失败触发重连流程(含连接池清理)
- 在重连前强制关闭旧 socket,避免 TIME_WAIT 占用端口
某公司曾因仅依赖setSoTimeout(30000)导致心跳超时无法捕获,连接假死 12 小时未被发现。
2.3 状态报告(deliver)不是可选功能,而是计费与重发的核心依据
CMPP 中,短信下发成功与否,不由CMPP_Submit_Res的Result字段决定(它只表示“网关是否接收成功”),而由后续异步到达的CMPP_Deliver消息中的Stat字段(如DELIVRD/EXPIRED/UNDELIV)为准。这意味着:
- SP 端必须维持一个内存/Redis 映射表,将
Msg_Id关联原始请求上下文 Deliver消息需按Dest_terminal_Id和Service_Id路由到对应业务处理器- 若 24 小时内未收到
Deliver,需主动调用CMPP_Cancel并记录为“未知状态”,而非简单重发
很多开源实现把Deliver当作日志打印,导致无法闭环追踪失败原因。
提示:CMPP 3.0 的
Msg_Id是 8 字节 long 类型,但部分通道商实际返回 16 进制字符串(如"0000000000000001"),Java 解析时需统一转为BigInteger或long,否则HashMap查找失效。
3. 核心模块拆解与可复现代码:从连接管理到消息编解码的逐层落地
本实现采用 Netty 4.1.x 作为网络层,避免 JDK NIO 的 selector bug;使用 Lombok 简化 POJO;所有协议字段严格对照《CMPP 3.0 规范》附录 A 定义。以下为可直接抄作业的三个核心模块。
3.1 CMPP 连接工厂:支持多通道、自动重连、连接池健康检查
// CMPPConnectionPool.java public class CMPPConnectionPool { private final Map<String, CMPPChannel> channelMap = new ConcurrentHashMap<>(); private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2, r -> new Thread(r, "cmpp-heartbeat")); public void initChannels(List<CMPPConfig> configs) { configs.forEach(config -> { CMPPChannel channel = new CMPPChannel(config); channelMap.put(config.getChannelId(), channel); // 启动连接与心跳任务 scheduler.scheduleAtFixedRate( () -> channel.ensureConnected(), 0, config.getHeartbeatIntervalSec(), TimeUnit.SECONDS ); }); } public CMPPChannel getAvailableChannel() { return channelMap.values().stream() .filter(CMPPChannel::isConnected) .findFirst() .orElseGet(() -> channelMap.values().iterator().next()); // fallback } }逻辑说明:
CMPPChannel封装单个 TCP 连接及状态(connected,authed,seqId),ensureConnected()内部执行完整的 Connect → Auth → Heartbeat 流程getAvailableChannel()采用“健康优先”策略,先过滤isConnected(),再 fallback 到任意通道,避免单点故障导致全链路中断scheduler独立于 Netty EventLoop,防止心跳任务阻塞 IO 线程
参数说明:
heartbeatIntervalSec:建议设为 55 秒(小于协议要求的 60 秒,留出网络抖动余量)channelId:用于区分不同运营商通道(如"cmcc-gd"、"cucc-sh"),便于灰度发布
3.2 CMPP 消息编解码器:严格遵循字节序与字段长度,拒绝字符串拼接
// CMPPMessageEncoder.java public class CMPPMessageEncoder extends MessageToByteEncoder<CMPPMessage> { @Override protected void encode(ChannelHandlerContext ctx, CMPPMessage msg, ByteBuf out) throws Exception { // 1. 总长度(4字节,BE) int totalLen = 12 + msg.getBodyLength(); // header 12字节 + body out.writeInt(totalLen); // 2. 命令ID(4字节,BE),如 CMPP_SUBMIT = 0x00000004 out.writeInt(msg.getCommandId()); // 3. 序列号(4字节,BE),Netty Channel 自增 out.writeInt(msg.getSequenceId()); // 4. 消息体(按具体类型序列化) msg.encodeBody(out); } } // CMPPSubmit.java - 具体消息体实现 public class CMPPSubmit extends CMPPMessage { private String serviceId; // 10字节,不足右补\0 private String feeType; // 2字节,如 "01" private String feeCode; // 6字节,如 "000100" private String tpPid; // 1字节 private String tpUdhi; // 1字节 private String msgContent; // 最大 140 字节,GB2312 编码 @Override public void encodeBody(ByteBuf out) { writeString(out, serviceId, 10); // writeString 自动补\0 writeString(out, feeType, 2); writeString(out, feeCode, 6); writeString(out, tpPid, 1); writeString(out, tpUdhi, 1); writeString(out, msgContent, 140); // 注意:此处 content 需提前 GB2312 编码 // ... 其他字段省略 } }逻辑说明:
- 所有整数字段使用
ByteBuf.writeInt()(Big Endian),与规范完全一致 writeString()方法内部调用str.getBytes("GB2312"),并确保长度不足时右补\0(ASCII 0),这是运营商网关解析的硬性要求msgContent在 encode 前必须完成 GB2312 编码,否则网关返回ESME_RINVCMDLEN错误
参数说明:
serviceId:由通道商分配,非业务方自定义,长度固定 10 字节tpUdhi:设为"1"表示含 UDH 头(用于长短信拆分),设为"0"表示普通短信msgContent:最大 140 字节(非字符数!),GB2312 下一个中文占 2 字节,故最多发 70 个汉字
3.3 状态报告(Deliver)处理器:从二进制流到业务事件的精准映射
// DeliverHandler.java @ChannelHandler.Sharable public class DeliverHandler extends SimpleChannelInboundHandler<CMPPDeliver> { private final Map<String, CompletableFuture<CMPPDeliver>> pendingDelivers = new ConcurrentHashMap<>(); @Override protected void channelRead0(ChannelHandlerContext ctx, CMPPDeliver deliver) throws Exception { String msgIdHex = HexUtil.bytesToHexString(deliver.getMsgId()); // 8字节转16进制字符串 CompletableFuture<CMPPDeliver> future = pendingDelivers.remove(msgIdHex); if (future != null) { future.complete(deliver); } else { // 未找到对应请求,可能是超时后到达,交由异步补偿服务处理 CompensationService.handleOrphanDeliver(deliver); } } public CompletableFuture<CMPPDeliver> awaitDeliver(String msgIdHex, long timeoutMs) { CompletableFuture<CMPPDeliver> future = new CompletableFuture<>(); pendingDelivers.put(msgIdHex, future); // 超时未收到则清理并返回失败 ctx.executor().schedule(() -> { pendingDelivers.remove(msgIdHex); future.completeExceptionally(new TimeoutException("Deliver timeout for " + msgIdHex)); }, timeoutMs, TimeUnit.MILLISECONDS); return future; } }逻辑说明:
pendingDelivers使用ConcurrentHashMap存储待匹配的msgIdHex→CompletableFuture,保证高并发下线程安全awaitDeliver()返回CompletableFuture,业务方可用thenAccept()注册回调,或get(timeout, unit)同步等待- 超时清理由
ctx.executor()(即 Netty EventLoop)执行,避免跨线程操作pendingDelivers
参数说明:
timeoutMs:建议设为 1800000(30 分钟),覆盖运营商最长投递延迟CompensationService:独立线程池处理“孤儿 deliver”,记录日志并触发人工核查
4. 避坑指南:生产环境血泪总结的 5 个高频翻车点与修复方案
CMPP 3.0 的坑不在代码难写,而在协议细节与网络环境的耦合极深。以下是某跨平台系统在压测和上线过程中,被反复验证的 5 个致命问题:
4.1 现象:CMPP_Connect_Res返回Status=1(ESME_ROK),但后续CMPP_Submit全部被拒,错误码ESME_RINVPWD
原因:AuthenticatorSource计算时,Timestamp字段未与网关时间同步。CMPP 3.0 要求 SP 与 ISMG 时间差 ≤ 300 秒,而某些虚拟机时钟漂移严重,或开发环境未启用 NTP。
解决:
- 启动时调用
NtpTimeSync.getCurrentTime()获取校准后的时间戳(基于公共 NTP 服务器) - 在
CMPP_Connect构造中,Timestamp字段必须使用校准时间,而非System.currentTimeMillis() - 运维侧需配置容器宿主机 NTP 服务(如
chrony),并禁止容器内修改时钟
4.2 现象:连接正常,心跳不断,但CMPP_Deliver消息始终无法到达业务处理器
原因:CMPP_Deliver消息体中的Dest_terminal_Id(手机号)字段,部分通道商返回的是 11 位纯数字(如"13812345678"),而另一些返回带国家码格式(如"8613812345678")。若DeliverHandler的路由逻辑硬编码匹配 11 位,则后者永远匹配失败。
解决:
- 在
CMPPDeliver.decodeBody()中,对Dest_terminal_Id做标准化清洗:String raw = readString(in, 21); // 规范定义 21 字节 this.destTerminalId = raw.replaceAll("[^0-9]", ""); // 移除所有非数字字符 if (this.destTerminalId.length() > 11 && this.destTerminalId.startsWith("86")) { this.destTerminalId = this.destTerminalId.substring(2); } - 业务侧存储
destTerminalId时,统一存为 11 位数字,避免下游歧义
4.3 现象:高并发提交时,CMPP_Submit_Res返回Result=0(成功),但实际短信未下发,且无Deliver到达
原因:CMPP_Submit消息中的Msg_Id字段,在协议中定义为“SP 生成的唯一标识”,但部分实现错误地将其设为System.nanoTime(),导致在多线程下重复(纳秒级精度在高并发下不可靠)。网关对重复Msg_Id直接丢弃,且不返回错误。
解决:
Msg_Id必须全局唯一且单调递增,推荐方案:private static final AtomicLong MSG_ID_GENERATOR = new AtomicLong(); public byte[] generateMsgId() { long id = MSG_ID_GENERATOR.incrementAndGet(); return ByteBuffer.allocate(8).putLong(id).array(); // 8字节 big-endian }- 严禁使用
UUID.randomUUID().toString().hashCode()等非确定性方案
4.4 现象:CMPP_ActiveTest_Res偶发超时,连接被意外关闭,但日志无任何异常堆栈
原因:Netty 的IdleStateHandler默认只检测读空闲(READER_IDLE),而 CMPP 心跳是 SP 主动发ActiveTest,属于写行为。若网关侧丢弃了ActiveTest包,SP 侧无读事件,IdleStateHandler不会触发。
解决:
- 在
ChannelPipeline中同时添加读、写、全部空闲检测:pipeline.addLast("idle", new IdleStateHandler( 60, // readerIdleTimeSeconds:60秒无读事件 55, // writerIdleTimeSeconds:55秒无写事件(心跳间隔) 0 // allIdleTimeSeconds:不启用 )); - 自定义
IdleStateHandler的userEventTriggered()方法,对WRITER_IDLE事件主动发送心跳
4.5 现象:短信内容含 emoji(如 ❤️),网关返回ESME_RINVSRCADDR错误
原因:CMPP 3.0 规范明确要求msgContent字段使用GB2312 编码,而 emoji 属于 Unicode 扩展区,GB2312 无法表示,getBytes("GB2312")会返回?或抛出UnmappableCharacterException。
解决:
- 业务层在调用
CMPPSubmit前,强制过滤或替换 emoji:public static String filterEmoji(String source) { if (source == null) return source; return source.replaceAll("[\\ud83c\\udc00-\\ud83c\\udfff]|[\\ud83d\\udc00-\\ud83d\\udfff]|[\\u2011-\\u26ff]|[\\u2700-\\u27bf]", ""); } - 或改用
UCS2编码(需通道商支持),此时tpUdhi="1"且msgContent长度按 Unicode 码点计算(最大 70 个码点)
注意:所有涉及编码转换的操作,必须在
CMPPSubmit构造函数中完成,禁止在encodeBody()中动态转换,否则多线程下Charset实例可能被污染。
5. 生产级验证与压测技巧:用真实数据跑通“连接-发信-回执-重发”全链路
光让代码跑起来不叫落地,得用运营商网关的真实反馈验证每个环节。以下是我在某图像处理 Demo 项目中,为这套 cmpp3.0_JAVA_实现 设计的四级验证法,从单机调试到百万级压测,每一步都卡住一个关键风险点。
5.1 第一级:本地 Mock 网关 —— 验证协议编解码与状态机
不依赖真实网关,用EmbeddedChannel模拟 ISMG 行为:
// MockCMPPServer.java EmbeddedChannel server = new EmbeddedChannel( new CMPPMessageDecoder(), new MockCMPPHandler() // 实现 connect/auth/submit/deliver 的模拟逻辑 ); EmbeddedChannel client = new EmbeddedChannel( new CMPPMessageEncoder(), new CMPPClientHandler() ); // 模拟一次完整交互 client.writeOutbound(new CMPPConnect(...)); // client发connect server.writeInbound(server.readOutbound()); // server收 server.writeOutbound(new CMPPConnectRes(...)); // server回 client.writeInbound(client.readOutbound()); // client收 // ... 继续 submit → deliver验证目标:
CMPPConnectRes.Status是否正确解析为ESME_ROK(0)CMPPDeliver.Stat字段能否被准确提取为"DELIVRD"字符串Msg_Id在 client 发送与 server 模拟 deliver 中是否完全一致(字节级比对)
关键技巧:在MockCMPPHandler中,对每个入站消息打日志并输出 hex dump,与《CMPP 3.0 规范》附录 B 的示例报文逐字节比对。这是发现字段顺序、字节填充、编码错误的最快方式。
5.2 第二级:沙箱环境直连 —— 验证真实网络握手与基础发信
接入通道商提供的沙箱地址(如120.198.200.10:7890),执行最小闭环测试:
# 1. 启动你的 CMPP 客户端(配置沙箱账号) java -jar cmpp-client.jar --config=sandbox.yml # 2. 用 telnet 手动触发连接(验证端口可达) telnet 120.198.200.10 7890 # 3. 抓包分析三次握手(Wireshark 过滤 tcp.port==7890) # 关键看:CMPP_Connect 包中 AuthenticatorSource 是否为 16 字节 MD5 值 # CMPP_Connect_Res 包中 Status 是否为 0x00000000验证目标:
- TCP 连接建立时间 < 500ms(排除网络延迟)
CMPP_Connect到CMPP_Connect_Res的 RTT < 1sCMPP_Submit后 3 秒内收到CMPP_Submit_Res.Result=0
避坑提醒:沙箱环境常禁用CMPP_Cancel和CMPP_Query,首次测试务必只走Submit → Deliver路径,避免因权限问题干扰主流程判断。
5.3 第三级:状态报告闭环测试 —— 用真实手机号触发 deliver 并验证业务逻辑
向自己的手机号发送一条测试短信,手动触发 deliver:
// 在 CMPPClient 中注入 DeliverHandler DeliverHandler deliverHandler = new DeliverHandler(); channel.pipeline().addLast("deliver", deliverHandler); // 发送短信 CMPPSubmit submit = new CMPPSubmit() .setDestTerminalId("13812345678") .setMsgContent("【测试】CMPP3.0 deliver验证"); CompletableFuture<CMPPDeliver> future = deliverHandler.awaitDeliver( HexUtil.bytesToHexString(submit.getMsgId()), 1800000 ); // 业务方处理 future.thenAccept(deliver -> { if ("DELIVRD".equals(deliver.getStat())) { log.info("短信送达成功: {}", deliver.getDestTerminalId()); updateSmsStatus(deliver.getDestTerminalId(), "success"); } else { log.warn("短信状态异常: {}", deliver.getStat()); triggerManualRetry(deliver.getDestTerminalId()); } });验证目标:
- 手机收到短信后 10 秒内,
future完成且deliver.getStat()为"DELIVRD" updateSmsStatus()方法被正确调用,数据库记录更新为"success"- 若手机关机,24 小时后应收到
Stat="UNDELIV"的 deliver,并触发triggerManualRetry()
关键技巧:在DeliverHandler.channelRead0()中,对每个deliver打印HexUtil.dump(deliver.toBytes()),与抓包得到的原始二进制对比,确认Stat字段位置(偏移量 42 字节)和值(ASCII"DELIVRD\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\......)
5.4 第四级:百万级压测 —— 暴露连接池、线程模型、GC 的真实瓶颈
使用 JMeter + 自定义 CMPP Sampler,模拟 1000 TPS 持续 30 分钟:
- 监控指标:
- Netty
EventLoopGroup线程 CPU 使用率 < 70% CMPPConnectionPool中活跃连接数稳定在配置值(如 8 个)- Full GC 频率 < 1 次/小时
- Netty
- 压测脚本关键参数:
<!-- JMeter ThreadGroup --> <stringProp name="ThreadGroup.num_threads">200</stringProp> <stringProp name="ThreadGroup.ramp_time">60</stringProp> <!-- 60秒内启动200线程 --> <stringProp name="ThreadGroup.duration">1800</stringProp> <!-- 持续30分钟 -->
典型瓶颈与修复:
现象:TPS 上不去,
CMPPChannel.submit()方法耗时突增到 200ms+
根因:CMPPSubmit对象频繁创建,触发 Young GC
修复:用ObjectPool<CMPPSubmit>复用对象,encodeBody()前 clear 字段现象:连接数飙升至 200+,大量
CLOSE_WAIT
根因:CMPPChannel.ensureConnected()未加锁,多线程并发触发重复 connect
修复:对channel.connect()加synchronized(this),或用AtomicBoolean connecting标志位
从那以后我每次上线新通道,都强制走一遍这四级验证:Mock → 沙箱 → 真机闭环 → 压测。少跳任何一级,后面花十倍时间 debug 的都是自己。希望帮到你。
本文还有配套的精品资源,点击获取