
简介这份资源面向需要在 Java 与 Android 环境中落地 CoAP 协议的开发者提供一套可运行的服务器端与客户端参考实现帮助理解轻量级物联网通信协议的实际编码方式。压缩包共 297 个文件约 582KB以 262 个 java 源文件为主体辅以 12 个 xml 布局与配置、3 个 properties 与 3 个 gradle 构建脚本另有少量 png 图示和 jar 依赖整体结构清晰、便于按模块阅读。内容分为 app 与 coaplib 两部分前者是 Android 服务器端代码后者包含 Java 服务器实现及客户端测试代码可对照验证请求响应流程。代码中体现了 CoAP 基于 UDP 的双层事务机制、重传处理、资源发现与 /.well-known/core 查询等要点读者可据此掌握 GET、POST、PUT、DELETE 操作资源及与 Web 服务映射的思路。目前已有 506 人学习适合具备一定 Java 基础、希望快速搭建 CoAP 通信原型的开发者参考。1. 从一份 Java 源码说起CoAP 服务端到底能拿来干什么物联网项目里最容易被低估的一环是设备和服务端之间那条“短消息通道”。HTTP 太重MQTT 要长连接和 Broker而 CoAP 恰好卡在中间UDP 之上、报文小、支持观察模式适合电池供电的传感器定时上报。这份 CoAPService 源码就是干这件事的——它把 CoAP 的服务端实现拆成两块一块跑在 Android 上让手机或平板直接当 CoAP 服务器一块是纯 Java 服务端能丢进任何 JVM 环境里跑。系统开源代码结构不复杂适合两类人一是想给 Android 设备加个轻量服务端能力的开发者二是想读懂 CoAP 报文怎么在 Java 里被拼出来、解析掉的人。下面按“它是什么 → 怎么跑起来 → 核心代码长什么样 → 怎么改”的顺序拆。2. 项目结构与技术选型两个服务端入口一套 CoAP 协议栈2.1 目录骨架与模块职责这份源码的目录组织方式很典型按运行环境切分而不是按功能切分。根目录下通常能看到两个并列的工程目录一个是 Android 侧的 app 模块一个是纯 Java 侧的 server 模块外加一个被两边共同依赖的 coap-core 协议库。这种“协议核心 双端外壳”的切法好处是 CoAP 的报文编解码、消息类型、Option 处理只写一遍Android 和 JVM 各自只负责网络收发的宿主环境差异。常见的目录结构大致如下CoAPService/ ├── coap-core/ # 协议核心报文编解码、Option、消息类型 │ └── src/main/java/... ├── coap-server-java/ # 纯 Java 服务端入口 │ └── src/main/java/... ├── coap-server-android/ # Android 服务端入口Activity Service │ └── src/main/java/... └── README.mdcoap-core 里最关键的是三类东西CoAP 消息对象对应 RFC 7252 的 Ver/Type/Code/MessageID/Token/Options/Payload 七段结构、Option 的编号与长度编码、以及 UDP 数据报和消息对象之间的双向转换。coap-server-java 里是一个 main 入口加一个资源路由表负责绑定端口、收包、分发到对应资源处理器。coap-server-android 里则多了一层 Android 生命周期管理通常用一个 Service 持有 UDP socketActivity 只做启停和日志展示。提示如果你拿到的源码里没有独立的 coap-core而是把协议代码复制进了两个工程先别急着重构跑通再合并否则一开始就陷进编译错误里。2.2 技术栈与依赖边界技术栈本身很克制Java 标准库的java.net.DatagramSocket和DatagramPacket承担全部网络 IO没有引入 Netty 这类重型框架。这对学习是好事——CoAP 跑在 UDP 上一个 socket 收发包的逻辑用标准库就能写清楚不需要事件循环那套抽象。Android 侧额外依赖 Android SDK 的 Service 组件和少量权限声明纯 Java 侧则只需要 JDK 8 及以上。构建工具按源码常见做法是 Maven 或 Gradle 二选一。纯 Java 模块用 Maven 的话pom.xml里通常只有 JUnit 这类测试依赖Android 模块用 Gradlebuild.gradle里配置compileSdk、minSdk和 applicationId。这里没有数据库、没有消息队列、没有外部服务所有状态都在内存里所以“跑起来”的门槛低到只需要一个能发 UDP 包的客户端。模块运行环境关键依赖职责coap-coreJVM / Android 通用JDK 标准库报文编解码、Option 处理coap-server-javaJVMJDK 8UDP 监听、资源路由、响应组装coap-server-androidAndroidAndroid SDKService 持有 socket、生命周期管理2.3 为什么用 UDP 裸写而不是上框架这是这份源码最值得说的一点。CoAP 的设计初衷就是“UDP 上的 REST”报文头只有 4 字节固定部分后面跟 Token 和 Option整个 GET 请求可能就十几字节。用DatagramSocket直接收发的代码量并不大核心循环就是一个receive加一个send。上框架反而会把“报文长什么样”这层遮住对想二次开发、想加自定义 Option 的人来说是负担。我一般会建议先读裸写版本把DatagramPacket的getData()、getLength()、getOffset()三个方法搞清楚——CoAP 报文解析翻车十有八九是没按getLength()截断把缓冲区尾部的脏数据也当成 payload 解析了。这个坑后面避坑章节还会展开。3. 把服务端跑起来Java 端与 Android 端两条路径3.1 纯 Java 服务端的启动流程纯 Java 端的入口通常是一个带main方法的类职责就三件事创建 socket、绑定端口、进入收包循环。下面这段是这类项目里最常见的骨架我按源码风格补全了关键注释public class CoapJavaServer { private static final int PORT 5683; // CoAP 默认端口 public static void main(String[] args) throws Exception { // 绑定 UDP 端口0.0.0.0 表示监听所有网卡 DatagramSocket socket new DatagramSocket(PORT); System.out.println(CoAP server listening on PORT); byte[] buffer new byte[1024]; // CoAP 报文通常很小1KB 足够 while (true) { DatagramPacket packet new DatagramPacket(buffer, buffer.length); socket.receive(packet); // 阻塞等待直到有数据报到达 // 关键只取实际收到的长度不能用 buffer.length byte[] data Arrays.copyOf(packet.getData(), packet.getLength()); CoapMessage request CoapCodec.decode(data); CoapMessage response Router.handle(request); byte[] out CoapCodec.encode(response); socket.send(new DatagramPacket(out, out.length, packet.getAddress(), packet.getPort())); } } }这段代码里每个参数都有讲究。PORT用 5683 是 CoAP 的约定端口改端口意味着客户端也得跟着改联调时最容易在这里对不上。buffer大小设 1024 是经验值CoAP 单报文理论上可以更大但物联网场景下 payload 通常就几十字节设太大浪费内存设太小会截断。packet.getLength()是实际收到的字节数packet.getData()返回的是整个缓冲区两者混用是新手最常见的错误。响应发回时用packet.getAddress()和packet.getPort()而不是重新解析这样 NAT 环境下也能正确回到请求方。启动命令按构建工具不同# Maven 方式 mvn -pl coap-server-java exec:java -Dexec.mainClasscom.example.coap.CoapJavaServer # 或者先打包再跑 mvn -pl coap-server-java package java -jar coap-server-java/target/coap-server-java.jar跑起来后终端会打印监听日志。验证方式是用任意 CoAP 客户端发一个 GET比如命令行工具发coap://127.0.0.1:5683/hello服务端应该返回 2.05 Content 和一段 payload。如果客户端一直超时先确认防火墙有没有放行 UDP 5683UDP 没有“连接被拒绝”这种明确反馈超时是唯一症状。3.2 Android 端作为服务端的启动与权限Android 端多出来的复杂度全在生命周期和权限上。UDP socket 不能放在 Activity 里因为屏幕旋转或切后台 Activity 重建socket 就跟着没了。常见做法是写一个Service在onStartCommand里创建 socket 并起一个后台线程收包onDestroy里关闭 socket。public class CoapServerService extends Service { private DatagramSocket socket; private Thread worker; Override public int onStartCommand(Intent intent, int flags, int startId) { worker new Thread(() - { try { socket new DatagramSocket(5683); byte[] buf new byte[1024]; while (!Thread.currentThread().isInterrupted()) { DatagramPacket p new DatagramPacket(buf, buf.length); socket.receive(p); // 解析、路由、回包逻辑与 Java 端一致 handleAndReply(p); } } catch (Exception e) { Log.e(CoapServer, socket error, e); } }); worker.start(); return START_STICKY; // 被系统回收后尝试重建 } Override public void onDestroy() { if (socket ! null) socket.close(); // 关闭会打断 receive 阻塞 if (worker ! null) worker.interrupt(); super.onDestroy(); } }START_STICKY的含义是服务被系统杀掉后尽量重建但不要指望它百分百生效Android 后台限制因版本而异。socket.close()会让阻塞中的receive抛异常退出这是关闭收包线程的标准手法比单纯interrupt()可靠因为interrupt()打断不了阻塞在 native IO 上的线程。权限方面AndroidManifest.xml里至少要声明网络权限uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE /如果服务端要监听局域网内其他设备的请求还得确保设备连在同一个网段并且没有开启会隔离客户端的访客网络。Android 10 以后对后台网络有一定限制测试时把应用切到前台最稳。3.3 用客户端验证一次完整请求服务端跑起来只是第一步真正验证要靠一次完整的请求响应。CoAP 客户端可以用现成工具也可以自己写十几行 Java。自己写的好处是能看清 Token 和 MessageID 怎么对应DatagramSocket client new DatagramSocket(); byte[] req CoapCodec.encode(CoapMessage.get(/hello) .mid(0x1234) // 消息 ID用于匹配响应 .token(new byte[]{0x01, 0x02})); // Token用于匹配请求 client.send(new DatagramPacket(req, req.length, InetAddress.getByName(192.168.1.10), 5683)); byte[] buf new byte[1024]; DatagramPacket resp new DatagramPacket(buf, buf.length); client.setSoTimeout(3000); // UDP 无连接必须自己设超时 client.receive(resp); CoapMessage msg CoapCodec.decode( Arrays.copyOf(resp.getData(), resp.getLength())); System.out.println(code msg.getCode() payload msg.getPayload());mid和token是两个不同层级的东西MessageID 用于消息层的重传去重Token 用于请求响应匹配。很多实现只校验其中一个结果并发请求一多就串包。setSoTimeout必须设否则receive会永久阻塞调试时看起来像“卡死”实际是在等一个永远不会来的包。4. 核心代码拆解一条 CoAP 报文从字节到对象的往返4.1 报文头解析4 字节里的 Ver/Type/Code/MIDCoAP 固定头只有 4 字节但信息密度很高。第一个字节高 2 位是版本号固定为 01接着 2 位是消息类型CON/NON/ACK/RST再 4 位是 Token 长度第二个字节是 Code比如 0.01 是 GET2.05 是 Content第三、四字节是 MessageID。解析代码必须按位操作不能按字节读public static CoapMessage decode(byte[] data) { if (data.length 4) throw new IllegalArgumentException(too short); int first data[0] 0xFF; int version (first 6) 0x03; // 高 2 位 int type (first 4) 0x03; // 次 2 位 int tkl first 0x0F; // 低 4 位Token 长度 int code data[1] 0xFF; int mid ((data[2] 0xFF) 8) | (data[3] 0xFF); int offset 4; byte[] token Arrays.copyOfRange(data, offset, offset tkl); offset tkl; // 后面是 Option 序列和 0xFF 分隔符 payload Options options Options.decode(data, offset); byte[] payload options.getPayload(); return new CoapMessage(version, type, code, mid, token, options, payload); } 0xFF是必须的Java 的byte是有符号的data[0]如果是0x80会变成 -128直接右移会带符号扩展算出来的版本号就错了。tkl最大 8超过 8 的报文按规范是非法的解析时要校验。mid拼装时先左移 8 位再或顺序不能反否则大小端就错了。Option 部分从offset开始一直解析到遇到0xFF为止0xFF之后全是 payload。4.2 Option 编码Delta 与 Length 的变长玩法CoAP 的 Option 不是“编号 长度 值”这么直白而是用 Delta与上一个 Option 编号的差值和 Length 两个字段各自支持 0~12 直接编码、13 表示后面跟 1 字节扩展、14 表示跟 2 字节扩展。这样设计是为了让常见的小编号 Option 只占 1 字节头。编码逻辑是这份源码里最容易写错的地方static void encodeOption(ByteArrayOutputStream out, int lastNumber, CoapOption opt) { int delta opt.getNumber() - lastNumber; int len opt.getValue().length; int deltaNibble delta 13 ? delta : (delta 269 ? 13 : 14); int lenNibble len 13 ? len : (len 269 ? 13 : 14); out.write((deltaNibble 4) | lenNibble); if (deltaNibble 13) out.write(delta - 13); else if (deltaNibble 14) { out.write((delta - 269) 8); out.write((delta - 269) 0xFF); } if (lenNibble 13) out.write(len - 13); else if (lenNibble 14) { out.write((len - 269) 8); out.write((len - 269) 0xFF); } out.write(opt.getValue(), 0, len); }lastNumber是上一个已编码 Option 的编号第一个 Option 的lastNumber是 0。Delta 为 0 表示重复上一个编号这在 Uri-Path 分段时很常见比如/a/b会编码成两个编号 11 的 Option第二个的 Delta 就是 0。扩展字节的偏移量是 13 和 269 而不是 13 和 14因为 13 和 14 这两个值本身被用作“有扩展”的标志实际值从 13 和 269 开始算。这个细节写错报文在标准客户端上就会被判为格式错误而且错误很隐蔽——自己写的解码器可能能解出来因为编解码用了同一套错误逻辑。4.3 资源路由把 Uri-Path 映射到处理器服务端收到请求后要根据 Uri-Path 找到对应的处理逻辑。这份源码的路由表通常是一个MapString, ResourceHandlerkey 是路径字符串value 是处理函数。分发时把请求里的多个 Uri-Path Option 按顺序拼起来public class Router { private static final MapString, ResourceHandler table new HashMap(); static { table.put(/hello, req - CoapMessage.content(hello coap)); table.put(/temp, req - CoapMessage.content(readTemperature())); } public static CoapMessage handle(CoapMessage req) { String path req.getOptions().getUriPath(); // 拼接所有 Uri-Path ResourceHandler h table.get(path); if (h null) { return CoapMessage.notFound(); // 4.04 } if (req.getCode() ! CoapCode.GET) { return CoapMessage.methodNotAllowed(); // 4.05 } return h.handle(req); } }getUriPath()内部要把所有编号为 11 的 Option 值用/连起来注意开头要补/。路由匹配是精确匹配不支持通配这对小型服务端够用要支持/sensor/1、/sensor/2这种得在 handler 里自己解析尾段或者把路由表改成前缀匹配。方法校验放在路由之后、handler 之前返回 4.05 而不是 4.04这个区分对客户端调试很重要——4.04 是路径不存在4.05 是路径存在但方法不对。4.4 响应组装与消息类型匹配响应不是随便回一个包就行消息类型要和请求对上。请求是 CONConfirmable响应就得是 ACK 并复用同一个 MessageID请求是 NON响应就是 NON 并用新的 MessageID。Token 必须原样带回客户端靠它匹配。这段逻辑如果写错表现是客户端收到了包但认为“不是给我的”然后一直重传直到超时。public static CoapMessage buildResponse(CoapMessage req, CoapMessage resp) { if (req.getType() CoapType.CON) { resp.setType(CoapType.ACK); resp.setMid(req.getMid()); // ACK 必须复用请求的 MID } else { resp.setType(CoapType.NON); resp.setMid(nextMid()); // NON 用新 MID } resp.setToken(req.getToken()); // Token 永远原样带回 return resp; }nextMid()一般是个自增计数器回绕到 0 再继续。Token 长度可以是 0但一旦请求带了 Token响应必须带同样的字节序列不能重新生成。这套匹配规则是 CoAP 在 UDP 这种无连接传输上还能做请求响应模型的基础也是二次开发时最不该动的地方。5. 避坑与排查五个真实会卡住你的问题5.1 客户端一直超时服务端日志却显示已收到现象服务端打印了收到请求也打印了发送响应但客户端始终超时。原因多半是响应发错了地址或端口。DatagramPacket在receive之后getAddress()和getPort()才是请求方的真实来源如果代码里图省事写死了InetAddress.getLocalHost()或者用了监听端口回发包就发到错误的地方去了。解决方式是严格用packet.getAddress()和packet.getPort()构造响应包并且在 NAT 或容器环境下确认服务端看到的源地址确实是客户端地址。5.2 报文解析出来 payload 尾部带乱码现象payload 字符串后面多出一串不可见字符或旧数据。原因是用packet.getData()而不是Arrays.copyOf(data, packet.getLength())。DatagramPacket的缓冲区是复用的上一次收到的长报文会残留在缓冲区尾部这次收到短报文getData()返回的仍是整个缓冲区。解决方式是所有解析入口都先按getLength()截断这个习惯要贯彻到每一个receive调用点。5.3 Android 端切后台后服务端失联现象应用在前台时客户端能正常请求切到后台几分钟后全部超时。原因是 Android 对后台进程的网络和 CPU 有限制Service 可能被挂起或回收。解决方式是测试阶段保持前台正式场景考虑前台服务加通知或者接受“仅前台可用”的产品定位。另外确认onStartCommand返回了START_STICKY但这只是“尽量重建”不是保证。5.4 Option 编码后标准客户端返回 4.00 Bad Request现象自己写的客户端和服务端互通正常换成标准 CoAP 客户端就报 4.00。原因通常是 Option 的 Delta/Length 扩展字节算错尤其是 Delta 大于 12 的情况。解决方式是对照 RFC 7252 的 3.1 节逐字节核对或者抓包看十六进制重点检查 13/14 这两个标志值有没有被当成实际值参与运算。编解码共用一套错误逻辑时自测是发现不了的必须用第三方客户端交叉验证。5.5 端口被占用导致启动直接失败现象java -jar启动时报BindException: Address already in use。原因是 5683 端口已被其他进程占用可能是上一次没退干净的实例也可能是系统里别的 CoAP 服务。解决方式是lsof -i :5683或netstat -anp | grep 5683找到占用进程并处理调试期也可以临时换端口启动但记得客户端同步改。UDP 端口占用不像 TCP 那样有 TIME_WAIT通常进程退出就释放如果释放不掉检查是不是有子进程还活着。6. 二次开发加一个自定义资源与观察模式的改造思路拿到这份源码后最实际的改造是加自己的资源。以加一个/status返回设备状态为例改动点只有三处路由表加一行、写一个 handler、如果需要动态数据就注入数据源。handler 的签名保持CoapMessage - CoapMessage这样路由层完全不用动。我一般会先把 handler 写成纯函数输入请求输出响应不碰 socket这样单元测试可以直接调不用起服务。table.put(/status, req - { String json {\uptime\: uptimeSeconds() }; return CoapMessage.content(json) .option(CoapOption.contentFormat(50)); // 50 application/json });contentFormat这个 Option 编号是 12值 50 表示 JSON客户端据此决定怎么解析 payload。不加这个 Option很多客户端会按纯文本处理JSON 里的引号在日志里看着没问题但结构化解析会失败。再往深一层是观察模式Observe。CoAP 的 Observe 靠 Option 编号 6 实现客户端在 GET 里带Observe: 0表示订阅服务端在响应里带Observe: 序列号之后每次状态变化主动推一个 NON 响应给订阅者。改造要点是维护一个订阅者列表key 用客户端地址加端口加 Token每次推送时序列号递增客户端靠序列号判断新旧。这里最容易翻车的是订阅者清理——客户端不主动取消订阅就消失了服务端会一直往一个死地址发包。常见做法是记录最后一次确认时间超过阈值没收到 ACK 就移除或者限制订阅者总数。验证改造是否成功我习惯用两步先用自己写的客户端跑通一次请求响应确认编解码和路由没问题再换一个标准 CoAP 客户端做交叉验证确认 Option 编码符合规范。两步都过才认为这个资源是真的可用而不是“在我机器上能跑”。从那以后我每次改 CoAP 相关代码都强制先用抓包工具看一眼十六进制再相信日志里的字符串——报文层的错误日志往往会骗你。希望帮到你。本文还有配套的精品资源点击获取