简介这份资源提供了一套完整的CoAP协议Java实现面向物联网通信开发者和Android服务端开发者帮助理解并落地基于UDP的轻量级受限应用协议。包内包含Android服务器端代码、Java服务器代码以及Java客户端测试代码可对照学习CoAP的双层事务机制、重传处理、资源发现与资源描述等核心特性。资源共297个文件以262个java源码为主辅以12个xml配置、10个png示意图、3个properties与3个gradle构建脚本压缩包约582KB目录结构清晰便于按模块检索。已有504人学习下载。读者可从中获取可运行的CoAP服务端与客户端示例理解GET、POST、PUT、DELETE操作资源的方式以及/.well-known/core资源发现和rt类型筛选等用法适合作为物联网协议入门与二次开发参考。1. 从一次设备联调说起这套 CoAP Java 源码到底能干什么去年帮一个做智能硬件的团队排查联调问题设备端用 CoAP 上报数据服务端却怎么都收不到 ACK抓包一看是消息 ID 和 Token 对不上。当时翻了不少资料最后是靠一套 Java 实现的 CoAP 服务端源码把链路跑通的。这次要拆的就是这样一份资源一套用 Java 实现 CoAP 协议的源码包含 Android 服务器端和标准 Java 服务器端两个方向。CoAP 本身是受限应用协议跑在 UDP 之上专为低功耗、弱网、资源受限的设备设计像传感器、智能电表、可穿戴设备这类场景用得很多。这份源码的价值在于它把 CoAP 的消息层、请求响应层、观察模式这些核心机制用 Java 落地了还给了 Android 端做服务端的实现路径。适合谁一是做 IoT 平台、需要自建 CoAP 接入层的后端开发二是做 Android 设备端、想让手机直接当 CoAP 服务端接收指令的移动开发三是学协议实现、想对照 RFC 看代码的学生和转行者。系统开源意味着你可以直接读、直接改、直接嵌进自己的项目里。2. 技术选型与整体架构为什么用 Java 扛 CoAP 服务端2.1 CoAP 协议在 Java 里的落地方式CoAP 和 HTTP 最大的区别是传输层。HTTP 跑 TCPCoAP 跑 UDP这意味着 Java 里不能用现成的 Servlet 容器那套东西得自己管 Socket、自己处理重传和超时。这份源码的核心思路是用DatagramSocket或DatagramChannel做底层收发上面叠一层消息编解码再往上做请求路由和资源管理。协议分层大致是这样层级职责对应 Java 实现消息层处理 CON/NON/ACK/RST 四种消息类型管消息 ID 和重传Message 类 重传调度器请求/响应层方法GET/POST/PUT/DELETE、响应码、Token 匹配Request/Response 类 Token 管理器资源层资源路径注册、发现/.well-known/coreResource 抽象类 资源树观察层Observe 订阅与通知ObserveRelation 管理常见做法是消息层和请求层合并处理因为 CoAP 的 ACK 可以携带响应piggybacked response分开反而增加复杂度。这份源码在这一点上做了折中消息解析独立但 ACK 与响应的绑定在同一个处理循环里完成。2.2 Android 端与 Java 端的架构差异Android 服务器端和标准 Java 服务器端共用同一套协议核心差异在运行环境和生命周期管理上。标准 Java 端通常是一个独立进程启动后绑定端口监听用线程池处理并发请求。核心启动代码大概长这样// 标准 Java 服务端启动入口 public class CoapServer { private final DatagramSocket socket; private final ResourceTree resourceTree; private final ExecutorService workerPool; public CoapServer(int port) throws SocketException { // 绑定 UDP 端口CoAP 默认端口是 5683 this.socket new DatagramSocket(port); this.resourceTree new ResourceTree(); // 工作线程池处理请求逻辑避免阻塞收包线程 this.workerPool Executors.newFixedThreadPool(8); } public void start() { // 收包循环单独一个线程 new Thread(() - { byte[] buffer new byte[1024]; while (!socket.isClosed()) { DatagramPacket packet new DatagramPacket(buffer, buffer.length); try { socket.receive(packet); // 收到包后丢给线程池处理收包线程立即回去等下一个包 workerPool.submit(() - handlePacket(packet)); } catch (IOException e) { // 端口关闭时会抛异常正常退出 if (!socket.isClosed()) e.printStackTrace(); } } }, coap-receiver).start(); } private void handlePacket(DatagramPacket packet) { // 解析 CoAP 消息路由到对应资源生成响应后回发 CoapMessage msg CoapMessage.decode(packet.getData(), packet.getLength()); CoapMessage response resourceTree.dispatch(msg); if (response ! null) { byte[] out response.encode(); socket.send(new DatagramPacket(out, out.length, packet.getAddress(), packet.getPort())); } } }这段代码的关键点有三个。第一收包线程和业务线程分离socket.receive是阻塞的如果直接在收包线程里做业务处理高并发下会丢包。第二缓冲区大小设成 1024 字节CoAP 单包通常不超过这个值但如果你要传大块数据走 Block-Wise 传输得调大或者分片处理。第三handlePacket里先解码再分发解码失败要能优雅处理不能因为一个畸形包把整个服务搞崩。Android 端的差异在于Android 没有传统意义上的main方法常驻得把 CoAP 服务端跑在Service里用WifiManager.MulticastLock保证息屏后还能收组播包。另外 Android 对后台 Socket 有限制从某个版本开始应用退到后台后 UDP 收包可能被挂起常见做法是配合前台服务Foreground Service来维持。2.3 资源树与请求分发机制CoAP 的资源发现机制和 HTTP 不一样客户端可以 GET/.well-known/core拿到服务端所有可用资源列表。这份源码用一棵资源树来管理路径映射// 资源树路径到 Resource 的映射 public class ResourceTree { private final MapString, Resource resources new ConcurrentHashMap(); // 注册资源path 形如 /sensors/temp public void addResource(String path, Resource resource) { resources.put(path, resource); } // 根据请求路径找到对应资源并执行 public CoapMessage dispatch(CoapMessage request) { String path request.getUriPath(); // 处理资源发现请求 if (/.well-known/core.equals(path)) { return buildDiscoveryResponse(); } Resource res resources.get(path); if (res null) { // 4.04 Not Found return CoapMessage.error(request, ResponseCode.NOT_FOUND); } // 按方法分发GET/POST/PUT/DELETE switch (request.getCode()) { case GET: return res.handleGET(request); case POST: return res.handlePOST(request); case PUT: return res.handlePUT(request); case DELETE: return res.handleDELETE(request); default: return CoapMessage.error(request, ResponseCode.METHOD_NOT_ALLOWED); } } }ConcurrentHashMap的选择是因为资源注册可能发生在运行时而请求分发是多线程并发的。dispatch方法里先判断资源发现路径再查表查不到返回 4.04。这里有个细节CoAP 的响应码和 HTTP 不一样4.04 对应 Not Found4.05 对应 Method Not Allowed别搞混了。资源发现响应需要按 CoRE Link Format 拼接格式是/sensors/temp;rttemperature多个资源用逗号分隔。3. 核心模块拆解消息编解码与请求处理链路3.1 CoAP 消息格式与编解码实现CoAP 消息头固定 4 字节后面跟 Token、Options 和 Payload。头部的结构是版本2 bit、类型2 bit、Token 长度4 bit、Code8 bit、Message ID16 bit。编解码是整个协议实现里最容易翻车的地方因为 bit 操作多稍不留神就错位。// CoAP 消息编解码核心 public class CoapMessage { private int version 1; private MessageType type; // CON, NON, ACK, RST private int tokenLength; private byte[] token; private int code; // 请求方法或响应码 private int messageId; private MapInteger, byte[] options; private byte[] payload; public byte[] encode() { // 计算总长度4 字节头 token options payload 标记 ByteArrayOutputStream out new ByteArrayOutputStream(); // 第一个字节版本(2bit) 类型(2bit) Token长度(4bit) int firstByte (version 6) | (type.getValue() 4) | tokenLength; out.write(firstByte); out.write(code); // Message ID 占两个字节大端序 out.write((messageId 8) 0xFF); out.write(messageId 0xFF); if (token ! null) out.write(token, 0, token.length); // Options 按编号升序编码每个 option 有 delta/length 压缩格式 encodeOptions(out); if (payload ! null payload.length 0) { out.write(0xFF); // payload 标记 out.write(payload, 0, payload.length); } return out.toByteArray(); } public static CoapMessage decode(byte[] data, int length) { CoapMessage msg new CoapMessage(); int firstByte data[0] 0xFF; msg.version (firstByte 6) 0x03; msg.type MessageType.fromValue((firstByte 4) 0x03); msg.tokenLength firstByte 0x0F; msg.code data[1] 0xFF; msg.messageId ((data[2] 0xFF) 8) | (data[3] 0xFF); int offset 4; if (msg.tokenLength 0) { msg.token Arrays.copyOfRange(data, offset, offset msg.tokenLength); offset msg.tokenLength; } // 解析 Options 和 Payload parseOptionsAndPayload(msg, data, offset, length); return msg; } }encode里第一个字节的拼接是重点版本左移 6 位类型左移 4 位Token 长度占低 4 位。decode时反过来用掩码取。Message ID 用大端序两个字节拼起来。Options 的编码用了 delta 压缩相同编号的 option 只存差值这是 CoAP 为了省流量做的优化实现时要注意 delta 超过 12 时需要扩展字节。Payload 前面有个 0xFF 标记没有 payload 就不写这个字节。3.2 请求处理与 Token 匹配机制CoAP 的 Token 用来匹配请求和响应尤其在非确认消息NON和分离响应separate response场景下Token 是唯一能关联请求和响应的字段。Message ID 只管消息层的 ACK不管业务层的匹配。// Token 管理器维护请求与响应的对应关系 public class TokenManager { // key 是 Token 的十六进制字符串value 是等待响应的请求上下文 private final ConcurrentHashMapString, RequestContext pending new ConcurrentHashMap(); private final AtomicInteger tokenCounter new AtomicInteger(0); // 生成新 Token通常用递增计数器加随机前缀 public byte[] generateToken() { int count tokenCounter.incrementAndGet(); byte[] token new byte[4]; // 前两字节用随机数避免重启后 Token 重复 new Random().nextBytes(token); token[2] (byte) ((count 8) 0xFF); token[3] (byte) (count 0xFF); return token; } // 注册等待响应的请求 public void register(byte[] token, RequestContext ctx) { pending.put(bytesToHex(token), ctx); } // 收到响应时根据 Token 找到对应请求 public RequestContext match(byte[] token) { return pending.remove(bytesToHex(token)); } }generateToken里用随机数加计数器是为了避免服务重启后 Token 和之前的请求撞车。register和match用ConcurrentHashMap保证线程安全match里用remove而不是get因为匹配成功后这个上下文就不需要了及时清理防止内存泄漏。实际项目中pending里的条目要设超时比如 30 秒没响应就自动移除否则弱网环境下会越积越多。3.3 观察模式Observe的实现要点Observe 是 CoAP 里比较有特色的机制客户端订阅一个资源资源状态变化时服务端主动推送通知。实现上分两块服务端维护订阅关系客户端处理通知序列号。// 观察关系管理 public class ObserveManager { // 资源路径 - 订阅该资源的客户端列表 private final MapString, ListObserveRelation relations new ConcurrentHashMap(); public void addRelation(String path, InetSocketAddress client, byte[] token) { relations.computeIfAbsent(path, k - new CopyOnWriteArrayList()) .add(new ObserveRelation(client, token)); } // 资源变化时调用向所有订阅者推送通知 public void notifyObservers(String path, byte[] newPayload) { ListObserveRelation list relations.get(path); if (list null) return; for (ObserveRelation rel : list) { // 通知消息里要带 Observe option值是递增的序列号 CoapMessage notification CoapMessage.notify(rel.getToken(), newPayload); notification.setObserve(rel.nextSequence()); send(rel.getClient(), notification); } } }Observe option 的序列号是 24 位整数递增到最大值后回绕。客户端收到通知后要比较序列号如果新通知的序列号比上一个小回绕除外说明是旧消息应该丢弃。这个机制在弱网下容易出问题因为 UDP 不保证顺序常见做法是客户端维护一个时间窗口窗口外的通知直接忽略。4. 跑起来编译、配置与联调验证4.1 环境准备与编译步骤这份源码是标准 Java 项目结构没有用 Maven 或 Gradle 的话直接javac编译也行。但实际项目里我建议用构建工具管理依赖因为 CoAP 实现可能用到日志、JSON 解析这些库。# 假设项目目录结构是 src/main/java 和 src/main/resources # 用 javac 直接编译 mkdir -p out find src/main/java -name *.java sources.txt javac -d out sources.txt # 如果项目带了 build.gradle用 gradle 编译 ./gradlew build # 如果带了 pom.xml用 maven 编译 mvn clean package编译时常见的问题是 Java 版本不匹配。CoAP 源码里如果用了var或者List.of这类语法需要 JDK 11 以上。Android 端模块需要 Android SDK用./gradlew assembleDebug出 APK。编译前先确认JAVA_HOME指向的版本java -version看一眼别用 JDK 8 去编 JDK 17 的代码。4.2 服务端启动与端口配置标准 Java 服务端启动后默认监听 5683 端口这是 CoAP 的约定端口。如果端口被占用或者需要跑多个实例得能改端口。// 启动入口支持命令行参数指定端口 public class Main { public static void main(String[] args) throws Exception { int port 5683; // CoAP 默认端口 if (args.length 0) { port Integer.parseInt(args[0]); } CoapServer server new CoapServer(port); // 注册示例资源 server.addResource(/sensors/temp, new TemperatureResource()); server.addResource(/sensors/humidity, new HumidityResource()); server.start(); System.out.println(CoAP server started on port port); // 阻塞主线程保持进程存活 Thread.currentThread().join(); } }启动后如果看到CoAP server started on port 5683说明进程起来了。但起来不代表能通还得验证 UDP 端口确实在监听。Linux 上用ss -ulnp | grep 5683Windows 上用netstat -anp udp | findstr 5683。如果端口没监听检查是不是被防火墙拦了或者DatagramSocket绑定失败抛了异常但被吞了。4.3 用客户端工具验证请求响应服务端跑起来后需要一个 CoAP 客户端来发请求验证。常见的有coap-clientlibcoap 提供的命令行工具或者自己写个简单的 Java 客户端。# 用 coap-client 发 GET 请求 coap-client -m get coap://127.0.0.1:5683/sensors/temp # 发 POST 请求带 payload coap-client -m post -e hello coap://127.0.0.1:5683/sensors/temp # 观察模式订阅 coap-client -m get -s 60 coap://127.0.0.1:5683/sensors/temp如果客户端收不到响应按这个顺序排查先确认服务端进程还在再看服务端日志有没有收到包然后检查响应码是不是 4.04 或 4.05。常见的一个坑是客户端和服务端的 Token 长度不一致有些客户端默认不带 Token服务端如果强制要求 Token 就会匹配失败。另一个坑是 Message ID 回绕短时间内发大量请求Message ID 从 65535 回到 0如果服务端用 Message ID 做去重可能会误判。5. 避坑与常见问题排查5.1 收不到 ACK 或响应超时现象客户端发出 CON 消息后等不到 ACK重传几次后放弃。原因通常有三个。一是服务端根本没收到包可能是防火墙拦了 UDP或者服务端绑定的是127.0.0.1而客户端从外部发过来。二是服务端收到了但解码失败畸形包被丢弃且没打日志。三是 ACK 发出去了但客户端没收到回程路由有问题。解决先在服务端收包循环里加日志打印收到的字节数和来源地址。如果日志都没有就是网络层问题检查防火墙和绑定地址。如果有日志但没响应在解码处加 try-catch 打印异常堆栈。回程问题用tcpdump抓包确认 ACK 是否真的发出去了。5.2 Android 端息屏后服务不可达现象Android 设备当 CoAP 服务端屏幕亮着能收到请求息屏几分钟后就收不到了。原因是 Android 的电源管理会限制后台网络活动UDP 收包在 Doze 模式下会被挂起。另外 WiFi 芯片在息屏后可能进入省电模式组播包收不到。解决用前台服务Foreground Service加常驻通知让系统知道这个应用在做重要的事。同时获取WifiManager.MulticastLock防止 WiFi 组播被过滤。如果设备是 Android 6.0 以上还要在设置里关掉该应用的电池优化。这些措施会增加耗电但做服务端本来就不是省电场景。5.3 大 payload 传输失败现象发送超过 1KB 的数据时请求发不出去或者响应被截断。原因是 CoAP 单包受 UDP MTU 限制通常不超过 1152 字节IPv6 最小 MTU 1280 减去头部。超过这个大小需要走 Block-Wise 传输把数据分块。解决实现 Block1请求体分块和 Block2响应体分块选项。每个块带 Block option标明块编号、块大小和是否有后续块。接收方按编号重组。如果不想实现分块就把 payload 控制在 1KB 以内大文件走其他通道。5.4 资源发现返回格式错误现象GET/.well-known/core返回的内容客户端解析不了。原因是 CoRE Link Format 的格式要求严格每个资源用/path;attrvalue表示多个资源用逗号分隔不能有多余空格或换行。解决拼接时用StringBuilder严格按格式来属性值用双引号包住。返回的 Content-Format 要设成application/link-format编号 40。如果客户端还是解析不了用coap-client直接请求看原始输出对比 RFC 6690 的示例。5.5 多线程下的资源状态竞争现象多个客户端同时 PUT 同一个资源资源状态出现不一致或者观察者收到的通知顺序错乱。原因是资源处理逻辑没有做同步多个线程同时读写同一个字段。解决资源类里对状态字段用synchronized或者AtomicReference保护。观察通知的序列号生成也要原子操作用AtomicInteger而不是普通 int。如果资源处理逻辑复杂考虑给每个资源配一个单线程执行器把并发请求串行化牺牲一点吞吐换一致性。6. 二次开发从能跑到能用的改造思路拿到这份源码跑通只是第一步真正要用起来得做几处改造。我一般会从三个方向下手资源模型、传输扩展、可观测性。资源模型方面源码里的Resource抽象类通常只给了handleGET这些方法实际项目里资源是有状态的需要持久化。我的做法是引入一个ResourceStore接口把资源状态存到内存或数据库Resource实现只负责协议交互状态读写委托给 Store。这样换存储不用改协议层。改造后的结构大概是// 资源状态存储接口方便替换实现 public interface ResourceStore { byte[] read(String path); void write(String path, byte[] value); void addListener(String path, ChangeListener listener); } // 资源实现只处理协议状态交给 Store public class TemperatureResource extends Resource { private final ResourceStore store; public TemperatureResource(ResourceStore store) { this.store store; } Override public CoapMessage handleGET(CoapMessage request) { byte[] value store.read(/sensors/temp); return CoapMessage.success(request, value); } Override public CoapMessage handlePUT(CoapMessage request) { store.write(/sensors/temp, request.getPayload()); return CoapMessage.success(request, null); } }传输扩展方面CoAP over TCP 和 CoAP over WebSocket 是后来加的如果设备端支持 TCP用 TCP 传输能省掉重传逻辑。改造点在于把DatagramSocket抽象成Transport接口UDP 和 TCP 各实现一份。这个改动量不小但收益是弱网下更稳定。可观测性方面源码里通常只有System.out.println生产环境得换成结构化日志加上请求耗时、成功率、重传次数这些指标。我习惯在handlePacket入口和出口各埋一个计时点把耗时打到日志里超过阈值的请求单独标记。这样出问题时不用抓包看日志就能定位是网络慢还是处理慢。验证改造是否成功别只测正常路径。我一般会写一组边界测试Token 长度为 0 和 8 的情况、Message ID 从 65535 回绕、payload 刚好 1024 字节、观察者订阅后立刻断开。这些场景在正常联调里不容易碰到但上线后一定会遇到。从那以后我每次改协议层代码都强制走一遍这组边界用例宁可多花半小时也不想半夜被叫起来查丢包。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?