一直都想写些什么,刚好这两年一直都在做聊天app,想了很久决定把底层网络库开源出来。两年来这个库经历了好几个阶段:
本次要开源出来的是c/c++实现的版本,取名fastsocks
,github地址:
fastsock
参考了Telegram
的部分实现,如果对大家对Telegram
还不熟悉,可以去它的官网 逛逛。
长连接必然要涉及到序列化/反序列化、数据包收发、重连、动态ip/port切换、推送等内容,接下来都将逐一介绍。
新版本的android studio
已经支持ndk开发,并且加入了cmake 的支持,如果大家对cmake还不熟悉,请查看官网。下面是我的开发环境:
android studio版本 | gradle版本 | ndk版本 |
---|---|---|
2.3.1 | 2.2.3 | 14.1 |
fastscoks
使用cmake编译c/c++代码,开发之前先安装好cmake插件,当然了为了方便调试c/c++代码,最好是把LLDB也安装好。
和大多数sdk一样,fastsocks
启动起来需要一些配置,可以参考fastsocks-simple的相关代码,所有对fastsocks
的操作都在一个java类里面:
com.me.fastsocks.tcp.ConnectionsManager
主要是设置应用层和底层库的监听PacketDispatcherDelegate,并且调用注册相关jni函数。有时候我们会遇到c/c++的crash,底层库如果崩溃会将崩溃文件保存至crashPath。
void init(PacketDispatcherDelegate dispatcherDelegate,
Context context,String crashPath);
安卓c/c++崩溃定位比较困难,借助breakpad
相关工具定位崩溃会简单很多。breakpad
本身就跨平台并且支持android移植,项目里已经有编译好的.a库,fastsocks
使用关联静态库的方式使用breakpad
。
定位崩溃需要的工具也需要自己手动编译,github地址:
这里需要注意:必须在Linux环境下才能编译和使用,如果大家没有Linux环境可以装个VMware,添加个ubuntu虚拟机,至于怎么定位崩溃这里就不赘述了,网上很多这样的教程。
一般都在登录成功之后启动服务,注销登录的时候销毁服务,参考:
com.me.fastsocks.utils.StartUtils
启动服务的时候需要将userId设置到底层库,这里我们使用long类型标示一个用户,当然了如果大家的userId不是long类型的,可以修改相应的实现。
void startSdkService(Context context);
void stopSdkService(Context context);
一般长连接都是一组ip和端口,并且通过服务器下发ip列表,大家可以从服务器获取到列表之后顺序添加服务器地址:
void addSvrAddr(String address, short port);
应用启动时会随机从数组中选取一个ip先连上,Datacenter.cpp对应:
static const char *adds[] = {
"192.168.1.100",
"192.168.1.110",
"192.168.1.112",
"192.168.1.113",
"192.168.1.114"
};
有时候我们需要固定连接一个ip地址,设置完长连接ip之后,底层库会只连接这个ip地址。比如说我们在测试的时候一般都会连测试环境,设置长连接ip:
void setSvrAddr(String address, short port);
协议一般都会包含包头和包体两个部分,包头一般不加密,包体都会选择加密。底层库默认有一个包体结构:
#pragma pack(push, 1)
//一字节对齐,方便拷贝
typedef struct PacketHeader{
uint16_t pkgLen; // 包大小(unsigned short)
uint8_t headLen; // 头部大小
uint8_t version; // 协议版本,当前为1
int32_t cmd; // 协议命令字
int64_t uin; // 用户账户uin(唯一的一个标识)
int32_t seq; // 包的seq
} PacketHeader;
#pragma pack(pop)
如果要自定义自己的协议,需要修改要修改两处:
底层默认的心跳和握手命令码对应Datacenter.cpp:
static const int HANDSHAKE_CMD = 257;//握手命令码
static const int PING_CMD = 259;//心跳命令码
心跳和握手都是底层自动发送的,其中心跳没有包体。
socket连接成功之后一般都需要握手,握手成功之后才能发送和接收数据包,底层连接成功之后会回调onHandshakeConnected方法,需要在这个方法里面返回握手数据和检验握手结果,实例:
int onHandshakeConnected(int buffer){
if(buffer == 0){
// 返回握手信息
return requestHandShake();
} else {
// 校验握手信息
byte[] data = NativeByteBuffer.with(buffer);
//TODO 判断data数据包里面的握手返回是否成功
// 如果返回0表示握手成功,否则握手失败
// 握手失败会重连
return -1;
}
}
发送请求始于命令码,用命令码来标示客户端数据包,实例:
ConnectionsManager.getInstance().with(1280)
.buffer(new byte[128])//要发送的数据放到这里
.timeout(8000)//自定义超时时间
.flags(RequestFlag.FlagReSend)//请求类型
.loadSendListener(newOnSendRequestListener() {//请求回调
@Override
public void onSendComplete(byte[] cmpleteBuffer,
ConnectionsManager.ERROR error) {
//如果error为null,表示请求成功,否则失败
}
@Override
public void onSendCancel() {
//请求被取消
}
})
.start();
RequestFlag用来标示请求类型,比如:请求是否有回包(默认所有数据包都有回包)、是否需要重发(默认不重发)、是否可以在握手成功之前发送(默认都是只能在握手成功之后发送)。
public enum RequestFlag{
UNKNOWN(0), //未知
FlagWithoutAck(1), //没有回包
FlagReSend(2), //数据包可以重发
FlagWithoutLogin(4); //数据包可以在握手之前发送
}
如何判断是发送的回包和收到新数据包至关重要,试想一下如果发送回包和收到新数据包错乱了后果会有多严重。这里介绍底层库的分发策略,参考ConnectionsManager.cpp对应:
void onConnectionRecviedData() {
Request *request = getRequestWithSeq(seq);
if(request != nullptr && request->cmdId == cmd - 1) {
//回调发送完成
sendRequest->onComplete(buffer, 0, "");
} else {
//回调收到新消息
delegate->onRecvMessages(cmd, buffer);
}
}
客户端将seq放到包头发送给服务器,服务器返回相同的seq。客户端收到数据包时匹配本地seq和命令码,匹配成功回调发送完成,否则回调收到新消息。
初始化的时候需要设置PacketDispatcherDelegate,收到新消息、长连接状态发生改变会回调相应的方法。
收到消息时,底层库会把命令码和包体数据抛到应用层,对应:
// 新消息
void msgRecv(int cmd, byte[] datas);
底层连接状态如果发生改变时,会回调相应的连接状态到应用层,此时一般都会去更新UI,对应:
//连接状态改变
void onConnectionStateChanged(int status);
连接状态定义:
//连接状态改变
enum ConnectionState{
UNKNOWN(-1),//未知
ConnectionStateConnecting(1),//连接中
ConnectionStateWaitingForNetwork(2),//没有网络
ConnectionStateConnected(3);//连接成功
}
由于代码已经开源出来了,策略的具体实现大家可以参考相应代码。这里只是简单介绍下几个模块。
做过长连接的人都知道,心跳的重要性。而心跳间隔也会影响发现长连接出问题的灵敏度,fastsocks
的心跳策略是分为前台和后台两种,前台:20s一次,后台:1min一次。并且有回包的数据包会代替心跳,注意:有回包的数据包。
ip的选择首先要符合随机性,这样才能降低服务器的荷载,如果所有客户端都连接同一台服务器,服务器很容易宕机。其次是可用性,收到过数据包的ip要比没有收到过数据包的ip连接次数更多,TcpConnection.cpp对应:
void onDisconnected(int reason) {
failedConnectionCount++;
if(failedConnectionCount == 1){
if(currentConnectionRecvData){
willRetryConnectCount = 5;
} else {
willRetryConnectCount = 1;
}
}
}
经验表明非http连接的80端口经常被封,如果连接超时或者握手超时
首先切换端口,当端口切换了10次之后继续切换ip。
Datacenter.cpp对应:
void addAddressAndPort(std::string address, uint16_t port) {
std::random_shuffle();//随机打乱svr顺序
}
//切换ip或者端口
void Datacenter::switchAddressOrPort() {
if (strlen(nioServer.nioIp)) {
return;
}
//先增加端口的索引,再增加地址的索引
if(currentPortIndex < 10){
currentPortIndex++;
} else {
if(currentAddressIndex < addresses.size() - 1){
currentAddressIndex++;
} else {
currentAddressIndex = 0;
}
currentPortIndex = 0;
}
}
除非应用层主动调用断开连接,否则底层必须重连,来保证长连接的可用性,默认重连间隔为1s,TcpConnection.cpp对应:
reconnectTimer = new Timer([&]{
DEBUG_D("reconnectTimer execute , begin connect.");
reconnectTimer->stop();
connect();
});
超时分为连接超时和读写超时,也是动态改变的。
当切换ip和端口时连接的超时为8s,否则为15秒,TcpConnection.cpp对应:
void connect() {
if(isTryingNextPort){
//切换端口连接超时为8秒
setTimeout(8);
} else {
//切换ip连接超时为15秒
setTimeout(15);
}
}
当应用处于前台时,读写超时设置为30s;应用处于后台时,读写超时设置为70s。
水平有限,难免做到尽善尽美,欢迎大家指正和学习!