第 11 篇《External Control》里我们讲了 wsf_external_control 插件会在本机 127.0.0.1:31000 起一个 TCP server。本篇就动手——用 Java 写一个 TCP 客户端连上去,把它真正"驱动"起来。我会给出可对照的代码片段、JSON 指令格式、Spring Boot 集成方式,再附一段 Python 等价实现和踩坑清单。
一、先看清通信契约
在写代码前要明确:我们连的是 127.0.0.1:31000 的 TCP server,连接建立后双向收发管道分隔的纯文本命令(真实格式为COMMAND|key=value|...),每条消息以换行符 \n 作为分隔(行协议,简单可靠)。客户端主动拉取状态、主动下发指令;插件主动推送 track 快照(响应 DATA|...)。记住这个"行协议 + 管道文本"的基调,后面代码都围绕它(第 11 篇已用安装实证纠正了此前"JSON"的说法)。
为什么是 127.0.0.1(环回地址)而不是某个局域网 IP?因为外部控制追求最低延迟与最高安全——控制程序通常和 AFSIM 引擎跑在同一台机器上,走环回网卡几乎零网络开销,也不会把仿真端口暴露给外部网络被人误连。如果你的架构确实需要跨机控制(例如后端部署在另一台服务器),那就得把插件监听地址改成可达 IP,并叠加防火墙与鉴权,否则等于把"开火权"敞开了大门。对绝大多数学习与原型场景,守住 127.0.0.1 是最省心也最稳妥的选择。
二、Java TCP 客户端核心代码
下面是一个最小可对照的 Java 客户端骨架:建立 Socket、拿到读写流、按行收发。
public class AFSIMClient { private final String host = ”127.0.0.1”; private final int port = 31000; private Socket socket; private BufferedReader in; private BufferedWriter out; public void connect() throws IOException { socket = new Socket(host, port); // 连接插件 TCP server in = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); out = new BufferedWriter( new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)); System.out.println(”已连接 AFSIM 外部控制端口 ” + host + ”:” + port); } // 发送一行 JSON 指令(以 \n 结尾) public void send(String json) throws IOException { out.write(json); out.write(”\n”); out.flush(); } // 读取插件推送的一行(track / 状态) public String receive() throws IOException { return in.readLine(); }}
注意:务必用 StandardCharsets.UTF_8 统一编码,否则中文平台名或日志可能出现乱码(详见第四节坑点)。
三、COMMAND|key=value 指令封装(真实线协议)
结合真实项目里的 UAV_01(RECON_STRIKE_UAV)与 Enemy_Ship(ENEMY_DDG),一条让无人机抵近并打击的指令长这样:
MOVE_TO|platform=UAV_01|lat=32.85|lon=120.50|alt=8000|speed=220FIRE|platform=UAV_01|weapon=fox3|target=Enemy_Ship|qty=1
插件收到后,会分别翻译成第 11 篇讲的 FlyToEvent 与 FireWeaponEvent,入队由仿真执行。封装成方法很自然:
public void moveTo(String platform, double lat, double lon, double alt, double speed) throws IOException { String cmd = String.format(”MOVE_TO|platform=%s|lat=%f|lon=%f|alt=%f|speed=%f”, platform, lat, lon, alt, speed); send(cmd);}public void fireWeapon(String platform, String weapon, String target) throws IOException { String cmd = String.format(”FIRE|platform=%s|weapon=%s|target=%s|qty=1”, platform, weapon, target); send(cmd);}
有两个易错点值得提前说:第一,坐标单位与原点必须和插件端约定一致(经纬度用十进制度或度分秒串,取决于插件解析逻辑),否则无人机会"飞到一个莫名其妙的点";第二,target 填的是平台名字符串(如 "Enemy_Ship"),插件会去 WsfTrackList 里查这个名字对应的 track 再开火,名字拼错就会"找不到目标"。在真实项目里,我们通常会先发 GET_STATUS / LIST_PLATFORMS 确认目标存在且在我方射程内,再下发 FIRE,避免盲目开火。
四、与 Spring Boot 集成
真实项目里,客户端不会裸跑,而是作为 Spring Boot 的一个服务 bean 管理连接生命周期,并通过 Controller 暴露给前端。核心类就是 ExternalControlService:
@Servicepublic class ExternalControlService { private AFSIMClient client; @PostConstruct public void init() throws IOException { client = new AFSIMClient(); client.connect(); // 应用启动即连 127.0.0.1:31000 startReceiveLoop(); // 后台线程持续拉取 track } private void startReceiveLoop() { new Thread(() -> { try { while (true) { String line = client.receive(); // 插件推送的 track/状态 if (line == null) break; handleTrack(line); // 更新态势、触发决策 } } catch (IOException e) { /* 重连逻辑 */ } }).start(); } public void orderStrike() throws IOException { client.flyTo(”UAV_01”, 120000, 85000, 5000); client.fireWeapon(”UAV_01”, ”Enemy_Ship”); }}
再用一个 Controller 把能力暴露出去:
@RestController@RequestMapping(”/afsim”)public class ExternalControlController { @Autowired private ExternalControlService svc; @PostMapping(”/strike”) public String strike() throws IOException { svc.orderStrike(); return ”指令已下发”; }}
这样,前端点一下按钮,后端就通过 TCP 把指令送进仿真——一个完整的"感知-决策-行动"闭环就搭好了。
五、Python 等价客户端
如果你更习惯 Python(比如做算法验证),等价实现非常短:
import socketdef main(): s = socket.create_connection((”127.0.0.1”, 31000))# 真实协议:管道分隔纯文本,以 \n 结尾 s.sendall(b”MOVE_TO|platform=UAV_01|lat=32.85|lon=120.50|alt=8000|speed=220\n”) print(s.recv(4096).decode(”utf-8”))# 形如 OK|... 或 DATA|... s.close()if __name__ == ”__main__”: main()
可见 Python 与 Java 的差异只在语法,通信契约完全一致——同一套COMMAND|key=value管道文本协议,谁都能连。
在真实项目里,我们的客户端遵循一个简单但稳健的"先读后写"模式:启动后先持续 receive() 插件推送的 track 快照,把它解析成内存里的态势对象;业务线程(如 Controller 收到前端请求)再基于这份最新态势决定要不要下发 MOVE_TO / FIRE。也就是说,读是常驻后台线程,写是按需触发,两者通过同一个 Socket 协作。多线程共用一个输出流时要加锁,避免两条指令黏在一起被插件当一条解析而报错。这个小细节,是多数初学者第一次联调时最容易被绊倒的地方。
六、常见坑清单
- 连接被拒(Connection refused):99% 是
wsf_external_control 插件没启动,或仿真还没跑到监听那一步。确认 AFSIM 场景已加载该插件、仿真正在运行,再确认端口确实是 31000 而非被改过。别急着改代码,先看 Wizard 运行日志有没有"external control listening"字样。 - 编码乱码:务必客户端与插件两端统一 UTF-8;Java 不要依赖平台默认编码。Windows 上
new OutputStreamWriter(socket.getOutputStream()) 不指明字符集时会用系统默认(GBK),与插件的 UTF-8 一碰面中文就乱码——始终显式传 StandardCharsets.UTF_8。 - 心跳/超时:长时间无交互,某些环境会断连。可在空闲时周期性发一个轻量 ping 消息保活,断连后做指数退避重连,尽量不要用死循环无间隔重连把 CPU 打满。
- 消息边界:用
\n 行分隔,读取端要用 readLine 或按行切分,别一次性读半条命令。若一次 recv 拿到两条消息,要按 \n 拆开逐条解析——每条都是形如 COMMAND|key=value|... 的纯文本,黏在一起会被插件当成一条错指令。 - 指令未生效:确认平台名拼写与仿真内一致(
UAV_01 不是 uav_01),且目标在射程/航程内。插件不会替你校验"能不能打到",发错只会被静默忽略或报错。 - 多线程写竞争:如上一节所说,后台读线程与业务写线程共用 Socket 输出流,务必加锁串行化写操作,否则两条指令黏连会导致解析失败。
小结
- 外部控制是 TCP 行协议 + 管道文本:客户端连
127.0.0.1:31000,按 \n 分隔收发形如 COMMAND|key=value|... 的纯文本消息(不是 JSON)。 - Java 客户端核心是
Socket + BufferedReader/Writer,统一 UTF-8;指令以 MOVE_TO / FIRE 等 COMMAND|... 文本行下发,响应以 OK| / ERROR| / DATA| 文本行回传。 - Spring Boot 中由
ExternalControlService 管理连接与接收循环,经 Controller 暴露给业务/前端。 - Python 客户端与 Java 同契约,几行即可连上,适合算法快速验证。
- 踩坑重点:连接被拒=插件未启动、编码统一、心跳保活、行边界、平台名大小写。想扩展协议或自己写插件,见 第 14 篇《自定义 C++ 插件开发》。
下期预告
代码闭环已经打通,但"我到底有没有连对、指令有没有生效、态势长啥样",光看日志不够直观。下一篇《13 可视化利器 Wizard:快速调试与态势呈现》带你用 AFSIM 自带的 Wizard 把场景树、track、外部控制连接状态一目了然地呈现出来,调试效率直接拉满。点赞收藏关注,咱们下期见!