
简介实现CoAP的Java源码包面向物联网开发者与Android/Java后端工程师用于快速搭建基于UDP的CoAP服务端与客户端测试环境。压缩包围绕CoAP协议实现展开包含app模块Android服务器端代码与coaplib模块Java服务器代码及客户端测试代码可帮助理解CoAP的资源发现、URI访问、GET/POST/PUT/DELETE操作及轻量化传输特性适合需要集成或扩展CoAP能力的项目中参考。包内共297个文件以262个Java源文件为核心另有少量XML配置、PNG资源、Gradle构建脚本、properties及说明文档整体结构清晰便于定位服务端、客户端与库代码压缩包大小仅582KB轻量易用。目前已有503人学习/下载适合具备一定Java基础、希望快速上手CoAP协议实现及Android端服务集成的开发者。1. 为什么 CoAPService 值得专门写一组 Java 源码看到标题第一反应这不是又一个给 HTTP 换皮的轮子。CoAPService 是一个以 Java 源码形式提供的 CoAP 服务器端实现目标环境圈得很窄Android 服务器端和普通 Java 服务器端。它解决的是智能单品、工控网关这类资源受限场景的读写问题——设备侧只发几百字节的控制指令却要求几百毫秒内拿到响应HTTP 的 TCP 握手和头开销太重CoAP 的 UDP 模型正好接得住。适合谁看正给 IoT 终端做本地调试接口的 Android 开发者以及想在公司内网起一个轻量级服务器端做原型验证的 Java 后端。这个标题值得跟下去因为 CoAP 的坑不在协议本身而在 Android 的电源管理和 UDP 端口。2. 动手前先把 CoAP 的资源模型理清GET/PUT 背后的消息交互2.1 CoAP 的消息层CON、NON 与重传的超时关系CoAP 不是“HTTP over UDP”它自己有一套消息语义。每个请求或响应都落在四种消息类型里CON需要确认、NON不需要确认、ACK确认和 RST重置。服务端处理业务之前消息层要先回答“这条 UDP 报文是不是新的、要不要重传”。消息类型典型场景失败行为CON开灯、下发控制指令超时重传指数退避默认最多 4 次NON温度遥测、心跳上报不重传丢了就丢了ACK对 CON 的响应确认同时携带响应内容省一个 RTTRST收到不认识的消息终止会话常用于资源不存在对服务器端而言最直接的感受是一个 CON 请求如果一直没回 ACK 或响应Californium 会按 2 秒、4 秒、8 秒的节奏重传。所以你的handleGET如果不是幂等操作就可能被连续触发。这个特性在真正写业务方法时要提前想到后面第 5 章会专门讲去重。2.2 资源层把 URI 映射成 CoapResource 而不是 Socket 监听服务端的代码组织方式不是监听端口然后手拆字节而是把 URI 映射到一个CoapResource上。比如客户端访问coap://192.168.1.10:56830/temperature框架会把请求路由到名为temperature的资源对象。先看一个最小资源定义import org.eclipse.californium.core.CoapResource; import org.eclipse.californium.core.coap.CoAP; import org.eclipse.californium.core.server.resources.CoapExchange; public class TempResource extends CoapResource { private float temperature 21.6f; public TempResource(String name) { super(name); setVisible(true); setObservable(true); } Override public void handleGET(CoapExchange exchange) { exchange.respond(String.format(%.1f, temperature)); } }这里super(name)里的字符串就是地址中的最后一段例如temperature。setObservable(true)开启观察者模式客户端可以长期订阅这个资源温度一变服务端主动推送这就是 CoAP 替代轮询的核心能力。handleGET里的CoapExchange把请求头和响应体封装在一起respond()会自动生成 2.05 Content 响应码。写 CoAP 服务端要忘掉 Socket 和输入输出流把注意力放在三个方法上handleGET、handlePUT、handlePOST。PUT 是控制类指令POST 可以当成创建资源或触发动作DELETE 用得最少。2.3 为什么服务端实现选 JavaAndroid 运行时和依赖体积的平衡标题里明确写了 Java 源码这不是随口一说。CoAP 服务端常见的 C 语言实现有 libcoap但把它塞进 Android 要用 JNI 桥接光是编译交叉工具链、处理jstring和指针生命周期就够喝一壶。Go 编译产物在 Android 上要么用 gomobile 封装要么用外部进程通信调试链路很长。Java 的优势在于 ART 虚拟机原生认识字节码Californium 这类纯 Java 库能直接打进 APK。依赖体积上仅 CoAP 核心库大概几百 KB对 APK 来说可忽略。更实在的是Java 服务端和 Android 服务端可以共用同一套CoAPService封装类只差 Service 生命周期和权限配置。这也是为什么很多人愿意围绕“CoAPService”这个名字做一层自己的封装而不是把 Californium 的 API 直接撒得到处都是。2.4 服务端可以只处理“请求”吗和 HTTP 框架的类比做过 Spring MVC 的人会把CoapResource类比成 Controller把handleGET类比成GetMapping。但有一层差异必须看清楚HTTP 框架帮你处理了 URL 编码、Content-Type 协商和响应状态码的大多数情况CoAP 的CoapExchange更偏底层它只保证把报文按资源路径分发过来正文格式和错误响应要自己判断。比如客户端用 POST 发来一行on要在handlePOST里自己决定是按纯文本解析还是按 JSON 解析。代码里常见的写法是先取exchange.getRequestText()再手动判断内容。所以在设计资源方法时我一般会先约定每个资源的请求正文格式再在方法第一行就把非法输入挡掉Override public void handlePOST(CoapExchange exchange) { String payload exchange.getRequestText(); if (payload null || payload.length() 128) { exchange.respond(CoAP.ResponseCode.BAD_REQUEST, invalid length); return; } // 业务处理 exchange.respond(CoAP.ResponseCode.CHANGED); }BAD_REQUEST对应 HTTP 的 400CHANGED对应 204。这套响应码表不复杂但必须由服务端主动返回否则弱网下的客户端会一直等到超时。3. 用 Java 搭一个可运行的 CoAP 服务器端CoAPService 的工程骨架与实践3.1 工程骨架与依赖Californium 加上一层 CoAPService 封装CoAP 在 Java 领域最成熟的协议栈是 Eclipse Californium这个标题里的“CoAPService”未必指向某个固定开源项目更常见的做法是在 Californium 外面再包一层自己的服务类把端点创建、资源注册、启动停止收拢到一个入口。这样做的好处是 Java 环境和 Android 环境都能调用同一个类。先建一个普通 Maven 工程只有两个包service 和 resource。依赖上只需要一个核心坐标dependency groupIdorg.eclipse.californium/groupId artifactIdcalifornium-core/artifactId version3.0.0/version /dependency如果你用 Gradle就写成implementation org.eclipse.californium:californium-core:3.0.0。版本号 3.0.0 只表示一个可用基线实际项目建议用当前拉到的 3.x 稳定版2.x 的 API 在CoapServer构造上略有差异。核心库会带出它自己依赖的 element-connector不需要额外声明。3.2 核心源码CoAPService 的启动、资源注册与请求分发下面是整个服务端的入口类。它把端口、资源列表和生命周期都封装起来Java 的main和 Android 的Service都只跟它打交道。package cn.iot.coap.service; import org.eclipse.californium.core.CoapResource; import org.eclipse.californium.core.CoapServer; import org.eclipse.californium.core.network.CoapEndpoint; public class CoAPService { private final int port; private final CoapServer server; private boolean started; public CoAPService(int port) { this.port port; // 只指定 UDP 端口内部使用默认绑定地址 0.0.0.0 CoapEndpoint endpoint new CoapEndpoint.Builder() .setPort(port) .build(); // CoapServer 可以挂多个 endpoint这里只挂一个 UDP 端点 server new CoapServer(); server.addEndpoint(endpoint); } public void addResource(CoapResource resource) { server.add(resource); } public synchronized void start() { if (started) { return; } server.start(); started true; } public synchronized void stop() { if (!started) { return; } server.stop(); started false; } public synchronized boolean isRunning() { return started; } public int getPort() { return port; } }CoapEndpoint.Builder().setPort(port)是创建 UDP 端点的推荐方式。CoapServer本身只是一个容器它不持有端口真正监听端口的是CoapEndpoint。server.add(resource)把资源挂载到根路径下资源注册顺序不影响路由因为框架按 URI 路径匹配。start()里加入started标记是为了防止 Android 的START_STICKY机制重复调用导致重复 start这个细节在移植到安卓时特别关键。3.3 Java 服务器端入口main 函数里把服务拉起来package cn.iot.coap; import cn.iot.coap.service.CoAPService; import cn.iot.coap.resource.LightResource; import cn.iot.coap.resource.TempResource; public class Main { public static void main(String[] args) throws Exception { int port 5683; if (args.length 0) { port Integer.parseInt(args[0]); } CoAPService service new CoAPService(port); service.addResource(new LightResource()); service.addResource(new TempResource()); service.start(); System.out.println(CoAP server listening on UDP port); // main 线程不能退出否则守护线程也跟着没了 Thread.currentThread().join(); } }Thread.currentThread().join()是让主线程永久挂起、等待子线程的一种简单写法。CoAP 服务内部用的是非阻塞 I/O 和守护线程如果 main 直接返回进程会立刻结束。如果你用的是 Spring Boot可以把CoAPService声明成一个Component在PostConstruct里 start在PreDestroy里 stop效果一样。3.4 读源码时的调用链一条 GET 是怎么走到 handleGET 的拿到一套现成的 CoAP 服务器端源码第一件事不是满屏找业务逻辑而是顺着一条请求走一遍。以GET coap://192.168.1.10:5683/light为例完整路径是系统 UDP Socket 收到报文交给UdpConnectorUdpConnector把报文包装成RawData送进Matcher做消息去重和 MID 校验匹配完的请求进入CoapServer的MessageDelivererMessageDeliverer.getResource(path)按路径找到LightResource调用LightResource.handleGET(exchange)开始执行业务代码。很多源码调不通问题出在第 2 步或者第 4 步。第 2 步出问题通常是同一个源 IP 的多个请求用了相同 MID被去重掉了第 4 步出问题一般是资源创建时名字带成了/light而不是light导致路径匹配多了一层斜杠。读源码时看到CoapServer、Endpoint、MessageDeliverer、CoapResource这四个类按这个顺序看就足够。3.5 代码里的参数调整端点和资源名的边界条件一段能跑的代码参数往往比方法体更重要。CoAPService里要注意的参数有三个端口、资源路径、观察者开关。端口在 0 到 65535 之间但 CoAP 的默认端口 5683 在很多系统上需要 root 权限测试时我习惯直接传 56830 这样的高位端口避开权限和冲突。资源路径的命名不要带斜杠new LightResource(light)会挂到/light如果你传/light客户端就要访问//light这个问题在源码里肉眼很难看出来。观察者开关setObservable(true)要谨慎它对每个订阅客户端都会维护观察关系如果资源状态频繁变化通知风暴会把 UDP 吞吐打满只有真正需要主动上报的字段才打开。4. 把 CoAPService 嵌进 Android权限、Service 生命周期与局域网细节4.1 AndroidManifest 配置UDP 权限与 Service 注册把同样的 Java 服务端源码搬到 Android Studio第一处改动在AndroidManifest.xml。CoAP 是 UDP网络权限必须声明服务要跑在后台Android 8 之后必须以前台服务方式运行所以FOREGROUND_SERVICE也要带上。uses-permission android:nameandroid.permission.INTERNET/ uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE/ uses-permission android:nameandroid.permission.ACCESS_WIFI_STATE/ uses-permission android:nameandroid.permission.WAKE_LOCK/ uses-permission android:nameandroid.permission.FOREGROUND_SERVICE/ application service android:name.service.CoapDaemonService android:exportedfalse/ /application这里不需要CHANGE_WIFI_STATECoAP 服务端只是监听不修改 WiFi 配置。UDP Socket 的创建也不需要运行时权限只要清单里写了INTERNET就行。很多人第一次翻车是因为把service写到了application外面导致 App 一启动就崩。4.2 用 Android Service 托管 CoAPService前台服务与生命周期CoAP 服务端不能直接放在 Activity 里页面一退就被回收。标准做法是写一个Service在onCreate中构建CoAPService在onStartCommand中 start在onDestroy中 stop。下面是一个完整可用的模板package cn.iot.coap.service; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.Service; import android.content.Intent; import android.content.pm.ServiceInfo; import android.net.wifi.WifiManager; import android.os.Build; import android.os.IBinder; import android.util.Log; public class CoapDaemonService extends Service { private static final String TAG CoAPService; private static final int NOTIFICATION_ID 101; private static final String CHANNEL_ID coap_server; private CoAPService coapService; private WifiManager.WifiLock wifiLock; Override public void onCreate() { super.onCreate(); createNotificationChannel(); // 复用第 3 章的 CoAPService端口用高位端口避开权限问题 coapService new CoAPService(56830); coapService.addResource(new LightResource()); WifiManager wifiManager (WifiManager) getApplicationContext().getSystemService(WIFI_SERVICE); wifiLock wifiManager.createWifiLock( WifiManager.WIFI_MODE_FULL_HIGH_PERF, coap_server_lock); wifiLock.acquire(); Notification notification new Notification.Builder(this, CHANNEL_ID) .setSmallIcon(android.R.drawable.ic_menu_compass) .setContentTitle(CoAP server) .setContentText(UDP port 56830) .build(); if (Build.VERSION.SDK_INT 29) { startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); } else { startForeground(NOTIFICATION_ID, notification); } } Override public int onStartCommand(Intent intent, int flags, int startId) { try { coapService.start(); } catch (Exception e) { Log.e(TAG, start failed, e); } return START_STICKY; } Override public void onDestroy() { coapService.stop(); if (wifiLock ! null wifiLock.isHeld()) { wifiLock.release(); } super.onDestroy(); } private void createNotificationChannel() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { NotificationChannel channel new NotificationChannel( CHANNEL_ID, CoAP Service, NotificationManager.IMPORTANCE_LOW); NotificationManager manager getSystemService(NotificationManager.class); manager.createNotificationChannel(channel); } } Override public IBinder onBind(Intent intent) { return null; } }这段代码有四个关键点。第一startForeground在 Android 10 以上需要指定前台服务类型DATA_SYNC是 CoAP 这类数据同步服务常选的类型。第二wifiLock用WIFI_MODE_FULL_HIGH_PERF防止息屏后 WiFi 进入节能模式导致 UDP 不可达。第三START_STICKY让系统在资源紧张被杀后尝试重启服务但重启会重新走onStartCommand所以CoAPService.start()内部必须有started判断。第四通知渠道要在startForeground之前创建否则 Android 8 以上直接抛异常。4.3 局域网细节获取本机 IP、WiFi 锁与 adb reverse 的坑CoAP 服务端要暴露给局域网里的其它设备客户端需要一个可达的 IP。手机上自己看192.168.1.10容易代码里获取却要注意多网卡问题。下面的方法返回第一个可用的 IPv4 地址private String getLocalIpAddress() { try { java.util.Enumerationjava.net.NetworkInterface interfaces java.net.NetworkInterface.getNetworkInterfaces(); while (interfaces.hasMoreElements()) { java.net.NetworkInterface networkInterface interfaces.nextElement(); if (networkInterface.isLoopback() || !networkInterface.isUp()) { continue; } java.util.Enumerationjava.net.InetAddress addresses networkInterface.getInetAddresses(); while (addresses.hasMoreElements()) { java.net.InetAddress address addresses.nextElement(); if (address instanceof java.net.Inet4Address) { return address.getHostAddress(); } } } } catch (java.net.SocketException e) { Log.e(TAG, get ip failed, e); } return 127.0.0.1; }注意这里返回的是第一个 IPv4 地址手机同时开了热点和 WiFi 时可能拿到的是热点网段的地址而不是当前 WiFi 的地址。更稳妥的做法是把所有非回环 IP 打出来用日志确认客户端该连哪一个。还有一个容易踩的坑很多人为了省事用adb reverse tcp:56830 tcp:56830让手机上的服务可以被电脑访问。但adb reverse只支持 TCP 协议CoAP 走的是 UDP这个通道根本不通。电脑想调试手机上的 CoAP 服务最直接的办法还是让手机和电脑连到同一个 WiFi然后直接用局域网 IP 访问。5. CoAP 在 Java/Android 上的避坑清单端口、休眠、大包与守护线程5.1 端口绑定失败Address already in use 的疑难杂症现象同一个 App 在开发环境反复安装、卸载、重新运行第二次打开时偶尔抛出java.net.BindException: Address already in use。原因UDP 端口不像 TCP 那样能通过连接状态判断是否被占用。上一次进程退出后内核端口释放有延迟如果CoapServer.stop()没有完全完成新的CoapEndpoint立刻绑同一个端口就会冲突。更隐蔽的是Android 系统里其它 App 也可能占了 5683 这个默认端口。解决方案有两层。第一层启动前探测端口是否可用只做提前预警不当作锁使用private boolean isPortAvailable(int port) { try (java.net.DatagramSocket socket new java.net.DatagramSocket(null)) { socket.bind(new java.net.InetSocketAddress(port)); return true; } catch (java.net.SocketException e) { return false; } }第二层也是我真正推荐的做法测试环境直接避开默认端口。用 56830 这类高位端口基本不会和系统服务冲突。如果仍然冲突用adb shell netstat -anu | grep 5683看到底是谁占着比盲目重启靠谱得多。5.2 Android 息屏之后CoAP 请求变成“已发送但手机没反应”现象电脑在局域网里向手机上的 CoAP 服务发 PUT 指令屏幕亮着时一切正常锁屏几秒后请求直接超时点亮屏幕瞬间请求突然到达。原因手机息屏后CPU 休眠Android 的网络栈虽然还在但应用进程不再被调度同时 WiFi 进入低功耗模式UDP 端口不再监听。CoAP 本身没有保活机制服务端被系统挂起后重传多少次都没用。解决在 Service 里同时持有WifiLock和PartialWakeLock。第 4 章的代码已经加了WifiLock还要防止 CPU 休眠再加一个锁PowerManager powerManager (PowerManager) getSystemService(POWER_SERVICE); PowerManager.WakeLock cpuLock powerManager.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, coap:cpu); cpuLock.acquire();这会让服务不进 Doze 的 CPU 休眠但手机仍然会亮屏锁定后黑屏。要注意的是前台服务本身只能降低被回收概率不能代替 CPU 唤醒锁只加WifiLock不加PartialWakeLock息屏后 CPU 还是可能睡过去。5.3 大包负载神秘消失Blockwise 协商失败的现场现象服务端返回一段超过 1KB 的 JSON 配置客户端第一次请求偶发拿到完整数据第二次请求超时换成小字段又一切正常。原因CoAP 底层是 UDP报文最大理论接近 64KB但大多数网络链路把超过 1024 字节的报文视为巨型包丢包率极高。Californium 默认启用 Blockwise 分块但如果客户端是个用 UDP Socket 手写的裸客户端不支持 Block2 分块选项两端协商就断了。解决要么让客户端也换成支持 Blockwise 的 CoAP 库要么在服务端把资源返回体控制在 1024 字节以内。调试时要看的日志关键字是BlockwiseStatus如果发现服务端日志里一直在“Request canceled”而业务代码没执行多半就是大包分块协商失败。服务端这边能做的额外一件事是把大资源拆成几个小资源让客户端分别拉取而不是强行追求一次响应完整。5.4 CON 重传风暴同一个 PUT 被执行了两次的幂等血泪现象弱网环境下手机 App 收到一个 502 或超时但服务端日志显示handlePUT被调了三次设备状态被重复切换。原因CoAP 的 CON 消息要求在超时时间内收到 ACK。客户端第一次发 PUT 没等到 ACK按指数退避重发同样的请求如果服务端前一次处理完但 ACK 丢失第二次重发就会再次进入handlePUT。协议层能去重但必须有相同的 Message ID 和 Token客户端库如果每次都生成新的 Token服务端就认为是新请求。解决服务端资源方法要写成幂等并且状态没变化时不触发changed()。下面是最常见的写法private String status off; private synchronized void setStatus(String newStatus, CoapExchange exchange) { if (newStatus.equals(status)) { exchange.respond(CoAP.ResponseCode.CHANGED); return; } status newStatus; changed(); exchange.respond(CoAP.ResponseCode.CHANGED); }这张“后悔药”是给服务端吃的状态相同就返回成功但不再通知观察者也不做物理开关动作。状态切换动作本身比如 GPIO 翻转一定放在status变更之后并且只执行一次。5.5 日志不打印System.out 在 Android 上消失现象Java 服务器端跑得好好的System.out.println能输出 CoAP 日志同样的代码搬到 Android Studio 里运行Logcat 里什么都看不到。原因Californium 内部使用java.util.logging而这个日志框架在 Android 上不会自动输出到 Logcat默认级别可能被调到 WARNING 甚至更严。Android 的应用代码里System.out也经常被 IDE 过滤掉看起来就像消息没进来。解决在资源方法里直接用android.util.Log打印关键节点不要依赖三方框架透传日志。我习惯在每个handleGET、handlePUT第一行加一条入站日志Log.d(CoAPService, PUT /light from exchange.getSourceAddress().getHostAddress() : exchange.getSourcePort());这样无论协议内部发生了什么至少能看到请求进入资源方法。如果这条日志有而外部客户端报超时那问题就出在respond()之后的链路而不是资源没注册。6. 验证和进阶coap-client 命令、抓包与响应耗时打印技巧6.1 用 coap-client 快速验证服务器端服务端起来之后不要急着写客户端代码。Linux 或 macOS 上装好 libcoap 后直接用命令行验证coap-client -m get coap://192.168.1.10:56830/light coap-client -m put -e on coap://192.168.1.10:56830/light coap-client -m get coap://192.168.1.10:56830/light-m指定方法-e指定 payload默认是纯文本。三个命令按顺序执行应该依次看到 off、成功响应、on。如果第一条就超时用ping 192.168.1.10确认网段通不通能 ping 通但 CoAP 不通重点检查端口和防火墙。6.2 抓包确认 CON/NON 与响应耗时客户端验证通过后打开 Wireshark 抓包过滤表达式写coap。看两条信息一是请求包的 Type 字段确认客户端默认用的是 CON 还是 NON二是 Block2 选项看大包分块是否发生。响应耗时的打印我通常会加在资源方法入口和出口long begin System.nanoTime(); // 业务逻辑 long costMs TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - begin); Log.d(CoAPService, GET /light cost costMs ms);我自己养成的习惯是每写一个CoapResource先打印入站和出站耗时再写业务逻辑。这样后来接手的同事不需要翻协议栈只看日志就能判断是网络慢还是业务慢。CoAP 的报文可以很小但调试它的时候信息越显眼越好。希望这个方案能帮你在 Java 和 Android 上少踩几个 UDP 的阴影早日把那盏灯点亮。本文还有配套的精品资源点击获取