使用Netty+Protobuf实现游戏WebSocket通信

战士的最高境界,就是不拿盾牌也能开盾墙 --阿利斯塔

在前面的两篇博文中 使用Netty+Protobuf实现游戏TCP通信 制作一款游戏协议联调工具 已经介绍了Java游戏如何使用Netty和Protobuf实现TCP通信,本文将实现Java游戏另一种比较常用的通信协议,这在当今的H5游戏和微信小程序游戏中使用比较广泛,即使用Netty和Protobuf实现WebSocket通信,读者会发现游戏的WebSocket通信和TCP通信在使用Netty框架后,它们的代码结构是不需要怎么改变的,这也验证了使用Netty网络框架的另一个优点,即把游戏通信协议改为另一种时(如TCP改为Websocket),它的改动是比较少的,如果自己手写实现网络通信,这样的改动可能是灾难性的。此外,该WebSocket通信实例将会给游戏协议加一层安全防范机制,以让读者能有一些其他的收获,即做到循序渐进,逐步提高。

游戏之网络初篇 中已经介绍了Websocket和Http的关系,它其实是Http协议的升级版,使用它可以实现web客户端和服务器后台的全双工通信。一个Websocket的连接建立过程大致是如下的:客户端(浏览器)首先向服务端发起HTTP连接请求,但这个请求中包含了一些与平常HTTP请求不同的附加头信息,比如会附带“Upgrade: websocket 和 Connection: Upgrade” 以及Websocket版本信息,用以表明此HTTP协议需要升级为Websocket,随后,服务器接收到信息后,如果服务端也支持Websocket,则会返回一些附带Websocket的升级信息给客户端,这样,客户端和服务端的Websocket通信就建立起来了,此后,它们间的通信就可以不用HTTP协议了,可以直接互发数据了。

使用Netty+Protobuf实现游戏TCP通信 中已经介绍过,在网络中,数据都是以二进制字节流传输的,但是在以Websocket通信的游戏中,客户端(浏览器)通常都是处理Json格式的文本协议的,因为浏览器处理Json非常容易,所以,这时客户端和服务端在Packet数据包中byte[] bytes存储的实际上是Json文本格式的二进制流(即将类似{"account":xs996,"password":123456,"platform":3}这样的协议内容编码成二进制流,而游戏TCP通信通常则是把其中的"xs996,123456,3"编码成二进制流,可见Websockt的Json格式协议占用的数据包会大点),因此,以Json格式作为内容传输的二进制流转为Protobuf的过程是:

String json = new String(packet.getBytes(), Charset.forName("UTF-8"));//Packet转json文本
Message.Builder builder = message.newBuilderForType();//message为具体协议方法
JsonFormat.merge(json, builder);
Message msg = builder.build();//转Protobuf

而TCP通信中通常就是对传输数据对象编码成二进制流,即并不是先转Json再编码成二进制流,所以它的解码为Protobuf的过程是:

Message msg = message.newBuilderForType().mergeFrom(packet.getBytes()).build();//转Protobuf,其中message为具体协议方法

相应的Json文本格式编码过程如下:

byte[] bytes = JsonFormat.printToString(message).getBytes(Charset.forName("UTF-8")); //message为Protobuf协议
Packet packet = new Packet(Packet.HEAD_TCP, cmd, bytes);

而TCP通信中普通对象的编码过程如下:

byte[] bytes = message.toByteArray();
Packet packet = new Packet(Packet.HEAD_TCP, cmd, bytes);

首先看客户端和服务端如何接入Websocket的及如何处理握手,此为本文的重点之一。
先看客户端的Websocket接入,核心代码如下:
NettyWebsocketClient.java

    private final CRC16CheckSum checkSum = new CRC16CheckSum();

    public void connect(String host, int port){
        EventLoopGroup client = new NioEventLoopGroup();
        Bootstrap bootstrap = new Bootstrap();
        bootstrap.group(client);
        bootstrap.channel(NioSocketChannel.class);
        bootstrap.option(ChannelOption.SO_KEEPALIVE, true);
        bootstrap.option(ChannelOption.TCP_NODELAY, true);
        
        bootstrap.handler(new ChannelInitializer<Channel>() {
            @Override
            protected void initChannel(Channel ch) throws Exception {
                ChannelPipeline pipeline = ch.pipeline();
                //HTTP编解码器
                pipeline.addLast("http_codec", new HttpClientCodec());
                //HTTP消息聚合,使用FullHttpResponse和FullHttpRequest到ChannelPipeline中的下一个ChannelHandler,这就消除了断裂消息,保证了消息的完整。
                pipeline.addLast("http_aggregator", new HttpObjectAggregator(65536));
                pipeline.addLast("protobuf_decoder", new ProtoDecoder(null, 5120));
                pipeline.addLast("client_handler", new ClientHandler());
                pipeline.addLast("protobuf_encoder", new ProtoEncoder(checkSum, 2048));
            }
        });
        
        ChannelFuture future;
        try {
            URI websocketURI = new URI(String.format("ws://%s:%d/", host, port));
            HttpHeaders httpHeaders = new DefaultHttpHeaders();
            //进行握手
            WebSocketClientHandshaker handshaker = WebSocketClientHandshakerFactory.newHandshaker(websocketURI, WebSocketVersion.V13, (String)null, true,httpHeaders);
            channel = bootstrap.connect(websocketURI.getHost(), websocketURI.getPort()).sync().channel();
            ClientHandler handler = (ClientHandler)channel.pipeline().get("client_handler");
            handler.setHandshaker(handshaker);
            // 通过它构造握手响应消息返回给客户端,
            // 同时将WebSocket相关的编码和解码类动态添加到ChannelPipeline中,用于WebSocket消息的编解码,
            // 添加WebSocketEncoder和WebSocketDecoder之后,服务端就可以自动对WebSocket消息进行编解码了
            handshaker.handshake(channel);
            //阻塞等待是否握手成功
            future = handler.handshakeFuture().sync();
            System.out.println("----channel:"+future.channel());
        } catch (Exception e) {
            e.printStackTrace();
        }
        //future.channel().closeFuture().awaitUninterruptibly();
    }
    
    public void send(Message msg) {
        if (channel == null || msg == null || !channel.isWritable()) {
            return;
        }
        int cmd = ProtoManager.getMessageID(msg);
        Packet packet = new Packet(Packet.HEAD_TCP, cmd, msg.toByteArray());
        channel.writeAndFlush(packet);
    }

它的ClientHandler.java因为要处理与服务端的HTTP握手,及握手成功后数据处理,它的核心代码如下:

public class ClientHandler extends SimpleChannelInboundHandler<Object> {
    WebSocketClientHandshaker handshaker;
    ChannelPromise handshakeFuture;
    
    public void handlerAdded(ChannelHandlerContext ctx) {
        this.handshakeFuture = ctx.newPromise();
    }
    public WebSocketClientHandshaker getHandshaker() {
        return handshaker;
    }

    public void setHandshaker(WebSocketClientHandshaker handshaker) {
        this.handshaker = handshaker;
    }

    public ChannelPromise getHandshakeFuture() {
        return handshakeFuture;
    }

    public void setHandshakeFuture(ChannelPromise handshakeFuture) {
        this.handshakeFuture = handshakeFuture;
    }

    public ChannelFuture handshakeFuture() {
        return this.handshakeFuture;
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        //System.out.println("channelRead0  " + this.handshaker.isHandshakeComplete());
        Channel ch = ctx.channel();
        FullHttpResponse response;
        if (!this.handshaker.isHandshakeComplete()) {
            try {
                response = (FullHttpResponse)msg;
                //握手协议返回,设置结束握手
                this.handshaker.finishHandshake(ch, response);
                //设置成功
                this.handshakeFuture.setSuccess();
                //System.out.println("WebSocket Client connected! response headers[sec-websocket-extensions]:{}"+response.headers());
            } catch (WebSocketHandshakeException var7) {
                FullHttpResponse res = (FullHttpResponse)msg;
                String errorMsg = String.format("WebSocket Client failed to connect,status:%s,reason:%s", res.status(), res.content().toString(CharsetUtil.UTF_8));
                this.handshakeFuture.setFailure(new Exception(errorMsg));
            }
        } else if (msg instanceof FullHttpResponse) {//1.第一次握手请求消息由HTTP协议承载,所以它是一个HTTP消息,执行handleHttpRequest方法来处理WebSocket握手请求。
            response = (FullHttpResponse)msg;
            //this.listener.onFail(response.status().code(), response.content().toString(CharsetUtil.UTF_8));
            throw new IllegalStateException("Unexpected FullHttpResponse (getStatus=" + response.status() + ", content=" + response.content().toString(CharsetUtil.UTF_8) + ')');
        } else if(msg instanceof Packet){
            Packet packet = (Packet)msg;
            System.out.println("\n<<<<<<<<<<<<收到服务端协议:"+packet.getCmd()+"<<<<<<<<<<<<");
            
            Class<?> clazz = ProtoManager.getRespMap().get(packet.getCmd());
            Method m = ClassUtils.findMethod(clazz, "getDefaultInstance");
            Message message = (Message) m.invoke(null);
            msg = message.newBuilderForType().mergeFrom(packet.getBytes()).build();
            ProtoPrinter.print(msg);
        }else {//2.客户端通过socket提交请求消息给服务端,WebSocketServerHandler接收到的是已经解码后的WebSocketFrame消息。
            WebSocketFrame frame = (WebSocketFrame)msg;
            if (frame instanceof TextWebSocketFrame) {
                TextWebSocketFrame textFrame = (TextWebSocketFrame)frame;
                //this.listener.onMessage(textFrame.text());
                System.out.println("TextWebSocketFrame");
            } else if (frame instanceof BinaryWebSocketFrame) {
                BinaryWebSocketFrame binFrame = (BinaryWebSocketFrame)frame;
                System.out.println("BinaryWebSocketFrame received------------------------");
            } else if (frame instanceof PongWebSocketFrame) {
                System.out.println("WebSocket Client received pong");
            } else if (frame instanceof CloseWebSocketFrame) {
                System.out.println("receive close frame");
                //this.listener.onClose(((CloseWebSocketFrame)frame).statusCode(), ((CloseWebSocketFrame)frame).reasonText());
                ch.close();
            }
        }
    }
}

这样,客户端的webSocket升级就完成了,再来看服务端的。
NettyWebsocketServer.java 它的接入Websocket核心代码如下:

public class NettyWebsocketServer {
    private static final Logger log = LoggerFactory.getLogger(NettyWebsocketServer.class);
    
    private final EventLoopGroup bossGroup;
    private final EventLoopGroup workerGroup;
    private final ServerBootstrap bootstrap;
    
    private int upLimit = 2048;
    private int downLimit = 5120;
    
        //循环冗余校验
    private final CRC16CheckSum upCheckSum = new CRC16CheckSum();
    
    public NettyWebsocketServer(){
        bossGroup = new NioEventLoopGroup();
        workerGroup = new NioEventLoopGroup(4);
        bootstrap = new ServerBootstrap();
        bootstrap.group(bossGroup, workerGroup)
                .channel(NioServerSocketChannel.class)
                .option(ChannelOption.SO_BACKLOG, 5)
                .childOption(ChannelOption.TCP_NODELAY, true);
    }
    
    public void bind(String ip, int port) {
        bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
            @Override
            protected void initChannel(SocketChannel ch) throws Exception {
                ch.pipeline().addLast("http-codec", new HttpServerCodec())//HTTP编解码器
                        .addLast("aggregator", new HttpObjectAggregator(65536))//HTTP消息聚合
                        .addLast("websocket", new WebSocketServerProtocolHandler("/", null, true))//处理http升级websocket,还有心跳
                        .addLast("decoder", new ProtoDecoder(upCheckSum, upLimit))
                        .addLast("server-handler", new ServerHandler())
                        .addLast("encoder", new ProtoEncoder(downLimit));
            }
        });
        InetSocketAddress address = new InetSocketAddress(ip, port);
        try {
            bootstrap.bind(address).sync();
        } catch (InterruptedException e) {
            log.error("bind {} : {} failed", ip, port, e);
            shutdown();
        }
    }

服务端的Websocket初始化及握手是通过WebSocketServerProtocolHandler来完成的,升级后,双端建立通信通道,客户端与服务端的通信便和以前的《使用Netty+Protobuf实现游戏TCP通信》大同小异了,由此也体现了Netty变更协议的方便性。

游戏如何防刷 一文中,提到了游戏协议的安全防范,因为在网络传输过程中,传输的数据是可能被破解和篡改的,游戏中也会如此,利益的驱动能使某些人修改协议,盗刷游戏资源,这种情况还很常见,因此很有必要对游戏的协议做一层加密或完整性校验等防范措施。此为本文的重点之二。

在上面的代码中,可以看到编解码的handler中多了一个CRC16CheckSum对象,它就是用作协议的完整性校验的(专业名词叫循环冗余校验,该校验占用两个字节,即包含了一个16位的二进制CRC值,该值由输入数据按照一定规则计算出来,然后附加到Packet数据包中,接收端在收到数据时重新计算该CRC值,然后与Packet数据包中的CRC值进行比较,如果这两个值不相等,就表示数据传输发生了错误),它的核心代码如下:

public class CRC16CheckSum {
    
    public byte[] checksum(byte[] bytes) {
        int crc = 0xffff;
        for (int i = 0; i < bytes.length; i++) {
            if (bytes[i] < 0) {
                crc ^= (int) bytes[i] + 256;
            } else {
                crc ^= (int) bytes[i];
            }
            for (int j = 0; j < 8; j++) {
                if ((crc & 0x0001) != 0) {
                    crc >>= 1;
                    crc ^= 0xa001;
                } else {
                    crc >>= 1;
                }
            }
        }

        byte[] result = new byte[2];
        result[0] = (byte)((crc >> 8) & 0xff);
        result[1] = (byte) (crc & 0xff);
        return result;
    }

    
    public int length() {
        return 2;
    }
}

在客户端编码时,核心代码如下:
ProtoEncoder.java

public class ProtoEncoder extends ChannelOutboundHandlerAdapter {
    private static final Logger log = LoggerFactory.getLogger(ProtoEncoder.class);
    public static final AttributeKey<Short> SEND_SID = AttributeKey.valueOf("SEND_SID");
    
    private final int limit;
    private final CRC16CheckSum checkSum;

    public ProtoEncoder(CRC16CheckSum checkSum, int limit) {
        this.checkSum = checkSum;
        this.limit = limit;
    }

    @Override
    public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
        if (msg instanceof Packet) {
            Packet packet = (Packet) msg;
            if (packet.getBytes().length > limit && log.isWarnEnabled())
                log.warn("cmd[{}], packet size[{}], is over limit[{}]", packet.getCmd(), packet.getBytes().length, limit);
            
            if (checkSum == null) {
                int size = 7 + packet.getBytes().length;
                ByteBuf buf = ctx.alloc().buffer(size);
                try {
                    buf.writeByte(packet.getHead());
                    buf.writeShort(packet.getBytes().length + 4);
                    buf.writeInt(packet.getCmd());
                    buf.writeBytes(packet.getBytes());
                    msg = new BinaryWebSocketFrame(buf);
                } catch (Exception e) {
                    buf.release();
                    throw e;
                }
            } else {
                int size = 7 + packet.getBytes().length + checkSum.length();
                ByteBuf buf = ctx.alloc().buffer(size);
                try {
                    buf.writeByte(packet.getHead());
                    size = 2 + 2 + 4 + packet.getBytes().length;
                    ByteBuf temp = Unpooled.buffer(size, size);
                    temp.writeShort(getSid(ctx));
                    temp.writeShort(packet.getBytes().length + 4);
                    temp.writeInt(packet.getCmd());
                    temp.writeBytes(packet.getBytes());
                    byte[] check = checkSum.checksum(temp.array());
                    buf.writeBytes(check);
                    buf.writeBytes(temp);
                    temp.release();
                    msg = new BinaryWebSocketFrame(buf);
                } catch (Exception e) {
                    buf.release();
                    throw e;
                }
            }
        }
        super.write(ctx, msg, promise);
    }

    private short getSid(ChannelHandlerContext ctx) {
        Attribute<Short> attr = ctx.channel().attr(SEND_SID);
        if (attr.get() == null) {
            attr.set((short)1);
            return 1;
        }
        short sid = (short)(attr.get() + 1);
        if (sid == Short.MAX_VALUE) {
            attr.set((short)0);
        } else {
            attr.set(sid);
        }
        return sid;
    }
}

在服务端解码时核心代码如下:
ProtoDecoder.java

public class ProtoDecoder extends ChannelInboundHandlerAdapter{
    public static final AttributeKey<Short> RECV_SID = AttributeKey.valueOf("RECV_SID");
    
    private final int limit;
    private final CRC16CheckSum checkSum;

    public ProtoDecoder(CRC16CheckSum checkSum, int limit) {
        this.limit = limit;
        this.checkSum = checkSum;
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        if (msg instanceof BinaryWebSocketFrame) {
            BinaryWebSocketFrame frame = (BinaryWebSocketFrame)msg;
            try {
                ByteBuf in = frame.content();
                if (checkSum == null) {
                    if (in.readableBytes() < 7)
                        throw new IllegalArgumentException();
                    byte head = in.readByte();
                    short length = in.readShort();
                    if (length <= 0 || length > limit)
                        throw new IllegalArgumentException();
                    int cmd = in.readInt();
                    if (in.readableBytes() < length - 4)
                        throw new IllegalArgumentException();
                    byte[] bytes = new byte[length - 4];
                    in.readBytes(bytes);
                    ctx.fireChannelRead(new Packet(head, cmd, bytes));
                } else {
                    if (in.readableBytes() < 7 + checkSum.length())
                        throw new IllegalArgumentException();
                    in.markReaderIndex();
                    byte head = in.readByte();

                    byte[] orig = new byte[checkSum.length()];
                    in.readBytes(orig);
                    short sid = in.readShort();

                    if (!checkSid(ctx, sid))
                        throw new IllegalArgumentException();

                    short length = in.readShort();
                    if (length <= 0 || length > limit)
                        throw new IllegalArgumentException();
                    int cmd = in.readInt();
                    if (in.readableBytes() < length - 4)
                        throw new IllegalArgumentException();
                    byte[] bytes = new byte[length - 4];
                    in.readBytes(bytes);

                    byte[] check = new byte[2 + 2 + length];
                    in.resetReaderIndex();
                    in.skipBytes(1 + checkSum.length());
                    in.readBytes(check);

                   //检验循环冗余检验码是否一致,不是,则抛弃该协议
                    byte[] compare = checkSum.checksum(check);
                    for (int i = 0; i < orig.length; i++) {
                        if (orig[i] != compare[i]) {
                            throw new IllegalArgumentException();
                        }
                    }

                    ctx.fireChannelRead(new Packet(head, sid, cmd, bytes));
                }
                return;
            } finally {
                frame.release();
            }
        }
        ctx.fireChannelRead(msg);
    }

    private boolean checkSid(ChannelHandlerContext ctx, short sid) {
        Attribute<Short> attr = ctx.channel().attr(RECV_SID);
        if (attr.get() == null) {
            attr.set((short)1);
            return sid == 1;
        }
        if (sid != attr.get() + 1)
            return false;
        if (sid == Short.MAX_VALUE)
            attr.set((short)0);
        else
            attr.set(sid);
        return true;
    }
}

做了这一层校验后,在一定程度上防止了协议被篡改的可能。此外,还有CRC32,MD5都可以用作协议完整性校验,感兴趣的读者可以在网络上搜索这两种实现,一大把,CRC16在游戏中足以够用。
此外,通常游戏给协议做安全防范还有一种方法,在上面代码中也体现出来了,就是客户端和服务端的建立连接的channel可以维护一个私有的协议序号,每请求一条协议,该序号就递增1,如果两端序号不相等,说明可能是没经过客户端程序发上来的协议,因此把它丢弃掉,这也是一种简单的办法。通常来说,这两种防范已经够用了,在Websocket游戏通信中,通常还会做SSL认证(广泛用于Web浏览器与服务器之间的身份认证和加密数据传输),Netty对SSL也有很好的支持,生成证书后,Netty的核心代码为:

KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("SunX509");
KeyStore keyStore = KeyStore.getInstance("JKS");
keyStore.load(new FileInputStream(sslKey), sslPass.toCharArray());
keyManagerFactory.init(keyStore,sslPass.toCharArray());
SslContext sslContext = SslContextBuilder.forServer(keyManagerFactory).build();

SSLEngine engine = sslContext.newEngine(ch.alloc());
engine.setUseClientMode(false);
ch.pipeline().addFirst("ssl", new SslHandler(engine));

至此,游戏协议的安全防范机制就介绍完毕了。

该实例源码在github的地址为:
https://github.com/zhou-hj/NettyProtobufWebsocket.git


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容

  • 规则就是用来打破的 --金克丝 如大家对Netty和Protobuf还不甚了解,请先参照本系列网络介绍博文 游戏...
    小圣996阅读 5,393评论 1 8
  • 原文地址:http://www.ibm.com/developerworks/cn/java/j-lo-WebSo...
    敢梦敢当阅读 8,807评论 0 50
  • 我的爸爸特别爱看书,你看上书就是几个小时。特别入迷。忘记了时间,忘记了身边的一切事物。 有一次。爸爸正在图书馆里看...
    杨家鑫四五班阅读 1,058评论 0 2
  • 最近刷朋友圈,时不时的就会翻阅到大家感慨时间流逝,各种心灵鸡汤会安抚自己的弱小的心灵。貌似这样会激励自己鼎力...
    姚小笨YOYO阅读 618评论 2 5