ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java实现CoAP协议源码解析:从消息编解码到Android服务端实战

Java实现CoAP协议源码解析:从消息编解码到Android服务端实战

简介:这份资源提供了一套完整的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 跑 TCP,CoAP 跑 UDP,这意味着 Java 里不能用现成的 Servlet 容器那套东西,得自己管 Socket、自己处理重传和超时。这份源码的核心思路是:用DatagramSocket或DatagramChannel做底层收发,上面叠一层消息编解码,再往上做请求路由和资源管理。

协议分层大致是这样:

层级职责对应 Java 实现
消息层处理 CON/NON/ACK/RST 四种消息类型,管消息 ID 和重传Message 类 + 重传调度器
请求/响应层方法(GET/POST/PUT/DELETE)、响应码、Token 匹配Request/Response 类 + Token 管理器
资源层资源路径注册、发现(/.well-known/core)Resource 抽象类 + 资源树
观察层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 Map<String, 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 Found,4.05 对应 Method Not Allowed,别搞混了。资源发现响应需要按 CoRE Link Format 拼接,格式是</sensors/temp>;rt="temperature",多个资源用逗号分隔。

3. 核心模块拆解:消息编解码与请求处理链路

3.1 CoAP 消息格式与编解码实现

CoAP 消息头固定 4 字节,后面跟 Token、Options 和 Payload。头部的结构是:版本(2 bit)、类型(2 bit)、Token 长度(4 bit)、Code(8 bit)、Message ID(16 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 Map<Integer, 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 ConcurrentHashMap<String, 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 Map<String, List<ObserveRelation>> 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) { List<ObserveRelation> 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 5683,Windows 上用netstat -anp udp | findstr 5683。如果端口没监听,检查是不是被防火墙拦了,或者DatagramSocket绑定失败抛了异常但被吞了。

4.3 用客户端工具验证请求响应

服务端跑起来后,需要一个 CoAP 客户端来发请求验证。常见的有coap-client(libcoap 提供的命令行工具)或者自己写个简单的 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>;attr="value"表示,多个资源用逗号分隔,不能有多余空格或换行。

解决:拼接时用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 字节、观察者订阅后立刻断开。这些场景在正常联调里不容易碰到,但上线后一定会遇到。从那以后我每次改协议层代码,都强制走一遍这组边界用例,宁可多花半小时,也不想半夜被叫起来查丢包。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进