ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java实现CMPP3.0短信接入:核心协议与Netty长连接实践

Java实现CMPP3.0短信接入:核心协议与Netty长连接实践 简介面向Java服务端开发者的CMPP3.0短信协议实现资源覆盖中国移动短信网关的短信提交、接收、状态查询等核心业务场景。压缩包共17个文件包含14个Java源文件、2个TXT说明文档和1个properties配置文件整体仅23KB代码结构紧凑适合快速阅读与二次开发。已有947人学习下载。资源内应涵盖CMPP命令封装、GBK编解码、TCP长连接与心跳维持、多线程收发处理、异常重试及状态管理等关键实现通过阅读源码可直观理解CMPP3.0从建链到短信上行的完整交互流程搭配txt说明文档可掌握配置项与消息格式是学习短信网关接入和协议解析的实用参考。 如果你接到一个“用Java实现CMPP3.0短信接入”的需求老实讲真正的难点不在Java而在协议细节和长连接的工程化处理。CMPP3.0是中国移动短信网关的主流接入协议企业短信、验证码、通知触达基本都跑在这条链路上。Java侧要做的事说白了就是三件建立TCP长连接、按协议格式提交短信、接收并处理状态报告。听起来简单但每一年都有新团队在同一个坑里翻车——序列号溢出、粘包没拆干净、鉴权时间戳算错、线程被阻塞到OOM。这篇记录就按我自己的实操路径来写从协议结构到Netty实现再到常见故障排查尽量给你一套能直接抄作业的方案。1. CMPP3.0协议基础与整体设计思路1.1 先搞懂CMPP3.0到底是一个什么东西CMPP的全称是 China Mobile Peer to Peer是中国移动定义的短信网关接口协议3.0版本是目前企业短信接入最常见的一个版本。它跑在TCP之上采用“请求-响应”模式客户端也就是我们的Java服务主动连接网关登录鉴权通过后就能提交短信、接收状态报告、处理主动下行的消息。我们在代码里打交道最多的是下面这几组命令Command_Id名称方向作用0x00000001CMPP_CONNECT客户端-网关登录鉴权0x80000001CMPP_CONNECT_RESP网关-客户端鉴权结果0x00000004CMPP_SUBMIT客户端-网关提交短信0x80000004CMPP_SUBMIT_RESP网关-客户端提交结果0x00000005CMPP_DELIVER网关-客户端上行短信/状态报告0x80000005CMPP_DELIVER_RESP客户端-网关确认收到0x00000008CMPP_ACTIVE_TEST双向心跳探测0x80000008CMPP_ACTIVE_TEST_RESP双向心跳响应从工程角度看CMPP3.0和2.0的差别其实不算大主要变化集中在计费字段的强制要求、编码格式支持范围以及对长连接状态管理的约束。但只要你是在做网关对接就必须按3.0的字段规范来填少一个服务代码或者计费类型不对网关都会直接拒掉。1.2 报文结构核心12字节头是所有消息的通用骨架CMPP所有消息都有一个固定12字节的消息头这是整个拆包和封包逻辑的地基Total_Length4字节无符号整数表示整条消息的总长度包含头部12字节Command_Id4字节无符号整数表示命令类型Sequence_Id4字节无符号整数表示消息流水号用来关联请求和响应后面的消息体则根据Command_Id各不相同。比如CMPP_CONNECT消息体由Source_Addr6字节企业代码、AuthenticatorSource16字节MD5鉴权串、Version1字节版本、Timestamp4字节时间戳组成CMPP_SUBMIT的字段更多包括Msg_Id、Pk_total、Pk_number、Registered_Delivery、Msg_Fmt、Msg_Src、FeeCode、DestTerminalId、Msg_Content等。一个很容易被忽略的点协议里所有整数都是网络字节序也就是大端序。Java的ByteBuf默认就是大端所以这个问题在Netty下基本不会踩但如果你是自己用InputStream手写解析就一定要用DataInputStream的readInt()和writeInt()别用ByteBuffer默认的小端序硬套。1.3 为什么整体方案选Netty而不是普通Socket有人问CMPP3.0是简单的请求响应用Java原生Socket加一个线程池不行吗早期我确实这么干过后来发现行不通。原因有三个第一CMPP长连接是双向异步的。登录成功之后网关可能在你发请求的间隙主动推送状态报告CMPP_DELIVER你需要同时处理“自己的响应”和“网关的推送”如果用阻塞式Socket还得单独起接收线程线程模型很别扭。第二拆包逻辑和高并发提交无法解耦。短信提交是高频操作一个网关账号每秒提交几十上百条很常见原生Socket很难把“协议解析”和“业务处理”干净地拆开。第三Netty本身就是为这种长连接、高并发、需要自定义二进制协议的场景设计的EventLoop模型、ByteBuf、Pipeline链式处理天然契合CMPP协议实现。所以最终方案就是用Netty作为通信层协议编解码器独立成类业务逻辑通过Handler处理。这样无论是对接一个网关账号还是多个网关账号都只是配置差异代码主体完全复用。2. Java实现CMPP3.0的核心细节解析2.1 拆包如何正确处理TCP粘包和半包TCP是流式传输没有消息边界。网关连续发来的多条CMPP消息可能会粘在一起也可能一条消息分成几次到达。这个问题所有CMPP新手都会遇到表现就是解析出来的报文乱掉字段错位甚至直接解码异常。CMPP3.0拆包的依据就是头部4字节的Total_Length。我习惯在Netty里写一个ByteToMessageDecoder核心逻辑只有几步可读字节数不足4时直接返回等待后续数据读取Total_Length判断是否合法不能小于12也不能超过预设上限比如10240如果可读字节数小于Total_Length重置读指针继续等待否则读取完整的一条消息交给后续Handler这里有一个值得写进代码的经验Total_Length最大不要不设限制地信任。有些异常包或者攻击包会把长度写得非常大导致ByteBuf一直凑不齐数据内存持续堆积。我会做两层防护第一层在Decoder里判断长度范围第二层在Netty pipeline上设置maxFrameLength超限直接抛异常并关闭连接避免异常流量拖垮整个服务。2.2 登录鉴权MD5序列串和时间戳的计算CMPP_CONNECT能不能通过完全取决于AuthenticatorSource和Timestamp对不对。网上很多实现把这俩字段写错最常见的就是时间戳格式搞错。CMPP3.0里Timestamp不是标准的yyyyMMddHHmmss而是一个4字节的整数由月日时分秒拼成格式是MMddHHmmss注意月份要带前导零日期也要带比如4月1日14点5分6秒表达出来就是0401140506。AuthenticatorSource的计算方式按协议文档是String sourceAddr 123456; // 企业代码6位 String password your_password; // 登录密码 String timestamp new SimpleDateFormat(MMddHHmmss).format(new Date()); String raw sourceAddr \u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000 password timestamp; byte[] authSource md5(raw.getBytes(StandardCharsets.UTF_8));这段代码里的关键点就是sourceAddr后面必须拼9个0x00字节然后再拼密码和时间戳。这9个空字节是协议硬性规定的少一个都不行。整个MD5结果16字节加上前面的6字节企业代码、1字节版本、4字节时间戳组成完整的CMPP_CONNECT消息体。登录响应的处理也要严谨CMPP_CONNECT_RESP里第1个字节是Status0表示成功非0值分别代表各种鉴权错误比如密码错、IP不在白名单。状态码非0时网关通常会马上断开连接所以登录Handler里要记录失败原因并触发重连策略而不是无限重试同一个错误请求。2.3 序列号管理一个容易爆雷的低级错误Sequence_Id用于关联请求和响应每次发送新消息都要自增。Java里如果直接用int自增一旦超过Integer.MAX_VALUE就会溢出成负数。CMPP协议里的Sequence_Id是4字节无符号整数理论取值范围是0到4294967295但网关对负数Sequence_Id的处理态度很直接——当作非法请求丢弃或者响应回来后根本对不上。我的做法是统一用一个AtomicLong维护全局序号发送时取模后转intprivate final AtomicLong sequenceGenerator new AtomicLong(1); public int nextSequenceId() { long next sequenceGenerator.getAndIncrement(); if (next Integer.MAX_VALUE) { sequenceGenerator.set(1); next sequenceGenerator.getAndIncrement(); } return (int) next; }还有一个隐蔽问题多线程发送时如果每个线程各自维护序号很容易产生重复Sequence_Id导致提交响应错配。必须全局共享同一个序号生成器保证单调递增且不重复。配置多个网关账号时最好在客户端实例内部各自保存独立序号不要跨账号混用。2.4 编码选择中文内容必须和Msg_Fmt对应CMPP_SUBMIT里的Msg_Content字段用什么编码由Msg_Fmt决定。最常见的是Msg_Fmt8表示GBK编码这也是大量网关默认支持的格式。如果你的短信内容是中文编码时一定要用GBK而不是UTF-8否则发出去的短信会是乱码。这里有个实际问题Java字符串在内存中是UTF-16转GBK字节码时要用content.getBytes(Charset.forName(GBK))Msg_Length也必须是GBK编码后的字节数而不是字符串长度。一个中文在GBK里占2字节如果你按String.length()去填长度短信内容会被网关截断这类问题排查起来非常费劲。CMPP3.0规范里也支持UTF-16编码但实际对接中发现有些网关对UTF-16的支持并不完善。所以我的默认策略是能用GBK就GBK只有在明确确认网关支持其他编码时才切换。3. 实操过程完整实现CMPP3.0客户端3.1 工程结构设计与依赖准备用Maven工程Java版本建议8起步Netty用4.1.x。核心依赖就一个netty-all加上slf4j做日志其余尽量少引。整个工程建议按下面结构组织src/main/java/com/yourcompany/cmpp/ ├── CmppClient.java // 客户端入口管理连接与生命周期 ├── CmppClientInitializer.java // Netty pipeline初始化 ├── codec/ │ ├── CmppMessageDecoder.java // 拆包解码 │ ├── CmppMessageEncoder.java // 封包编码 │ └── CmppHeader.java // 12字节消息头 ├── handler/ │ ├── CmppConnectHandler.java // 登录、登录响应 │ ├── CmppSubmitHandler.java // 短信提交 │ ├── CmppDeliverHandler.java // 状态报告与上行 │ └── CmppHeartbeatHandler.java // 心跳 └── service/ └── SmsSendService.java // 业务封装标题是“cmpp3.0_JAVA_实现”所以这个结构我按我实际项目的目录截图式的写法给你你可以直接照搬。核心逻辑集中在codec和handler两层service层只做参数组装和回调通知不要让业务代码侵入协议逻辑。3.2 消息头定义与编解码器实现消息头是所有消息的通用前置部分定义成独立的POJO最方便。public class CmppHeader { private int totalLength; private int commandId; private int sequenceId; // 构造方法、getter/setter省略 }Decoder的核心逻辑如下注意在数据不足时要通过input.resetReaderIndex()来暂停消费这是Netty拆包的标准姿势public class CmppMessageDecoder extends ByteToMessageDecoder { private static final int HEADER_SIZE 12; private static final int MAX_FRAME_SIZE 10 * 1024; Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) { in.markReaderIndex(); if (in.readableBytes() 4) { return; } int totalLength in.readInt(); if (totalLength HEADER_SIZE || totalLength MAX_FRAME_SIZE) { ctx.close(); return; } if (in.readableBytes() totalLength - 4) { in.resetReaderIndex(); return; } ByteBuf frame in.readRetainedSlice(totalLength - 4); try { CmppMessage message parseFrame(frame, totalLength); if (message ! null) { out.add(message); } } finally { frame.release(); } } }Encoder反过来把CmppMessage序列化成ByteBuf。要注意Total_Length必须包含整个消息头加消息体所以Encoder里先计算body长度再写入头部的totalLengthpublic class CmppMessageEncoder extends MessageToByteEncoderCmppMessage { Override protected void encode(ChannelHandlerContext ctx, CmppMessage msg, ByteBuf out) { byte[] body msg.getBody(); int totalLength 12 (body null ? 0 : body.length); out.writeInt(totalLength); out.writeInt(msg.getCommandId()); out.writeInt(msg.getSequenceId()); if (body ! null) { out.writeBytes(body); } } }3.3 登录、心跳与断线重连真正上线的时候光能连上网关是不够的还得能扛住网络抖动。登录成功后要做两件事启动心跳定时任务注册断线重连钩子。心跳我一般用CMPP_ACTIVE_TEST每60秒发送一次连续3次没收到ACTIVE_TEST_RESP就主动断开重连。时间间隔可以根据网关要求调整但别小于30秒否则网关可能把你的探测当作攻击行为。Netty下心跳和定时任务可以用eventLoop的schedule实现不额外引入线程池channel.eventLoop().scheduleAtFixedRate(() - { if (channel.isActive()) { CmppMessage heartbeat buildActiveTest(); channel.writeAndFlush(heartbeat); } }, 60, 60, TimeUnit.SECONDS);断线重连要注意退避策略。网关侧如果账号异常重连再频繁也没用。我习惯用指数退避从1秒开始最大到30秒避免连接风暴。每次重连都要重新走完整登录流程不能直接在旧连接上发消息。3.4 提交短信与状态报告处理提交一条短信的完整过程是客户端组装CMPP_SUBMIT消息发送到网关网关返回CMPP_SUBMIT_RESP里面带Msg_Id网关分配的消息ID和Result0表示成功非0表示失败。很多初学者以为SUBMIT_RESP成功就是短信已经下发其实不对SUBMIT_RESP只表示“网关收到了”真正是否下发成功要看状态报告。所以在提交逻辑里我会用Sequence_Id关联一个请求Future等待SUBMIT_RESP。等待不能无限期我设5秒超时超时后把这条消息标记为提交异常并清理Map中对应的Future防止Future越积越多造成内存泄漏。private final MapInteger, CompletableFutureCmppSubmitResp pendingSubmit new ConcurrentHashMap(); public CompletableFutureCmppSubmitResp submit(CmppSubmitRequest request) { CompletableFutureCmppSubmitResp future new CompletableFuture(); pendingSubmit.put(request.getSequenceId(), future); channel.writeAndFlush(request); return future.orTimeout(5, TimeUnit.SECONDS) .whenComplete((resp, ex) - pendingSubmit.remove(request.getSequenceId())); }状态报告通过CMPP_DELIVER下发关键标识是Is_Report字段。当Is_Report1时Msg_Content里放的是状态码字符串比如DELIVRD表示成功下发EXPIRED表示过期UNDELIV表示无法投递。收到后要回一个CMPP_DELIVER_RESP告诉网关“我收到了”否则网关会一直重推。回包的Msg_Id和Result必须和收到的DELIVER一致这一点很多人都漏了。状态报告和原始提交的关联一般通过Msg_Id或者业务侧的msgId字段完成建议在提交短信时把业务ID写在扩展字段或LinkID里方便后续对账。4. 常见问题与排查技巧实录4.1 连接建立后立刻断开登录都过不了这是对接CMPP3.0最常见的首坑。现象是TCP能连上但刚发完CONNECT就被网关踢掉。排查顺序按概率从高到低第一企业代码、密码是否正确。CMPP3.0的Source_Addr是6位企业代码不是登录名也不是IP。第二IP白名单。网关一般会要求把服务器出口IP加到白名单。第三时间戳格式。有人用yyyyMMddHHmmss结果网关怎么都对不上鉴权。第四AuthenticatorSource序列串的0x00字节数量。排查技巧先在本地用tcpdump或者Wireshark抓包看CONNECT_RESP的Status值再根据Status码去查网关文档比盲猜快得多。4.2 提交成功但用户收不到短信状态报告状态异常SUBMIT_RESP返回Result0不代表短信最终成功。这时候要看状态报告。DELIVRD是成功送达这是最理想的。除此之外还有几种常见状态EXPIRED表示短信超时UNDELIV表示网关尝试下发但失败REJECTD表示被网关拒收。收到这些状态后业务侧要能解析并触发补偿或者告警。还有一类情况更隐蔽状态报告压根没回来。这通常是因为CMPP_DELIVER_RESP回包格式不对或者客户端处理DELIVER时抛异常导致连接断开。我遇到过回包Msg_Id顺序填错网关不断重推最终把接收队列撑爆的情况。处理DELIVER一定记得try-catch兜底任何异常都不能影响回包。4.3 Sequence_Id重复导致响应错配并发提交时如果Sequence_Id生成没有全局唯一网关返回的SUBMIT_RESP就会被错误关联到其他请求上。现象是日志里出现“提交消息A成功但回调内容对应的是消息B”。这事的根源要么是序号生成器没有并发安全要么是int溢出成负数。修好全局序号生成器并且给每个请求打上独立标识后这个坑就再也没出现过。4.4 线程被卡死和内存缓慢增长等SUBMIT_RESP如果用同步Wait网关响应稍微慢一点线程池就打满了后面所有请求全部排队。改造方案就是用异步Future加超时机制。内存增长则是pendingSubmit里的Future只put不remove时间长了必然OOM。这块没有捷径只能靠代码层面严格要求每个Future都必须有明确的完成、超时、异常路径保证最终一定触发remove。监控上我建议定期打印pendingSubmit.size()一旦超过阈值就告警能提前发现隐藏的问题。5. 一些实际经验和建议CMPP3.0的Java实现协议本身并不复杂真正决定上线后稳不稳的是那些看不到的工程细节。我自己踩过的坑里最深刻的教训是一定要把协议编解码层和业务层隔离干净不要在Handler里写业务逻辑否则排查问题的时候你会被一堆短信模板和状态码的代码搅得头大。编解码器独立之后就算以后要接其他协议比如SGIP或者SMGP也只需要替换协议层业务层完全不用动。调试阶段建议多抓包少猜。Wireshark虽然不支持CMPP的自动解析但能清楚看到十六进制报文自己对照协议文档逐字节核对几次下来对字段的理解会非常深。网关侧给的测试号码一定提前准备好测试环境的状态报告可能延迟几分钟不要一没收到报告就怀疑代码。最后再分享一个小技巧发短信时把业务方传过来的消息ID放在LinkID字段里状态报告回调的时候就能直接关联到业务数据省去一套自建映射表的麻烦。这个字段大部分业务都会忽略但真正做消息对账的时候你会感谢当初留了这个字段。CMPP3.0这条链路只要把基础打扎实后面扩展多网关、多通道、智能路由都是水到渠成的事。本文还有配套的精品资源点击获取
返回列表