简介:本资源是面向Java开发者与短信网关集成工程师的CMPP3.0协议实战实现包,聚焦中国移动短信平台对接中的核心难点——长连接管理、GBK编码处理、心跳保活、多线程收发及状态查询等关键环节。压缩包共17个文件,含14个Java源码(覆盖ConnectionManager、CmppClient、MessageEncoder/Decoder、定时器任务等核心模块)、2个说明类txt文档(含配置说明与流程注解)以及1个MsgConfig.properties配置文件,整体仅23KB,轻量精炼,便于快速导入学习与二次开发。已有949人下载学习,适合中高级Java工程师深入理解电信级协议集成逻辑。读者可直接复用完整TCP通信骨架、标准化消息编解码逻辑、健壮的心跳与重连策略,以及清晰的日志跟踪与异常处理范式,显著降低CMPP3.0对接落地门槛。
1. CMPP 3.0 Java 实现:不是“发个短信”那么简单,而是运营商级网关通信的完整闭环
你手头有个 Java 后端服务,要对接三大运营商(移动、联通、电信)的短信网关——不是调用某云厂商封装好的 HTTP 接口,而是直连 SMGP/CMPP 协议层,走 TCP 长连接、心跳保活、消息序列号校验、状态报告回执、重发机制、路由分发……这时候,一个“能跑通”的 CMPP 3.0 Java 实现,就不是 demo 级玩具,而是生产环境里扛压的通信底座。这个项目正是这样一套可落地、可调试、可嵌入 Spring Boot 工程、带完整协议解析与会话管理的 CMPP 3.0 客户端实现。它不依赖任何商业 SDK,纯 Java NIO + Netty 构建,覆盖 CMPP_Connect、CMPP_Submit、CMPP_Deliver、CMPP_ActiveTest 全流程,支持多 SP(服务提供商)实例隔离、失败消息本地持久化重试、状态报告异步回调。适合需要自主可控短信通道、对投递成功率和时延有硬性要求的金融、政务、IoT 平台类项目。如果你正被“状态报告收不到”“连接莫名断开”“submit 返回 success 但实际没发出去”这类玄学问题折磨,这个包就是你该拆开的第一份黑匣子。
2. 技术栈与架构设计:为什么选 Netty 而不是传统 Socket?协议分层怎么切?
2.1 核心技术选型:Netty 5.0 + JDK 8+ + SLF4J 日志门面
项目采用Netty 5.0(非 4.x)作为底层通信框架,而非 JDK 原生 Socket 或 Apache Mina。这不是跟风——CMPP 是典型的“高并发低延迟 + 长连接保活 + 多消息类型混杂”的协议场景。Netty 5 的 EventLoopGroup 分离读写线程、零拷贝 ByteBuf、Pipeline 可插拔编解码器,天然适配 CMPP 的帧结构(固定头 + 可变体)。比如 CMPP_Submit 请求包总长 = 12(固定头)+ 6(源地址)+ 6(目的地址)+ 可变内容长度,Netty 的 LengthFieldBasedFrameDecoder 可精准切帧,避免粘包;而传统阻塞 IO 在 100+ 并发连接下,线程数爆炸,心跳超时判断失准,这是血泪经验换来的选型。
提示:项目未使用 Spring Integration 或 Camel 这类企业集成框架,刻意保持轻量。所有协议逻辑集中在
cmpp-core模块,无外部中间件依赖,部署即用。
2.2 协议分层模型:从字节流到业务对象的四层映射
CMPP 3.0 协议本身是二进制流,项目将其抽象为清晰的四层结构:
| 层级 | 模块位置 | 职责 | 关键类举例 |
|---|---|---|---|
| 物理层 | netty包 | TCP 连接管理、心跳发送/响应、连接断开重连 | CmppChannelInitializer,CmppHeartbeatHandler |
| 协议层 | codec包 | CMPP 消息编解码、消息头校验(Sequence_Id、Command_Id)、CRC32 校验 | CmppMessageEncoder,CmppMessageDecoder |
| 会话层 | session包 | 单连接会话状态维护(登录态、当前 Sequence_Id、待确认消息队列) | CmppSession,CmppSessionManager |
| 业务层 | service包 | 封装 Submit/Deliver/Cancel 等操作,提供异步回调接口 | CmppSubmitService,CmppDeliverCallback |
这种分层让调试变得可追踪:当一条 Submit 消息发不出去,你可以逐层排查——是CmppMessageEncoder生成的字节数组长度不对?还是CmppSession中的nextSequenceId被并发修改错乱?抑或CmppChannelHandler里channel.writeAndFlush()返回Future为false?每层职责单一,改起来不牵一发而动全身。
2.3 目录结构与模块职责:一眼看清“哪个文件管什么”
项目采用 Maven 多模块结构,目录精简但意图明确:
cmpp3.0_java/ ├── cmpp-core/ # 核心协议实现:编解码、会话、Netty 集成 │ ├── codec/ # CmppMessageEncoder/Decoder,含 CMPP_Head 解析 │ ├── session/ # CmppSession(含 loginState、seqIdGenerator)、SessionManager(单例注册) │ └── netty/ # ChannelInitializer、CmppClientBootstrap、心跳 Handler ├── cmpp-spring-boot-starter/ # Spring Boot 自动装配:@ConfigurationProperties 绑定配置 │ └── autoconfigure/ # CmppAutoConfiguration,注入 CmppSubmitService 等 Bean ├── cmpp-demo/ # 可直接运行的 Spring Boot 示例:含 application.yml 配置、Controller 调用入口 └── pom.xml # 父 POM,统一管理 Netty、SLF4J、JUnit 版本注意:cmpp-spring-boot-starter不是必须项。如果你的项目是传统 Servlet 或 Quarkus,直接依赖cmpp-core即可,手动 newCmppClientBootstrap启动客户端——这正是它“不绑定框架”的底气。
3. 核心代码逐行解析:从登录到发短信,关键链路怎么走?
3.1 登录流程:CMPP_Connect 请求如何构造与响应处理
CMPP 3.0 登录不是简单发个包,它要求 SP(你的系统)在 Connect 包中携带准确的 Source_Addr(SP 代码)、AuthenticatorSource(密码 MD5 加盐摘要)、Version(协议版本),且服务端返回的 Response 必须校验 AuthenticatorISMG 字段。项目中CmppConnectRequest类封装了这一逻辑:
// cmpp-core/src/main/java/cn/cmpp/message/CmppConnectRequest.java public class CmppConnectRequest extends CmppMessage { private String sourceAddr; // SP 代码,如 "10690123456" private String authenticatorSource; // MD5(Password + SharedSecret + Timestamp) private int version = 0x20; // CMPP 3.0 固定为 0x20 private long timestamp; // 当前时间戳(秒级) public CmppConnectRequest(String sourceAddr, String password, String sharedSecret) { super(CMPP_CONNECT); this.sourceAddr = sourceAddr; this.timestamp = System.currentTimeMillis() / 1000; // 关键:AuthenticatorSource = MD5(password + sharedSecret + timestamp) this.authenticatorSource = DigestUtils.md5Hex( password + sharedSecret + timestamp ); } @Override public byte[] toBytes() { // 固定头:Total_Length(4) + Command_Id(4) + Sequence_Id(4) ByteBuffer buffer = ByteBuffer.allocate(12 + 6 + 6 + 1 + 4 + 8); buffer.putInt(getTotalLength()); // 总长 = 12+6+6+1+4+8 = 37 buffer.putInt(getCommandId()); // 0x00000001 buffer.putInt(getSequenceId()); // 可变体部分 buffer.put(StringUtils.rightPad(sourceAddr, 6, "\0").getBytes(StandardCharsets.ISO_8859_1)); buffer.put(authenticatorSource.getBytes(StandardCharsets.ISO_8859_1)); // 16字节MD5 buffer.put((byte) version); buffer.putInt((int) timestamp); return buffer.array(); } }逻辑说明:
toBytes()严格按 CMPP 3.0 规范生成二进制流。sourceAddr必须右补\0至 6 字节(ISO-8859-1 编码),authenticatorSource是 16 字节原始 MD5 值(非 hex 字符串!),version写死0x20。很多翻车点在于:把 MD5 当字符串传(32 字节)、sourceAddr长度不足不补零、时间戳用毫秒而非秒——这些都会导致网关直接拒连。
3.2 发送短信:CMPP_Submit 如何保证消息不丢、序列号不乱
CMPP_Submit是最易出问题的环节。项目通过CmppSession维护每个连接的AtomicInteger nextSequenceId,确保同一连接内 Sequence_Id 严格递增且不重复;同时将待发送消息放入ConcurrentLinkedQueue<CmppSubmitRequest>,由独立线程轮询发送并等待响应:
// cmpp-core/src/main/java/cn/cmpp/session/CmppSession.java public class CmppSession { private final AtomicInteger nextSequenceId = new AtomicInteger(1); private final ConcurrentLinkedQueue<CmppSubmitRequest> pendingSubmits = new ConcurrentLinkedQueue<>(); // 发送 Submit 请求(非阻塞) public void submit(CmppSubmitRequest request) { request.setSequenceId(nextSequenceId.getAndIncrement()); pendingSubmits.offer(request); // 触发 Netty Channel 写入 channel.writeAndFlush(request).addListener(future -> { if (!future.isSuccess()) { log.error("Submit write failed for seqId: {}", request.getSequenceId(), future.cause()); // 写入失败,放回队列重试(或落库) pendingSubmits.offer(request); } }); } // 收到 CMPP_Submit_Resp 后的回调 public void onSubmitResponse(CmppSubmitResponse response) { // 根据 response.getSequenceId() 匹配原始请求 CmppSubmitRequest original = findPendingBySeq(response.getSequenceId()); if (original != null) { original.setMsgId(response.getMsgId()); // 保存网关分配的 Msg_Id original.getCallback().onSuccess(original, response.getMsgId()); } } }参数说明:
CmppSubmitRequest中msgContent必须是 GBK 编码字节数组(非 String!),destTerminalId是手机号数组(每个 21 字节,含 \0 结尾),serviceId是业务代码(如 "10690000")。onSubmitResponse中的response.getMsgId()是网关返回的 8 字节唯一标识,后续查状态、撤回都靠它——项目强制要求业务方保存此 ID,否则状态报告无法关联。
3.3 状态报告接收:CMPP_Deliver 如何解包并路由到业务回调
运营商网关会主动推送CMPP_Deliver包(状态报告),其Msg_Content是 TLV 结构:Msg_Id(8)+Stat(7)+Submit_Time(10)+Done_Time(10)+Dest_Terminal_Id(21)+SM_Service_Type(2)。项目在CmppDeliverDecoder中精准提取:
// cmpp-core/src/main/java/cn/cmpp/codec/CmppDeliverDecoder.java public class CmppDeliverDecoder extends MessageToMessageDecoder<ByteBuf> { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception { // 跳过固定头(12字节) in.skipBytes(12); // 读取 Msg_Id(8字节) byte[] msgIdBytes = new byte[8]; in.readBytes(msgIdBytes); String msgId = Hex.encodeHexString(msgIdBytes); // 转为16进制字符串 // 读取 Stat(7字节,如 "DELIVRD") byte[] statBytes = new byte[7]; in.readBytes(statBytes); String stat = new String(statBytes, StandardCharsets.ISO_8859_1).trim(); // 读取 Submit_Time(10字节,格式 "YYMMDDHHMM") byte[] submitTimeBytes = new byte[10]; in.readBytes(submitTimeBytes); String submitTime = new String(submitTimeBytes, StandardCharsets.ISO_8859_1); // 构建 Deliver 对象并回调 CmppDeliver deliver = new CmppDeliver(msgId, stat, submitTime, ...); callback.onDeliver(deliver); } }逻辑说明:
CmppDeliverDecoder不做业务判断,只负责“拆包”。stat字段值必须严格比对(如"DELIVRD"表示成功送达,"UNDELIV"表示发送失败),业务方在onDeliver()回调中根据stat更新数据库状态。项目预留了CmppDeliverCallback接口,你只需实现它,就能收到所有状态报告——无需自己监听端口或解析字节流。
4. 运行与调试:从零启动 demo,快速验证是否连得上、发得出
4.1 三步启动 demo 工程:配置、编译、运行
项目附带cmpp-demo模块,是一个标准 Spring Boot Web 应用,启动即暴露/api/sms/send接口。按以下步骤操作(以 IntelliJ IDEA 为例):
配置
application.yml(路径:cmpp-demo/src/main/resources/application.yml)
填写你从运营商获取的真实参数(务必脱敏!):cmpp: host: 221.179.180.123 # 运营商网关 IP(示例,非真实) port: 7890 # 网关端口(常见 7890/8888) source-addr: "10690123456" # SP 代码(6位数字) shared-secret: "abc123" # 运营商分配的共享密钥 password: "sp123456" # SP 密码 version: 0x20 # CMPP 3.0 固定值 reconnect-interval: 30000 # 断线重连间隔(毫秒)编译并运行
在cmpp-demo模块右键 →Run 'Application',观察控制台日志:[INFO] CmppClientBootstrap: Connecting to 221.179.180.123:7890... [INFO] CmppConnectHandler: Login success! SessionId=1234567890 [INFO] CmppHeartbeatHandler: Heartbeat sent, waiting for response...发送测试请求
使用 curl 或 Postman 调用:curl -X POST http://localhost:8080/api/sms/send \ -H "Content-Type: application/json" \ -d '{"mobile":"13800138000","content":"【测试】您的验证码是123456"}'成功返回:
{"code":200,"msg":"success","data":{"msgId":"0000000000000001"}}
注意:首次运行若卡在
Connecting...,大概率是网络不通或防火墙拦截。用telnet 221.179.180.123 7890先验证基础连通性。
4.2 关键日志解读:看懂 Netty 和协议层的“悄悄话”
项目日志按LoggerFactory.getLogger("cmpp")分组,重点关注三类日志:
| 日志前缀 | 出现场景 | 判定依据 |
|---|---|---|
CmppConnectHandler | 登录阶段 | Login success!表示 Connect 成功;Login failed: 2表示密码错误(CMPP 错误码 2) |
CmppHeartbeatHandler | 心跳保活 | Heartbeat timeout! Reconnecting...表示网关未响应心跳,触发重连 |
CmppSubmitService | 发送阶段 | Submit sent, seqId=12345表示请求已发出;onSubmitResponse: seqId=12345, msgId=00000001表示收到网关响应 |
提示:开启 DEBUG 日志(
logging.level.cmpp=DEBUG)可看到每条消息的原始字节数组(hex dump),用于比对协议规范。例如DEBUG cmpp.codec.CmppMessageEncoder - Encode CMPP_SUBMIT, bytes: 0000004a 00000004 00003039 ...—— 前 4 字节0000004a即 Total_Length(74 字节),符合预期。
4.3 网关模拟器调试法:没有真实网关也能跑通全流程
开发阶段常无真实网关权限。项目提供CmppMockServer(位于cmpp-demo/src/test/java),一个轻量级 Netty 服务端,可模拟 CMPP 网关行为:
// 启动模拟服务器(测试用) public class CmppMockServer { public static void main(String[] args) throws Exception { EventLoopGroup bossGroup = new NioEventLoopGroup(); EventLoopGroup workerGroup = new NioEventLoopGroup(); ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast( new CmppMessageDecoder(), // 解码请求 new MockCmppServerHandler() // 业务逻辑:自动返回 Success 响应 ); } }); ChannelFuture f = bootstrap.bind(7890).sync(); System.out.println("Mock server started on port 7890"); f.channel().closeFuture().sync(); } }运行此程序后,将application.yml中host改为127.0.0.1,即可用本地模拟器验证整个流程——登录、发短信、收状态报告,全部闭环。这是调试协议逻辑的后悔药。
5. 常见问题排查:那些让你凌晨三点还在抓头发的坑
5.1 现象:CmppConnectHandler日志显示Login failed: 1,连接立即断开
原因:CMPP 错误码 1 表示“消息长度错误”。常见于CmppConnectRequest.toBytes()计算Total_Length时出错。例如:sourceAddr实际长度为 5 字节却未补\0,导致总长少 1 字节;或authenticatorSource传入的是 32 字节 hex 字符串(如"e10adc3949ba59abbe56e057f20f883e"),而非 16 字节原始 MD5 值。
解决:打开 DEBUG 日志,检查Encode CMPP_CONNECT, bytes:后的 hex dump,对照规范计算总长。用xxd -p工具验证 MD5 是否为 16 字节:echo -n "pwd+secret+1234567890" | md5sum | xxd -r -p | wc -c应输出16。
5.2 现象:Submit sent, seqId=123日志出现,但onSubmitResponse从未被调用,pendingSubmits队列持续增长
原因:网关返回了CMPP_Submit_Resp,但CmppMessageDecoder未能正确识别Command_Id(应为0x00000005),导致消息被丢弃。根源常是CmppMessageDecoder中in.readInt()读取Command_Id时,因粘包或半包导致字节错位。
解决:在CmppMessageDecoder.decode()开头加日志log.debug("Raw buffer readableBytes: {}", in.readableBytes()),确认每次解码前缓冲区至少有 12 字节(固定头长度)。若常小于 12,需检查LengthFieldBasedFrameDecoder的lengthFieldOffset(应为 0)和lengthAdjustment(应为 0)是否配置正确。
5.3 现象:状态报告CMPP_Deliver收到了,但Stat字段是乱码(如ELIVRD)
原因:CMPP_Deliver的Stat字段定义为 7 字节 ASCII 字符串,但解码时用了 UTF-8(new String(bytes, UTF_8)),而网关可能用 ISO-8859-1 发送。UTF-8 解析 7 字节 ASCII 会因字节范围问题产生乱码。
解决:强制使用StandardCharsets.ISO_8859_1解码:new String(statBytes, StandardCharsets.ISO_8859_1).trim()。项目代码已修正,但若你自行修改解码逻辑,务必注意此点。
5.4 现象:应用运行数小时后,CmppHeartbeatHandler频繁打印Heartbeat timeout!,连接反复断开重连
原因:Netty 的IdleStateHandler设置不合理。项目默认readerIdleTime为 30 秒,但某些运营商网关心跳超时阈值为 60 秒,导致客户端先判定超时断连。
解决:在CmppChannelInitializer中调整IdleStateHandler参数:
// 将 readerIdleTime 从 30 秒改为 65 秒(略大于网关要求) pipeline.addLast(new IdleStateHandler(65, 0, 0, TimeUnit.SECONDS));同时在CmppHeartbeatHandler.userEventTriggered()中,增加对IdleStateEvent.FIRST_READER_IDLE_STATE_EVENT的特殊处理,避免首次空闲就发心跳。
5.5 现象:CmppSubmitService.submit()抛出RejectedExecutionException
原因:CmppSession内部使用ScheduledThreadPoolExecutor处理重试任务,但线程池corePoolSize默认为 1,高并发提交时任务队列满(LinkedBlockingQueue容量 Integer.MAX_VALUE)导致 OOM 风险,或拒绝新任务。
解决:在CmppSessionManager初始化时,显式设置线程池:
private final ScheduledExecutorService retryExecutor = new ScheduledThreadPoolExecutor(4, r -> { Thread t = new Thread(r, "cmpp-retry-thread"); t.setDaemon(true); return t; });将核心线程数设为 4,并设为守护线程,避免应用关闭时线程池阻塞。
6. 二次开发实战:如何接入 Spring Cloud、添加重试策略、自定义状态报告存储
6.1 接入 Spring Cloud Alibaba:让 CMPP 客户端支持 Nacos 配置中心
项目原生支持application.yml,但生产环境需动态配置网关地址、SP 密码等。接入 Nacos 只需两步:
添加依赖(
cmpp-spring-boot-starter/pom.xml):<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> <version>2022.0.0.0</version> </dependency>改造
CmppAutoConfiguration,从NacosConfigProperties读取配置:@Configuration @ConditionalOnClass(CmppClientBootstrap.class) public class CmppAutoConfiguration { @Bean @ConditionalOnMissingBean public CmppClientBootstrap cmppClientBootstrap( NacosConfigProperties nacosConfigProperties, CmppProperties properties) { // 从 Nacos 获取配置,覆盖 yml 中的值 String host = nacosConfigProperties.getServerAddr(); // 或从 dataId 读取 properties.setHost(host); return new CmppClientBootstrap(properties); } }启动时添加 JVM 参数
-Dspring.cloud.nacos.config.server-addr=127.0.0.1:8848,配置自动拉取。
6.2 增强重试策略:从“无限重试”到“指数退避 + 最大次数”
原项目对发送失败的消息仅做简单队列重试,易打爆网关。升级为指数退避(Exponential Backoff):
// 在 CmppSession 中新增 retryScheduler private final ScheduledExecutorService retryScheduler = Executors.newSingleThreadScheduledExecutor(); public void scheduleRetry(CmppSubmitRequest request, int attempt) { if (attempt > 3) { // 最大重试 3 次 log.warn("Submit failed after {} attempts, dropping: {}", attempt, request.getMobile()); return; } long delay = (long) Math.pow(2, attempt) * 1000; // 第1次延1s,第2次2s,第3次4s retryScheduler.schedule(() -> { log.info("Retry submit for {} (attempt {}), delay {}ms", request.getMobile(), attempt, delay); submit(request); // 重新提交 }, delay, TimeUnit.MILLISECONDS); }调用方在submit()失败时,不再直接pendingSubmits.offer(),而是scheduleRetry(request, 1)。
6.3 自定义状态报告存储:从内存 Map 到 MySQL 持久化
项目默认用ConcurrentHashMap<String, CmppDeliver>存储状态报告,进程重启即丢失。替换为 MySQL 存储:
建表语句(MySQL):
CREATE TABLE cmpp_deliver_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, msg_id VARCHAR(16) NOT NULL COMMENT '网关返回的Msg_Id', mobile VARCHAR(11) NOT NULL COMMENT '手机号', stat VARCHAR(10) NOT NULL COMMENT 'DELIVRD/UNDELIV/UNKNOWN', submit_time DATETIME COMMENT '提交时间', done_time DATETIME COMMENT '完成时间', create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_msg_id (msg_id), INDEX idx_mobile (mobile) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;实现
CmppDeliverCallback接口:@Service public class DbDeliverCallback implements CmppDeliverCallback { @Autowired private JdbcTemplate jdbcTemplate; @Override public void onDeliver(CmppDeliver deliver) { String sql = "INSERT INTO cmpp_deliver_log (msg_id, mobile, stat, submit_time, done_time) VALUES (?, ?, ?, ?, ?)"; jdbcTemplate.update(sql, deliver.getMsgId(), deliver.getDestTerminalId(), deliver.getStat(), deliver.getSubmitTime(), deliver.getDoneTime() ); } }在
CmppAutoConfiguration中,将DbDeliverCallback注入CmppClientBootstrap,替代默认内存回调。
从那以后我每次对接新网关,都强制走一遍CmppMockServer+ DEBUG 日志 + Wireshark 抓包三件套——哪怕文档写得再“标准”,网关实现总有微妙差异。CMPP 不是 HTTP,没有浏览器开发者工具帮你点开看响应头,它的错误藏在字节流里,只有亲手拆开每一个包,才能真正掌控这条通往亿级用户的短信通道。希望帮到你。
本文还有配套的精品资源,点击获取