一、背景
笔者前段时间开发了一款非常有意思的项目,已知在同一个局域网下有两款App分别运行在不同的设备上,业务上两款App分别具有不同的功能,同时两款App需要支持互相通信。后面我们称两款设备上的两款App一个为Server,一个为Client。
基于此我们需要考虑以下几点
- 如何发现对方
- 两款App如何通信
- 通信协议如何选择
二、发现对方
在局域网中查找识别我们可以基于DNS-SD协议。
1、DNS-SD协议介绍
DNS-SD(Domain Name System - Service Discovery)是一种用于在局域网(Local Area Network,LAN)中发现服务的协议。它使用DNS协议扩展了域名系统(Domain Name System,DNS)的功能,使得客户端能够在局域网中查找特定类型的服务,并获取有关该服务的信息。
在Android 中也有对应的Api可以使用:NsdManager。 NsdManager主要实现三个功能:
- 注册
- 发现
- 解析
其中一台设备作为服务端(即上面的Server App)通过NsdManager注册服务,提供自己的IP地址与端口号,同时可以提供用于标识自己的信息,例如设备ID。当一个局域网中有多台服务时,客户端在发现服务后,可以基于标识信息确认是否为自己需要找到的服务端。
另一台设备作为客户端(即上面的Client App),通过NsdManager的发现接口去发现服务。发现服务之后,再调用解析接口,获取到对应的信息,如服务端的IP地址、端口号、设备标识信息等。
详细的官方介绍地址:developer.android.com/reference/a...
2、注册
作为服务端的Server App,通过NsdManager注册。
less
NsdManager nsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE);
NsdServiceInfo serviceInfo = new NsdServiceInfo();
serviceInfo.setServiceName(ServiceName);
serviceInfo.setServiceType(ServiceType);
int localPort = getLocalPort();
serviceInfo.setPort(localPort);
serviceInfo.setAttribute(Attribute_UUID, UuidManager.INSTANCE.getUUID());
String ipAddress = LocalIpUtils.getIPAddress();
serviceInfo.setAttribute(Attribute_IP, ipAddress);
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, new NsdManager.RegistrationListener() {
@Override
public void onRegistrationFailed(NsdServiceInfo nsdServiceInfo, int i) {
//异常上报
}
@Override
public void onUnregistrationFailed(NsdServiceInfo nsdServiceInfo, int i) {}
@Override
public void onServiceRegistered(NsdServiceInfo nsdServiceInfo) {
//注册成功
}
@Override
public void onServiceUnregistered(NsdServiceInfo nsdServiceInfo) {}
});
3、发现
Client App,调用NsdManager#discoverServices()接口去发现服务。
less
NsdManager nsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE);
discoveryListener = new NsdManager.DiscoveryListener() {
@Override
public void onStartDiscoveryFailed(String s, int i) {}
@Override
public void onStopDiscoveryFailed(String s, int i) {}
@Override
public void onDiscoveryStarted(String s) {}
@Override
public void onDiscoveryStopped(String s) {}
@Override
public void onServiceFound(NsdServiceInfo nsdServiceInfo) {}
@Override
public void onServiceLost(NsdServiceInfo nsdServiceInfo) {}
};
nsdManager.discoverServices(NsdServer.ServiceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener);
4、解析
Client App 收到onServcieFound回调之后,就可以调用解析接口解析数据了
typescript
nsdManager.resolveService(nsdServiceInfo, new NsdManager.ResolveListener() {
@Override
public void onServiceResolved(NsdServiceInfo nsdServiceInfo) {
Map<String, byte[]> attributes = nsdServiceInfo.getAttributes();
}
@Override
public void onResolveFailed(NsdServiceInfo nsdServiceInfo, int i) {}
});
二、TCP通信
发现设备之后,我们要考虑两台设备要如何通信。此时我们可以有两套方案,分别是HTTP以及TCP。
HTTP
使用HTTP的话,就是在Server App上启动一个HTTP服务,我们可以预定义一些接口用于和Client来通信。 优点是比较简单,缺点是Server App没有办法主动通知Client App。
TCP
直接使用TCP协议的话,我们可以考虑选择一个支持TCP通信的框架,自定义一个简易的通信协议。优点是客户端与服务端可以互相通信,满足我们的需求。缺点是相对复杂一些。
在我们的项目中,我们有两台设备交互通信的需求,所以选择了TCP协议。
1、TCP通信库的选择
在我们的项目中使用了Netty作为TCP通信框架。关于Netty其大名鼎鼎,网络上的分析文章一大把,这里就不详细介绍了。Netty在国内有一位步道的大神名为李林峰(在华为工作),有兴趣的同学可以去看看大神的书。
2、简易的TCP协议设计
由于业务比较简单,所以这里我们将TCP通信协议进行简化。整体协议大致如下:[Int][String]。
其中Int用于指定后面String字符串长度,我们可以基于这个Int来处理拆包、粘包的问题,这个逻辑后面详细介绍。String可以是JSON结构,可以依据业务来定义JSON中字段。
3、TCP服务端
使用Netty实现TCP服务端大致代码
scss
bossGroup = new NioEventLoopGroup();
workerGroup = new NioEventLoopGroup();
//构建引导程序
mBootstrap = new ServerBootstrap();
//设置EventGroup
mBootstrap.group(bossGroup, workerGroup);
//设置Channel
mBootstrap.channel(NioServerSocketChannel.class);
mBootstrap.option(ChannelOption.SO_BACKLOG, 128);
//设置的好处是禁用Nagle算法。表示不延迟立即发送
//这个算法试图减少TCP包的数量和结构性开销,将多个较小的包组合较大的包进行发送。
//这个算法收TCP延迟确认影响,会导致相继两次向链接发送请求包。
mBootstrap.option(ChannelOption.TCP_NODELAY, false);
mBootstrap.option(ChannelOption.SO_KEEPALIVE, true);
mBootstrap.childHandler(new CustomChannelInitializer());
channelFuture = mBootstrap.bind(inetPort).sync();
以上TCP服务就启动了。
4、TCP客户端
使用Netty实现TCP客户端大致代码:
初始化
scss
//构建线程池
mGroup = new NioEventLoopGroup();
//构建引导程序
mBootstrap = new Bootstrap();
//设置EventGroup
mBootstrap.group(mGroup);
//设置Channel
mBootstrap.channel(NioSocketChannel.class);
//设置的好处是禁用Nagle算法。表示不延迟立即发送
//这个算法试图减少TCP包的数量和结构性开销,将多个较小的包组合较大的包进行发送。
//这个算法收TCP延迟确认影响,会导致相继两次向链接发送请求包。
mBootstrap.option(ChannelOption.TCP_NODELAY, false);
mBootstrap.option(ChannelOption.SO_KEEPALIVE, true);
mBootstrap.remoteAddress(ip.getIp(), ip.getPort());
mBootstrap.handler(new CustomChannelInitializer());
建立连接
java
ChannelFuture channelFuture = mBootstrap.connect();
channelFuture.addListener(new FutureListener() {
@Override
public void operationComplete(Future future) {
final boolean isSuccess = future.isSuccess();
writeLog("operation complete future.isSuccess: " + isSuccess);
}
});
断开连接
php
public void disConnect(boolean onPurpose) {
try {
if (mGroup != null) {
mGroup.shutdownGracefully();
}
} catch (Exception e) {
e.printStackTrace();
}
try {
if (mChannel != null) {
mChannel.close();
}
} catch (Exception e) {
e.printStackTrace();
}
try {
if (mChannelHandlerContext != null) {
mChannelHandlerContext.close();
}
} catch (Exception e) {
e.printStackTrace();
}
}
我们可以在operationComplete()方法中,确认建立连接成功。
ChannelInitializer
为了让更多协议和其他各种方式处理数据,Netty有了Handler组件。Handler就是为了处理Netty里面的置顶事件或一组事件。 ChannelInitializer 的作用就是将Handler 添加到ChannelPipeline中。当你发送或收到消息的时候,这些Handler就决定怎么处理你的数据。
java
public class CustomChannelInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel socketChannel) {
ChannelPipeline pipeline = socketChannel.pipeline();
mCustomDecoder = new CustomDecoder();
mCustomEncoder = new CustomEncoder();
mCustomHandler = new CustomHandler();
pipeline.addLast(mCustomDecoder);
pipeline.addLast(mCustomEncoder);
pipeline.addLast(mCustomHandler);
}
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
super.handlerAdded(ctx);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
super.exceptionCaught(ctx, cause);
}
}
ChannelPipeline 是一个管道,Handler就是里面一层一层要对数据进行处理的事件。所有的Handler都一个顶层接口ChannelHandler。ChannelHandler有两个子接口:
- ChannelInboundHandler
- ChannelOutboundHandler
Netty中数据流有2个方向:
- 数据进(应用收到消息)的时候与ChannelInboundHandler有关。
- 数据出(应用发出数据)的时候与ChannelOutboundHandler有关。
为了将数据从一端发送到另一端,一般都会有一个或多个ChannelHandler用各种方式对数据进行操作。 决定这些Handler以一种特定的顺序处理数据的是ChannelPipeline。
ChannelInboundHandler与ChannelOutboundHandler可以混在同一个ChannelPipeline里面。当应用收到数据时,首先从ChannelPipeline的头部进入到一个ChannelInboundHandler 。第一个ChannelInboundHandler处理后传给下一个ChannelInboundHandler。 然后ChannelPipeline中没有其他的ChannelInboundHandler 了数据就会到达ChannelPipeline的尾部,也就是应用对数据的处理已经完成了。
数据流出的过程是返过来的,首先从ChannelPipeline的尾部开始进入到最后一个ChannelOutboundHandler,最后一个ChannelOutboundHandler处理后,传给前面一个ChannelOutboundHandler。 和进不同的,进是从前到后,而出是从后到前。没有多余的ChannelOutboundHandler的时候,数据进入实际网络中传输,触发一些IO操作。
一旦一个ChannelHandler被添加到ChannelPipeline中,它就会获得一个ChannelHandlerContext。一般情况下,获得这个对象并持有它是安全的, 不过在数据包协议的时候不一样安全,例如UDP协议。 在Netty中有两种发送数据的方式。你可以写到Channel中或者使用ChannelhandlerContext对象。他们的主要区别是,直接写到Channel,则数据会从ChannelPipeline头部传到尾部。 每一个ChannelHandler都会处理数据,而使用ChannelHandlerContext则是将数据传送到下一个ChannelHandler。
一个 Channel 包含了一个 ChannelPipeline, 而 ChannelPipeline 中又维护了一个由 ChannelHandlerContext 组成的双向链表, 并且每个 ChannelHandlerContext 中又关联着一个 ChannelHandler。 入站事件和出站事件在一个双向链表中,入站事件会从链表head往后传递到最后一个入站的handler,出站事件会从链表tail往前传递到最前一个出站的handler,两种类型的handler互不干扰。
我们在 CustomChannelInitializer#initChannel()方法中添加编码、解码器。其中 CustomHandler 继承自SimpleChannelInboundHandler用来接收消息。
三、编解码
当你用Netty接收或发送消息,必须将其从一种格式转成另一种格式。比如收消息,你需要从字节转为Java对象。发消息就是将Java对象转成字节发出去。
Netty中有各种各样的编码器和解码器基类。
- ByteToMessageDecoder
- MessageToByteEncoder
- ProtobufEncoder
- ProtobufDecoder
- StringDecoder
- StringEncode
这里,编码器都是继承自ChannelInboundHandlerAdapter或者实现了ChannelInboundHandler。当读到数据时,会调用ChannelRead方法。重写此方法,然后就会调用decode 方法进行解码。并且会执行ChannelHandlerContext,fireChannelRead方法,将解码后的消息传给下一个Channelhandler。 当发送消息的时候,也执行类似的过程,编码器将消息转为字节,然后传给下一个ChannelOutboundHandler。
在我们的项目中,由于自定义了协议所以使用了ByteToMessageDecoder、MessageToByteEncoder。
1、编码
编码器比较简单,我们可以自定义类继承 MessageToByteEncoder 来实现解码。 需要注意的是,按照我们定义的协议顺序向 ByteBuf 中写入数据就好了。示例代码:
scala
public class CustomEncoder extends MessageToByteEncoder<CustomMessage> {
@Override
protected void encode(ChannelHandlerContext channelHandlerContext, CustomMessage message, ByteBuf byteBuf) {
//byteBuf.writeInt();
//byteBuf.writeByte();
}
}
2、解码
解码我们要解决TCP拆包、粘包的问题。
一般TCP中应对拆包、粘包基本有以下几种方案:
- 消息定长,这但比较好理解,由于定长了,我们可以直接判断ByteBuffer中数组长度。不过在实践过程中,一般我们不会使用这个方案,因为扩展性太差了。
- 使用特殊字符作为结尾。
- 自定义协议。
前面我们有提到我们的通信协议为[Int][String]实际上就是一个非常简易的自定义协议(由于业务简单,所以这里没有设计的非常复杂)。我们使用Int来标记后面的String长度,这样两端收到消息后,按照这个格式解析实际上就知道消息的长度了。
在Netty中,我们可以自定义类继承自 ByteToMessageDecoder,来处理解码。Netty中 ByteBuf 来处理数据。具体的步骤是:
- 先标记已读位置:byteBuf.markReaderIndex();
- 判断 ByteBuf 可读是否达到4个字节长度,如果不足直接返回,重置已读位置。
- 读取前4个字节,转为Int。
- 如果字符长度 > 0,那么接下来继续读 length 长度的字符,转为String。
- 这样整个协议就解码完成了。
这里需要注意的是:客户端要与服务端约定好是大端字节序还是小端字节序。
以上在局域网中,两款App进行TCP通信就已经搭建好了,我们有了协议上层业务就可以基于此来封装业务需要的逻辑了。实际上很多IM 通信SDK,基本上都是自定义通信协议,只是协议会比本篇中举例的协议要复杂的多。
四、优化点
项目上线一段时间后,用户反馈偶现存在两台设备无法通信的问题。后面经过调研发现是服务端进程一直存在(即Server App 并没有挂),只是TCP服务挂了。
我们在客户端有处理断线重连逻辑,但是在服务端没有做任何监控重启逻辑。
如何优化
经过调研,我们在服务端Server App中,另外启动一个客户端来与TCP服务端建立连接(相当于是我监控我自己了),如果建立失败,就重启TCP服务。
- 建立一个TCP客户端,启动轮询逻辑
- 如果该客户端没有与服务端建立连接,那么尝试建立连接。
- 如果连续三次都无法建立成功连接,那么认为此时TCP服务存在异常,重启TCP服务。
- 如果可以正常与TCP服务建立连接,那么开始向TCP服务发送心跳包
- 如果连续三次TCP服务没有回复心跳回包,那么也认为TCP服务存在异常,重启TCP服务。 以上,就是我们针对TCP服务端的优化,上面逻辑上线后,无法建立连接的反馈就没有了。
五、总结
本篇主要是介绍如何在局域网中,两个APP使用Dns-SD协议来发现对方。自定义协议进行TCP通信,使用Netty作为TCP通信框架。