上一篇聊了代驾系统的整体架构和三级派单,这篇往下钻一层:司机端和乘客端挂在 Netty 长连接上,手机进电梯、切后台、弱网闪断是家常便饭,连接断了之后怎么发现、怎么重连、断线期间的消息怎么不丢,这套机制才是派单链路真正能用起来的地基。
全部代码来自我们的开源代驾系统项目。
一、协议设计:两个枚举定生死
长连接第一件事不是写代码,是定协议。我们把"客户端要干什么"和"服务端回了什么"拆成两个枚举。
客户端操作类型(NettyHandleEnums):
CONNECT(1):第一次连接或重连时的初始化KEEPALIVE(2):心跳保活COORDINATE_DRIVER(3):司机坐标上报
服务端返回码(NettyCodeEnums)截取几个关键的:
java
KEEPALIVE(1,"心跳正常"),
KEEPALIVE_ERROR(-1,"心跳异常"),
CONNECT(2,"第一次连接正常"),
CONNECT_ERROR(-2,"第一次连接异常"),
DRIVER_UNFINISHED_ORDER(29,"司机有未完成订单"),
DRIVER_STATUS_RESET(30,"刷新司机状态"),
ORDER_MOVE(100,"订单行驶中"),
SOCKET_TIME_OUT(101,"读写超时"),
LOGIN_ERROR(997,"账号登录失效")
注意 DRIVER_UNFINISHED_ORDER 和 ORDER_MOVE 这两个码------它们存在的意义就是重连恢复,后面会讲到。
二、服务端 pipeline:10 秒空闲就踢
Netty 的 pipeline 配置在 DataAcceptInitializer 里:
java
ChannelPipeline pipeline = socketChannel.pipeline();
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new ChunkedWriteHandler());
pipeline.addLast(new HttpObjectAggregator(1024*64));
//===========================增加心跳支持==============================
/**
* 针对客户端,如果在1分钟时间内没有向服务端发送读写心跳(ALL),则主动断开连接
*/
pipeline.addLast(new IdlleStateHandler(0, 0, 10, TimeUnit.SEONDS));
pipeline.addLast(dataAcceptHandler);
pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
IdleStateHandler(0, 0, 10) 意思是读空闲不查、写空闲不查,但读+写加起来 10 秒没有任何动静就触发 ALL_IDLE 事件。客户端心跳间隔必须小于这个值,实际 App 端是几秒一次。
触发超时后的处理在 userEventTriggered:
java
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
if (evt instanceof IdleStateEvent) {
IdlleStateEvent evt = (IdleStateEnvent) evt;
if (event.state() == IdleState.ALL_IDLE) {
log.info("===客户端===(ALL_IDLE 总超时 重新连接)");
}
}
ctx.channel().writeAndFlush(new MsgVo(NettyCodeEnums.SOCKET_TIME_OUT).toJsonStringbuf());
channelInactive(ctx);//清除netty通道
}
先给客户端推一个 SOCKET_TIME_OUT(101)(万一它还活着,知道自己被踢了、立刻重连),然后进 channelInactive 清理通道。
三、CONNECT:重连不只是重连,是状态恢复
连接管理用的是个单机 HashMap,key 带角色前缀:
java
public class UserChanelRel {
private static HashMap<String, Channel> manage = new HashMap<>();
// key 前缀区分角色:driver:{id} / user:{id}
public static void put(String senderId, Channel channel){
manage.put(senderid, channel);
}
}
CONNECT: //客户端连接--第一次或者重连处理 if (chanelContext.equals("CONNECT")) { //每次连接或者重连时,把连接存入连接池中 UserChanelRel.put(didStr, ctx.channel()); channel.writeAndFlush(new MsgVo(NettyCodeEnums.CONNECT).toJsonStringbuf()); if (cal.isEquals(channelIdentity, StaticUtils.STATUS_YES)){ //校验司机是否处于服务状态 OrderMain orderMain = orderMainService.selectOrderByDriver(did); if (orderMain != null) { //反馈司机有未完成订单 MsgVo msgVo = new MsgVo(NettyCodeEnums.DRIVER_UNFINISHED_ORDER); msgVo.setOrderCode(orderMain.getOrderCode()); channel.writeAndFlush(msgVo.toJsonStringbuf()); } }
scss
这里的重点是:**重连成功不等于完事**。司机端重连后,服务端要查他名下有没有未完成订单,有就推 `DRIVER_UNFINISHED_ORDER`,App 收到后把订单界面恢复出来。用户端更细------如果重连时带着 orderCode,且订单正在行驶中,服务端会把里程、等待时长、费用等完整状态通过 `ORDER_MOVE` 推回去,用户端直接接着断线前的界面渲染。
这是踩过坑才明白的:只恢复连接不恢复状态,用户看到的就是一个"回到首页"的 App,订单凭空消失,投诉就是这么来的。
## 四、KEEPALIVE:心跳顺手干三件正事
心跳包不只是保活,我们的心跳分支顺手做了三件事:
```java
case KEEPALIVE:
//1. 心跳包超过350字节判定为非法,直接断开
if (length > 350) {
ctx.channel().writeAndFlush(new MsgVo(NettyCodeEnums.SOCKET_ERROR).toJsonStringbuf());
channelInactive(ctx);
UserChanelRel.delChannel(didStr);
}
//2. 没走过CONNECT的心跳请求,移除
Channel channel1 = UserChanelRel.get(didStr);
if (channel1 == null){
ctx.writeAndFlush(new MsgVo(NettyCodeEnums.SOCKET_ERROR).toJsonStringbuf());
channelInactive(ctx);
}
先做两道安全校验:包太大(>350 字节)的是恶意流量,踢;没经过 CONNECT 注册就发心跳的,也是非法连接,踢。
然后分角色处理。司机端心跳里带着经纬度,所以心跳 = 位置上报,一举两得:
java
if (cal.isEquals(channelIdentity, StaticUtils.STATUS_YES)) {
//更新司机位置
DDriver driver = dDriverService.getById(did);
driver.setLonVal(requestVo.getLongitude());
driver.setLatVal(requestVo.getLatitude());
driver.setAddress(requestVo.getAddress());
//异常状态的司机,名下没单则恢复为可接单
Integer state = driver.getState();
if (cal.isEquals(state,StaticUtils.STATUS_SUCCESS)||cal.isEquals(state,StaticUtils.STATUS_FAIL)){
OrderMain orderMain = orderMainService.selectOrderByDriver(did);
if (orderMain==null){
driver.setState(StaticUtils.STATUS_YES);
}
}
dDriverService.updateById(driver);
//回推最新司机状态,让App刷新接单开关
MsgVo msgVo = new MsgVo(NettyCodeEnums.DRIVER_STATUS_RESET);
msgVo.setDriverStatus(driver.getState());
ctx.channel().writeAndFlush(msgVo.toJsonStringbuf());
}
用户端心跳则承担消息补偿------断线期间推不出去的消息暂存在 Redis,心跳一上来就补发:
java
}else {
String key = redisUtilsServer.getLey(didStr);
if (StringUtils.isNotEmpty(key)){
MsgVo msgVo = JSONAbject.parseObject(key, MsgVo.class);
ctx.channel().writeAndFlush(msgVo.toJsonStringbuf());
redisUtilsService.delete(didStr);
}
}
- 唨户端心跳则承担消息补偿**------断线期间推不出去的消息暂存在 Redis,推送走消息队列广播。
- channelInactive 里更新司机离线状态的代码被注释掉了。现在的逻辑是司机掉线后状态不立刻变,等下次心跳或派单时才发现。这是个妥协:立刻置离线会误伤闪断的司机,不置又会给已掉线司机派单,我们在派单侧做了二次校验来兜底。
写在最后
长连接这块的完整代码(NettyServer、DataAcceptInitializer、DataAcceptHandler、UserChanelRel、协议枚举)都在开源仓库里,配上一篇的派单和计价,主链路是闭环的。下一篇准备聊聊抢单池的并发处理,有兴趣的可以先 Star。