Android 長連線初體驗(基於netty)

chay發表於2019-01-11

Android 長連線初體驗(基於netty)

前言

眾所周知,推送和 IM 在 Android 應用中很常見,但真正自己去實現的比較少,我們大多會去選擇第三方提供的成熟方案,如極光推送、雲信等,因為行動網路具有不確定性,因此自己實現一套穩定的方案會耗費很多精力,這對於小公司來說是得不償失的。

推送和 IM 我們平時用的很多,但真正瞭解原理的不多,真正動手實現過的不多。推送和 IM 本質上都是長連線,無非是業務方向不同,因此我們以下統稱為長連線。今天我們一起來揭開長連線的神祕面紗。

netty 是何物

雖然很多人都對 netty 比較熟悉了,但是可能還是有不瞭解的同學,因此我們先簡單介紹下 netty。

Netty是由 JBOSS 開發的一個 Java 開源框架

Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers & clients.

Netty是一個非同步事件驅動的網路應用程式框架,用於快速開發可維護的高效能協議伺服器和客戶端。

這段簡介摘自 netty 官網,是對 netty 的高度概括。已經幫你們翻譯好了 ^ _ ^

Netty is a NIO client server framework which enables quick and easy development of network applications such as protocol servers and clients. It greatly simplifies and streamlines network programming such as TCP and UDP socket server.
'Quick and easy' doesn't mean that a resulting application will suffer from a maintainability or a performance issue. Netty has been designed carefully with the experiences earned from the implementation of a lot of protocols such as FTP, SMTP, HTTP, and various binary and text-based legacy protocols. As a result, Netty has succeeded to find a way to achieve ease of development, performance, stability, and flexibility without a compromise.

Netty是一個NIO客戶端伺服器框架,可以快速簡單地開發協議伺服器和客戶端等網路應用程式。 它極大地簡化和簡化了TCP和UDP套接字伺服器等網路程式設計。
“快速而簡單”並不意味著由此產生的應用程式將受到可維護性或效能問題的困擾。 Netty的設計經驗非常豐富,包括FTP,SMTP,HTTP以及各種基於二進位制和文字的傳統協議。 因此,Netty已經成功地找到了一個方法來實現輕鬆的開發,效能,穩定性和靈活性,而不用妥協。

一複製就停不下來了 =。= 主要是覺得官網介紹的很準確。

這裡提到了 事件驅動,可能大家覺得有點陌生,事件驅動其實很簡單,比如你點了下滑鼠,軟體執行相應的操作,這就是一個事件驅動模型,再舉一個例子,Android 中的 Message Looper Handler 也是事件驅動,通過 Handler 傳送一個訊息,這個訊息就相當於一個事件,Looper 取出事件,再由 Handler 處理。

這些特性就使得 netty 很適合用於高併發的長連線。

今天,我們就一起使用 netty 實現一個 Android IM,包括客戶端和服務端。

構思

作為一個 IM 應用,我們需要識別使用者,客戶端建立長連線後需要彙報自己的資訊,伺服器驗證通過後將其快取起來,表明該使用者線上。

客戶端是一個一個的個體,伺服器作為中轉,比如,A 給 B 傳送訊息,A 先把訊息傳送到伺服器,並告訴伺服器這條訊息要發給誰,然後伺服器把訊息傳送給 B。

伺服器在收到訊息後可以對訊息進行儲存,如果 B 不線上,就等 B 上線後再將訊息傳送過去。

Android 長連線初體驗(基於netty)

實戰

新建一個專案

  1. 編寫客戶端程式碼

新增 netty 依賴

implementation 'io.netty:netty-all:4.1.9.Final'
複製程式碼

netty 已經出了 5.x 的測試版,為了穩定,我們使用最新穩定版。

  • 和伺服器建立連線
// 修改為自己的主機和埠
private static final String HOST = "10.240.78.82";
private static final int PORT = 8300;

private SocketChannel socketChannel;

NioEventLoopGroup group = new NioEventLoopGroup();
new Bootstrap()
    .channel(NioSocketChannel.class)
    .group(group)
    .option(ChannelOption.TCP_NODELAY, true) // 不延遲,直接傳送
    .option(ChannelOption.SO_KEEPALIVE, true) // 保持長連線狀態
    .handler(new ChannelInitializer<SocketChannel>() {
        @Override
        protected void initChannel(SocketChannel socketChannel) throws Exception {
            ChannelPipeline pipeline = socketChannel.pipeline();
            pipeline.addLast(new IdleStateHandler(0, 30, 0));
            pipeline.addLast(new ObjectEncoder());
            pipeline.addLast(new ObjectDecoder(ClassResolvers.cacheDisabled(null)));
            pipeline.addLast(new ChannelHandle());
        }
    })
    .connect(new InetSocketAddress(HOST, PORT))
    .addListener((ChannelFutureListener) future -> {
        if (future.isSuccess()) {
            // 連線成功
            socketChannel = (SocketChannel) future.channel();
        } else {
            Log.e(TAG, "connect failed");
            // 這裡一定要關閉,不然一直重試會引發OOM
            future.channel().close();
            group.shutdownGracefully();
        }
    });
複製程式碼
  • 身份認證
LoginInfo loginInfo = new LoginInfo();
loginInfo.setAccount(account);
loginInfo.setToken(token);
CMessage loginMsg = new CMessage();
loginMsg.setFrom(account);
loginMsg.setType(MsgType.LOGIN);
loginMsg.setContent(loginInfo.toJson());
socketChannel.writeAndFlush(loginMsg.toJson())
        .addListener((ChannelFutureListener) future -> {
            if (future.isSuccess()) {
                // 傳送成功,等待伺服器響應
            } else {
                // 傳送成功
                close(); // 關閉連線,節約資源
            }
        });
複製程式碼
  • 處理伺服器發來的訊息
private class ChannelHandle extends SimpleChannelInboundHandler<String> {
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        super.channelInactive(ctx);
        // 連線失效
        PushService.this.close();
    }

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        super.userEventTriggered(ctx, evt);
        if (evt instanceof IdleStateEvent) {
            IdleStateEvent e = (IdleStateEvent) evt;
            if (e.state() == IdleState.WRITER_IDLE) {
                // 空閒了,發個心跳吧
                CMessage message = new CMessage();
                message.setFrom(myInfo.getAccount());
                message.setType(MsgType.PING);
                ctx.writeAndFlush(message.toJson());
            }
        }
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
        Gson gson = new Gson();
        CMessage message = gson.fromJson(msg, CMessage.class);
        if (message.getType() == MsgType.LOGIN) {
            // 伺服器返回登入結果
        } else if (message.getType() == MsgType.PING) {
            Log.d(TAG, "receive ping from server");
            // 收到伺服器回應的心跳
        } else if (message.getType() == MsgType.TEXT) {
            Log.d(TAG, "receive text message " + message.getContent());
            // 收到訊息
        }

        ReferenceCountUtil.release(msg);
    }
}
複製程式碼

這些程式碼要長期在後臺執行,因此我們放在 Service 中。

  1. 編寫伺服器程式碼

新建一個 Android Library 模組作為服務端,新增同樣的依賴

  • 啟動 netty 服務並繫結埠
new ServerBootstrap()
    .group(new NioEventLoopGroup(), new NioEventLoopGroup())
    .channel(NioServerSocketChannel.class)
    .option(ChannelOption.SO_BACKLOG, 128)
    .option(ChannelOption.TCP_NODELAY, true) // 不延遲,直接傳送
    .childOption(ChannelOption.SO_KEEPALIVE, true) // 保持長連線狀態
    .childHandler(new ChannelInitializer<SocketChannel>() {
        @Override
        protected void initChannel(SocketChannel socketChannel) {
            ChannelPipeline pipeline = socketChannel.pipeline();
            pipeline.addLast(new ObjectEncoder());
            pipeline.addLast(new ObjectDecoder(ClassResolvers.cacheDisabled(null)));
            pipeline.addLast(new NettyServerHandler());
        }
    })
    .bind(port)
    .addListener((ChannelFutureListener) future -> {
        if (future.isSuccess()) {
            System.out.println("netty server start");
        } else {
            System.out.println("netty server start failed");
        }
    });
複製程式碼
  • 處理客戶端發來的訊息
public class NettyServerHandler extends SimpleChannelInboundHandler<String> {
    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        // Channel失效,從Map中移除
        NettyChannelMap.remove(ctx.channel());
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, String msg) {
        Gson gson = new Gson();
        CMessage message = gson.fromJson(msg, CMessage.class);
        if (message.getType() == MsgType.PING) {
            System.out.println("received ping from " + message.getFrom());
            // 收到 Ping,回應一下
            Channel channel = NettyChannelMap.get(message.getFrom());
            if (channel != null) {
                channel.writeAndFlush(message.toJson());
            }
        } else if (message.getType() == MsgType.LOGIN) {
            // 使用者登入
            LoginInfo loginInfo = gson.fromJson(message.getContent(), LoginInfo.class);
            if (UserManager.get().verify(loginInfo)) {
                loginInfo.setCode(200);
                loginInfo.setMsg("success");
                message.setContent(loginInfo.toJson());
                ctx.channel().writeAndFlush(message.toJson());
                NettyChannelMap.add(loginInfo.getAccount(), ctx.channel());
                System.out.println(loginInfo.getAccount() + " login");
            } else {
                loginInfo.setCode(400);
                loginInfo.setMsg("使用者名稱或密碼錯誤");
                message.setContent(loginInfo.toJson());
                ctx.channel().writeAndFlush(message.toJson());
            }
        } else if (message.getType() == MsgType.TEXT) {
            // 傳送訊息
            Channel channel = NettyChannelMap.get(message.getTo());
            if (channel != null) {
                channel.isWritable();
                channel.writeAndFlush(message.toJson()).addListener((ChannelFutureListener) future -> {
                    if (!future.isSuccess()) {
                        System.out.println("send msg to " + message.getTo() + " failed");
                    }
                });
            }
        }
        ReferenceCountUtil.release(msg);
    }
}
複製程式碼

已登入的使用者快取在 NettyChannelMap 中。

這裡可以加入離線訊息快取邏輯,如果訊息傳送失敗,需要快取起來,等待使用者上線後再傳送。

如果服務端在本機執行,需要和客戶端在同一個區域網,如果是在公網執行則不需要。

執行效果

Android 長連線初體驗(基於netty)
Android 長連線初體驗(基於netty)

原始碼

只看上面的程式碼可能還是有點懵逼,建議大家跑一下原始碼,會對 netty 有一個更清晰的認識。 github.com/wangchenyan…

總結

今天我們一起認識了 netty,並使用 netty 實現了一個簡單的 IM 應用。這裡我們僅僅實現了 IM 核心功能,其他比如保活機制、斷線重連不在本文討論範圍之內。

我們今天實現的長連線和第三方長連線服務商提供的長連線服務其實並無太大差異,無非是後者具有成熟的保活、短線重連機制。

讀完本文,是否覺得長連線其實也沒那麼神祕?

但是不要驕傲,我們今天學習的只是最簡單的用法,這只是皮毛,要想完全瞭解其中的原理還是要花費很多功夫的。

遷移自我的簡書 2017.12.27

相關文章