WebRTC 学习报告

WebRTC 学习报告

 

O_禾火_O 关注

2018.04.02 20:42* 字数 5432 阅读 76评论 0喜欢 1

刚开始接触webRTC,会遇到很多问题。webRTC移动端兼容性检测,如何配置MediaStreamConstraints, 信令(iceCandidate, sessionDescription)传输方式的选择,iceCandidate和sessionDescription设置的先后顺序,STUN和TURN的概念,如何实现截图及录制视频及上传图片和视频功能,如何高效跟踪错误等等。笔者带着这些问题来写这个笔记,敬请指教!

一、introduction

WbRTC是Web Real-Time Communication的缩写,顾名思义,也就是web实时通讯技术,它允许网络应用或站点,在不借助中间媒介、不安装任何插件或者第三方软件的情况下,建立浏览器之间P2P链接,实现视频流或者其他任意数据的传输。而Web开发者也无需关注多媒体的数字信号处理过程,只需编写简单的Javascript程序调用相应的接口即可实现。

说的简单明了一点就是让浏览器提供JS的即时通信接口。这个接口所创立的信道并不是像WebSocket一样,打通一个浏览器与WebSocket服务器之间的通信,而是通过一系列的信令,建立一个浏览器与浏览器之间P2P的信道,这个信道可以发送任何数据,而不需要经过服务器。并且WebRTC通过实现MediaStream,通过浏览器调用设备(包括PC端和移动端)的摄像头、话筒,使得浏览器之间可以传递音频和视频

WebRTC 是一组开放的 API,用于实现实时音视频通话并且已成为 W3C 标准草案,目前是WebRTC 1.0版本,Draft 状态。

二、兼容性

目前主流浏览器对 WebRTC 标准的支持情况如下(目前主要支持chrome和Firefox):

浏览器支持情况

Chrome(29+)/Opera(36+)支持

Firefox(45+)支持

Android Browser支持(5.0+)

Safari/iOS支持

Edge(14+)支持

IE不支持

实时查看WebRTC在浏览器中的支持情况: http://caniuse.com/#search=webRTC

三、WebRTC 系统包含的元素

WebRTC系统所包含的典型元素:

Web服务器

运行于各种设备和操作系统之上的浏览器

台式机

平板电脑

手机和其他服务器

PSTN(公用交换电话网)门户

以及其他互联网通信终端

WebRTC支持上述所有设备之间的通信。

四、WebRTC三个API

1、MediaStream(又称getUserMedia)

通过MediaStream的API能够通过设备的摄像头及话筒获得视频、音频的同步流

用法

navigator.mediaDevices.getUserMedia(constraints);

一般来说,navigator.mediaDevices.getUserMedia用法:

navigator.mediaDevices.getUserMedia(constraints).then(function(stream){/* use the stream *//* handleSuccess  成功回调函数*/}).catch(function(err){/* handle the error *//* handleError  失败处理函数*/});

之前一直以为getUserMedia()和mediaDevice.getUserMedia()的写法可以相互借鉴,刚刚在实践的时候发现是不可以的。getUserMedia接受三个参数(约束条件、成功回调函数、失败回调函数),mediaDevice.getUserMedia只接受一个参数,其他处理函数是以.then和.catch来实现的。

常见的错误可以点击这里进行查看 ==> Common getUserMedia() Errors

注意navigator.getUserMedia此方法已经被navigator.mediaDevices.getUserMedia取代。

三个参数

(1)constraints:约束项

属性:约束项可以具有下面这两个属性中的一个或两个:

1.audio—表示是否需要音频轨道

2.video—表示是否需要视频轨道

分辨率:width、height

关键字:min、max、exact、ideal

获取移动设备的前置或者后置摄像头

使用视频轨道约束的facingMode属性。可接受的值有:user(前置摄像头),environment(后置摄像头),left和right。

帧速率:frameRate

使用特定的网络摄像头或者麦克风。

(2)handleSuccess:成功回调函数

如果调用成功,传递给它一个流对象

(3)handleError:失败回调函数

如果调用失败,传递给它一个错误对象

约束对象可定义如下属性:

属性含义

* video:是否接受视频流

* audio:是否接受音频流

* MinWidth:视频流的最小宽度

* MaxWidth:视频流的最大宽度

* MinHeight:视频流的最小高度

* MaxHiehgt:视频流的最大高度

* MinAspectRatio:视频流的最小宽高比

* MaxAspectRatio:视频流的最大宽高比

* MinFramerate:视频流的最小帧速率

* MaxFramerate:视频流的最大帧速率

如果网页使用了getUserMedia,浏览器就会询问用户,是否许可提供信息。如果用户拒绝,就调用回调函数handleError。发生错误时,回调函数的参数是一个Error对象,它有一个code参数,取值如下:

PERMISSION_DENIED:用户拒绝提供信息。

NOT_SUPPORTED_ERROR:浏览器不支持指定的媒体类型。

MANDATORY_UNSATISHIED_ERROR:指定的媒体类型未收到媒体流。

配置MediaStreamConstraints

所谓MediaStreamConstraints,就是navigator.mediaDevices.getUserMedia(constraints)传入的constraints,至于它的写法及功能,参考MDN,本文不做赘述。我在这里想要强调的是,对于移动端来说控制好视频图像的大小是很重要的,例如本项目中想要对方的图像占据全屏,这不仅是改变video元素的样式或者属性能做到的,首先要做的是改变MediaStreamConstraints中的视频分辨率(width, height),使其长宽比例大致与移动端屏幕的类似,然后再将video元素的长和宽设置为容器的长和宽(例如100%)。

另外对于getUserMedia一定要捕获可能出现的错误,如果是老的API,设置onErr回调,如果是新的(navigator.mediaDevices.getUserMedia),则catch异常。这样做的原因:getUserMedia往往不会完全符合我们的预期,有时即使设置的是ideal的约束,仍然会报错,如果不追踪错误,往往一脸懵逼。这也是后文要提到的高效追踪错误的方法之一。

兼容浏览器的getUserMedai的写法

var getUserMedia = (navigator.getUserMedia||navigator.webkitGetUserMedia||navigator.mozGetUserMedia||msGetUserMedia);

检测webRTC的可行性

主要从getUserMedia和webRTC本身来入手,示例代码如下:

functiondetectWebRTC(){constWEBRTC_CONSTANTS = ['RTCPeerConnection','webkitRTCPeerConnection','mozRTCPeerConnection','RTCIceGatherer'];constisWebRTCSupported = WEBRTC_CONSTANTS.find((item) =>{returniteminwindow;  });constisGetUserMediaSupported = navigator && navigator.mediaDevices && navigator.mediaDevices.getUserMedia;if(!isWebRTCSupported ||typeofisGetUserMediaSupported ==='undefined') {returnfalse;  }returntrue;}

HTMLMediaElement.srcObject

接口的srcObject属性HTMLMediaElement设置或返回作为媒体源的对象HTMLMediaElement,该对象可以是a mediaStream、a mediaSource、a blog 、a file。

用法

varmediaStream = HTMLMediaElement .srcObject HTMLMediaElement .srcObject = mediaStream

这个例子使用srcObject并优雅地回退到src如果srcObject抛出错误。

constmediaSource =newMediaSource();constvideo =document.createElement('video');try{  video.srcObject = mediaSource;}catch(error) {  video.src = URL.createObjectURL(mediaSource);}

URL.createObjectURL()

注意:使用 一个MediaStream对象作为此方法的输入正在被弃用。这个方法正在被讨论是否应该被移除. 所以,你应当在你使用MediaStream时避免使用这个方法,而用HTMLMediaElement.srcObject() 替代.

getUserMediaMedai的实现简单代码

navigator.getUserMedia = navigator.getUserMedia ||                        navigator.webkitGetUserMedia ||                          navigator.mozGetUserMedia ||                        navidator.msGetUserMedia;varconstraints = {// 音频、视频约束audio:true,// 指定请求音频Trackvideo: {// 指定请求视频Trackmandatory: {// 对视频Track的强制约束条件width: {min:320},height: {min:180}      },optional: [// 对视频Track的可选约束条件{frameRate:30}      ]  }};varvideo =document.querySelector('video');functionsuccessCallback(stream){if(window.URL) {    video.src =window.URL.createObjectURL(stream);  }else{    video.src = stream;  }}functionerrorCallback(error){console.log('navigator.getUserMedia error: ', error);}navigator.getUserMedia(constraints).then(function(){    successCallback(stream);}).catch(function(){    errorCallback(error)});

如果本机有多个摄像头/麦克风,这时就需要使用MediaStreamTrack.getSources方法指定,到底使用哪一个摄像头/麦克风。

2、 RTCPeerConnection(核心API)

RTCPeerConnection的作用是在浏览器之间建立数据的“点对点”(peer to peer)通信,也就是将浏览器获取的麦克风或摄像头数据,传播给另一个浏览器。这里面包含了很多复杂的工作,比如信号处理、多媒体编码/解码、点对点通信、数据安全、带宽管理等等。

不同客户端之间的音频/视频传递,是不用通过服务器的。但是,两个客户端之间建立联系,需要通过服务器。服务器主要转递两种数据。

通信内容的元数据:打开/关闭对话(session)的命令、媒体文件的元数据(编码格式、媒体类型和带宽)等。

网络通信的元数据:IP地址、NAT网络地址翻译和防火墙等。

WebRTC协议没有规定与服务器的通信方式,因此可以采用各种方式,比如WebSocket。通过服务器,两个客户端按照Session Description Protocol(SDP协议)交换双方的元数据。

RTCPeerConnection是在Chrome中和Android设备中使用,经过几次迭代之后RTCPeerConnection现在支持 Chrome and Opera作为webkitRTCPeerConnection,Firefox 作为mozRTCPeerConnection。Google维护一个函数库adapter.js,用来抽象掉浏览器之间的差异。

UDP和TCP的区别

(Transmisson Control Protocol,TCP)传输控制协议,它保证如下几点:

任何送出的数据都有送达的确认

任何未送达接收端的数据会被重传并停止发送更多的数据。

数据是唯一的,接收端不会有重复的数据

(User Datagram Protocol, UDP)用户数据报协议,以下是他不保证的事情:

不保证数据发送或接收扥先后顺序。

不保证每一个数据包都能都传送到接收端吗;一些数据可能在半路丢失。

不跟踪每一个数据包的状态,即使接收端有数据丢失也会继续传输。

TCP的这些限制使得WEBRTC开发者选择UDP作为传输协议,webRTC的音频、视频和数据在浏览器间的传输不需要最可靠但需要最快。这意味着允许丢失帧,也就是说这类应用来说UDP是一个更好的选择。但不意味着webRTC就不使用TCP协议!

如果UDP传输失败, ICE 会尝试TCP: 首先是 HTTP, 然后才会选择 HTTPS。如果直接连接失败,通常因为企业的NAT转发和防火墙,也就是ICE使用了第三方中转。这样一来,ICE首先使用STUN和UDP和直接连接端点,失败之后返回TURN服务器,表达式‘finding cadidates’指向找到的网络接口和端口。

没有服务器的RTCPeerConnection

请求和被请求都在同一个页面,它们可以直接通过RTCPeerConnection对象在页面上交换信息,而不需要使用中介的信号机制。WebRTC samples用的是这种方式,但一般来说,实际开发的时候,这种方式不会用到。

有服务器的RTCPeerConnection

在现在的世界里,WebRTC需要服务器,但是服务器配置非常简单,步骤如下:

用户找到对方并交换双信息,比如名字。

WebRTC客户端应用交换网络信息。

两个端交换多媒体数据信息。

WebRTC客户端遍历NAT 网关和防火墙。

其他方面,WebRTC需要四个类型的服务器端功能:

用户连接和通信

信号量

NAT/防火墙转发

如果通信失败再次发送

服务器主要转递两种数据。

通信内容的元数据:打开/关闭对话(session)的命令、媒体文件的元数据(编码格式、媒体类型和带宽)等。

网络通信的元数据:IP地址、NAT网络地址翻译和防火墙等。

WebRTC协议没有规定与服务器的信令通信方式,因此可以采用各种方式,比如WebSocket(HTTP、数据通道等)。通过服务器,两个客户端按照Session Description Protocol(SDP协议)交换双方的元数据。

兼容浏览器的PeerConnection写法

varPeerConnection = (window.PeerConnection ||window.webkitPeerConnection00 ||window.webkitRTCPeerConnection ||window.mozRTCPeerConnection);

信号: session控制,网络和多媒体信息

WebRTC使用RTCPeerConnection进行两个浏览器之间的数据流的通信,但是也需要一种机制来协调沟通控制信息,这一个过程叫做信号。信号的方法和协议不是WebRTC指定的,而是RTCPeerConnection API的一部分。

信号用来交换以下三个类型的信息:

Session控制信息:用来初始化或是关闭通信,并报告错误。

网络配置:本机的IP和端口等配置。

媒体功能:编码解码器配置。

建立webRTC的基本会话流程

singing server信令服务器:用来开始和结束通话,即开始视频、结束视频这些操作指令和通信数据的交换等。

注意:使用turn服务器的时候,客户端AB不直接通信,而是分别与turn通信从而建立链接。

使用stun服务器的时候,因为客户端B无法访问客户端A的内网IP,所以A通过stun服务器获得自己的外网IP再发送给B,从而建立链接。

webRTC基本会话流程

首先双方都建立一个RTCPeerConnection的实例,其中一方(称为offer方)用RTCPeerConnection.createOffer()创建一个会话描述sessionDescription,该会话描述包含SDP报文信息和该sessionDescription的类型(type)

接下来调用RTCPeerConnection.setLocalDescription()方法将本地的localDescription设置为刚才创建的sessionDescription。之后将创建的sessionDescription发送给对方(称为answer方),发送方式没有规定,可以通过服务器中转,可以通过IM软件发送(这里使用WebSocket信令服务器)。

answer端接收到sessionDescription后调用RTCPeerConnection. setRemoteDescription方法设置,然后调用RTCPeerConnection. createAnswer方法产生自己的sessionDescription。

再将创建的sessionDescription发送给offfer方,同样发送方式没有规定。offer方接收到sessionDescrip后调用RTCPeerConnection. setRemoteDescription方法设置,这样双方的SDP信息就交换完成了。

在完成SDP的交换后双方还要交换ICE candidate信息。双方首先设置RTCPeerConnection.onicecandidate回调函数,当candidate可用时,双方中的一方将所有icecandidate发送给对方,发送方式同样没有规定,接收方调用RTCPeerConnection.addIceCandidate方法接收candidate信息。经过这些步骤后双方连接就建立完成了。

注意:RTCPeerConnection.addStream 被 RTCPeerConnection.addTrack取代;

3、RTCDataChannel (不常用)

RTCDataChannel 使得浏览器之间(点对点)建立一个高吞吐量、低延时的信道,用于传输任意数据。表示连接的两个对等端之间的 双向数据通道

我们可以使用channel = pc.createDataCHannel("someLabel");来在PeerConnection的实例上创建Data Channel,并给与它一个标签

例子

varpc =newRTCPeerConnection();vardc = pc.createDataChannel("my channel", dataChannelOptions);dc.onmessage =function(event){console.log("received: "+ event.data);};dc.onopen =function(){console.log("datachannel open");};dc.onclose =function(){console.log("datachannel close");};

dataChannelOptions为数据通道选项,可以自行配置,并且这个配置项是可选的:

//这两个是主要使用的配置项,其他的配置大多在高级应用中才会使用vardataChannelOptions = {reliable:false,//设置消息传递是否进行担保maxRetransmitTime:3000//设置消息传送失败时,多久重新发送}

DataChannel使用方式几乎和WebSocket一样,有几个事件

onopen

onclose

onmessage

onerror

四个状态,可以通过readyState获取:

connecting: 浏览器之间正在试图建立channel

open:链接建立成功,可以进行通信,可以使用send方法发送数据了

closing:通道正在被销毁

closed:通道已经被关闭了,无法进行通信

两个暴露的方法:

close(): 用于关闭channel

send():用于通过channel向对方发送数据

当创建一个数据通道后,你必须等onopen事件触发后才能发送消息。在通道打开前发送消息,会抛出一个异常,这个异常指出通道还没准备好发送消息。

语法和websoket比较相似,用了send()和message()方法。

通信是出现在两个浏览器之间的,所以RTCDataChannel会比WebSocket快一些。RTCDataChannel 支持Chrome, Opera和Firefox.。

数据通道支持如下类型

1、String:Javascript 基本的字符串

2、Blob:一种文件格式的原始数据

3、ArrayBuffer: 确定数组长度的数据类型

4、ArrayBufferView:基础的数据视图

将需要的变量传入send方法中,浏览器会做剩下的工作。你可以咋接收消息的一边通过测试来确定数据类型:

dataChannel.onmessage =function(event){vardata = event.data;if(datainstanceofBlob){// 处理Blob}elseif(datainstanceofArraBuffer){//处理ArrayBuffer}elseif(datainstanceofArrayBufferView){// ArrayBufferView}else{//处理String}}

加密与安全

webRTC运行时,对于所有协议的实现,都会强烈执行加密功能。这意味着浏览器间的每一个对等连接,都自动处于高的安全级别中。所使用的加密技术都应该满足对等应用的以下几个要求:

信息在传输过程中被窃取,无法进行读取

第三方不能够伪造信息,是消息看上去像是链接者发送的

消息在传输给另一方时不能进行编辑

加密算法的快速性,一支持客户端之间的最大宽带

为了满足各方面的需求,我们的协议使用的是DTLS

安全传输层协议(TLS)用于在两个通信应用程序之间提供保密性和数据完整性。

该协议由两层组成: TLS 记录协议(TLS Record)和 TLS 握手协议。

TLS的流行是因为直接嵌入到应用层和传输层,是的很少甚至不需要改变应用本身的逻辑二使应用变得安全。使用TLS的唯一缺点就是它必须基于TCP的应用,但我们的webRTC协议并不是在其基础上工作的,

DTLS(Datagram Transport Layer Security)即数据包传输层安全性协议。

DTLS源于希望能有一个像TLS一样简单易用的协议,但拥有UDP传输层的功能。它吸取了TLS协议许多相同的概念,并增加了对UDP的支持。

到这里最大的收获,你需要知道数据通道和webRTC应用中,数据是安全的。

problem progress

1、信令(iceCandidate, sessionDescription)传输方式的选择

要传输的信令包括两个部分:sessionDescription(也就是你传输的是什么?阿猫阿狗)和iceCandidate(以什么样的方式传输?火车空运)。为了便于传输可将其处理成字符串,另一端接收时还原并用对应的构造函数构造对应的实例即可。

webRTC并没有规定信令的传输方式,而是完全由开发者自定义。常见的方式有短轮询webSocket(socket.io等)

短轮询的优点无非是简单,兼容性强,但在并发量较大时,服务器负荷会很重。

webSocket就不存在这个问题,但webSocket搭建起来较为复杂,并不是所有的浏览器都支持websocket

综合来说socket.io是个不错的解决方案,事件机制和自带的房间概念对撮合视频会话都是天然有利的,并且当浏览器不支持websocket时可以切换为轮询,也解决了兼容性的问题。

webSokket属于长链接。

2、STUN和TURN的概念

STUN服务器是用来取外网地址的。

TURN服务器是在P2P失败时进行转发的

stun和turn服务的作用主要处理打洞与转发,配合完成ICE协议。

首先尝试使用P2P,如果失败将求助于TCP,使用turn转发两个端点的音视频数据,turn转发的是两个端点之间的音视频数据不是信令数据。因为turn服务器是在公网上,所以他能被各个客户端找到,另外turn服务器转发的是数据流,很占用带宽和资源。

3、webRTC移动端兼容性检测

文章前面有getUserMedia及RTCPeerConnection的浏览器兼容写法

4、如何配置MediaStreamConstraints

约束项在文章前面部分

5、如何高效跟踪错误

整个双向视频涉及到的步骤较多,做好错误追踪是非常重要的。像getUserMedia时,一定要catch可能出现的异常。因为不同的设备,不同的浏览器或者说不同的用户往往不能完全满足我们设置的constraints。还有在实例化RTCPeerConnection时,往往会出现不可预期的错误,常见的有STUN、TURN格式不对,还有createOffer时传递的offerOptions格式不对,正确的应该为:

constofferOptions = {'offerToReceiveAudio':true,'offerToReceiveVideo':true};

这些问题还在思考中:

1、如何实现截图及录制视频及上传图片和视频功能。

2、两个不同的客户端实现对等连接的时候,如何获取对方的视频和音频?

3、iceCandidate和sessionDescription设置的先后顺序

notice

考虑版本向上兼容,更要向下兼容!!!

手动运行demo最简单方法就是cd到HTML文件所在目录下,然后python -m SimpleHTTPServer(装了python的话),然后在浏览器中输入http://localhost:8000/{文件名称}.html

对video元素要特殊处理。设置autoPlay属性,对播放本地视频源的video还要设置muted属性以去除回音。针对IOS播放视频自动全屏的特性,还要设置playsinline属性的值为true。

通常有报道说一个平台支持WebRTC,一般都说他们支持getUserMedia,而不支持其它RTC组件,开发的时候需要先弄清楚。

在非官方文档中,并非所有的信息都是正确的,有分歧的地方要学习实践检验(比如有些文档在使用getUserMedai的时候省略navigator,运行的时候会报错)。所以尽可能多看官方文档!

google提供的实现stun协议的测试服务器(stun:stun.l.google.com:19302)

参考资料:

[1]通过WebRTC实现实时视频通信(三)

[2]webRTC实战总结

[3]WebRTC API

[4]使用WebRTC搭建前端视频聊天室——入门篇

[5]webrtc进阶-信令篇-之三:信令、stun、turn、ice

[6]getUserMedai()视频约束

[7]WebRTC1-原理探究

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

推荐阅读更多精彩内容