上一篇讲了 BizBuddy 渠道网关的整体架构,这篇说实现。
先说结论有多重要:客户内网没有公网 IP 的时候,"能不能用长连接"这一条直接决定了项目能不能落地。Webhook 再优雅也救不了这个场景,因为方向不对——它要平台来回调你,而你没有能被人回调的地址。
长连接为什么能免公网 IP
区别就一点:连接是谁发起的。
Webhook 是平台发起、你接。你的服务必须在公网上有块地盘。
长连接反过来,你的应用主动去连平台网关,之后消息复用这条已经建好的 TCP / WebSocket 通道推给你。没有入站连接,自然就不需要公网 IP、域名、备案、内网穿透这一整套东西。顺带的,回调地址的 URL 校验握手也不需要了;建连时已经完成鉴权,之后的报文是明文的,Encrypt Key 和 Verification Token 都不用参与。
代价在后面:连接得自己维护——重连、退避、单副本部署,这些是长连接形态躲不开的功课。
钉钉 Stream:JSON 帧,但有个坑
钉钉 Stream 的帧是 JSON,理论上零依赖手写一个 WebSocket 客户端也能跑通。我们直接用了框架封好的长连接客户端,装配时传一个"收到报文怎么办"的回调就行:
case AiChannel.PLATFORM_DINGTALK -> {
// 框架的 apiBase 只管 OpenAPI(accessToken / 出站),
// 长连接注册地址是另一个独立字段,默认硬编码在默认基址上,不跟着 apiBase 走。
String streamRegisterUrl = StringUtils.isBlank(apiBase)
? null
: apiBase + "/v1.0/gateway/connections/open";
DingTalkChannelProperties props = new DingTalkChannelProperties(
appId, appSecret, robotCode,
apiBaseOrNull, /* ... */
streamRegisterUrl);
dingTalkStreamLocal = new DingTalkStreamClient(props, this::onDingTalkPayload);
}
注意那段注释。这是实打实踩过的坑:把 API 基址改成内网代理之后,OpenAPI 调用都正常了,长连接却一直起不来,日志里还看不出原因。翻框架源码才发现,"OpenAPI 基址"和"长连接注册地址"是两个独立字段,后者默认指向公网地址。上面那段显式派生就是修法。
内网机房、出口受限的环境,这个坑几乎必踩。
飞书长连接:私有协议,只能上官方 SDK
飞书长连接的帧是私有 protobuf。手写的意思是要自己维护帧格式、心跳、ACK、重连——投入不小,还容易踩协议的坑。所以这里我们做了一个权衡,也是整个项目里唯一一次破例新增第三方依赖:
<dependency>
<groupId>com.larksuite.oapi</groupId>
<artifactId>oapi-sdk</artifactId> <!-- 2.8.7 -->
</dependency>
理由很直接:飞书长连接的建连能力只有官方 SDK 有,自己实现属于重复造轮子。好在 SDK 自带 shaded 的 protobuf 和 okhttp,跟项目里的 protobuf 版本不打架。
我们没有让 SDK 的类型渗进业务代码,中间包了一层 FeishuStreamClient,全文不到 120 行:
public FeishuStreamClient(String appId, String appSecret, String domain, Consumer<JsonNode> onEvent) {
this.onEvent = onEvent;
// 长连接建连即鉴权、报文明文,dispatcher 的两个参数一律留空
EventDispatcher dispatcher = EventDispatcher.newBuilder("", "")
.onCustomizedEvent("im.message.receive_v1", new CustomEventHandler() {
@Override
public void handle(EventReq event) {
dispatch(event);
}
})
.build();
Client.Builder builder = new Client.Builder(appId, appSecret).eventHandler(dispatcher);
if (StringUtils.isNotBlank(domain)) {
// SDK 内部按 domain + 固定路径拼接,尾斜杠要去掉
builder.domain(domain.endsWith("/") ? domain.substring(0, domain.length() - 1) : domain.trim());
}
builder.onReconnecting(() -> log.info("飞书长连接重连中:appId={}", appId));
builder.onReconnected(() -> log.info("飞书长连接已重连:appId={}", appId));
this.client = builder.build();
}
public void start() throws Exception {
client.start(); // 异步,建连后立刻返回
client.awaitReady(10_000L); // 等握手,失败抛 ServerUnreachableException
}
public void stop() {
client.close(); // close 之后 SDK 不再自动重连
}
private void dispatch(EventReq event) {
try {
JsonNode payload = OBJECT_MAPPER.readTree(event.getBody());
onEvent.accept(payload); // 只入队,不阻塞
} catch (Exception e) {
log.warn("飞书长连接事件报文解析失败,已丢弃:原因={}", e.getMessage());
}
}
这段代码里有三个容易写错的地方,都在上面了:
newBuilder("", "") 的两个参数是 Verification Token 和 Encrypt Key,长连接形态用不上,但必须传空串,传 null 会出问题。
domain 要带 scheme(比如 https://open.feishu.cn),而且不能有尾斜杠,SDK 内部会拿它拼固定路径。
event.getBody() 拿到的是 schema 2.0 信封的原始 JSON,明文。不用解密、不用验签,可以直接丢给入站解析器。
三个官方约束,不守一定出问题
第一,3 秒内必须 ACK。 飞书要求收到事件后 3 秒内响应,所以 SDK 回调里绝不能在原地干活——更不能等 AI 把回答生成完:
private void dispatch(EventReq event) {
JsonNode payload = OBJECT_MAPPER.readTree(event.getBody());
onEvent.accept(payload); // 这里只是转交,立刻返回
}
真正的处理(解析、幂等、身份校验、调智能体、出站回复)全部异步丢到业务线程池。这一点反直觉,但它是长连接形态能不能稳的关键。
第二,集群模式,必须单副本接收。 飞书长连接是集群模式:同一个应用起了多个客户端副本时,一条消息只会随机投给其中一个。配上我们幂等走 Redis(跨副本共享)的设计,结论是:渠道长连接按单副本接收部署,幂等和限流用 Redis 兜。
这不是缺陷,是官方口径下的正常用法。但一定要主动讲清楚,否则上线后一定会被问"为什么另一个副本收不到消息"。
第三,配额和重连参数别自己发挥。 每应用最多 50 个连接,我们每个渠道只建一个 client;重连间隔、退避策略由服务端下发,不要自己拍脑袋调。
另外,长连接形态只支持企业自建应用,应用商店那种第三方应用用不了。选型阶段就要确认,别做到一半才发现。
谁来建连,谁来断连
长连接是应用主动连出去的,就必须有人负责拉起和关掉。这部分放在运行期装配的注册表里:
| 时机 | 做什么 |
|---|---|
应用就绪(ApplicationReadyEvent) | 按启用中的长连接渠道批量建连 |
| 渠道保存 / 删除 / 停用 | 先 stop 旧连接,再按新配置重建(靠指纹 id + updateTime 判断要不要重建) |
应用关闭(@PreDestroy) | 全部停连 |
装配层对外只暴露一个判定,把"钉钉恒长连接"和"飞书看 mode"收在同一个方法里:
public boolean isLongConnection() {
return dingTalkStream != null || feishuStream != null;
}
public void start() {
if (feishuStream != null) {
feishuStream.start(); // 建连 + 等握手
} else if (dingTalkStream != null) {
dingTalkStream.start();
} else {
return; // Webhook 形态,什么也不用做,等平台回调
}
log.info("渠道长连接已启动:channelCode={}", channelCode);
}
收报文之后的处理,两个平台长得一样
长连接收到报文之后怎么处理,跟 Webhook 形态是同一套骨架:
private void onFeishuPayload(JsonNode payload) {
Optional<String> eventId = FeishuInboundMapper.extractEventId(payload);
if (eventId.isPresent() && !idempotency.firstSeen(channelCode + "|" + eventId.get())) {
return; // 幂等:重复事件丢掉
}
Optional<InboundMessage> inbound = feishuMapper.map(payload);
if (inbound.isEmpty()) {
return; // 非文本或结构异常,静默丢弃
}
InboundMessage message = inbound.get();
if (!rateLimiter.allow(channelCode, message.peer().key())) {
log.warn("飞书长连接触发防自环限流:channelCode={}, peer={}", channelCode, message.peer().key());
return;
}
inboundHandler.accept(message); // 交给上层:身份 → 会话 → 无人值守执行
}
钉钉那边是同一个骨架,换的只有两处:幂等键从 event_id 换成 msgId,解析器换成钉钉的 inbound mapper。
还有一点值得提:飞书长连接的报文交给的是和 Webhook 形态同一份解析器。因为 getBody() 本来就是 schema 2.0 信封,两种形态在解析层天然合流。这也印证了那句"形态是接入层的事,不该是业务层的事"。
两边对比
| 钉钉 Stream | 飞书长连接 | |
|---|---|---|
| 帧协议 | JSON | 私有 protobuf |
| 要不要第三方 SDK | 不要,框架已封装 | 要,oapi-sdk(本项目唯一破例依赖) |
| 怎么建连 | start() | start() + awaitReady(ms) |
| 报文形态 | JSON | JSON(schema 2.0 信封,明文) |
| 要验签/解密件吗 | 不要,建连鉴权 | 不要,建连鉴权 |
| 重连 | 自动 | 自动,autoReconnect 默认开 |
| 主要坑 | 注册地址不跟 apiBase 走,得自己派生 | 三个参数细节:空串、domain 带 scheme、getBody 是明文 |
| 硬约束 | 集群模式单副本 | 3 秒 ACK、集群模式单副本、≤50 连接、仅自建应用 |
最后
长连接的价值可以用一句话概括:它把"要求你有公网可达"变成了"只要你能出网"。 对私有化交付来说,这一条往往就是生死线。
但代价也是实打实的:省下的是网络配置,多出来的是连接生命周期管理、3 秒 ACK 逼出来的异步化,以及单副本的部署纪律。这三件事做扎实,长连接就是内网场景下最合适的 IM 接入形态。
下一篇收个尾,讲落地:《企业级 AI 平台的 IM 接入:从选型到上线,一篇讲完》——建表、后台配置、身份映射、上线检查表,还有一张真实报错对照表,可以直接当交付手册用。
关于 BizBuddy
BizBuddy 是一个全量开源的企业级智能体平台(MIT License):RuoYi-Vue-Plus(Spring Boot 4.1.1 / JDK 21)做底座,AgentScope Java Harness 做智能体内核,纯 Java 全栈、不含 Python,权限、审计、配额、多渠道接入等能力全部随仓库开源。
欢迎 Star、Issue 与共建。