基于WebSocket的在线聊天室(二)

效果预览

前言


在上一篇文章中已经对websocket的做了一定的介绍,并给出了一个文本聊天室的例子,本文将继续对其进行功能扩展,加上语音和视频的功能(感觉瞬间高大上了有木有 *_*)

相关技术


在做功能之前也是找了不少资料的,发现网上对于视频通话的实现基本都是采用了WebRTC这么个东东

WebRTC,名称源自网页实时通信(Web Real-Time Communication)的缩写,是一个支持网页浏览器进行实时语音对话或视频对话的技术,是谷歌2010年以6820万美元收购Global IP Solutions公司而获得的一项技术。2011年5月开放了工程的源代码,在行业内得到了广泛的支持和应用,成为下一代视频通话的标准。

看别人的例子用起来貌似挺简单的样子,但因为是谷歌的东西,还要连STUN服务器什么的,就放弃了深入研究,有兴趣的自行百度吧。本文中是根据websocket和html5的一些特性进行开发的。

主要步骤


1. 调用摄像头
2. 画面捕捉
3. 图片传输
4. 图片接收和绘制

注:上面步骤以视频传输为例,音频部分类似,但也有区别,后面再讲

调用摄像头


jAlert = function(msg, title, callback) {
    alert(msg);
    callback && callback();
};
//媒体请求成功后调用的回调函数
sucCallBack = function(stream) {
    //doSomething
};
//媒体请求失败后调用的回调函数
errCallBack = function(error) {
    if (error.PERMISSION_DENIED) {
        jAlert('您拒绝了浏览器请求媒体的权限', '提示');
    } else if (error.NOT_SUPPORTED_ERROR) {
        jAlert('对不起,您的浏览器不支持摄像头/麦克风的API,请使用其他浏览器', '提示');
    } else if (error.MANDATORY_UNSATISFIED_ERROR) {
        jAlert('指定的媒体类型未接收到媒体流', '提示');
    } else {
        jAlert('相关硬件正在被其他程序使用中', '提示');
    }
};
//媒体请求的参数,video:true表示调用摄像头,audio:true表示调用麦克风
option = {video:true}
//兼容各个浏览器
navigator.getUserMedia = (navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia);

var userAgent = navigator.userAgent,
    msgTitle = '提示',
    notSupport = '对不起,您的浏览器不支持摄像头/麦克风的API,请使用其他浏览器';
try {
    if (navigator.getUserMedia) {
        if (userAgent.indexOf('MQQBrowser') > -1) {
            errCallBack({
                NOT_SUPPORTED_ERROR: 1
            });
            return false;
        }
        navigator.getUserMedia(option, sucCallBack, errCallBack);
    } else {
        /*
        if (userAgent.indexOf("Safari") > -1 && userAgent.indexOf("Oupeng") == -1 && userAgent.indexOf("360 Aphone") == -1) {
            //由于没有Safari浏览器不能对已有方法进行测试,有需要可以自行度之
        } //判断是否Safari浏览器
        */
        errCallBack({
            NOT_SUPPORTED_ERROR: 1
        });
        return false;
    }
} catch (err) {
    errCallBack({
        NOT_SUPPORTED_ERROR: 1
    });
    return false;
}

兼容性测试:

IE系列:摄像头/麦克风都不支持

Edge:摄像头/麦克风都支持,不过有个小BUG,请求摄像头的时候会提示“是否允许xxx使用你的麦克风”,影响不大。

Chrome系列(包括支持极速模式的国产山寨浏览器):摄像头基本都支持,语音功能不一定都支持(和chromium的版本以及电脑的声卡驱动有关系),今天更新了qq浏览器,貌似最新的chromium(47.0.2526.80)不能直接访问媒体API了

getUserMedia() no longer works on insecure origins. To use this feature, you should consider switching your application to a secure origin, such as HTTPS. See https://goo.gl/rStTGz for more details.

解决办法:

用HTTPS或者在chrome运行参数里加--able-web-security

Firefox(45.0.2):摄像头/麦克风都支持

  • 硬件设备同时仅能被一个浏览器访问,不过同一个浏览器可以打开多个标签页来多次调用
  • 文章最后会给出的项目里面有几个页面是用来测试媒体 API 的,自己去寻找吧

画面捕捉


从上面一步我们已经取得了媒体流(sucCallBack中的stream),这一步就要对它进行处理。

首先,将其作为video控件的视频源

sucCallBack = function(stream) { 
    var video = document.getElementById("myVideo");
    video.src = window.URL.createObjectURL(stream);
    video.play();
}

接着,将video中的画面绘制到canvas上

var video = document.getElementById("myVideo");
var canvas = document.getElementById("canvas");
var context = canvas.getContext("2d");
video.addEventListener("play", drawCanvas);
function drawCanvas() {
    if (video.paused || video.ended) {
        return;
     }
    context.drawImage(video, 0, 0, 640, 360);//将video当前画面绘制到画布上
    setTimeout(drawCanvas, 30);
}

图片传输


从canvas获得图片

var canvas = document.getElementById("canvas");
var img = new Image();
//形如"data:image/png;base64,iVBORw0KG..."逗号前内容为文件类型,格式,编码类型,逗号之后为base64编码内容
var url = canvas.toDataURL("image/png");
img.src = url;
document.body.appendChild(img);

通过websocket对图片进行传输,java代码和上文中基本差不多,就多了一个@OnMessage注释的方法,

@OnMessage(maxMessageSize = 10000000)
public void OnMessage(ByteBuffer message) {
    //TODO
}

注意maxMessageSize参数,由于一张图片比较大,如果该值设置过小的话,服务端无法接收而导致连接直接断开

定时发送数据

function sendFrame(){
    //TODO
    setTimeout(sendFrame, 300)
}
//或
setTimeout(function() {
    requestAnimationFrame(sendFrame)
}, 300)

requestAnimationFrame的区别在于如果此页面不是浏览器当前窗口的当前标签(即此标签页被挂起),那么其中的回调函数(sendFrame)会被挂起,直到此标签页被激活后再执行,而只使用setTimeout在标签页被挂起的时候还会继续执行。所以requestAnimationFrame可以用来实现 挂起页面达到暂停视频通讯 的效果

客户端有两种发送方式,一种是发送Blob对象

function getWebSocket(host) {
    var socket;
    if ('WebSocket' in window) {
        socket = new WebSocket(host)
    } else if ('MozWebSocket' in window) {
        socket = new MozWebSocket(host)
    }
    return socket
};
// 将base64编码的二进制数据转为Blob对象
function dataURLtoBlob(dataurl) {
    var arr = dataurl.split(','),
        mime = arr[0].match(/:(.*?);/)[1],
        bstr = atob(arr[1]),
        n = bstr.length,
        u8arr = new Uint8Array(n);
    while (n--) {
        u8arr[n] = bstr.charCodeAt(n);
    }
    return new Blob([u8arr], {
        type: mime
    });
};
function sendFrame(){
    // socket = getWebSocket ("ws://" + window.location.host + "/websocket/chat")
    socket.send(dataURLtoBlob(canvas.toDataURL("image/png")));//使用已建立的socket对象发送数据
    setTimeout(sendFrame, 300);
}

另一种是先用对象包装,再转为字符串发送

msg = {
    type: 3,
    msg: canvas.toDataURL("image/png").substring("data:image/png;base64,".length),// 截掉内容头
};
// 对数据进行base64解码,减少要发送的数据量
msg.msg = window.atob(msg.msg);
// 序列化成json串
socket.send(JSON.stringify(msg))

说说这里遇到的坑吧

  1. 在第一种方式中,先是获取图片的Blob文件,但为了让其他用户知道是谁发送的,需要发送发送者的username(或者id),本来试过先用string发送username,再紧跟着发送一个Blob,虽然并未出错但是感觉数据多了会错乱,而且接收的时候处理起来比较复杂,于是就想着把username并入Blob。不过貌似没有string和Blob直接合并的方法,于是我先把字符串生成一个Blob("text/plain"),然后把两个Blob合并起来
    new Blob([textBlob, imageBlob])

  2. 由于第一种方式发送的时候会生成一个Blob对象,再加上是通过setTimeout这种定时递归得到方式发送的,内存占用(在chrome任务管理器中查看)蹭蹭蹭暴涨,没多久就占用几百兆内存,虽然在几处地方手动置为null来释放引用有点改善,但效果也不是很明显,个人感觉是频率太高(100ms间隔即1秒10帧左右的图片)导致GC来不及释放。相较之下,第二种方式虽然内存也会涨,不过基本会稳定在50M左右。这个问题以后再细究,如果哪位看官有想法欢迎评论指教
    在项目代码中设置videoClient.sendType = 1;切换到第一种方式

  3. 正常两个人(窗口)视频通讯的时候,第一种方式毫无压力,不过再增加用户之后就爆了,每个窗口都在不断的断开重连,从接收到的数据看应该是数据包间的内容错乱了。因为我一个Blob是用户名和图片数据组合在一起的,形如zhangsaniVBORw0KG.....,其中'zhangsan'为用户名,后面部分为图片数据,数据错乱了之后就变成了#%#sanVBORw0KG.....之类的,这样正常解析就会出错导致连接断开。所以,人多了就只能采用第二种方式发送了。

图片接收和绘制


对应上一步的两种方式,接收图片也有两种方式

在onmessage方法中先这么处理

if (typeof(message.data) == "string") {
    //TODO
} else if (message.data instanceof Blob) {
    //TODO
}

第一种接收方式(Blob)

function renderFrame(_host, blob) {
    readBlobAsDataURL(_host, function(host) {
        _host = null;
        host = DataURLtoString(host);
        var canvas = document.getElementById("canvas"),
            url = URL.createObjectURL(blob),
            img = new Image();
        img.onload = function() {
            window.URL.revokeObjectURL(url);
            var context = canvas.getContext("2d");
            context.drawImage(img, 0, 0, canvas.width, canvas.height);
            img = null;
            blob = null;
        };
        img.src = url;
    })
};
// 通过FileReader来读取Blob中封装的字符串内容
function readBlobAsDataURL(blob, callback) {
    var a = new FileReader();
    a.onload = function(e) {
        callback(e.target.result);
    };
    a.readAsDataURL(blob);
}
// 进行base64编码解码
function DataURLtoString(dataurl) {
    return window.atob(dataurl.substring("data:text/plain;base64,".length))
}
var offset = 14;//我采用14位长度的时间戳字符串作为username
renderFrame(new Blob([msg.data.slice(0, offset)], {
    type: "text/plain"
}), new Blob([msg.data.slice(offset)], {
    type: "image/png"
}))

第二种方式

renderFrame2 = function(host, data) {
    var canvas = document.getElementById("canvas"),
        img = new Image();
    img.onload = function() {
        window.URL.revokeObjectURL(url);
        var context = canvas.getContext("2d");
        context.drawImage(img, 0, 0, canvas.width, canvas.height);
        img = null;
    };
    // 对数据重新进行base64编码后作为图片的源
    img.src = "data:image/png;base64," + window.btoa(data)
};
// 将socket.onmessage方法中接收到的数据转为msg对象
var msg = JSON.parse(message.data);
renderFrame2(msg.host, msg.msg)

至此视频部分的内容都结束了,接下来讲下音频部分的差异

调用麦克风


同摄像头调用的第一步,设置 option = {audio:true} 得到麦克风的流,但是不能向摄像头一样直接给video控件的src赋值就好了,还需要下面一系列的操作。

1. 创建“录音机对象”

audioContext = window.AudioContext || window.webkitAudioContext;
context = new audioContext();
config = {
    inputSampleRate: context.sampleRate,//输入采样率,取决于平台
    inputSampleBits: 16,//输入采样数位 8, 16
    outputSampleRate: 44100 / 6,//输出采样率
    oututSampleBits: 8,//输出采样数位 8, 16
    channelCount: 2,//声道数
    cycle: 500,//更新周期,单位ms
    volume: _config.volume || 1 //音量
};
var bufferSize = 4096;//缓存大小
//创建“录音机对象”
recorder = context.createScriptProcessor(bufferSize, config.channelCount, config.channelCount); // 第二个和第三个参数指的是输入和输出的声道数
buffer = [];//音频数据缓冲区
bufferLength = 0;//音频数据缓冲区长度

2. 将音频输入和“录音机对象”关联

//通过音频流创建输入音频对象
audioInput = context.createMediaStreamSource(stream);//stream即getUserMedia成功后得到的流
//设置录音机录音处理事件,每次缓存(上一步中)满了执行回调函数,
recorder.onaudioprocess = function(e) {
    var inputbuffer = e.inputBuffer,
        channelCount = inputbuffer.numberOfChannels,
        length = inputbuffer.length;
    channel = new Float32Array(channelCount * length);
    for (var i = 0; i < length; i++) {
        for (var j = 0; j < channelCount; j++) {
            channel[i * channelCount + j] = inputbuffer.getChannelData(j)[i];
        }
    }
    buffer.push(channel);//缓存数据存入音频缓冲区
    bufferLength += channel.length;
};
// 创建 '音量对象',作为 '音频输入' 和 '录音机对象' 连接的桥梁
volume = context.createGain();
audioInput.connect(volume);
volume.connect(recorder);
// 当然也可以不通过 '音量对象' 直连 '录音机',但不能同时使用两种方式
// audioInput.connect(this.recorder);
recorder.connect(context.destination);//context.destination为音频输出
//连接完就开始执行onaudioprocess方法
//recorder.disconnect()可以停止“录音”
updateSource(callback);

3. 获取“音频流”

麦克风的输入都已经连接至“录音机对象”,“录音机对象”再将数据不断存入缓冲区,所以要得到稳定的“音频流”(不是真正意义上的流)可以以一定时间间隔(1秒)提取以此缓冲区的数据转为一秒时长的音频文件,将之通过audio控件播放即可。注意,这样处理会导致声音有1秒的延迟,可以减少这个时间间隔接近“实时”的效果。

第一部分:数据压缩合并

function compress() { //合并压缩
    //合并
    var buffer = this.buffer,
        bufferLength = this.bufferLength;
    this.buffer = []; //将缓冲区清空
    this.bufferLength = 0;
    var data = new Float32Array(bufferLength);
    for (var i = 0, offset = 0; i < buffer.length; i++) {
        data.set(buffer[i], offset);
        offset += buffer[i].length;
    }
    //根据采样频率进行压缩
    var config = this.config,
        compression = parseInt(config.inputSampleRate / config.outputSampleRate),
        //计算压缩率
        length = parseInt(data.length / compression),
        result = new Float32Array(length);
    index = 0;
    while (index < length) {
        result[index] = data[index++ * compression];
    }
    return result;//合并压缩后的数据
}

第二部分:将上一步的数据编码成WAV格式的文件

function encodeWAV(bytes) {
    var config = this.config,
        sampleRate = Math.min(config.inputSampleRate, config.outputSampleRate),
        sampleBits = Math.min(config.inputSampleBits, config.oututSampleBits),
        dataLength = bytes.length * (sampleBits / 8),
        buffer = new ArrayBuffer(44 + dataLength),
        view = new DataView(buffer),
        channelCount = config.channelCount,
        offset = 0,
        volume = config.volume;

    writeUTFBytes = function(str) {
        for (var i = 0; i < str.length; i++) {
            view.setUint8(offset + i, str.charCodeAt(i));
        }
    };
    // 资源交换文件标识符 
    writeUTFBytes('RIFF');
    offset += 4;
    // 下个地址开始到文件尾总字节数,即文件大小-8 
    view.setUint32(offset, 44 + dataLength, true);
    offset += 4;
    // WAV文件标志
    writeUTFBytes('WAVE');
    offset += 4;
    // 波形格式标志 
    writeUTFBytes('fmt ');
    offset += 4;
    // 过滤字节,一般为 0x10 = 16 
    view.setUint32(offset, 16, true);
    offset += 4;
    // 格式类别 (PCM形式采样数据) 
    view.setUint16(offset, 1, true);
    offset += 2;
    // 通道数 
    view.setUint16(offset, channelCount, true);
    offset += 2;
    // 采样率,每秒样本数,表示每个通道的播放速度 
    view.setUint32(offset, sampleRate, true);
    offset += 4;
    // 波形数据传输率 (每秒平均字节数) 单声道×每秒数据位数×每样本数据位/8 
    view.setUint32(offset, channelCount * sampleRate * (sampleBits / 8), true);
    offset += 4;
    // 快数据调整数 采样一次占用字节数 单声道×每样本的数据位数/8 
    view.setUint16(offset, channelCount * (sampleBits / 8), true);
    offset += 2;
    // 每样本数据位数 
    view.setUint16(offset, sampleBits, true);
    offset += 2;
    // 数据标识符 
    writeUTFBytes('data');
    offset += 4;
    // 采样数据总数,即数据总大小-44 
    view.setUint32(offset, dataLength, true);
    offset += 4;
    // 写入采样数据 
    if (sampleBits === 8) {
        for (var i = 0; i < bytes.length; i++, offset++) {
            var val = bytes[i] * (0x7FFF * volume);
            val = parseInt(255 / (65535 / (val + 32768)));
            view.setInt8(offset, val, true);
        }
    } else if (sampleBits === 16) {
        for (var i = 0; i < bytes.length; i++, offset += 2) {
            var val = bytes[i] * (0x7FFF * volume);
            view.setInt16(offset, val, true);
        }
    }
    return new Blob([view], {
        type: 'audio/wav'
    });
}

第三部:将上步得到的音频文件作为audio控件的声音源

function updateSource(callback) {
    var blob = encodeWAV(this.compress());
    var audio= document.getElementById("audio");
    //对上一秒播放的音频源进行释放
    var url = audio.src;
    url && window.URL.revokeObjectURL(url);
    if (blob.size > 44) { //size为44的时候,数据部分为空
        audio.src = window.URL.createObjectURL(blob);
    }
    setTimeout(function() {
        updateSource(callback);
    }, config.cycle);
}

麦克风的捕捉比起摄像头真的是麻烦很多,不能直接获取音频流,要进行数据压缩(采样频率,声道数,样本位数),还要转为wav编码(其他音频格式的编码还没去查过)。不过得到音频的Blob文件之后就可以和“视频传输"一样通过Blob或string两种数据类型收发。

总结


  • 对于上面提到的“内存泄漏”的情况还有待去研究。
  • 音频传输的话,本来音频文件就小,再加上压缩处理,传输的数据很小,但是图片传输就不一样了,我的项目中传输的图片用的100*100规格,我测试了下需要500k/s左右的上传和下载速度,在本机或者局域网中,再大点的尺寸或者用户再多点都毫无压力,但是部署到我的阿里云老爷机(1G内存,单核CPU)上,网速(上传速度最快300k/s)和后台处理性能两方面原因导致了延迟有一两分钟(>_<|||),所以后续还要继续研究图片压缩的问题。
  • 终于吃力地把语音视频功能实现了,感觉还是得找机会研究下WebRTC,享受下别人造好的轮子。

参考文章:


彩蛋


整个项目的代码已经上传至github

很多朋友对于项目构建比较疑惑,这里列一下导入步骤:

  • Eclipse
1. 从别的项目下复制一个.project文件, 修改其中的projectDescription-name(最好删除一些无关的内容)
2. Eclipse->import->General->Existing Projects into Workspace 导入项目
3. 右键项目Properties, Project Facets勾选 Dynamic Web Module 和 Java, 点击确定
4. 右键项目Properties->Deployment Assembly,删除WebContent这一行,添加WebRoot目录为根目录
5. 进入Java Build Path->Source, Default output floder 修改为 WebRoot/WEB-INF/classes
6. Java Build Path->Libary->Add Libary->Server Runtime, 选择Tomcat
7. 右键tomcat的server,Add and Remove, Add这个项目
8. 双击tomcat的server进入tomcat的配置页,点击Modules标签页->Edit, 将Path设为 /, [Ctrl + S]保存配置
9. 启动server,浏览器访问127.0.0.1:8080
10. 部署项目的另一种方式:tomcat的配置页->Add External Web Module,选择WebRoot目录,对于普通项目更推荐这种方式。
  • IntelliJ IDEA
1. File->Open直接打开项目目录
2. 根据右下角的自动检测,配置web.xml
3. Project Struture->Sources,选择src目录右键指定为Sources目录
4. Project Struture->Modules->Use module compile output path, 编译路径修改为 当前项目的WebRoot\WEB-INF\classes
5. Project Struture->Dependencies->Add->Libary->Tomcat(添加tomcat库), Add->Libary->JARs or directories, 引入项目lib下的fastjson包
6. Project Struture->Artifacts->Add->Web Application:Exploded->From Modules->OK(不要使用Facets下提示的自动创建Artifact)
7. 添加tomcat,修改tomcat配置(Classes添加tomcat\lib\websocket-api.jar, 当然这个包可以按第5步单独引入,不过要把Scope改为Provided),Deployment->Add->Artifact
8. 启动tomcat,浏览器访问127.0.0.1:8080
9. 部署项目的另一种方式:修改tomcat配置,Deployment->Add->External Source,选择WebRoot目录,对于普通项目更推荐这种方式。

注意:tomcat的lib不能打包到WEB-INF/lib目录下

项目中的几个页面:
  • main.html:聊天室入口页面
  • cameraTest.html:测试摄像头功能
  • microphoneTest*.html:测试麦克风功能

本文中的代码都是从项目中抽取出来的,并不完整,详细用法还是看项目代码吧。另外,摄像头和麦克风调用我分别封装成Camera类和MicroPhone类,以后简单调用即可,调用方式参见测试页面。

小小的吐槽


简书的风格看着很舒服,写文章排版也很省事,就是滚动条样式没设置,这么丑的滚动条太不和谐了。


转载请注明出处:https://www.jianshu.com/p/03a74d489f34

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

推荐阅读更多精彩内容