Java Netty长连接实现Android推送

罗嗦几句:

1.轮询(Pull)客户端定时的去询问服务器是否有新消息需要下发;确点很明显Android后台不停的访问网络费电还浪费流量。

2.推送(Push)服务端有新消息立即发送给客户端,这就没有时间的延迟,消息及时到达。

当时需求过来之后就首先考虑的这两个,开发的角度Pull实现起来简单省事,但从用户来说省电和省流量才是主要的,所以最后选用Push。客户端与服务端使用长连接,客户端定时向服务端发送心跳包维持长连接,当时自己使用socket来写的,最后发现客户端数量大了之后不太好维护而且会莫名的掉线,最后无奈用了别人集成好的[Netty](http://netty.io/),用了之后才发现对并发处理和心跳的维护都处理的很好。

正文开始

先看最终的效果
编辑好发送的内容,点击发送立即群发消息!

这里写图片描述
消息发送成功成功后会有消息提示,因为我只有一个手机连接,所以显示发送成功数1个,耗费的时间是2毫秒!
这里写图片描述
最后看手机的推送消息,看到效果还是挺不错的!
这里写图片描述

服务端设计:

  1. 数据库设计
    一.用户表,用户表的作用是在实际的项目运营中知道推送给指定的用户。主要有5个字段,第一个ID是主键,第二个use_id对应的是自己数据库中用户的编号,第三个ios_device_token方便给苹果用户推送,第四个mobile可以推送短信消息,第五个openid是用户授权登录系统后获取的用户标识,还有5个字段是保留字段方便扩展。

    CREATE TABLE `user` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
      `user_id` bigint(20) NOT NULL COMMENT '用户的ID',
      `ios_device_token` varchar(128) DEFAULT NULL COMMENT 'IOS的device_token令牌',
      `mobile` varchar(20) DEFAULT NULL COMMENT '用户手机号',
      `openid` varchar(200) DEFAULT NULL COMMENT '微信的OPENID',
      `obligate_1` varchar(100) DEFAULT NULL,
      `obligate_2` varchar(100) DEFAULT NULL,
      `obligate_3` varchar(100) DEFAULT NULL,
      `obligate_4` varchar(100) DEFAULT NULL,
      `obligate_5` varchar(100) DEFAULT NULL,
      PRIMARY KEY (`id`),
      KEY `user_id` (`user_id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=1028 DEFAULT CHARSET=utf8;

    二.消息表,系统在运营中会产生消息记录,消息记录的记载,记载之后可以保存用户离线消息,等待上线后Push给对应的用户,并且有消息有效期,超过有消息自动作废。同样的有4个保留字段方便扩展使用。

    CREATE TABLE `msg_que` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT,
      `user_id` bigint(20) NOT NULL COMMENT '用户ID',
      `msg_title` varchar(20) DEFAULT NULL COMMENT '消息头',
      `msg_type` smallint(1) DEFAULT '0' COMMENT '消息类型(0:普通消息;1:透传消息)',
      `msg_content` text COMMENT '消息内容',
      `create_time` timestamp NULL DEFAULT '0000-00-00 00:00:00' ON UPDATE CURRENT_TIMESTAMP COMMENT '消息创建时间',
      `valid_time` datetime DEFAULT '0000-00-00 00:00:00' COMMENT '消息有效时间(小时)',
      `state` smallint(1) DEFAULT '0' COMMENT '消息状态(0:未送达;1:已送达;2:失效)',
      `obligate_1` varchar(100) DEFAULT NULL,
      `obligate_2` varchar(100) DEFAULT NULL,
      `obligate_3` varchar(100) DEFAULT NULL,
      `obligate_4` varchar(100) DEFAULT NULL,
      PRIMARY KEY (`id`),
      KEY `user_id_refrence` (`user_id`),
      CONSTRAINT `user_id_refrence` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`) ON DELETE CASCADE ON UPDATE CASCADE
    ) ENGINE=InnoDB AUTO_INCREMENT=4446 DEFAULT CHARSET=utf8;
    
  2. 代码设计
    推送服务端使用的是Spring+SpringMVC+MyBatis,下面两个类就是服务端的启动代码与消息处理代码,一张图解释设计流程(凑合看吧):
    这里写图片描述

    /**
    *服务端启动Netty
    */
    @Controller
    public class NettyInitial implements InitializingBean, ServletContextAware {

        static final boolean SSL = System.getProperty("ssl") != null;
        //服务端netty的端口
        static final int NETTYPORT = Integer.parseInt(System.getProperty("port", "9800"));

        @Override
        public void setServletContext(ServletContext arg0) {
            //使用线程启动Netty服务端,否则会阻塞线程
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try{
                        startNettyConnect();
                    }catch (Exception e) {
                    }
                }
            }).start();
        }
        @Override
        public void afterPropertiesSet() throws Exception {
        }

        public void startNettyConnect() throws CertificateException, SSLException{
            final SslContext sslCtx;
            if (SSL) {
                SelfSignedCertificate ssc = new SelfSignedCertificate();
                sslCtx = SslContext.newServerContext(ssc.certificate(),
                        ssc.privateKey());
            } else {
                sslCtx = null;
            }

            // Configure the server.
            EventLoopGroup bossGroup = new NioEventLoopGroup(10);
            EventLoopGroup workerGroup = new NioEventLoopGroup(10);
            try {
                ServerBootstrap b = new ServerBootstrap();
                b.group(bossGroup, workerGroup)
                        .channel(NioServerSocketChannel.class)
                        .option(ChannelOption.SO_BACKLOG, 2048)
                        .childHandler(new ChannelInitializer<SocketChannel>() {
                            @Override
                            public void initChannel(SocketChannel ch)
                                    throws Exception {
                                ChannelPipeline p = ch.pipeline();
                                if (sslCtx != null) {
                                    p.addLast(sslCtx.newHandler(ch.alloc()));
                                }
                                p.addLast("idle", new IdleStateHandler(300, 300, 300));
                                p.addLast(new ObjectEncoder(), new ObjectDecoder(
                                        ClassResolvers.cacheDisabled(null)),
                                        new NettyServerHandler());
                            }
                        });
                b.option(ChannelOption.TCP_NODELAY, true);
                 //保持长连接状态
                b.childOption(ChannelOption.SO_KEEPALIVE, true);
                // Start the server.
                ChannelFuture f = b.bind(NETTYPORT).sync();
                // Wait until the server socket is closed.
                f.channel().closeFuture().sync();
            }catch (Exception e) {
                // TODO: handle exception
            } finally {
                // Shut down all event loops to terminate all threads.
                bossGroup.shutdownGracefully();
                workerGroup.shutdownGracefully();
            }
        }
    }


    /**
     * Netty模块消息处理
     * @author Administrator
     *
     */
    public class NettyServerHandler extends ChannelInboundHandlerAdapter{

        private MainHashMapObserver mainHashMapObserver = MainHashMapObserver.getInstace();

        @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) {
            mainHashMapObserver.addObserver(msg.toString(), (SocketChannel) ctx.channel());
            ctx.writeAndFlush("连接成功="+msg);
        }

        @Override
        public void channelActive(ChannelHandlerContext ctx) throws Exception {
            super.channelActive(ctx);
        }

        @Override
        public void channelReadComplete(ChannelHandlerContext ctx) {
            ctx.flush();
        }


        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
            mainHashMapObserver.deleteObserver((SocketChannel) ctx.channel());
            ctx.close();
        }

        @Override
        public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
            super.userEventTriggered(ctx, evt);
            if (evt instanceof IdleStateEvent) {
                IdleStateEvent event = (IdleStateEvent) evt;
                if (event.state().equals(IdleState.READER_IDLE)) {
                // 读超时

                }
                if(event.state().equals(IdleState.WRITER_IDLE)){
                //写超时
                    ctx.writeAndFlush("y");
                }
            }
        }
    }

客户端设计

  1. service代码

    将Android服务定义成远程服务,可以稍微避免一下下被系统杀死;在接收到服务器推送的消息后判断消息类型,如果是心跳包就不必理会,如果是有用的消息就选择notification或者后台处理

    public class NettyRemoteService extends Service {

    /**
     * 单一线程池
     */
    private ExecutorService executorService= Executors.newSingleThreadExecutor();

    /**
     * 是否需要继续运行
     */
    private boolean running = true;

    @Override
    public void onCreate() {
        super.onCreate();
    }

    @Override
    public IBinder onBind(Intent intent) {
        return mBinder;
    }

    @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        try {
            create();
        } catch (SSLException e) {
        }

        executorService.execute(new Mythread());

        return START_STICKY;
    }


    @Override
    public void onDestroy() {
        running = false;
        executorService.shutdown();
        if(f != null){
            f.channel().close();
        }
        stopSelf();
        super.onDestroy();
    }

/***********************************************************************************
     * start Netty长连接服务器保持数据通讯
     ***********************************************************************************/
    private static final boolean SSL = System.getProperty("ssl") != null;
    SslContext sslCtx;

    private void create() throws SSLException {
        if (SSL) {
            sslCtx = SslContext.newClientContext(InsecureTrustManagerFactory.INSTANCE);
        } else {
            sslCtx = null;
        }
    }
    ChannelFuture f;
    public void createBootStrap() {

        L.v("重连......");
        EventLoopGroup group = new NioEventLoopGroup();
        try {
            Bootstrap bootstrap = new Bootstrap();
            bootstrap.group(group).channel(NioSocketChannel.class).option(ChannelOption.TCP_NODELAY, true)
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        public void initChannel(SocketChannel ch) throws Exception {
                            ChannelPipeline p = ch.pipeline();
                            if (sslCtx != null) {
                                p.addLast(sslCtx.newHandler(ch.alloc(), "192.168.0.103", 9800));
                            }
                            p.addLast("idle", new IdleStateHandler(300, 300, 300));
                            p.addLast(new ObjectEncoder(), new ObjectDecoder(ClassResolvers.cacheDisabled(null)),
                                    new CarmgrClientHandler());
                        }
                    });

            //设置TCP协议的属性
            bootstrap.option(ChannelOption.SO_KEEPALIVE, true);
            bootstrap.option(ChannelOption.TCP_NODELAY, true);
            bootstrap.option(ChannelOption.SO_TIMEOUT, 5000);
            // Start the client. IP和Port是服务器运行的IP和自己设置的端口
            f = bootstrap.connect("192.168.0.103", 9800).sync();
            f.addListener(new ChannelFutureListener() {
                @Override
                public void operationComplete(ChannelFuture channelFuture) throws Exception {
                    if (channelFuture.isSuccess()) {

                    } else {

                    }
                }
            });
            f.channel().closeFuture().sync();
        } catch (Exception e) {
        } finally {
            // Shut down the event loop to terminate all threads.
            group.shutdownGracefully();
        }

    }

    private class Mythread extends Thread{
        @Override
        public void run() {
            while(running) {
                createBootStrap();
                try {
                    //休息30s重连
                    Thread.sleep(30 * 1000);
                } catch (InterruptedException e) {
                }
            }
        }
    }

    /**
     * 开启推送消息展示
     */
    private int notificaionId = 100;

    public void startNotification(String title, String context) {
        NotificationCompat.Builder builder = new NotificationCompat.Builder(this);

        // 设置通知的基本信息:icon、标题、内容
        builder.setSmallIcon(R.mipmap.launcher_icon);
        builder.setContentTitle(title);
        builder.setContentText(context);
        builder.setAutoCancel(true);

        // 设置通知的优先级
        builder.setPriority(NotificationCompat.PRIORITY_MAX);
        Uri alarmSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
        // 设置通知的提示音
        builder.setSound(alarmSound);
        builder.setDefaults(Notification.DEFAULT_ALL);

        // 设置通知的点击行为:这里启动一个 Activity
        Intent intent = new Intent(this, MainActivity.class);
        PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
        builder.setContentIntent(pendingIntent);

        if (notificaionId < 1000) {
            notificaionId++;
        } else {
            notificaionId = 100;
        }

        // 发送通知 id 需要在应用内唯一
        NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
        notificationManager.notify(notificaionId, builder.build());

    }

    /**
     * Netty消息接收内部类
     */
    private class CarmgrClientHandler extends ChannelInboundHandlerAdapter {

        /**
         * Creates a client-side handler.
         */
        public CarmgrClientHandler() {
            //TODO
        }

        @Override
        public void channelActive(ChannelHandlerContext ctx) {
            ctx.writeAndFlush("1");
        }

        @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) {

            if (msg.toString().equals("y")) {
                //心跳不用理会
            } else {
                //处理服务端推送的消息
            }
        }

        @Override
        public void channelReadComplete(ChannelHandlerContext ctx) {
            ctx.flush();
        }


        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
            // Close the connection when an exception is raised.
            ctx.close();
        }

        @Override
        public void channelInactive(ChannelHandlerContext ctx) throws Exception {
            super.channelInactive(ctx);
        }


        @Override
        public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
            super.userEventTriggered(ctx, evt);

            if (evt instanceof IdleStateEvent) {
                IdleStateEvent e = (IdleStateEvent) evt;
                switch (e.state()) {
                    case WRITER_IDLE:
                        ctx.writeAndFlush("1");
                        break;
                    default:
                        break;
                }
            }
        }
    }

    /***********************************************************************************
     * end Netty长连接服务器保持数据通讯
     ***********************************************************************************/
}

在完成这个推送的过程中也遇到过很多的问题,比如说Android service在后台运行一段时间就会挂掉然后掉线,这个问题的解决主要是提高service的存活率来着手,比如说设置成remote service、监听一些广播事件,两个服务相互监听互相吊起等,但是注意最后别搞成关不了的流氓软件了。

还有就是连接的用户数过多的时候群发消息问题,我自己的机器模拟启动1000个线程去连接服务端,写一个接口去获取当前的连接数:
这里写图片描述

运行2个小时后再次查看接口,发现还是833个,也就说一个都没有掉线(电脑端的网络比较稳定,手机端很复杂,所以手机端会出现掉线然后迅速重连的现象!);这时候再次测试群发数据查看到达率和耗时
这里写图片描述
这里写图片描述
这里写图片描述
这里写图片描述
这里写图片描述

消息推送所耗费的时间跟所发送数据大小(下面是发送少量数据所耗时)、心跳的时间间隔、电脑的带宽、电脑的性能都有影响
这里写图片描述
发送的消息我封装成JOSN字符串,所以实际的发送数据量比看见的还会稍微大点,全部送达而且耗费的时间还可以接受。正常情况下以800个120毫秒计算,每个0.15毫秒算在用户量不太大的情况下还可以接受。
这里写图片描述
数据量极少的情况下出现了耗时6ms的情况,也就是1s不到可以推送10万,当然这是理想的情况,服务端连接数达到10万还不知道会发生什么情况;单机测试就是这么的有局限性,如果多配几个电脑就能测试的更加准确。

至此这个推送就完成了,有很多代码或者说细节的问题没有在这里展示,因为是公司的项目所以选择性的粘贴了部分主流代码和思路,按照思路来的话是可以实现的,如果有不懂的欢迎留言咨询,互相进步!

  • 4
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值